Files
openclaw/agents/budget/workspace/skills/whooing-sync/scripts/gahee_reminder.py
T
hyowons 7e7d759b34 auto: 일일 백업 2026-08-04 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 02:00:05 +09:00

568 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""가희 주머니 잔액 자동 갱신.
매월 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 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
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회 알린다.
# 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" 같은 표현을 잔액으로 오인하지 않기 위함.
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]:
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:
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]:
args = ["history", "--chat-id", str(chat_id),
"--limit", str(HISTORY_LIMIT), "--attachments", "--json"]
if start_iso:
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 imessage")
return True
try:
result = subprocess.run(
["imsg", "send", "--to", handle, "--text", text,
"--service", "imessage"],
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 _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면 포맷 오류.
같은 라벨이 여러 번 나오면 마지막 값 사용 (수정 답신 케이스).
"""
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"❌ <b>가희 잔액 리마인더 발신 실패</b>\n월: {month_tag}\n수신자: {handle}")
return False
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"
f"월: {notify.escape_html(month_tag)} · 수신: {notify.escape_html(handle)}\n"
f"답신 들어오면 자동 분개 후 다시 보고드릴게요."
)
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"):
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"🖼️ <b>가희님이 이미지로 답신하셨어요</b>\n"
f"자동 분개는 텍스트만 지원돼요. 텍스트로 다시 요청하거나 직접 입력해주세요.\n"
f"<i>본문:</i> {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"️ <b>가희 답신 {len(gahee_texts)}건 도착 — 마지막 메시지만 분개</b>\n"
f"이전 {skipped}건은 수정/재발송으로 간주하고 분개하지 않아요.\n"
f"<i>첫 메시지 일부:</i> {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"⚠️ <b>가희 잔액 — 포맷 오류로 분개 중단</b>\n"
f"'라벨 : 금액' 페어 0건. 가희님께 다시 요청하거나 직접 입력해주세요.\n"
f"<i>원문:</i> {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"✅ <b>가희 잔액 갱신 완료</b>\n"
f"{notify.escape_html(summary)}"
)
new_watermark = last_ts or new_watermark
else:
# 후잉 API 실패 등 — 워터마크 갱신 안 함, 다음 사이클 재시도
notify.send(
f"❌ <b>가희 잔액 갱신 실패 — 다음 사이클 재시도</b>\n"
f"{notify.escape_html(summary)}\n"
f"<i>원문:</i> {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)
# 발송 실패가 조용히 넘어가지 않게 하는 안전망. 유예 전이면 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()