auto: 일일 백업 2026-08-04 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-08-04 02:00:05 +09:00
parent 153ef20ae5
commit 7e7d759b34
167 changed files with 2732 additions and 1306 deletions
@@ -10,6 +10,18 @@ iMessage에 들어오는 카드/은행 결제 알림을 후잉(whooing.com) 웹
## How
**on-demand 실행은 launchd 잡을 앞당겨 돌린다.** 스크립트를 직접 실행하지 말 것 —
에이전트 세션(코덱스)은 게이트웨이 node 를 TCC responsible process 로 물고 있어서
`imsg` 가 Messages DB 를 못 읽는다 (아래 "Full Disk Access" 참고). launchd 잡은 그 제약이 없다.
```bash
launchctl kickstart -p gui/$(id -u)/ai.openclaw.budget.whooing-sync
# 비동기 — 몇 초 뒤 결과 확인
tail -20 /Users/snowoyh/.openclaw/logs/whooing-sync.log
```
스크립트 직접 실행은 **FDA 를 가진 터미널 앱**(iTerm2 등)에서만 동작한다:
```bash
python3 /Users/snowoyh/.openclaw/agents/budget/workspace/skills/whooing-sync/scripts/whooing_sync.py
```
@@ -72,18 +84,42 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.openclaw.budget.whooi
## 전제조건: Full Disk Access (FDA)
launchd 컨텍스트에서 `imsg``~/Library/Messages/chat.db` 를 읽으려면 FDA 허용이 필요. 터미널에서 직접 돌릴 땐 터미널 앱의 FDA 를 자식 프로세스가 상속받지만, launchd 는 상속 안 됨.
`imsg``~/Library/Messages/chat.db` 를 읽으려면 FDA 허용이 필요. **판정 주체는 TCC responsible process** — 자기 자신이 아니라 프로세스 체인의 책임 프로세스다.
- 등록 대상: `/opt/homebrew/bin/imsg`
- 경로: 시스템 설정 → 개인정보 보호 및 보안 → 전체 디스크 접근 권한 → `+` 로 추가 → 토글 ON
- launchd → `/usr/bin/python3`(플랫폼 바이너리) → `imsg`: 책임이 `imsg` 자신이라 **`/opt/homebrew/bin/imsg` 승인으로 통과**
- 터미널 앱 → … → `imsg`: 터미널 앱(iTerm2 등)의 FDA 로 통과
- 게이트웨이 node → codex → bash → `imsg`: **게이트웨이 node 바이너리**의 FDA 로 판정됨
**FDA 누락 증상**: stderr 로그에
등록 경로: 시스템 설정 → 개인정보 보호 및 보안 → 전체 디스크 접근 권한 → `+` 로 추가 → 토글 ON
**FDA 누락 증상**: `imsg` 가 stderr 로 다음을 낸다.
```
❌ imsg chats 실행 실패: Expecting value: line 1 column 1 (char 0)
Permission Error: Cannot access Messages database
... requires Full Disk Access permission.
```
가 뜨고, stdout 은 `🟢 새 결제 메시지 없음` 으로 조용히 끝남. 오류처럼 보이지 않아 놓치기 쉬움. 맥 이전 / imsg 재설치 시 FDA 재등록 필요.
`imsg_cli.run_json()` 이 이걸 잡아 로그에 그대로 옮긴다:
```
⚠️ chat-id 7 history 실패: Messages DB 접근 거부 — 전체 디스크 접근 권한(FDA) 누락. ...
```
### 2026-08-03 사고: homebrew node 업그레이드로 FDA 무효화
node@22 가 2026-07-20 에 `22.22.2``22.23.1` 로 올라가며 **바이너리 경로가 바뀌어** 기존 FDA 승인이 무효화됐다 (시스템 TCC.db 에 22.22.2=허용 / 22.23.1=거부로 남아 있음). 게이트웨이가 7/21 새 바이너리로 재기동되면서 골디의 에이전트 세션에서 `imsg` 가 죽었다. **launchd 잡은 영향 없었다** — 그래서 자동 동기화는 정상인데 골디만 실패하는 그림이 됐고, 골디는 이걸 "문자 수집 실패"로 오진했다.
- 재발 시 확인: `lsof -p $(pgrep -f 'openclaw/dist/index.js gateway') -a -d txt | head -1` 로 실제 node 경로를 뽑아, 그 경로를 FDA 목록에 추가
- 그 다음 게이트웨이 재기동: `launchctl kickstart -k gui/$(id -u)/ai.openclaw.gateway`
- **on-demand 실행을 kickstart 로 바꾼 이유가 이것** — node 업그레이드마다 깨지는 의존을 실행 경로에서 뺐다
당시 오진을 만든 코드 결함 3개는 함께 고쳤다 (2026-08-03):
1. 호출부가 stderr·returncode 를 버려 권한 오류가 `Expecting value: line 1 column 1 (char 0)` 파싱 에러로 둔갑 → `imsg_cli.py` 공용 래퍼로 사유 노출
2. `resolve_chat_ids()` 가 권한 거부 시 **빈 chat id 매핑을 캐시에 저장** → 이후 launchd 포함 전 실행이 캐시 적중으로 조용히 0건 수집을 영구 반복. 이제 전 carrier 가 비면 저장 거부 + exit 3
3. `imsg chats` 기본값이 최근 20건이라 조용한 발신번호(현대카드 SMS·신한은행)가 목록에서 빠짐 → 캐시 재생성 시 그 carrier 수집이 조용히 끊김. `CHATS_LIMIT=200` 으로 전량 조회
맥 이전 / imsg 재설치 시에도 FDA 재등록 필요.
## 페어 매칭 (자기 계좌 간 이체)
@@ -118,7 +154,9 @@ launchd 컨텍스트에서 `imsg` 가 `~/Library/Messages/chat.db` 를 읽으려
- 사람 이름 송금(예: "박영춘", "이지윤")은 exact 룰로 등록하지 말고 default fallback 에 맡긴다. merchant_map 비대화 방지.
- `deposit` 은 default fallback 없음 — rule 없으면 raw 폴백 (수익/이체/환급 구분 위험 때문).
- `card_cancel` 은 **승인의 역분개**로 좌우를 뒤집어 POST 한다 (2026-08-03). 승인이 `{비용 ← 카드}` 이므로 취소는 `{카드 ← 비용}`. 부분취소도 취소 문자 금액 그대로 상쇄되어 맞는다. 이전엔 raw 폴백으로 후잉 자체 파서에 맡겼는데, 결과는 맞았지만 파싱을 외부에 의존하고 raw 폴백 알림이 매번 울렸다. 현대카드·신한카드(`매입취소`/`승인취소`) 공통.
- 기존 contains(예: "스타벅스 → 식비") / exact(예: "방효원 → 기초잔액(효원)") 는 계속 유효. fallback 은 둘 다 miss 일 때만 탄다.
- ⚠️ **주유소는 두 곳을 함께 손대야 한다**`whooing_sync.py``FUEL_KEYWORDS`(150,000원 선승인/취소 스킵)와 `whooing_merchant_map.json` contains 룰(실제 결제 → `차량유지비/주유비`)이 한 쌍이다. 한쪽만 넣으면 선승인이 후잉에 남거나 실제 주유가 기타비용으로 샌다. 상호에 주유/석유가 없는 충전소(현대가스·한경에너)는 상호 자체를 등록. 한경에너는 선승인이 149,900원이라 `FUEL_PREAUTH_AMOUNT`(150,000 정확일치) 스킵엔 안 걸리고 분류만 적용된다.
- 결과적으로 자잘한 인명 송금·가맹점 미등록 건은 전부 기타비용으로 자동 분류되고, 분류가 필요한 것만 후잉 UI 에서 사후 조정하거나 merchant_map 에 규칙 추가한다.
### 우선 룰 (whooing_overrides.json)
@@ -296,6 +334,9 @@ python3 .../whooing_balance.py --json \
### 흐름
1. **발신 게이트** — KST 기준 `day >= send_day_of_month` (catch-up 정책) 이고 시각이 `send_hour_kst` 이상이며 `last_sent_month` ≠ 이번 달이면 1회 발신. 25일에 Mac이 꺼져 있었어도 26~월말 사이 켜면 그 시점에 발신. 다음 달로 넘어가면 포기.
- 발신은 **`--service imessage`** (2026-08-03 전환, 아래 사고 참고). 가희님은 iMessage 사용자다.
- 성공 시 `last_sent_month` 와 함께 **`last_sent_at`** 을 찍는다 — 답신 미도착 감시의 시작점.
1-b. **답신 미도착 감시** (`_check_reply_overdue`) — 발신 후 `REPLY_GRACE_DAYS`(2일)가 지나도 가희님 답신이 없으면 관리자님께 텔레그램 1회. 유예 전이면 API 콜 0으로 즉시 반환하고, 답신이 확인되면 `last_sent_at` 을 지워 감시를 끝낸다. 같은 발신 건에 대한 재알림은 `reply_alert_for` 로 막는다. ⚠️ **재발신은 자동으로 하지 않는다** — 가희님이 늦게 답하는 경우 중복 독촉이 되고, 자동 발송 트리거를 늘리지 않는다는 방침에도 어긋난다.
2. **응답 폴링**`imsg chats --json` → 가희 chat 찾기 → `imsg history --chat-id X --start <last_processed_message_at> --attachments --json`. 가희 발신(is_from_me≠true) 메시지만 처리.
3. **1차 스캔** (즉시 워터마크 갱신):
- **이미지 첨부 있음** → 자동분개 X, 골디 텔레그램 알림만 ("이미지 답신 — 직접 처리 부탁")
@@ -322,6 +363,8 @@ python3 .../whooing_balance.py --json \
"message_template": "가계부 업데이트 날이에요. 계좌잔액 보내주시면 자동으로 반영됩니다.",
"last_sent_month": null,
"last_processed_message_at": null,
"last_sent_at": null,
"reply_alert_for": null,
"whooing_account_name": "가희주머니",
"income_account": "가희비밀주머니_수익",
"expense_account": "기타비용"
@@ -330,8 +373,29 @@ python3 .../whooing_balance.py --json \
- `last_sent_month` = "YYYY-MM" 발신 직후 기록. 같은 달에 재발신 안 함.
- `last_processed_message_at` = imsg history `--start` 의 ISO 워터마크. 처리한 메시지의 created_at 최댓값.
- `last_sent_at` = 발신 시각(UTC Z). 답신 미도착 감시의 기준점. 답신 확인되면 지워진다.
- `reply_alert_for` = 이미 "답신 없음" 알림을 보낸 `last_sent_at` 값. 같은 발신 건 재알림 방지.
- 발신 문구·트리거 일자·계정명 변경은 모두 이 JSON 직접 편집. 코드 재배포 불필요. whooing-sync 가 매 사이클 다시 읽음.
### 수동 발신 (`--send-now`)
발송이 실패해 그 달을 놓쳤을 때의 복구 수단. 날짜 게이트를 무시하고 1회 발신하며 `last_sent_month` 는 건드리지 않아 정규 25일 발신이 살아있다.
```bash
python3 gahee_reminder.py --send-now [--dry-run]
```
⚠️ **에이전트 세션·코디 셸에서 실행하면 20초 타임아웃으로 실패한다.** `imsg send` 는 AppleEvents 로 Messages 를 구동하는데, **자동화 → 메시지** 권한이 `imsg` 와 iTerm2 에만 허용돼 있고 Claude Code 에는 거부로 박혀 있다. launchd 경유로 돌려야 한다 — 임시 oneshot plist 를 만들어 `kickstart` 하고 로그를 읽은 뒤 `bootout` + 삭제한다(2026-08-03 실행 방식).
### 2026-07-25 사고: 미발송이 성공으로 기록됨
7/25 리마인더가 실제로 발송되지 않았는데(`chat.db`: `service=SMS, is_sent=0, error=4`) 시스템은 성공으로 기록했다. 가희주머니 7월 잔액이 안 들어와 8/1 월간 결산에서 빠졌다.
- **왜 실패했나** — `--service sms` 강제가 이 수신자에게 **3전 3패**였다(5/25 ×2, 7/25 ×1 전부 `error=4`). 살아남은 2건(5/25·6/25)은 Messages 가 임의로 **RCS 로 바꿔** 보낸 운이었고, 7월엔 그 폴백이 안 일어났다. 전체 통계로도 iMessage 137/139 성공 vs SMS 실패 3건(전부 이 리마인더).
- **왜 몰랐나** — `imsg send` 는 Messages 에 넘기기만 성공하면 rc=0 이고, 실제 실패는 그 뒤 비동기로 `chat.db``error` 에 찍힌다. 코드가 rc 만 봐서 성공 처리 → 오탐 텔레그램 → `last_sent_month` 갱신 → 그 달 재시도 차단 → 8월엔 catch-up 이 당월만 보므로 7월분 영구 유실.
- **왜 발송 검증을 자동 경로에 안 넣었나** — `is_sent`/`error``chat.db` 에만 있고 `imsg history` JSON 은 노출하지 않는다(실패한 메시지도 정상 조회됨). 직접 sqlite 조회가 유일한 길인데 **launchd 의 `/usr/bin/python3` 는 FDA 가 없어 `authorization denied` 로 막힌다**(2026-08-03 실측). 그래서 자동 경로는 권한과 무관한 답신 기반 감시를 쓰고, 정밀 확인(`_verify_sent`)은 FDA 가 있는 컨텍스트에서 도는 `--send-now` 에만 붙였다.
- `error=4` 의 정확한 의미는 확증 못 함 — 사고 시점 imagent 로그가 이미 롤오프됐다.
### 수신자 설정
`/Users/snowoyh/.openclaw/credentials/gahee_imessage.json`:
@@ -12,13 +12,18 @@
"""
from __future__ import annotations
import argparse
import json
import re
import sqlite3
import subprocess
import sys
import time
from datetime import datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
import imsg_cli
import notify
import whooing_balance
@@ -27,6 +32,13 @@ GAHEE_CRED = Path("/Users/snowoyh/.openclaw/credentials/gahee_imessage.json")
STATE_FILE = Path("/Users/snowoyh/.openclaw/agents/budget/workspace/state/gahee_reminder.json")
IMSG_TIMEOUT = 20
HISTORY_LIMIT = 50
# 발신 후 이 기간 안에 가희님 답신이 없으면 발송 실패를 의심하고 관리자님께 1회 알린다.
# imsg send 는 Messages 에 넘기기만 성공하면 rc=0 이라 발송 실패를 동기적으로 못 잡는다
# (2026-07-25 사고: SMS error=4 로 미발송인데 성공 처리돼 그 달 재시도가 막혔다).
# 실측 답신 지연은 3시간·38시간 — 2일이면 오탐이 적다.
REPLY_GRACE_DAYS = 2
CHAT_DB = Path.home() / "Library" / "Messages" / "chat.db"
APPLE_EPOCH = 978307200 # chat.db date 는 2001-01-01 기준 ns
# 라벨 + 금액 패턴. 라벨은 한글/영문/괄호/공백/숫자 1~20자.
# 콜론 구분 입력은 정수 전체를 허용하고, 공백 구분 입력은 천 단위 콤마가 있을 때만 허용한다.
# 일반 안내 문자의 "오전 09" 같은 표현을 잔액으로 오인하지 않기 위함.
@@ -77,15 +89,11 @@ def _normalize(handle: str) -> str:
def _imsg_chats() -> list[dict]:
try:
raw = subprocess.run(
["imsg", "chats", "--json"],
capture_output=True, text=True, timeout=IMSG_TIMEOUT,
)
return [json.loads(l) for l in raw.stdout.splitlines() if l.strip()]
except Exception as e:
print(f"⚠️ 가희 imsg chats 실패: {e}")
rows, err = imsg_cli.chats(IMSG_TIMEOUT)
if err:
print(f"⚠️ 가희 imsg chats 실패: {err}")
return []
return rows
def _find_gahee_chat_id(handle_norm: str) -> int | None:
@@ -97,26 +105,35 @@ def _find_gahee_chat_id(handle_norm: str) -> int | None:
def _imsg_history(chat_id: int, start_iso: str | None) -> list[dict]:
cmd = ["imsg", "history", "--chat-id", str(chat_id),
"--limit", str(HISTORY_LIMIT), "--attachments", "--json"]
args = ["history", "--chat-id", str(chat_id),
"--limit", str(HISTORY_LIMIT), "--attachments", "--json"]
if start_iso:
cmd += ["--start", start_iso]
try:
raw = subprocess.run(cmd, capture_output=True, text=True, timeout=IMSG_TIMEOUT)
return [json.loads(l) for l in raw.stdout.splitlines() if l.strip()]
except Exception as e:
print(f"⚠️ 가희 imsg history 실패: {e}")
args += ["--start", start_iso]
rows, err = imsg_cli.run_json(args, IMSG_TIMEOUT)
if err:
print(f"⚠️ 가희 imsg history 실패: {err}")
return []
return rows
def _send_imessage(handle: str, text: str, dry_run: bool) -> bool:
"""가희님께 리마인더 발신.
⚠️ rc=0 은 "Messages 에 넘겼다"는 뜻일 뿐 발송 성공이 아니다. 실제 실패는 그 뒤
비동기로 chat.db 의 error 칼럼에 찍힌다 — 여기서는 알 수 없다. 발송 여부 확인은
_verify_sent()(FDA 필요, CLI 전용) 또는 _check_reply_overdue()(답신 기반)가 맡는다.
⚠️ service 는 imessage 다. sms 강제는 이 수신자에게 3전 3패였고(5/25 ×2, 7/25 ×1
전부 error=4), 살아남은 2건은 Messages 가 임의로 RCS 로 바꿔준 운이었다. 가희님은
iMessage 사용자다(답신 2건 모두 iMessage). 2026-08-03 전환.
"""
if dry_run:
print(f"[dry-run] imsg send --to {handle} --text {text!r} --service sms")
print(f"[dry-run] imsg send --to {handle} --text {text!r} --service imessage")
return True
try:
result = subprocess.run(
["imsg", "send", "--to", handle, "--text", text,
"--service", "sms"],
"--service", "imessage"],
capture_output=True, text=True, timeout=IMSG_TIMEOUT,
)
if result.returncode != 0:
@@ -128,6 +145,46 @@ def _send_imessage(handle: str, text: str, dry_run: bool) -> bool:
return False
def _utc_z(dt: datetime) -> str:
"""imsg --start / state 워터마크와 같은 UTC Z 포맷."""
return dt.astimezone(ZoneInfo("UTC")).isoformat().replace("+00:00", "Z")
def _verify_sent(handle: str, after_unix: float, timeout: int = 20):
"""chat.db 직접 조회로 실제 발송 여부 확인. 반환 (verdict, detail).
verdict True=발송됨 / False=실패 / None=확인 불가.
chat.db 는 FDA 를 가진 컨텍스트에서만 읽힌다 — launchd(/usr/bin/python3)에선 못 읽으므로
확인 불가(None)로 떨어진다. 이 함수는 CLI 전용이고, 자동 경로는 답신 기반 감시를 쓴다.
"""
digits = re.sub(r"\D", "", handle)[-8:]
after_ns = int((after_unix - APPLE_EPOCH) * 1_000_000_000)
deadline = time.time() + timeout
detail = None
while True:
try:
con = sqlite3.connect(f"file:{CHAT_DB}?mode=ro", uri=True)
row = con.execute(
"select m.service, m.is_sent, m.error from message m "
"join handle h on m.handle_id = h.rowid "
"where h.id like ? and m.is_from_me = 1 and m.date >= ? "
"order by m.date desc limit 1",
(f"%{digits}", after_ns),
).fetchone()
con.close()
except Exception as e:
return None, f"chat.db 조회 불가 ({type(e).__name__}: {e})"
if row:
detail = f"service={row[0]} is_sent={row[1]} error={row[2]}"
if row[2]:
return False, detail
if row[1]:
return True, detail
if time.time() >= deadline:
return None, detail or "발신 기록이 아직 안 보임"
time.sleep(1)
def _parse_balance_text(text: str) -> dict[str, int]:
"""가희 답신 텍스트에서 '라벨 : 금액' 페어 추출. 빈 dict면 포맷 오류.
@@ -234,6 +291,9 @@ def _maybe_send_reminder(state: dict, dry_run: bool) -> bool:
if not dry_run:
state["last_sent_month"] = month_tag
# 답신 감시 시작점. 발송 성공 여부는 여기서 알 수 없으므로 답신으로 확인한다.
state["last_sent_at"] = _utc_z(now)
state.pop("reply_alert_for", None)
_save_json(STATE_FILE, state)
notify.send(
f"📨 <b>가희 잔액 리마인더 발신</b>\n"
@@ -243,6 +303,51 @@ def _maybe_send_reminder(state: dict, dry_run: bool) -> bool:
return True
def _check_reply_overdue(state: dict, dry_run: bool):
"""발신 후 REPLY_GRACE_DAYS 가 지나도 답신이 없으면 관리자님께 1회 알린다.
발송 실패를 직접 감지할 수 없어서(위 _send_imessage 주석) 결과로 대신 본다.
2026-07-25 처럼 미발송이 조용히 넘어가는 걸 막는 유일한 안전망이다.
⚠️ 재발신은 자동으로 하지 않는다 — 가희님이 늦게 답하는 경우 중복 독촉이 되고,
자동 발송 트리거를 늘리지 않는다는 방침에도 어긋난다. 알림 받고 --send-now 로 수동.
"""
sent_at = state.get("last_sent_at")
if not sent_at:
return
try:
sent_dt = datetime.fromisoformat(sent_at.replace("Z", "+00:00"))
except ValueError:
return
if _now_kst() - sent_dt < timedelta(days=REPLY_GRACE_DAYS):
return
if state.get("reply_alert_for") == sent_at:
return # 이번 발신 건은 이미 알림
handle_norm = _normalize(_gahee_handle())
chat_id = _find_gahee_chat_id(handle_norm)
if chat_id is None:
return
if any(_msg_from_gahee(m) for m in _imsg_history(chat_id, sent_at)):
# 답신 도착 — 감시 종료
if not dry_run:
state.pop("last_sent_at", None)
state.pop("reply_alert_for", None)
_save_json(STATE_FILE, state)
return
days = (_now_kst() - sent_dt).days
notify.send(
f"⚠️ <b>가희 잔액 리마인더 — {days}일째 답신 없음</b>\n"
f"발신: {notify.escape_html(sent_at)}\n"
f"발송 자체가 실패했을 수 있어요 (imsg 는 발송 실패를 알려주지 않아요).\n"
f"재발신: <code>python3 gahee_reminder.py --send-now</code>"
)
if not dry_run:
state["reply_alert_for"] = sent_at
_save_json(STATE_FILE, state)
def _msg_text(msg: dict) -> str:
"""imsg history 메시지 객체에서 본문 텍스트 추출. 키 변형(text/body) 모두 시도."""
for k in ("text", "body", "message", "content"):
@@ -402,9 +507,61 @@ def run(webhook_url: str, post_fn, dry_run: bool = False):
_maybe_send_reminder(state, dry_run=dry_run)
# 발신 직후 같은 사이클에서 폴링은 무의미하지만, 다음 사이클부터 자연스럽게 시작됨.
_poll_replies(state, webhook_url, post_fn, dry_run=dry_run)
# 발송 실패가 조용히 넘어가지 않게 하는 안전망. 유예 전이면 API 콜 없이 즉시 반환.
_check_reply_overdue(state, dry_run=dry_run)
except Exception as e:
print(f"❌ gahee_reminder.run 예외 (격리됨): {e}")
try:
notify.send(f"❌ <b>가희 모듈 예외</b>\n{notify.escape_html(str(e))[:500]}")
except Exception:
pass
def send_now(dry_run: bool = False) -> int:
"""날짜 게이트를 무시하고 리마인더 1회 발신 (수동용).
발송 실패로 그 달을 통째로 놓쳤을 때 복구 수단. last_sent_month 는 건드리지 않아
정규 발신(매월 25일)이 그대로 살아있다.
"""
state = _load_json(STATE_FILE, None)
if not state:
print("❌ 가희 state 파일 없음", file=sys.stderr)
return 1
handle = _gahee_handle()
template = state.get("message_template") or "가계부 업데이트 날이에요. 계좌잔액 보내주시면 자동으로 반영됩니다."
before = time.time()
if not _send_imessage(handle, template, dry_run=dry_run):
return 1
if dry_run:
print(f"[dry-run] 수신자={handle} · last_sent_month 는 그대로 유지")
return 0
state["last_sent_at"] = _utc_z(_now_kst())
state.pop("reply_alert_for", None)
_save_json(STATE_FILE, state)
verdict, detail = _verify_sent(handle, before)
if verdict is True:
print(f"✅ 발송 확인 — {detail}")
return 0
if verdict is False:
print(f"❌ 발송 실패 — {detail}", file=sys.stderr)
state.pop("last_sent_at", None)
_save_json(STATE_FILE, state)
return 1
# chat.db 를 못 읽는 컨텍스트이거나 아직 판정 전. 실패로 단정하지 않는다.
print(f"⚠️ 발송 확인 불가 — {detail}")
print(f" 답신이 {REPLY_GRACE_DAYS}일간 없으면 골디가 텔레그램으로 알립니다.")
return 0
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="가희 잔액 리마인더 (수동 발신용)")
ap.add_argument("--send-now", action="store_true",
help="날짜 게이트 무시하고 1회 발신. 정규 25일 발신은 유지")
ap.add_argument("--dry-run", action="store_true", help="실제 발송 없이 계획만 출력")
args = ap.parse_args()
if args.send_now:
sys.exit(send_now(dry_run=args.dry_run))
ap.print_help()
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
from __future__ import annotations
"""imsg CLI 호출 공용 래퍼.
호출부가 stdout JSON 파싱하고 stderr·returncode 버리면, FDA(전체 디스크 접근 권한)
누락 같은 권한 오류가 `Expecting value: line 1 column 1 (char 0)` 파싱 에러로 둔갑한다.
2026-08-03 골디 오진 사고의 원인 실제 원인은 homebrew node 업그레이드로 게이트웨이
바이너리 경로가 바뀌어 FDA 승인이 무효화된 것이었다. 판정을 한곳에 모아 사유를 그대로 노출한다.
"""
import json
import subprocess
# imsg 가 권한 거부 시 내는 문구 (imsg 0.5.0 기준).
FDA_MARKERS = ("Full Disk Access", "Cannot access Messages database")
FDA_HINT = (
"Messages DB 접근 거부 — 전체 디스크 접근 권한(FDA) 누락. "
"skills/whooing-sync/SKILL.md 의 'Full Disk Access' 섹션 참고"
)
# `imsg chats` 기본값은 최근 20건이라, 오래 조용한 발신번호(현대카드 SMS·신한은행 등)가
# 목록에서 빠진다. 그 상태로 chat id 캐시를 다시 만들면 해당 carrier 수집이 조용히 끊긴다.
# chat.db 전체 대화가 130개 수준이라 넉넉히 잡는다. (2026-08-03 실측으로 발견)
CHATS_LIMIT = 200
def _reason(stderr: str, stdout: str, fallback: str) -> str:
"""실패 사유 한 줄. FDA 마커는 stdout/stderr 어느 쪽에 있어도 잡는다."""
err = (stderr or "").strip()
out = (stdout or "").strip()
if any(m in err or m in out for m in FDA_MARKERS):
return FDA_HINT
detail = err or out
if detail:
return " ".join(detail.split())[:200]
return fallback
def run_json(args: list, timeout: int) -> "tuple[list, str | None]":
"""imsg 실행 → (rows, error). 성공 시 (list[dict], None), 실패 시 ([], 사유).
결과 0건과 실패를 구분한다 리스트지만 실패에만 error 붙는다.
"""
try:
p = subprocess.run(
["imsg"] + list(args),
capture_output=True, text=True, timeout=timeout,
)
except FileNotFoundError:
return [], "imsg CLI가 설치되어 있지 않습니다"
except subprocess.TimeoutExpired:
return [], f"타임아웃 ({timeout}s)"
except Exception as e:
return [], f"{type(e).__name__}: {e}"
out = p.stdout or ""
if p.returncode != 0:
return [], _reason(p.stderr, out, f"rc={p.returncode}")
if not out.strip():
# stderr 만 있고 stdout 이 비면 실패. 둘 다 비면 정상적으로 결과 0건.
if (p.stderr or "").strip():
return [], _reason(p.stderr, "", "빈 출력")
return [], None
rows = []
for line in out.splitlines():
line = line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
return [], _reason(p.stderr, line, "JSON 파싱 실패")
return rows, None
def chats(timeout: int) -> "tuple[list, str | None]":
"""대화 목록 조회 → (rows, error). CHATS_LIMIT 만큼 넓게 훑는다."""
return run_json(["chats", "--limit", str(CHATS_LIMIT), "--json"], timeout)
@@ -179,15 +179,16 @@ def parse_kakao_bank(raw: str, created_at: str) -> "dict | None":
return result
# 현대카드 승인 형식 (취소는 별도 샘플 확보 후 추가):
# 현대카드 블루멤버스 승인
# 방*원
# 5,170원 일시불
# 04/25 11:34
# 쿠팡
# 누적1,466,011원
HYUNDAI_CARD_APPROVE_RE = re.compile(
r"^\s*현대카드[^\n]*승인\s*\n"
# 현대카드 승인/취소 형식 (취소도 헤더 단어만 다르고 본문 구조는 같다):
# 현대카드 블루멤버스 승인 현대카드 블루멤버스 취소
# 방*원 방*원
# 5,170원 일시불 150,000원 일시불
# 04/25 11:34 08/02 13:06
# 쿠팡 현대가스
# 누적1,466,011원 누적4,696,608원
# 앞의 `현대카드[^\n]*` 가 greedy 라 "승인취소" 형태도 kind=취소 로 잡힌다.
HYUNDAI_CARD_RE = re.compile(
r"^\s*현대카드[^\n]*(?P<kind>승인|취소)\s*\n"
r"(?P<who>[^\n]+)\s*\n"
r"(?P<amount>[\d,]+)\s*원[^\n]*\n"
r"\d{2}/\d{2}\s+\d{2}:\d{2}\s*\n"
@@ -198,8 +199,8 @@ HYUNDAI_CARD_APPROVE_RE = re.compile(
# RCS/MAAP 형식은 한 줄로 들어온다:
# [Web발신] 현대카드 블루멤버스 승인 방*원 84,000원 일시불 05/16 11:57 네이버페이 누적820,000원
HYUNDAI_CARD_APPROVE_INLINE_RE = re.compile(
r"현대카드[^\n]*승인\s+"
HYUNDAI_CARD_INLINE_RE = re.compile(
r"현대카드[^\n]*(?P<kind>승인|취소)\s+"
r"(?P<who>\S+)\s+"
r"(?P<amount>[\d,]+)\s*원[^\n]*?"
r"\d{2}/\d{2}\s+\d{2}:\d{2}\s+"
@@ -211,13 +212,13 @@ HYUNDAI_CARD_APPROVE_INLINE_RE = re.compile(
def parse_hyundai_card(raw: str, created_at: str) -> "dict | None":
text = _normalize(raw).replace("[Web발신]", "").strip()
m = HYUNDAI_CARD_APPROVE_RE.search(text) or HYUNDAI_CARD_APPROVE_INLINE_RE.search(text)
m = HYUNDAI_CARD_RE.search(text) or HYUNDAI_CARD_INLINE_RE.search(text)
if not m:
return None
amount = int(m.group("amount").replace(",", ""))
merchant = re.sub(r"\s+", " ", m.group("merchant").strip()) or "(가맹점없음)"
result = {
"kind": "card_approval",
"kind": "card_cancel" if m.group("kind") == "취소" else "card_approval",
"entry_date": _iso_to_kst_date(created_at),
"amount": amount,
"merchant": merchant,
@@ -12,7 +12,6 @@ from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
import urllib.error
import urllib.parse
@@ -22,6 +21,7 @@ from pathlib import Path
from zoneinfo import ZoneInfo
import parsers as sms_parsers
import imsg_cli
import notify
import whooing_balance
import gahee_reminder
@@ -209,7 +209,11 @@ MONEY_AMOUNT_RE = re.compile(r"(?:\d[\d,]*\s*원|(?:입금|출금)\s+\d[\d,]*)")
# 주유소 카드 결제는 보통 150,000원 선승인 → 실제 주유금액 승인 → 150,000원 승인취소 순서로 온다.
# 선승인/취소는 후잉에 기록하지 않고, 실제 결제만 차량유지비/주유비로 남긴다.
FUEL_KEYWORDS = ("석유", "주유소", "주유")
# "현대가스"·"한경에너"는 상호에 주유/석유가 안 들어가는 충전소라 직접 등록 (2026-08-03 관리자님 확인).
# 새 주유소를 만나면 여기와 whooing_merchant_map.json contains **양쪽에** 상호를 추가한다 —
# 이 상수는 선승인/취소 스킵, merchant_map 은 실제 주유 결제 분류로 한 쌍이다.
# ⚠️ 한경에너는 선승인이 149,900원이라 FUEL_PREAUTH_AMOUNT 정확일치엔 안 걸린다(분류만 적용).
FUEL_KEYWORDS = ("석유", "주유소", "주유", "현대가스", "한경에너")
FUEL_PREAUTH_AMOUNT = 150_000
@@ -220,7 +224,7 @@ def _is_fuel_text(text: str) -> bool:
def _should_skip_fuel_preauth(parsed: dict | None, text: str) -> bool:
"""주유소 15만원 선승인/취소 스킵.
parsed 있으면 구조화 필드 기준으로 판단한다. 현대카드 취소처럼 아직 파서가 없는 형식은
parsed 있으면 구조화 필드 기준으로 판단한다. 파서가 읽은 형식은 폴백으로
raw 텍스트에서 '취소' + '150,000원' + 주유 키워드 조합만 보수적으로 스킵한다.
"""
if parsed:
@@ -281,39 +285,33 @@ def resolve_chat_ids(carriers):
needed = set(carriers.keys())
if needed.issubset(cached_keys):
return {k: cached[k] for k in needed}
try:
chats_raw = subprocess.run(
["imsg", "chats", "--json"],
capture_output=True, text=True, timeout=IMSG_TIMEOUT,
)
chats = [json.loads(l) for l in chats_raw.stdout.splitlines() if l.strip()]
except FileNotFoundError:
print("❌ imsg CLI가 설치되어 있지 않습니다.", file=sys.stderr)
sys.exit(3)
except Exception as e:
print(f"❌ imsg chats 실행 실패: {e}", file=sys.stderr)
chats, err = imsg_cli.chats(IMSG_TIMEOUT)
if err:
print(f"❌ imsg chats 실행 실패: {err}", file=sys.stderr)
sys.exit(3)
result = {}
for sender in carriers.keys():
ids = [c["id"] for c in chats if c.get("identifier") == sender and c.get("id") is not None]
result[sender] = ids
# 전 carrier 가 빈 리스트면 조회 자체가 실패한 것으로 본다. 이 상태를 캐시에 저장하면
# 이후 모든 실행(launchd 포함)이 캐시에 적중해 조용히 0건 수집을 영구 반복한다.
# 일부만 비는 건 정상 — 메시지 이력이 없는 carrier 가 있을 수 있다.
if not any(result.values()):
print("❌ imsg chats 결과에 매핑된 발신번호가 하나도 없습니다 — 캐시 저장 안 함", file=sys.stderr)
sys.exit(3)
save_json(CHAT_IDS_CACHE, result)
return result
def fetch_messages(chat_id, since):
cmd = ["imsg", "history", "--chat-id", str(chat_id), "--limit", str(HISTORY_LIMIT), "--json"]
args = ["history", "--chat-id", str(chat_id), "--limit", str(HISTORY_LIMIT), "--json"]
if since:
cmd += ["--start", since]
try:
raw = subprocess.run(cmd, capture_output=True, text=True, timeout=IMSG_TIMEOUT)
return [json.loads(l) for l in raw.stdout.splitlines() if l.strip()]
except subprocess.TimeoutExpired:
print(f"⚠️ chat-id {chat_id} history 타임아웃", file=sys.stderr)
return []
except Exception as e:
print(f"⚠️ chat-id {chat_id} history 실패: {e}", file=sys.stderr)
args += ["--start", since]
rows, err = imsg_cli.run_json(args, IMSG_TIMEOUT)
if err:
print(f"⚠️ chat-id {chat_id} history 실패: {err}", file=sys.stderr)
return []
return rows
# --- 룰 엔진 (whooing_overrides.json) -----------------------------------------
@@ -518,7 +516,19 @@ def build_structured(parsed, sender_info, accounts, merchant_map):
return {**base, "left": "기타비용", "right": carrier_acct}
if kind == "card_cancel":
return None # raw 폴백 (후잉이 원거래 찾아 상쇄)
# 승인의 역분개. 승인이 {left: 비용, right: 카드} 이므로 좌우를 뒤집는다.
# 부분취소(취소액 != 승인액)도 취소 문자 금액 그대로 상쇄되어 맞는다.
if rule:
left = rule.get("left")
if left:
out = {**base, "left": rule.get("right") or carrier_acct, "right": left}
if rule.get("item"):
out["item"] = rule["item"]
return out
return None
if cat:
return {**base, "left": carrier_acct, "right": cat}
return {**base, "left": carrier_acct, "right": "기타비용"}
if kind == "withdrawal":
if rule: