"""가희 주머니 잔액 자동 갱신. 매월 25일 10:00 KST 이후 첫 sync 사이클에서 가희님께 iMessage 리마인더 발신, 이후 사이클부터 가희 답신을 폴링해 후잉 `가희주머니` 차액 자동 분개. 운영 정책 (관리자님 결정): - 발신: 매월 25일 10:00 이후, 이번 달 미발신 상태일 때 1회 (idempotent) - 수신 텍스트: '라벨 : 금액' 형식 정규식 파싱 → 즉시 자동 분개 (안전장치 없음) - 수신 이미지(첨부 포함): 텔레그램 알림만, 분개 안 함 (관리자님 직접 처리) - 텔레그램 보고: 발신 직후, 포맷 오류, 분개 완료 3시점 - launchd 별도 plist 없음 — whooing-sync 사이클(매시 0/15/30/45분)에 통합 """ from __future__ import annotations import json import re import subprocess from datetime import datetime, timedelta from pathlib import Path from zoneinfo import ZoneInfo import notify import whooing_balance KST = ZoneInfo("Asia/Seoul") 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~20자. # 콜론 구분 입력은 정수 전체를 허용하고, 공백 구분 입력은 천 단위 콤마가 있을 때만 허용한다. # 일반 안내 문자의 "오전 09" 같은 표현을 잔액으로 오인하지 않기 위함. BALANCE_COLON_RE = re.compile( r"([가-힣A-Za-z][가-힣A-Za-z0-9()\s]{0,18}?)\s*[::]\s*([\d,]+)" ) BALANCE_SPACE_RE = re.compile( r"([가-힣A-Za-z][가-힣A-Za-z0-9()\s]{0,18}?)\s+(\d{1,3}(?:,\d{3})+)" ) # 가희님이 본인 확인용으로 적는 라벨 — dict에 포함되면 차액 계산 망가지므로 스킵. SKIP_LABELS = {"합계", "총합", "소계", "total", "sum"} # 가희님 현금성 계좌만 허용. 안내 문자 숫자나 주식 계좌를 잔액으로 오인하지 않기 위한 방어선. # 새 계좌가 생기면 이 목록에 먼저 추가한 뒤 자동 반영한다. ALLOWED_LABELS = {"국민", "국민(청약)", "신한", "기업", "하나", "하나(보험)", "카카오뱅크"} def _load_json(path: Path, default): try: return json.loads(path.read_text()) except FileNotFoundError: return default def _save_json(path: Path, data): path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n") def _now_kst() -> datetime: return datetime.now(KST) def _gahee_handle() -> str: cred = _load_json(GAHEE_CRED, {}) handle = (cred.get("handle") or "").strip() if not handle: raise RuntimeError("credentials/gahee_imessage.json 의 handle 이 비었습니다.") return handle def _normalize(handle: str) -> str: """+821055595428 / 010-5559-5428 / 01055595428 등을 01055595428 정규형으로.""" digits = re.sub(r"\D", "", handle or "") if digits.startswith("82"): digits = digits[2:] if digits and not digits.startswith("0"): digits = "0" + digits return digits 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}") return [] def _find_gahee_chat_id(handle_norm: str) -> int | None: for c in _imsg_chats(): ident = _normalize(str(c.get("identifier", ""))) if ident == handle_norm: return c.get("id") return 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"] 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}") return [] def _send_imessage(handle: str, text: str, dry_run: bool) -> bool: if dry_run: print(f"[dry-run] imsg send --to {handle} --text {text!r} --service sms") return True try: result = subprocess.run( ["imsg", "send", "--to", handle, "--text", text, "--service", "sms"], capture_output=True, text=True, timeout=IMSG_TIMEOUT, ) if result.returncode != 0: print(f"❌ 가희 imsg send 실패 rc={result.returncode}: {result.stderr[:200]}") return False return True except Exception as e: print(f"❌ 가희 imsg send 예외: {e}") return False def _parse_balance_text(text: str) -> dict[str, int]: """가희 답신 텍스트에서 '라벨 : 금액' 페어 추출. 빈 dict면 포맷 오류. 같은 라벨이 여러 번 나오면 마지막 값 사용 (수정 답신 케이스). """ out: dict[str, int] = {} for pattern in (BALANCE_COLON_RE, BALANCE_SPACE_RE): for m in pattern.finditer(text or ""): label = m.group(1).strip() if label.lower() in SKIP_LABELS: continue if re.search(r"\d$", label): continue # "오전 09:04" 같은 시간 표기 if label not in ALLOWED_LABELS: continue amount_s = m.group(2).replace(",", "") try: amount = int(amount_s) except ValueError: continue out[label] = amount return out def _format_memo(balances: dict[str, int], total: int, prev: int) -> str: parts = " ".join(f"{k}:{v:,}" for k, v in balances.items()) return f"[자동] 가희 잔액 갱신 | {parts} | 합계 {total:,} (이전 {prev:,})" def _apply_balance(balances: dict[str, int], state: dict, webhook_url: str, post_fn, dry_run: bool) -> tuple[bool, str]: """파싱된 잔액 dict → 후잉 가희주머니 차액 분개. 반환 (ok, 사용자_보고용_요약).""" if not balances: return False, "포맷 오류 — '라벨 : 금액' 페어 0건" total = sum(balances.values()) acct_name = state.get("whooing_account_name", "가희주머니") income = state.get("income_account", "가희비밀주머니_수익") expense = state.get("expense_account", "기타비용") try: whoo = whooing_balance.fetch_balances([acct_name], sides=("assets",)) except Exception as e: return False, f"후잉 잔고 조회 실패: {e}" prev = int(whoo.get(acct_name, 0)) diff = total - prev if diff == 0: return True, f"차액 0원 — 분개 생략. 합계 {total:,}원 (후잉과 일치)" entry_date = _now_kst().strftime("%Y%m%d") memo = _format_memo(balances, total, prev) if diff > 0: payload = { "entry_date": entry_date, "money": diff, "item": "가희 잔액 갱신", "left": acct_name, "right": income, "memo": memo[:200], } direction = f"증가 +{diff:,}원 ({income} → {acct_name})" else: payload = { "entry_date": entry_date, "money": abs(diff), "item": "가희 잔액 갱신", "left": expense, "right": acct_name, "memo": memo[:200], } direction = f"감소 -{abs(diff):,}원 ({acct_name} → {expense})" ok, status, body = post_fn(webhook_url, payload, dry_run=dry_run) if not ok: return False, f"후잉 분개 실패 status={status} body={body[:200]}" return True, f"합계 {total:,}원 (이전 {prev:,}) · {direction}" def _maybe_send_reminder(state: dict, dry_run: bool) -> bool: """발신 게이트. 트리거 조건 충족 시 1회 발신 후 state 업데이트. 발신 여부 반환. Catch-up 정책 (2026-05-21): `day >= target_day` 이면서 이번 달 미발신이면 발신. 25일에 Mac이 꺼져 있었어도 그 달 내 아무 날에 켜면 그 시점에 발신. 다음 달로 넘어가면 포기. """ now = _now_kst() target_day = int(state.get("send_day_of_month", 25)) target_hour = int(state.get("send_hour_kst", 10)) month_tag = now.strftime("%Y-%m") if now.day < target_day: return False if now.day == target_day and now.hour < target_hour: return False if state.get("last_sent_month") == month_tag: return False # 이번 달 이미 보냄 handle = _gahee_handle() template = state.get("message_template") or "가계부 업데이트 날이에요. 계좌잔액 보내주시면 자동으로 반영됩니다." ok = _send_imessage(handle, template, dry_run=dry_run) if not ok: notify.send(f"❌ 가희 잔액 리마인더 발신 실패\n월: {month_tag}\n수신자: {handle}") return False if not dry_run: state["last_sent_month"] = month_tag _save_json(STATE_FILE, state) notify.send( f"📨 가희 잔액 리마인더 발신\n" f"월: {notify.escape_html(month_tag)} · 수신: {notify.escape_html(handle)}\n" f"답신 들어오면 자동 분개 후 다시 보고드릴게요." ) return True def _msg_text(msg: dict) -> str: """imsg history 메시지 객체에서 본문 텍스트 추출. 키 변형(text/body) 모두 시도.""" for k in ("text", "body", "message", "content"): v = msg.get(k) if isinstance(v, str) and v.strip(): return v return "" def _msg_has_attachment(msg: dict) -> bool: """imsg --attachments 결과에서 첨부 존재 여부. 키 변형 보호적 처리.""" for k in ("attachments", "attachment", "files"): v = msg.get(k) if isinstance(v, list) and len(v) > 0: return True if isinstance(v, dict) and v: return True return False def _msg_from_gahee(msg: dict) -> bool: """가희(상대)가 보낸 메시지인지. is_from_me=true 또는 sender=관리자 본인이면 제외.""" if msg.get("is_from_me") is True: return False if msg.get("from_me") is True: return False # 일부 imsg 빌드는 sender/handle 필드를 줌 — 가희 normalized와 매칭 return True def _poll_replies(state: dict, webhook_url: str, post_fn, dry_run: bool): """가희 답신 폴링·분개. 정책 (2026-05-21): - 한 사이클에 가희 텍스트 메시지가 여러 통이면 **마지막 1통만 분개**. 이전 텍스트는 워터마크만 갱신. 가희가 수정·재발송했을 때 첫 메시지로 잘못 분개되는 사고 방지. - 이미지·빈 메시지·우리 발신 메시지는 즉시 워터마크 갱신 (재처리 무의미). - 후잉 API 일시 실패 등 **분개 실패** 시 워터마크 자체를 갱신하지 않아 다음 사이클에서 재시도. 포맷 오류는 재시도해도 결과 같으니 워터마크 갱신. - 분개 실패 시 같은 사이클에 보낸 이미지·이전 텍스트 알림은 다음 사이클에 중복 발송될 수 있음 (실무상 허용 — 후잉 다운은 드물고 회복 빠름). """ handle_norm = _normalize(_gahee_handle()) chat_id = _find_gahee_chat_id(handle_norm) if chat_id is None: # 아직 가희와 대화방 없음 (첫 발신 전). 폴링할 게 없음. return last_at = state.get("last_processed_message_at") # 최초 폴링이면 발신 시각 직후부터 — 가희 답신만 잡고 과거 메시지 무시. # state에 last_sent_month만 있고 시각이 없으면 안전하게 발신일 00:00 KST부터. if not last_at and state.get("last_sent_month"): y, m = state["last_sent_month"].split("-") target_day = int(state.get("send_day_of_month", 25)) start_dt = datetime(int(y), int(m), target_day, tzinfo=KST) last_at = start_dt.astimezone(ZoneInfo("UTC")).isoformat().replace("+00:00", "Z") msgs = _imsg_history(chat_id, last_at) if not msgs: return # created_at 오름차순 정렬 보장 msgs.sort(key=lambda x: x.get("created_at") or x.get("date") or "") # 1차 스캔: 이미지·빈 메시지·우리 발신은 그 자리에서 처리, 가희 텍스트만 따로 모음. new_watermark = last_at gahee_texts: list[tuple[str, str]] = [] # (ts, text) for msg in msgs: ts = msg.get("created_at") or msg.get("date") or "" if last_at and ts and ts <= last_at: continue if not _msg_from_gahee(msg): new_watermark = ts or new_watermark continue text = _msg_text(msg) has_attach = _msg_has_attachment(msg) if has_attach: preview = notify.escape_html((text[:80] + "…") if len(text) > 80 else text) notify.send( f"🖼️ 가희님이 이미지로 답신하셨어요\n" f"자동 분개는 텍스트만 지원돼요. 텍스트로 다시 요청하거나 직접 입력해주세요.\n" f"본문: {preview or '(없음)'}" ) new_watermark = ts or new_watermark continue if not text.strip(): new_watermark = ts or new_watermark continue gahee_texts.append((ts, text)) # 2차 처리: 텍스트가 여러 통이면 마지막만 분개. 이전 텍스트는 워터마크만 갱신. if not gahee_texts: if not dry_run and new_watermark and new_watermark != last_at: state["last_processed_message_at"] = new_watermark _save_json(STATE_FILE, state) return if len(gahee_texts) >= 2: # 이전 메시지들의 워터마크 갱신 (스킵 알림) skipped = len(gahee_texts) - 1 prev_preview = notify.escape_html(gahee_texts[0][1][:80]) notify.send( f"ℹ️ 가희 답신 {len(gahee_texts)}건 도착 — 마지막 메시지만 분개\n" f"이전 {skipped}건은 수정/재발송으로 간주하고 분개하지 않아요.\n" f"첫 메시지 일부: {prev_preview}" ) for prev_ts, _ in gahee_texts[:-1]: new_watermark = prev_ts or new_watermark last_ts, last_text = gahee_texts[-1] parsed = _parse_balance_text(last_text) if not parsed: notify.send( f"⚠️ 가희 잔액 — 포맷 오류로 분개 중단\n" f"'라벨 : 금액' 페어 0건. 가희님께 다시 요청하거나 직접 입력해주세요.\n" f"원문: {notify.escape_html(last_text[:300])}" ) # 포맷 오류는 재처리해도 결과 동일 — 워터마크 갱신 new_watermark = last_ts or new_watermark else: ok, summary = _apply_balance(parsed, state, webhook_url, post_fn, dry_run=dry_run) if ok: notify.send( f"✅ 가희 잔액 갱신 완료\n" f"{notify.escape_html(summary)}" ) new_watermark = last_ts or new_watermark else: # 후잉 API 실패 등 — 워터마크 갱신 안 함, 다음 사이클 재시도 notify.send( f"❌ 가희 잔액 갱신 실패 — 다음 사이클 재시도\n" f"{notify.escape_html(summary)}\n" f"원문: {notify.escape_html(last_text[:300])}" ) # 새 워터마크는 마지막 텍스트 메시지 직전(=이전 텍스트의 ts 또는 last_at)으로 롤백. # 같은 사이클에 이미 갱신된 이미지·이전 텍스트의 워터마크도 안전하게 함께 롤백된다. new_watermark = last_at # 분개 실패 시 워터마크 안 갱신 if not dry_run and new_watermark and new_watermark != last_at: state["last_processed_message_at"] = new_watermark _save_json(STATE_FILE, state) def run(webhook_url: str, post_fn, dry_run: bool = False): """whooing_sync.main() 끝에서 호출. 발신 게이트 + 응답 폴링. 실패해도 외부에 예외 전파 X — 결제 sync 흐름을 막지 않는다. """ try: state = _load_json(STATE_FILE, None) if not state: print("⚠️ 가희 state 파일 없음 — 스킵") return _maybe_send_reminder(state, dry_run=dry_run) # 발신 직후 같은 사이클에서 폴링은 무의미하지만, 다음 사이클부터 자연스럽게 시작됨. _poll_replies(state, webhook_url, post_fn, dry_run=dry_run) except Exception as e: print(f"❌ gahee_reminder.run 예외 (격리됨): {e}") try: notify.send(f"❌ 가희 모듈 예외\n{notify.escape_html(str(e))[:500]}") except Exception: pass