#!/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()