Initial commit: OpenClaw 워크스페이스 버전관리 시작
설정·스크립트·스킬·문서·큐레이션 메모리 추적. 시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)· 백업(clobbered/bak)·dream 캐시는 .gitignore로 제외. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
#!/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 = "❌ <b>후잉 수동입력 실패</b>"
|
||||
|
||||
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💳 <b>{left}</b> ← <b>{right}</b>",
|
||||
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📩 <b>원문</b>\n<code>{notify.escape_html(msg)}</code>"
|
||||
|
||||
reason_block = f"\n\n⚠️ <b>사유</b>\n{reason}"
|
||||
return header + body_block + reason_block
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user