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
@@ -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: