#!/usr/bin/env python3 """whooing-manual: iMessage 없이 후잉 웹훅에 직접 한 건 등록. 두 가지 모드: structured: --item/--money/--left/--right [--date YYYYMMDD] [--memo] raw: --message "원문 그대로" (후잉 자체 파서에 위임) structured 모드는 left/right가 whooing_accounts.json 차트에 존재하는지 검증한다. 차트에 없는 계정명은 후잉이 거부하므로 사전에 막는다. synced state는 건드리지 않는다 (SMS 동기화 흐름과 독립). 실패 시 whooing_failures.json에 기록. """ import argparse import json import sys import urllib.error import urllib.parse import urllib.request from datetime import datetime from pathlib import Path from zoneinfo import ZoneInfo import notify KST = ZoneInfo("Asia/Seoul") ROOT = Path("/Users/snowoyh/.openclaw") WORKSPACE = ROOT / "agents" / "budget" / "workspace" CREDENTIALS = ROOT / "credentials" / "whooing.json" ACCOUNTS_FILE = WORKSPACE / "state" / "whooing_accounts.json" FAILURES_FILE = WORKSPACE / "state" / "whooing_failures.json" # 후잉 웹훅은 HTTP 200을 반환하면서 본문에 "fail" / "Error : ..." 를 줄 수 있어, 본문 검증 필수. SUCCESS_BODY = "done" def load_json(path, default): if path.exists(): try: return json.loads(path.read_text()) except Exception: return default return default def save_json(path, data): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, ensure_ascii=False, indent=2)) def load_webhook_url(): cred = load_json(CREDENTIALS, {}) url = (cred.get("webhook_url") or "").strip() if not url: print("❌ credentials/whooing.json 의 webhook_url 이 비어 있습니다.", file=sys.stderr) sys.exit(2) return url def all_accounts(): data = load_json(ACCOUNTS_FILE, {}) names = set() for bucket in (data.get("categories") or {}).values(): for name in bucket: names.add(name) return names def post(webhook_url, payload, dry_run=False): """후잉 POST 결과 표준화. 반환: (ok: bool, status: int, body: str). HTTP 2xx + body == "done" 만 성공. 그 외(HTTP 200 + "fail" 포함) 모두 실패.""" if dry_run: return True, 200, "dry-run" # 후잉은 '+' 를 공백으로 디코드하지 않음. quote_via=quote 로 공백을 %20 으로 보내야 함. encoded = urllib.parse.urlencode(payload, quote_via=urllib.parse.quote).encode() req = urllib.request.Request(webhook_url, data=encoded, method="POST") # 후잉 파서는 '; charset=utf-8' 가 붙으면 거절. 순수 미디어타입만 보냄. req.add_header("Content-Type", "application/x-www-form-urlencoded") try: with urllib.request.urlopen(req, timeout=15) as r: body = r.read().decode("utf-8", errors="replace") ok = (200 <= r.status < 300) and body.strip().lower().startswith(SUCCESS_BODY) return ok, r.status, body except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="replace") if hasattr(e, "read") else str(e) return False, e.code, body except Exception as e: return False, 0, str(e) def record_failure(payload, status, body, mode): failures = load_json(FAILURES_FILE, {"failures": []}) failures.setdefault("failures", []).append({ "source": "manual", "mode": mode, "payload": payload, "status": status, "response": body[:500], "failed_at": datetime.now(KST).isoformat(), }) save_json(FAILURES_FILE, failures) def main(): ap = argparse.ArgumentParser(description="후잉 수동 등록 (iMessage 없이 직접 POST)") ap.add_argument("--message", help="raw 모드: 후잉 파서에 맡길 원문") ap.add_argument("--item", help="structured: 항목명 (예: 스타벅스)") ap.add_argument("--money", type=int, help="structured: 금액 (원, 양수)") ap.add_argument("--left", help="structured: 차변 계정 (후잉 차트명)") ap.add_argument("--right", help="structured: 대변 계정 (후잉 차트명)") ap.add_argument("--date", help="structured: YYYYMMDD (기본: 오늘 KST)") ap.add_argument("--memo", default="", help="structured: 메모") ap.add_argument("--dry-run", action="store_true", help="POST 안 하고 계획만 출력") ap.add_argument("--no-validate", action="store_true", help="차트 검증 생략 (위험)") args = ap.parse_args() if args.message and any([args.item, args.money, args.left, args.right]): print("❌ --message 와 structured 인자(--item/--money/--left/--right)는 함께 쓸 수 없습니다.", file=sys.stderr) sys.exit(2) webhook_url = load_webhook_url() if not args.dry_run else "(dry-run)" if args.message: payload = {"message": args.message} mode = "raw" display = args.message[:60].replace("\n", " ") else: missing = [f for f in ("item", "money", "left", "right") if not getattr(args, f)] if missing: print(f"❌ structured 필수 인자 누락: {', '.join('--'+m for m in missing)}", file=sys.stderr) sys.exit(2) if args.money <= 0: print("❌ --money 는 양수여야 합니다.", file=sys.stderr) sys.exit(2) if not args.no_validate: chart = all_accounts() unknown = [n for n in (args.left, args.right) if n not in chart] if unknown: print(f"❌ 차트에 없는 계정명: {unknown}", file=sys.stderr) print(" whooing_accounts.json 의 categories 에서 정확한 이름을 확인하거나, --no-validate 로 우회.", file=sys.stderr) sys.exit(2) entry_date = args.date or datetime.now(KST).strftime("%Y%m%d") if len(entry_date) != 8 or not entry_date.isdigit(): print(f"❌ --date 형식 오류 (YYYYMMDD 필요): {entry_date}", file=sys.stderr) sys.exit(2) # 후잉 structured 는 memo 필수(빈 문자열도 거절). 비면 공백 1개로 채워 통과. memo = args.memo if args.memo.strip() else " " payload = { "entry_date": entry_date, "item": args.item, "money": str(args.money), "left": args.left, "right": args.right, "memo": memo, } mode = "structured" display = f"{args.left} ← {args.right} {args.money:,}원 ({args.item})" ok, status, body = post(webhook_url, payload, dry_run=args.dry_run) if ok: print(f"✅ [{mode}] {status} | {display}") return print(f"❌ [{mode}] {status} body={body[:200]!r}") if not args.dry_run: record_failure(payload, status, body, mode) notify.send(_format_manual_failure(mode, payload, status, body)) sys.exit(1) def _format_manual_failure(mode: str, payload: dict, status: int, body: str) -> str: reason = notify.escape_html(notify.clean_reason(body, status)) header = "❌ 후잉 수동입력 실패" if mode == "structured": item = notify.escape_html(payload.get("item", "")) try: money_fmt = f"{int(payload.get('money', 0)):,}원" except Exception: money_fmt = f"{payload.get('money', '')}원" left = notify.escape_html(payload.get("left", "")) right = notify.escape_html(payload.get("right", "")) memo = (payload.get("memo") or "").strip() date = payload.get("entry_date", "") date_fmt = f"{date[:4]}-{date[4:6]}-{date[6:8]}" if len(date) == 8 else date lines = [ f"\n\n💳 {left}{right}", f" {money_fmt} · {item}", f" 날짜: {date_fmt}", ] if memo: lines.append(f" 메모: {notify.escape_html(memo)}") body_block = "\n".join(lines) else: msg = (payload.get("message") or "")[:240] body_block = f"\n\n📩 원문\n{notify.escape_html(msg)}" reason_block = f"\n\n⚠️ 사유\n{reason}" return header + body_block + reason_block if __name__ == "__main__": main()