auto: 일일 백업 2026-08-04 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user