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()