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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user