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:
hyowons
2026-06-04 15:39:41 +09:00
commit fed3526b20
199 changed files with 49671 additions and 0 deletions
@@ -0,0 +1,410 @@
"""가희 주머니 잔액 자동 갱신.
매월 25일 10:00 KST 이후 첫 sync 사이클에서 가희님께 iMessage 리마인더 발신,
이후 사이클부터 가희 답신을 폴링해 후잉 `가희주머니` 차액 자동 분개.
운영 정책 (관리자님 결정):
- 발신: 매월 25일 10:00 이후, 이번 달 미발신 상태일 때 1회 (idempotent)
- 수신 텍스트: '라벨 : 금액' 형식 정규식 파싱 → 즉시 자동 분개 (안전장치 없음)
- 수신 이미지(첨부 포함): 텔레그램 알림만, 분개 안 함 (관리자님 직접 처리)
- 텔레그램 보고: 발신 직후, 포맷 오류, 분개 완료 3시점
- launchd 별도 plist 없음 — whooing-sync 사이클(매시 0/15/30/45분)에 통합
"""
from __future__ import annotations
import json
import re
import subprocess
from datetime import datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
import notify
import whooing_balance
KST = ZoneInfo("Asia/Seoul")
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~20자.
# 콜론 구분 입력은 정수 전체를 허용하고, 공백 구분 입력은 천 단위 콤마가 있을 때만 허용한다.
# 일반 안내 문자의 "오전 09" 같은 표현을 잔액으로 오인하지 않기 위함.
BALANCE_COLON_RE = re.compile(
r"([가-힣A-Za-z][가-힣A-Za-z0-9()\s]{0,18}?)\s*[:]\s*([\d,]+)"
)
BALANCE_SPACE_RE = re.compile(
r"([가-힣A-Za-z][가-힣A-Za-z0-9()\s]{0,18}?)\s+(\d{1,3}(?:,\d{3})+)"
)
# 가희님이 본인 확인용으로 적는 라벨 — dict에 포함되면 차액 계산 망가지므로 스킵.
SKIP_LABELS = {"합계", "총합", "소계", "total", "sum"}
# 가희님 현금성 계좌만 허용. 안내 문자 숫자나 주식 계좌를 잔액으로 오인하지 않기 위한 방어선.
# 새 계좌가 생기면 이 목록에 먼저 추가한 뒤 자동 반영한다.
ALLOWED_LABELS = {"국민", "국민(청약)", "신한", "기업", "하나", "하나(보험)", "카카오뱅크"}
def _load_json(path: Path, default):
try:
return json.loads(path.read_text())
except FileNotFoundError:
return default
def _save_json(path: Path, data):
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
def _now_kst() -> datetime:
return datetime.now(KST)
def _gahee_handle() -> str:
cred = _load_json(GAHEE_CRED, {})
handle = (cred.get("handle") or "").strip()
if not handle:
raise RuntimeError("credentials/gahee_imessage.json 의 handle 이 비었습니다.")
return handle
def _normalize(handle: str) -> str:
"""+821055595428 / 010-5559-5428 / 01055595428 등을 01055595428 정규형으로."""
digits = re.sub(r"\D", "", handle or "")
if digits.startswith("82"):
digits = digits[2:]
if digits and not digits.startswith("0"):
digits = "0" + digits
return digits
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}")
return []
def _find_gahee_chat_id(handle_norm: str) -> int | None:
for c in _imsg_chats():
ident = _normalize(str(c.get("identifier", "")))
if ident == handle_norm:
return c.get("id")
return 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"]
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}")
return []
def _send_imessage(handle: str, text: str, dry_run: bool) -> bool:
if dry_run:
print(f"[dry-run] imsg send --to {handle} --text {text!r} --service sms")
return True
try:
result = subprocess.run(
["imsg", "send", "--to", handle, "--text", text,
"--service", "sms"],
capture_output=True, text=True, timeout=IMSG_TIMEOUT,
)
if result.returncode != 0:
print(f"❌ 가희 imsg send 실패 rc={result.returncode}: {result.stderr[:200]}")
return False
return True
except Exception as e:
print(f"❌ 가희 imsg send 예외: {e}")
return False
def _parse_balance_text(text: str) -> dict[str, int]:
"""가희 답신 텍스트에서 '라벨 : 금액' 페어 추출. 빈 dict면 포맷 오류.
같은 라벨이 여러 번 나오면 마지막 값 사용 (수정 답신 케이스).
"""
out: dict[str, int] = {}
for pattern in (BALANCE_COLON_RE, BALANCE_SPACE_RE):
for m in pattern.finditer(text or ""):
label = m.group(1).strip()
if label.lower() in SKIP_LABELS:
continue
if re.search(r"\d$", label):
continue # "오전 09:04" 같은 시간 표기
if label not in ALLOWED_LABELS:
continue
amount_s = m.group(2).replace(",", "")
try:
amount = int(amount_s)
except ValueError:
continue
out[label] = amount
return out
def _format_memo(balances: dict[str, int], total: int, prev: int) -> str:
parts = " ".join(f"{k}:{v:,}" for k, v in balances.items())
return f"[자동] 가희 잔액 갱신 | {parts} | 합계 {total:,} (이전 {prev:,})"
def _apply_balance(balances: dict[str, int], state: dict, webhook_url: str,
post_fn, dry_run: bool) -> tuple[bool, str]:
"""파싱된 잔액 dict → 후잉 가희주머니 차액 분개. 반환 (ok, 사용자_보고용_요약)."""
if not balances:
return False, "포맷 오류 — '라벨 : 금액' 페어 0건"
total = sum(balances.values())
acct_name = state.get("whooing_account_name", "가희주머니")
income = state.get("income_account", "가희비밀주머니_수익")
expense = state.get("expense_account", "기타비용")
try:
whoo = whooing_balance.fetch_balances([acct_name], sides=("assets",))
except Exception as e:
return False, f"후잉 잔고 조회 실패: {e}"
prev = int(whoo.get(acct_name, 0))
diff = total - prev
if diff == 0:
return True, f"차액 0원 — 분개 생략. 합계 {total:,}원 (후잉과 일치)"
entry_date = _now_kst().strftime("%Y%m%d")
memo = _format_memo(balances, total, prev)
if diff > 0:
payload = {
"entry_date": entry_date,
"money": diff,
"item": "가희 잔액 갱신",
"left": acct_name,
"right": income,
"memo": memo[:200],
}
direction = f"증가 +{diff:,}원 ({income}{acct_name})"
else:
payload = {
"entry_date": entry_date,
"money": abs(diff),
"item": "가희 잔액 갱신",
"left": expense,
"right": acct_name,
"memo": memo[:200],
}
direction = f"감소 -{abs(diff):,}원 ({acct_name}{expense})"
ok, status, body = post_fn(webhook_url, payload, dry_run=dry_run)
if not ok:
return False, f"후잉 분개 실패 status={status} body={body[:200]}"
return True, f"합계 {total:,}원 (이전 {prev:,}) · {direction}"
def _maybe_send_reminder(state: dict, dry_run: bool) -> bool:
"""발신 게이트. 트리거 조건 충족 시 1회 발신 후 state 업데이트. 발신 여부 반환.
Catch-up 정책 (2026-05-21): `day >= target_day` 이면서 이번 달 미발신이면 발신.
25일에 Mac이 꺼져 있었어도 그 달 내 아무 날에 켜면 그 시점에 발신. 다음 달로 넘어가면 포기.
"""
now = _now_kst()
target_day = int(state.get("send_day_of_month", 25))
target_hour = int(state.get("send_hour_kst", 10))
month_tag = now.strftime("%Y-%m")
if now.day < target_day:
return False
if now.day == target_day and now.hour < target_hour:
return False
if state.get("last_sent_month") == month_tag:
return False # 이번 달 이미 보냄
handle = _gahee_handle()
template = state.get("message_template") or "가계부 업데이트 날이에요. 계좌잔액 보내주시면 자동으로 반영됩니다."
ok = _send_imessage(handle, template, dry_run=dry_run)
if not ok:
notify.send(f"❌ <b>가희 잔액 리마인더 발신 실패</b>\n월: {month_tag}\n수신자: {handle}")
return False
if not dry_run:
state["last_sent_month"] = month_tag
_save_json(STATE_FILE, state)
notify.send(
f"📨 <b>가희 잔액 리마인더 발신</b>\n"
f"월: {notify.escape_html(month_tag)} · 수신: {notify.escape_html(handle)}\n"
f"답신 들어오면 자동 분개 후 다시 보고드릴게요."
)
return True
def _msg_text(msg: dict) -> str:
"""imsg history 메시지 객체에서 본문 텍스트 추출. 키 변형(text/body) 모두 시도."""
for k in ("text", "body", "message", "content"):
v = msg.get(k)
if isinstance(v, str) and v.strip():
return v
return ""
def _msg_has_attachment(msg: dict) -> bool:
"""imsg --attachments 결과에서 첨부 존재 여부. 키 변형 보호적 처리."""
for k in ("attachments", "attachment", "files"):
v = msg.get(k)
if isinstance(v, list) and len(v) > 0:
return True
if isinstance(v, dict) and v:
return True
return False
def _msg_from_gahee(msg: dict) -> bool:
"""가희(상대)가 보낸 메시지인지. is_from_me=true 또는 sender=관리자 본인이면 제외."""
if msg.get("is_from_me") is True:
return False
if msg.get("from_me") is True:
return False
# 일부 imsg 빌드는 sender/handle 필드를 줌 — 가희 normalized와 매칭
return True
def _poll_replies(state: dict, webhook_url: str, post_fn, dry_run: bool):
"""가희 답신 폴링·분개.
정책 (2026-05-21):
- 한 사이클에 가희 텍스트 메시지가 여러 통이면 **마지막 1통만 분개**. 이전 텍스트는 워터마크만 갱신.
가희가 수정·재발송했을 때 첫 메시지로 잘못 분개되는 사고 방지.
- 이미지·빈 메시지·우리 발신 메시지는 즉시 워터마크 갱신 (재처리 무의미).
- 후잉 API 일시 실패 등 **분개 실패** 시 워터마크 자체를 갱신하지 않아 다음 사이클에서 재시도.
포맷 오류는 재시도해도 결과 같으니 워터마크 갱신.
- 분개 실패 시 같은 사이클에 보낸 이미지·이전 텍스트 알림은 다음 사이클에 중복 발송될 수 있음 (실무상 허용 — 후잉 다운은 드물고 회복 빠름).
"""
handle_norm = _normalize(_gahee_handle())
chat_id = _find_gahee_chat_id(handle_norm)
if chat_id is None:
# 아직 가희와 대화방 없음 (첫 발신 전). 폴링할 게 없음.
return
last_at = state.get("last_processed_message_at")
# 최초 폴링이면 발신 시각 직후부터 — 가희 답신만 잡고 과거 메시지 무시.
# state에 last_sent_month만 있고 시각이 없으면 안전하게 발신일 00:00 KST부터.
if not last_at and state.get("last_sent_month"):
y, m = state["last_sent_month"].split("-")
target_day = int(state.get("send_day_of_month", 25))
start_dt = datetime(int(y), int(m), target_day, tzinfo=KST)
last_at = start_dt.astimezone(ZoneInfo("UTC")).isoformat().replace("+00:00", "Z")
msgs = _imsg_history(chat_id, last_at)
if not msgs:
return
# created_at 오름차순 정렬 보장
msgs.sort(key=lambda x: x.get("created_at") or x.get("date") or "")
# 1차 스캔: 이미지·빈 메시지·우리 발신은 그 자리에서 처리, 가희 텍스트만 따로 모음.
new_watermark = last_at
gahee_texts: list[tuple[str, str]] = [] # (ts, text)
for msg in msgs:
ts = msg.get("created_at") or msg.get("date") or ""
if last_at and ts and ts <= last_at:
continue
if not _msg_from_gahee(msg):
new_watermark = ts or new_watermark
continue
text = _msg_text(msg)
has_attach = _msg_has_attachment(msg)
if has_attach:
preview = notify.escape_html((text[:80] + "") if len(text) > 80 else text)
notify.send(
f"🖼️ <b>가희님이 이미지로 답신하셨어요</b>\n"
f"자동 분개는 텍스트만 지원돼요. 텍스트로 다시 요청하거나 직접 입력해주세요.\n"
f"<i>본문:</i> {preview or '(없음)'}"
)
new_watermark = ts or new_watermark
continue
if not text.strip():
new_watermark = ts or new_watermark
continue
gahee_texts.append((ts, text))
# 2차 처리: 텍스트가 여러 통이면 마지막만 분개. 이전 텍스트는 워터마크만 갱신.
if not gahee_texts:
if not dry_run and new_watermark and new_watermark != last_at:
state["last_processed_message_at"] = new_watermark
_save_json(STATE_FILE, state)
return
if len(gahee_texts) >= 2:
# 이전 메시지들의 워터마크 갱신 (스킵 알림)
skipped = len(gahee_texts) - 1
prev_preview = notify.escape_html(gahee_texts[0][1][:80])
notify.send(
f"️ <b>가희 답신 {len(gahee_texts)}건 도착 — 마지막 메시지만 분개</b>\n"
f"이전 {skipped}건은 수정/재발송으로 간주하고 분개하지 않아요.\n"
f"<i>첫 메시지 일부:</i> {prev_preview}"
)
for prev_ts, _ in gahee_texts[:-1]:
new_watermark = prev_ts or new_watermark
last_ts, last_text = gahee_texts[-1]
parsed = _parse_balance_text(last_text)
if not parsed:
notify.send(
f"⚠️ <b>가희 잔액 — 포맷 오류로 분개 중단</b>\n"
f"'라벨 : 금액' 페어 0건. 가희님께 다시 요청하거나 직접 입력해주세요.\n"
f"<i>원문:</i> {notify.escape_html(last_text[:300])}"
)
# 포맷 오류는 재처리해도 결과 동일 — 워터마크 갱신
new_watermark = last_ts or new_watermark
else:
ok, summary = _apply_balance(parsed, state, webhook_url, post_fn, dry_run=dry_run)
if ok:
notify.send(
f"✅ <b>가희 잔액 갱신 완료</b>\n"
f"{notify.escape_html(summary)}"
)
new_watermark = last_ts or new_watermark
else:
# 후잉 API 실패 등 — 워터마크 갱신 안 함, 다음 사이클 재시도
notify.send(
f"❌ <b>가희 잔액 갱신 실패 — 다음 사이클 재시도</b>\n"
f"{notify.escape_html(summary)}\n"
f"<i>원문:</i> {notify.escape_html(last_text[:300])}"
)
# 새 워터마크는 마지막 텍스트 메시지 직전(=이전 텍스트의 ts 또는 last_at)으로 롤백.
# 같은 사이클에 이미 갱신된 이미지·이전 텍스트의 워터마크도 안전하게 함께 롤백된다.
new_watermark = last_at # 분개 실패 시 워터마크 안 갱신
if not dry_run and new_watermark and new_watermark != last_at:
state["last_processed_message_at"] = new_watermark
_save_json(STATE_FILE, state)
def run(webhook_url: str, post_fn, dry_run: bool = False):
"""whooing_sync.main() 끝에서 호출. 발신 게이트 + 응답 폴링.
실패해도 외부에 예외 전파 X — 결제 sync 흐름을 막지 않는다.
"""
try:
state = _load_json(STATE_FILE, None)
if not state:
print("⚠️ 가희 state 파일 없음 — 스킵")
return
_maybe_send_reminder(state, dry_run=dry_run)
# 발신 직후 같은 사이클에서 폴링은 무의미하지만, 다음 사이클부터 자연스럽게 시작됨.
_poll_replies(state, webhook_url, post_fn, 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
@@ -0,0 +1,115 @@
"""Telegram notifier for 골디(budget) — 후잉 동기화 실패 알림 전송.
openclaw.json 의 channels.telegram.accounts.budget.botToken 과 allowFrom[0] (관리자 chat_id)
를 사용해 직접 sendMessage 호출. 외부 의존성 없음.
"""
import json
import re
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
CONFIG_PATH = Path("/Users/snowoyh/.openclaw/openclaw.json")
KST = ZoneInfo("Asia/Seoul")
def _load_budget_account():
cfg = json.loads(CONFIG_PATH.read_text())
acct = cfg["channels"]["telegram"]["accounts"]["budget"]
token = acct["botToken"]
chat_ids = acct.get("allowFrom") or []
return token, chat_ids
def send(text: str, parse_mode: str = "HTML") -> bool:
"""관리자에게 텔레그램 메시지 전송. 성공 시 True. 실패해도 예외 안 던짐 (알림 자체로 흐름 막지 않음)."""
try:
token, chat_ids = _load_budget_account()
except Exception as e:
print(f"⚠️ notify: config 로드 실패: {e}")
return False
if not chat_ids:
print("⚠️ notify: allowFrom 비어 있어 전송 대상 없음")
return False
ok = True
url = f"https://api.telegram.org/bot{token}/sendMessage"
for chat_id in chat_ids:
payload = {
"chat_id": chat_id,
"text": text[:4000],
"parse_mode": parse_mode,
"disable_web_page_preview": "true",
}
data = urllib.parse.urlencode(payload).encode()
req = urllib.request.Request(url, data=data, method="POST")
try:
with urllib.request.urlopen(req, timeout=10) as r:
body = r.read().decode("utf-8", errors="replace")
if r.status != 200:
print(f"⚠️ notify: telegram {r.status}: {body[:200]}")
ok = False
except Exception as e:
print(f"⚠️ notify: telegram 전송 실패: {e}")
ok = False
return ok
def escape_html(s: str) -> str:
return (s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
def format_kst(iso: str) -> str:
"""imsg created_at(UTC ISO) -> 'MM-DD HH:MM' KST. 파싱 실패 시 원문."""
if not iso:
return ""
try:
dt = datetime.fromisoformat(iso.replace("Z", "+00:00")).astimezone(KST)
return dt.strftime("%m-%d %H:%M")
except Exception:
return iso[:16]
def clean_reason(body: str, status: int) -> str:
"""후잉 응답 본문 -> 한국어 한 줄 사유. 모르는 패턴은 원문 그대로."""
body = (body or "").strip()
if status == 0:
return f"네트워크 오류 — {body[:200]}"
if not (200 <= status < 300):
return f"HTTP {status}{body[:200]}"
if body.lower() == "fail":
return "후잉이 형식 거절함 (사유 미제공). 보통 memo 누락 또는 인코딩 문제."
# "Error :" / "Error:" 접두어 제거
m = re.match(r"^Error\s*:?\s*(.+)$", body, flags=re.S)
inner = (m.group(1).strip() if m else body).strip()
# 알려진 패턴 → 한국어 번역
# 1) "left/right account does not exist at entry_date. ... [계정명]"
m = re.search(r"(left|right)\s+account\s+does\s+not\s+exist.*?\[(.+?)\]", inner, flags=re.I | re.S)
if m:
side = "차변" if m.group(1).lower() == "left" else "대변"
name = m.group(2).strip()
return f"{side} 계정 {name}’ 이(가) 후잉 차트에 없습니다. 계정명 오타이거나 거래일에 닫혀있는 계정입니다."
# 2) "[필드명] 필드가 없습니다"
m = re.search(r"\[(\w+)\]\s*필드가\s*없습니다", inner)
if m:
return f"필수 필드 {m.group(1)} 이(가) 빠졌습니다."
# 3) entry_date 관련
if re.search(r"entry_date.*(format|invalid|올바)", inner, flags=re.I):
return "거래일(entry_date) 형식 오류. YYYYMMDD 8자리여야 합니다."
# 4) money 관련
if re.search(r"money.*(invalid|format|숫자|negative)", inner, flags=re.I):
return "금액(money) 값이 잘못됐습니다. 양의 정수만 허용됩니다."
# 5) section / webhook URL 만료
if re.search(r"section|webhook|invalid\s*url|expired", inner, flags=re.I):
return f"웹훅/섹션 문제 — {inner[:200]}"
# 알려진 패턴 없음: 원문 (영문 그대로)
return inner[:300]
@@ -0,0 +1,223 @@
"""carrier별 결제/입출금 SMS 파서.
Python 3.9 호환을 위해 union type은 문자열로 표기.
각 파서는 (raw_text, created_at_iso) -> "dict | None".
반환 dict 필드:
- kind: "withdrawal" | "deposit" | "card_approval" | "card_cancel"
- entry_date: "YYYYMMDD"
- amount: int (원, 양수)
- merchant: str (가맹점/메모/송수신처)
- raw: 원문 그대로
파싱 실패 시 None을 반환해 raw 폴백 처리하도록 한다.
"""
import re
from datetime import datetime
from zoneinfo import ZoneInfo
KST = ZoneInfo("Asia/Seoul")
def _iso_to_kst_date(iso: str) -> str:
"""imsg created_at(UTC ISO) -> KST YYYYMMDD."""
try:
dt = datetime.fromisoformat(iso.replace("Z", "+00:00")).astimezone(KST)
return dt.strftime("%Y%m%d")
except Exception:
return datetime.now(KST).strftime("%Y%m%d")
def _normalize(text: str) -> str:
"""전각 공백·전각 영문 → 일반화."""
return text.replace("\u3000", " ").replace("\xa0", " ").strip()
HANA_RE = re.compile(
r"\[Web발신\]\s*\n"
r"하나,(?P<md>\d{2}/\d{2})\s+(?P<hm>\d{2}:\d{2})\s*\n"
r"(?P<acct>\S+)\s*\n"
r"(?P<kind>입금|출금)(?P<amount>[\d,]+)원\s*\n?"
r"(?P<merchant>[^\n]*)"
r"(?:\s*\n잔액(?P<balance>[\d,]+)원)?",
)
def parse_hana_bank(raw: str, created_at: str) -> "dict | None":
text = raw.strip()
m = HANA_RE.match(text)
if not m:
return None
kind = "deposit" if m.group("kind") == "입금" else "withdrawal"
amount = int(m.group("amount").replace(",", ""))
merchant = _normalize(m.group("merchant")) or "(메모없음)"
result = {
"kind": kind,
"entry_date": _iso_to_kst_date(created_at),
"amount": amount,
"merchant": merchant,
"raw": raw,
}
if m.group("balance"):
result["balance"] = int(m.group("balance").replace(",", ""))
return result
# 신한카드 신형식: "신한카드(6847)승인 방*원님 아파트 관리비 249,710원 정상승인 되었습니다."
# entry_date 는 SMS created_at 기반(_iso_to_kst_date)이라 메시지 내 날짜 추출 불필요.
SHINHAN_APPROVE_RE = re.compile(
r"신한(?:카드)?(?:\(\d+\))?\s*(?P<kind>승인|매입취소|승인취소)\s+"
r"(?P<who>\S+?님)\s+"
r"(?P<merchant>.+?)\s+"
r"(?P<amount>[\d,]+)\s*원",
re.DOTALL,
)
SHINHAN_BANK_RE = re.compile(
r"\[Web발신\]\s*\n"
r"신한\s*(?P<md>\d{2}/\d{2})\s+(?P<hm>\d{2}:\d{2})\s*\n"
r"(?P<acct>\S+)\s*\n"
r"(?P<kind>입금|출금)\s+(?P<amount>[\d,]+)\s*\n"
r"잔액\s+(?P<balance>[\d,]+)\s*\n?"
r"(?P<merchant>.*)",
re.DOTALL,
)
def parse_shinhan_bank(raw: str, created_at: str) -> "dict | None":
text = raw.strip()
m = SHINHAN_BANK_RE.match(text)
if not m:
return None
kind = "deposit" if m.group("kind") == "입금" else "withdrawal"
amount = int(m.group("amount").replace(",", ""))
merchant = _normalize(m.group("merchant")).split("\n")[0].strip() or "(메모없음)"
result = {
"kind": kind,
"entry_date": _iso_to_kst_date(created_at),
"amount": amount,
"merchant": merchant,
"raw": raw,
}
if m.group("balance"):
result["balance"] = int(m.group("balance").replace(",", ""))
return result
def parse_shinhan_card(raw: str, created_at: str) -> "dict | None":
text = _normalize(raw).replace("[Web발신]", "")
m = SHINHAN_APPROVE_RE.search(text)
if not m:
return None
kind_raw = m.group("kind")
kind = "card_cancel" if "취소" in kind_raw else "card_approval"
amount = int(m.group("amount").replace(",", ""))
merchant = m.group("merchant").strip().split("\n")[0]
merchant = re.sub(r"\s+", " ", merchant).strip() or "(가맹점없음)"
return {
"kind": kind,
"entry_date": _iso_to_kst_date(created_at),
"amount": amount,
"merchant": merchant,
"raw": raw,
}
KAKAOBANK_RE = re.compile(
r"\[Web발신\]\s*\[카카오뱅크\]\s+"
r"(?P<acct>\S+)\s+"
r"(?P<md>\d{2}/\d{2})\s+(?P<hm>\d{2}:\d{2})\s+"
r"(?P<kind>입금|출금)\s+(?P<amount>[\d,]+)원\s+"
r"(?P<merchant>[^\n]+?)"
r"(?:\s*\n잔액\s+(?P<balance>[\d,]+)원)?\s*$",
re.DOTALL,
)
def parse_kakao_bank(raw: str, created_at: str) -> "dict | None":
text = _normalize(raw)
m = KAKAOBANK_RE.search(text)
if not m:
return None
kind = "deposit" if m.group("kind") == "입금" else "withdrawal"
amount = int(m.group("amount").replace(",", ""))
merchant = _normalize(m.group("merchant")).split("\n")[0].strip() or "(메모없음)"
result = {
"kind": kind,
"entry_date": _iso_to_kst_date(created_at),
"amount": amount,
"merchant": merchant,
"raw": raw,
}
if m.group("balance"):
result["balance"] = int(m.group("balance").replace(",", ""))
return result
# 현대카드 승인 형식 (취소는 별도 샘플 확보 후 추가):
# 현대카드 블루멤버스 승인
# 방*원
# 5,170원 일시불
# 04/25 11:34
# 쿠팡
# 누적1,466,011원
HYUNDAI_CARD_APPROVE_RE = re.compile(
r"^\s*현대카드[^\n]*승인\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"
r"(?P<merchant>[^\n]+)"
r"(?:\s*\n누적\s*(?P<cumulative>[\d,]+)\s*원)?",
re.MULTILINE,
)
# RCS/MAAP 형식은 한 줄로 들어온다:
# [Web발신] 현대카드 블루멤버스 승인 방*원 84,000원 일시불 05/16 11:57 네이버페이 누적820,000원
HYUNDAI_CARD_APPROVE_INLINE_RE = re.compile(
r"현대카드[^\n]*승인\s+"
r"(?P<who>\S+)\s+"
r"(?P<amount>[\d,]+)\s*원[^\n]*?"
r"\d{2}/\d{2}\s+\d{2}:\d{2}\s+"
r"(?P<merchant>.+?)"
r"(?:\s+누적\s*(?P<cumulative>[\d,]+)\s*원?)?\s*$",
re.MULTILINE,
)
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)
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",
"entry_date": _iso_to_kst_date(created_at),
"amount": amount,
"merchant": merchant,
"raw": raw,
}
if m.group("cumulative"):
result["cumulative"] = int(m.group("cumulative").replace(",", ""))
return result
PARSERS = {
"+8215991111": parse_hana_bank,
"+8215447200": parse_shinhan_card,
"+8215778000": parse_shinhan_bank,
"+8215993333": parse_kakao_bank,
"+8215776200": parse_hyundai_card,
"15776200@botplatform.maapservice.com": parse_hyundai_card,
}
def parse(sender: str, raw: str, created_at: str) -> "dict | None":
fn = PARSERS.get(sender)
if fn is None:
return None
try:
return fn(raw, created_at)
except Exception:
return None
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""후잉 OpenAPI 잔액(bs.json) 조회.
Usage:
whooing_balance.py # 모든 섹션의 현재 잔액
whooing_balance.py --section-id 1 # 특정 섹션
whooing_balance.py --as-of 2026-04-23 # 특정 날짜 기준
whooing_balance.py --json # 원시 JSON 출력
"""
from __future__ import annotations
import argparse
import json
import secrets
import sys
import time
import urllib.parse
import urllib.request
from pathlib import Path
CRED_PATH = Path("/Users/snowoyh/.openclaw/credentials/whooing.json")
BASE = "https://whooing.com/api"
def build_api_key(app_id, token, signature) -> str:
nounce = secrets.token_hex(20)
ts = int(time.time())
return f"app_id={app_id},token={token},signiture={signature},nounce={nounce},timestamp={ts}"
def api_get(endpoint: str, api_key: str, params: dict | None = None) -> dict:
url = f"{BASE}/{endpoint}"
if params:
url += "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={"X-API-KEY": api_key})
with urllib.request.urlopen(req, timeout=15) as resp:
body = resp.read().decode("utf-8")
data = json.loads(body)
if data.get("code") != 200:
raise RuntimeError(f"후잉 API error {data.get('code')}: {data.get('message')} (endpoint={endpoint})")
return data["results"]
def fmt_won(n: int) -> str:
return f"{n:,}"
def fetch_asset_balances(account_names: list[str]) -> dict[str, int]:
"""주어진 자산 계정명들의 후잉 잔액을 dict로 반환 (호환용 별칭)."""
return fetch_balances(account_names, sides=("assets",))
def fetch_balances(account_names: list[str], sides: tuple = ("assets", "liabilities")) -> dict[str, int]:
"""주어진 계정명들의 후잉 잔액을 dict로 반환.
sides 에 'assets' / 'liabilities' / 'capital' 중 원하는 것만 포함.
name -> money. 미발견 계정은 dict에 포함하지 않음.
"""
cred = json.loads(CRED_PATH.read_text())
api_cfg = cred["api"]
def key() -> str:
return build_api_key(api_cfg["app_id"], api_cfg["token"], api_cfg["signature"])
end_date = time.strftime("%Y%m%d")
start_date = "19000101"
sections = api_get("sections.json", key())
if isinstance(sections, dict):
section_list = sections.get("sections") or sections.get("rows") or list(sections.values())
else:
section_list = sections
wanted = set(account_names)
out: dict[str, int] = {}
for sec in section_list:
sid = sec.get("section_id") or sec.get("id")
accounts_raw = api_get("accounts.json", key(), {"section_id": sid})
id_to_name: dict[str, str] = {}
for acc_list in accounts_raw.values():
if not isinstance(acc_list, list):
continue
for a in acc_list:
id_to_name[str(a.get("account_id"))] = a.get("title") or str(a.get("account_id"))
bs = api_get("bs.json", key(), {
"section_id": sid, "start_date": start_date, "end_date": end_date,
})
for side in sides:
for row in (bs.get(side) or {}).get("accounts", []) or []:
name = id_to_name.get(str(row.get("account_id")), "")
if name in wanted:
out[name] = row.get("money", 0)
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--section-id", help="섹션 id. 미지정 시 모든 섹션 조회.")
ap.add_argument("--as-of", help="기준일 YYYY-MM-DD. 기본값: 오늘.")
ap.add_argument("--json", action="store_true", help="원시 JSON 출력")
args = ap.parse_args()
cred = json.loads(CRED_PATH.read_text())
api_cfg = cred.get("api")
if not api_cfg:
print("error: credentials/whooing.json 에 'api' 블록이 없습니다.", file=sys.stderr)
return 2
def key() -> str:
return build_api_key(api_cfg["app_id"], api_cfg["token"], api_cfg["signature"])
end_date = (args.as_of or time.strftime("%Y-%m-%d")).replace("-", "")
start_date = "19000101"
sections = api_get("sections.json", key())
if isinstance(sections, dict):
section_list = sections.get("sections") or sections.get("rows") or list(sections.values())
else:
section_list = sections
if args.section_id:
section_list = [s for s in section_list if str(s.get("section_id")) == str(args.section_id)]
output: dict = {"as_of": end_date, "sections": []}
for sec in section_list:
sid = sec.get("section_id") or sec.get("id")
title = sec.get("title") or sec.get("name") or str(sid)
accounts_raw = api_get("accounts.json", key(), {"section_id": sid})
id_to_name: dict[str, str] = {}
for acc_type, acc_list in accounts_raw.items():
if not isinstance(acc_list, list):
continue
for a in acc_list:
id_to_name[str(a.get("account_id"))] = a.get("title") or str(a.get("account_id"))
bs = api_get("bs.json", key(), {
"section_id": sid,
"start_date": start_date,
"end_date": end_date,
})
sec_out = {"section_id": sid, "title": title, "groups": {}}
for key_name, ko in [("assets", "자산"), ("liabilities", "부채"), ("capital", "자본")]:
group = bs.get(key_name) or {}
total = group.get("total", 0)
items = []
for row in group.get("accounts", []) or []:
money = row.get("money", 0)
if money == 0:
continue
items.append({
"account_id": row.get("account_id"),
"name": id_to_name.get(str(row.get("account_id")), str(row.get("account_id"))),
"money": money,
})
items.sort(key=lambda x: abs(x["money"]), reverse=True)
sec_out["groups"][ko] = {"total": total, "items": items}
output["sections"].append(sec_out)
if args.json:
print(json.dumps(output, ensure_ascii=False, indent=2))
return 0
print(f"## 후잉 잔액 (기준일 {end_date[:4]}-{end_date[4:6]}-{end_date[6:]})\n")
for sec_out in output["sections"]:
print(f"### [{sec_out['section_id']}] {sec_out['title']}")
for ko in ("자산", "부채", "자본"):
g = sec_out["groups"][ko]
print(f"- **{ko} 합계:** {fmt_won(g['total'])}")
for it in g["items"]:
print(f" - {it['name']}: {fmt_won(it['money'])}")
print()
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -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()
File diff suppressed because it is too large Load Diff