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:10:57 +09:00
commit 549545bde6
199 changed files with 49671 additions and 0 deletions
@@ -0,0 +1,45 @@
# monthly-settlement
매월 1일에 후잉 잔액을 스냅샷으로 저장하고, 전월 스냅샷과 비교해 자산 변동을 리포트한다.
## When to use
- 매월 1일 05:00 KST cron 자동 호출 (`agent: budget`)
- 관리자님이 "이번달 결산", "월간 결산", "자산 변동 요약" 요청할 때
## How
```bash
python3 /Users/snowoyh/.openclaw/agents/budget/workspace/skills/monthly-settlement/scripts/monthly_settlement.py
```
기본 동작:
1. `whooing_balance.py --json` 호출해 현재 순자산/자산/부채 스냅샷 생성
2. `state/monthly_snapshots.json` 에서 전월 스냅샷 로드
3. 계정별 증감 계산 (신규/청산 포함)
4. 메일 발송: `gog gmail send --to mini.snowoyh@gmail.com`
- 제목 `[월간결산] YYYY년 M월 자산 변동`
- 모든 계정 증감을 절대값 내림차순으로 정렬
5. 골디 텔레그램 발송: 순자산 변동 한 줄 + ±100만원 이상 변동 top 5
6. 이번달 스냅샷을 `monthly_snapshots.json` 에 저장
첫 실행(비교 대상 없음)은 스냅샷만 저장하고 "첫 결산" 안내만 발송.
## Flags
- `--dry-run` — 전송·저장 없이 메일/텔레그램 본문만 stdout 출력
- `--no-send` — 전송 생략, 스냅샷 저장과 stdout 출력만 수행 (복구/재실행용)
- `--as-of YYYY-MM-DD` — 기준일 강제 지정 (기본: 오늘)
## Output
마지막 한 줄 요약:
```
✅ 월간결산 2026-04: 순자산 +1,234,567원, 메일+텔레그램 전송 완료
```
## 데이터
- 스냅샷 저장: `state/monthly_snapshots.json` (키: `YYYY-MM` = 스냅샷 시점의 월)
- 리포트 대상 월: 전월 (스냅샷 시점 기준 직전 달)
@@ -0,0 +1,462 @@
#!/usr/bin/env python3
"""골디 인박스 처리기 — agents/budget/inbox/incoming/ envelope 검증·디스패치.
처리 흐름:
1. incoming/*.json 정렬 스캔
2. envelope 검증 (필수 키, schema_version, topic 등록 여부)
3. idempotency: state/inbox_state.json 의 processed 에 message_id 있으면 skip → processed/
4. topic 핸들러 호출 (현재 securities_balance 만)
5. 성공 → processed/, 실패 → failed/ + 골디 텔레그램 자가 알림
6. state 갱신 (processed 누적, 최근 1000개 유지)
CLI:
python3 inbox_handler.py [--dry-run]
--dry-run 검증만 수행. 후잉 분개 webhook·파일 이동·state 저장은 하지 않음
monthly_settlement.py 에서도 import 해서 process_inbox() 호출.
"""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
import traceback
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
KST = ZoneInfo('Asia/Seoul')
ROOT = Path('/Users/snowoyh/.openclaw')
WORKSPACE = ROOT / 'agents' / 'budget' / 'workspace'
INBOX_DIR = ROOT / 'agents' / 'budget' / 'inbox'
INCOMING_DIR = INBOX_DIR / 'incoming'
PROCESSED_DIR = INBOX_DIR / 'processed'
FAILED_DIR = INBOX_DIR / 'failed'
STATE_FILE = WORKSPACE / 'state' / 'inbox_state.json'
CONFIG_PATH = ROOT / 'openclaw.json'
CREDENTIALS = ROOT / 'credentials' / 'whooing.json'
BALANCE_SCRIPT = WORKSPACE / 'skills' / 'whooing-sync' / 'scripts' / 'whooing_balance.py'
TELEGRAM_ACCOUNT = 'budget'
KNOWN_TOPICS = {'securities_balance'}
SUPPORTED_SCHEMA = {1}
SECURITIES_ASSET_NAME = '증권(효원)'
SECURITIES_GAIN_NAME = '주식평가수익'
SECURITIES_LOSS_NAME = '주식평가손실'
RECONCILE_NOISE_FLOOR = 10_000 # |차액| < 1만원: 노이즈, 분개 skip
RECONCILE_HARD_CAP = 100_000_000 # |차액| > 1억원: 분개 거부 (안전 가드)
PAYLOAD_AMOUNT_CAP = 10_000_000_000 # 100억 초과 금액: payload 거부
PROCESSED_RETENTION_DAYS = 30 # processed/ 보관 기간
FAILED_BACKLOG_THRESHOLD = 5 # failed/ 적체 alert 임계값
class ValidationError(Exception):
pass
def load_state() -> dict:
if STATE_FILE.exists():
try:
return json.loads(STATE_FILE.read_text())
except Exception:
pass
return {'processed': []}
def save_state(state: dict) -> None:
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))
_TELEGRAM_DRY_RUN = False
def send_telegram(text: str) -> bool:
if _TELEGRAM_DRY_RUN:
print(f'[telegram-dry-run] {text}', file=sys.stderr)
return True
try:
cfg = json.loads(CONFIG_PATH.read_text())
acct = cfg['channels']['telegram']['accounts'][TELEGRAM_ACCOUNT]
token = acct['botToken']
chat_ids = acct.get('allowFrom') or []
except Exception:
return False
if not chat_ids:
return False
url = f'https://api.telegram.org/bot{token}/sendMessage'
ok = True
for chat_id in chat_ids:
data = urllib.parse.urlencode({
'chat_id': chat_id,
'text': text[:4000],
'disable_web_page_preview': 'true',
}).encode()
try:
req = urllib.request.Request(url, data=data, method='POST')
with urllib.request.urlopen(req, timeout=15) as r:
if r.status != 200:
ok = False
except Exception:
ok = False
return ok
def validate_envelope(env: dict) -> None:
required = {'message_id', 'from', 'to', 'topic', 'created_at', 'schema_version', 'payload'}
missing = required - set(env)
if missing:
raise ValidationError(f"envelope 키 누락: {sorted(missing)}")
if env['to'] != 'budget':
raise ValidationError(f"to != 'budget': {env['to']}")
if env['topic'] not in KNOWN_TOPICS:
raise ValidationError(f"미등록 topic: {env['topic']}")
if env['schema_version'] not in SUPPORTED_SCHEMA:
raise ValidationError(f"미지원 schema_version: {env['schema_version']}")
if not isinstance(env['payload'], dict):
raise ValidationError('payload 가 dict 가 아님')
def validate_securities_payload(payload: dict) -> None:
for key in ('as_of', 'accounts', 'totals', 'owner_scope'):
if key not in payload:
raise ValidationError(f'payload 키 누락: {key}')
as_of = payload['as_of']
try:
as_of_dt = datetime.strptime(as_of, '%Y-%m-%d').date()
except Exception:
raise ValidationError(f'as_of 형식 오류 (YYYY-MM-DD): {as_of}')
if as_of_dt.day not in (1, 10, 20):
raise ValidationError(f'as_of 는 매월 1·10·20일이어야 함: {as_of}')
totals = payload['totals']
for key in ('deposit', 'eval_amount', 'total'):
if key not in totals:
raise ValidationError(f'totals.{key} 누락')
v = totals[key]
if not isinstance(v, int) or v < 0:
raise ValidationError(f'totals.{key} 비정상: {v!r}')
if v > PAYLOAD_AMOUNT_CAP:
raise ValidationError(f'totals.{key} 100억 초과 (가드): {v:,}')
if not isinstance(payload['accounts'], list) or not payload['accounts']:
raise ValidationError('accounts 비어있음')
sum_total = sum(int(a.get('total', 0)) for a in payload['accounts'])
if sum_total != totals['total']:
raise ValidationError(
f'accounts 합계 불일치: {sum_total:,} vs totals.total={totals["total"]:,}'
)
def fetch_whooing_balance(as_of: str | None = None) -> dict:
cmd = ['python3', str(BALANCE_SCRIPT), '--json']
if as_of:
cmd += ['--as-of', as_of]
p = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if p.returncode != 0:
raise RuntimeError(f'whooing_balance.py 실패: {p.stderr.strip() or p.stdout.strip()}')
return json.loads(p.stdout)
def get_whooing_balance_for(name: str, current: dict) -> int | None:
for sec in current.get('sections', []) or []:
items = (sec.get('groups', {}).get('자산', {}) or {}).get('items', []) or []
for it in items:
if it.get('name') == name:
return int(it.get('money', 0))
return None
def post_whooing(payload: dict, dry_run: bool = False) -> tuple[bool, str]:
if dry_run:
return True, 'dry-run'
try:
cred = json.loads(CREDENTIALS.read_text())
except Exception as e:
return False, f'credentials 읽기 실패: {e}'
url = (cred.get('webhook_url') or '').strip()
if not url:
return False, 'webhook_url 비어있음'
encoded = urllib.parse.urlencode(payload, quote_via=urllib.parse.quote).encode()
req = urllib.request.Request(url, data=encoded, method='POST')
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('done')
return ok, body
except Exception as e:
return False, str(e)
def handle_securities_balance(envelope: dict, current_balance: dict, dry_run: bool = False) -> dict:
"""차액 reconcile + 후잉 자동 분개. 반환: {action, delta, before, after, journal_ok, note, journal?}.
action ∈ {aligned, journaled, noise, skipped, rejected, journal_failed}.
"""
payload = envelope['payload']
target_total = int(payload['totals']['total'])
current = get_whooing_balance_for(SECURITIES_ASSET_NAME, current_balance)
base = {
'topic': 'securities_balance',
'as_of': payload.get('as_of'),
'target_total': target_total,
}
if current is None:
return {
**base,
'action': 'skipped',
'note': f'후잉 자산에 "{SECURITIES_ASSET_NAME}" 항목 없음',
'delta': None, 'before': None, 'after': None, 'journal_ok': None,
}
delta = target_total - current
if delta == 0:
return {**base, 'action': 'aligned', 'delta': 0,
'before': current, 'after': current, 'journal_ok': True,
'note': '차액 0 — 분개 불필요'}
if abs(delta) < RECONCILE_NOISE_FLOOR:
return {**base, 'action': 'noise', 'delta': delta,
'before': current, 'after': current, 'journal_ok': True,
'note': f'차액 {delta:+,}원 — 1만원 미만, 분개 skip'}
if abs(delta) > RECONCILE_HARD_CAP:
return {**base, 'action': 'rejected', 'delta': delta,
'before': current, 'after': current, 'journal_ok': False,
'note': f'차액 {delta:+,}원 — 1억원 초과, 분개 거부 (가드)'}
entry_date = payload['as_of'].replace('-', '')
ym = payload['as_of'][:7]
if delta > 0:
item = f'{ym} 평가차익'
left, right, money = SECURITIES_ASSET_NAME, SECURITIES_GAIN_NAME, delta
else:
item = f'{ym} 평가차손'
left, right, money = SECURITIES_LOSS_NAME, SECURITIES_ASSET_NAME, -delta
journal = {
'entry_date': entry_date,
'item': item,
'money': str(money),
'left': left,
'right': right,
'memo': f'레이 inbox reconcile (msg={envelope["message_id"][:8]})',
}
ok, body = post_whooing(journal, dry_run=dry_run)
return {
**base,
'action': 'journaled' if ok else 'journal_failed',
'delta': delta,
'before': current,
'after': current + delta if ok else current,
'journal_ok': ok,
'note': body[:200] if not dry_run else 'dry-run',
'journal': journal,
}
def move_to(src: Path, dest_dir: Path, dry_run: bool = False) -> None:
if dry_run:
return
dest_dir.mkdir(parents=True, exist_ok=True)
target = dest_dir / src.name
if target.exists():
target = dest_dir / f'{src.stem}__{datetime.now(KST).strftime("%H%M%S")}{src.suffix}'
shutil.move(str(src), str(target))
def gc_processed(retention_days: int = PROCESSED_RETENTION_DAYS, dry_run: bool = False) -> dict:
"""processed/ 에서 mtime 이 retention_days 초과한 *.json 삭제.
반환: {removed: [filename, ...], kept: int}.
"""
if not PROCESSED_DIR.exists():
return {'removed': [], 'kept': 0}
cutoff = datetime.now(KST).timestamp() - retention_days * 86400
removed: list[str] = []
kept = 0
for fpath in PROCESSED_DIR.glob('*.json'):
try:
if fpath.stat().st_mtime < cutoff:
if not dry_run:
fpath.unlink()
removed.append(fpath.name)
else:
kept += 1
except OSError:
kept += 1
return {'removed': removed, 'kept': kept}
def count_failed_backlog() -> int:
"""failed/ 의 *.json 개수. 적체 alert 판단용."""
if not FAILED_DIR.exists():
return 0
return sum(1 for _ in FAILED_DIR.glob('*.json'))
def process_inbox(current_balance: dict | None = None, dry_run: bool = False) -> dict:
"""반환: {processed, failed, reconcile}. settlement 에서 호출 시 current_balance 주입.
current_balance 가 None 이면 첫 securities_balance 처리 시 lazy fetch.
dry_run=True 시 후잉 webhook + 텔레그램 자가 알림 모두 stdout 으로만 출력.
"""
global _TELEGRAM_DRY_RUN
_TELEGRAM_DRY_RUN = dry_run
INCOMING_DIR.mkdir(parents=True, exist_ok=True)
state = load_state()
processed_set = set(state.get('processed', []))
summary: dict = {
'processed': [], 'failed': [], 'reconcile': {},
'gc_removed': [], 'failed_backlog': 0,
}
# 매 호출마다 GC 한 번 + failed 적체 카운트 (incoming 비어있어도 수행)
gc_result = gc_processed(dry_run=dry_run)
summary['gc_removed'] = gc_result['removed']
files = sorted(INCOMING_DIR.glob('*.json'))
if not files:
summary['failed_backlog'] = count_failed_backlog()
return summary
balance = current_balance
for fpath in files:
try:
env = json.loads(fpath.read_text())
except Exception as e:
move_to(fpath, FAILED_DIR, dry_run=dry_run)
summary['failed'].append({'file': fpath.name, 'reason': f'JSON 파싱 실패: {e}'})
send_telegram(f'⚠️ 골디 inbox: JSON 파싱 실패\n{fpath.name}\n{e}')
continue
msg_id = env.get('message_id', '?')
if msg_id in processed_set:
move_to(fpath, PROCESSED_DIR, dry_run=dry_run)
continue
try:
validate_envelope(env)
topic = env['topic']
if topic == 'securities_balance':
validate_securities_payload(env['payload'])
if balance is None:
balance = fetch_whooing_balance()
result = handle_securities_balance(env, balance, dry_run=dry_run)
summary['reconcile'].setdefault(topic, []).append(result)
if result['action'] in ('rejected', 'journal_failed'):
move_to(fpath, FAILED_DIR, dry_run=dry_run)
reason = result['note']
summary['failed'].append({'file': fpath.name, 'reason': reason, 'message_id': msg_id})
send_telegram(f'⚠️ 골디 inbox: 분개 {result["action"]}\n{fpath.name}\n{reason}')
continue
# 분개 성공/aligned/noise/skipped 모두 processed 로 이동
if result.get('journal_ok') and result['action'] == 'journaled' and balance is not None:
# 후잉 잔액 in-memory 업데이트 (다음 파일 reconcile 시 정합성 위해)
for sec in balance.get('sections', []):
items = (sec.get('groups', {}).get('자산', {}) or {}).get('items', []) or []
for it in items:
if it.get('name') == SECURITIES_ASSET_NAME:
it['money'] = result['after']
else:
raise ValidationError(f'핸들러 없음: {topic}')
move_to(fpath, PROCESSED_DIR, dry_run=dry_run)
processed_set.add(msg_id)
summary['processed'].append({'file': fpath.name, 'topic': topic, 'message_id': msg_id})
except ValidationError as e:
move_to(fpath, FAILED_DIR, dry_run=dry_run)
summary['failed'].append({'file': fpath.name, 'reason': str(e), 'message_id': msg_id})
send_telegram(f'⚠️ 골디 inbox: 검증 실패\n{fpath.name}\n{e}')
except Exception as e:
move_to(fpath, FAILED_DIR, dry_run=dry_run)
tb = traceback.format_exc()[-500:]
summary['failed'].append({
'file': fpath.name, 'reason': f'{type(e).__name__}: {e}',
'message_id': msg_id, 'traceback': tb,
})
send_telegram(f'⚠️ 골디 inbox: 처리 예외\n{fpath.name}\n{type(e).__name__}: {e}')
if not dry_run and summary['processed']:
new_processed = list(processed_set)[-1000:]
state['processed'] = new_processed
save_state(state)
# 처리 후 failed 적체 재카운트 (이번 사이클의 신규 failed 포함)
summary['failed_backlog'] = count_failed_backlog()
return summary
def format_summary(summary: dict) -> str:
"""결산 메일 본문에 추가할 마크다운. reconcile 결과 + 실패 요약 + 적체 alert."""
backlog = summary.get('failed_backlog', 0)
has_alert = backlog >= FAILED_BACKLOG_THRESHOLD
has_content = (
summary['processed'] or summary['failed'] or summary.get('reconcile') or has_alert
)
if not has_content:
return ''
lines = ['## 인박스', '']
for result in summary.get('reconcile', {}).get('securities_balance', []):
action = result.get('action')
before = result.get('before')
after = result.get('after')
delta = result.get('delta')
if action == 'aligned':
lines.append(f'- **증권(효원):** {before:,}원 — 레이 ground truth 와 일치')
elif action == 'journaled':
sign = '평가차익' if (delta or 0) > 0 else '평가차손'
lines.append(
f'- **증권(효원):** {before:,}원 → {after:,}'
f'({sign} **{abs(delta):,}원** 자동 분개)'
)
elif action == 'noise':
lines.append(f'- **증권(효원):** 차액 {delta:+,}원 (1만원 미만, 분개 skip)')
elif action == 'skipped':
lines.append(f'- **증권(효원):** {result.get("note", "skip")}')
elif action == 'rejected':
lines.append(f'- **증권(효원):** ⚠️ 차액 {delta:+,}원 — 분개 거부 (안전 가드)')
elif action == 'journal_failed':
lines.append(f'- **증권(효원):** ⚠️ 분개 실패 — {result.get("note", "")[:120]}')
if summary['failed']:
lines.append('')
lines.append('### 이번 사이클 실패')
for f in summary['failed']:
lines.append(f'- `{f["file"]}` — {f["reason"]}')
if has_alert:
lines.append('')
lines.append(
f'### ⚠️ failed/ 적체 {backlog}건 — 검토·수동 삭제 필요'
)
lines.append(f'- 위치: `agents/budget/inbox/failed/`')
return '\n'.join(lines).rstrip() + '\n'
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument('--dry-run', action='store_true', help='검증만 수행, 분개 webhook·파일 이동·state 저장 안 함')
args = ap.parse_args()
summary = process_inbox(dry_run=args.dry_run)
print(json.dumps(summary, ensure_ascii=False, indent=2))
return 0 if not summary['failed'] else 1
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,378 @@
#!/usr/bin/env python3
"""월간 결산 — 후잉 순자산 변동을 전월 스냅샷과 비교해 메일/텔레그램으로 보고.
매월 1일 05:00 KST cron 실행 전제. 전월 스냅샷과 비교해:
- 메일: 계정별 증감 전체 (절대값 내림차순)
- 골디 텔레그램: 순자산 변동 + ±100만원 이상 top 5
Flags:
--dry-run 전송·저장 없이 본문만 stdout
--no-send 전송 생략, 스냅샷만 저장
--as-of DATE 기준일 강제 지정 (YYYY-MM-DD)
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import urllib.parse
import urllib.request
from datetime import date, datetime
from pathlib import Path
from zoneinfo import ZoneInfo
sys.path.insert(0, str(Path(__file__).parent))
import inbox_handler # noqa: E402
KST = ZoneInfo("Asia/Seoul")
WORKSPACE = Path("/Users/snowoyh/.openclaw/agents/budget/workspace")
STATE_FILE = WORKSPACE / "state" / "monthly_snapshots.json"
BALANCE_SCRIPT = WORKSPACE / "skills" / "whooing-sync" / "scripts" / "whooing_balance.py"
CONFIG_PATH = Path("/Users/snowoyh/.openclaw/openclaw.json")
EMAIL_RECIPIENT = "mini.snowoyh@gmail.com"
TELEGRAM_ACCOUNT = "budget" # 골디
TOP_THRESHOLD = 1_000_000
TOP_MAX = 5
def fmt_won(n: int) -> str:
sign = "+" if n > 0 else ("-" if n < 0 else "")
return f"{sign}{abs(n):,}"
def fetch_balance(as_of: str | None) -> dict:
cmd = ["python3", str(BALANCE_SCRIPT), "--json"]
if as_of:
cmd += ["--as-of", as_of]
p = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if p.returncode != 0:
raise RuntimeError(f"whooing_balance.py 실패: {p.stderr.strip() or p.stdout.strip()}")
return json.loads(p.stdout)
def load_snapshots() -> dict:
if STATE_FILE.exists():
try:
return json.loads(STATE_FILE.read_text())
except Exception:
pass
return {}
def save_snapshots(snaps: dict) -> None:
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps(snaps, ensure_ascii=False, indent=2))
def section_items(section: dict, group_ko: str) -> dict[str, dict]:
"""group의 items를 account_id → {name, money}로 변환."""
items = section.get("groups", {}).get(group_ko, {}).get("items", []) or []
return {str(it["account_id"]): {"name": it["name"], "money": it["money"]} for it in items}
def section_total(section: dict, group_ko: str) -> int:
return int(section.get("groups", {}).get(group_ko, {}).get("total", 0) or 0)
def compute_section_deltas(prev: dict, curr: dict) -> dict:
"""전월/이번달 섹션 한 쌍을 받아 순자산/자산/부채 변동 + 계정별 변동 리스트 반환."""
result = {
"section_id": curr.get("section_id"),
"title": curr.get("title"),
"totals": {},
"accounts": [], # [{group, account_id, name, prev, curr, delta}]
}
for group_ko in ("자산", "부채", "자본"):
prev_total = section_total(prev, group_ko) if prev else 0
curr_total = section_total(curr, group_ko)
result["totals"][group_ko] = {
"prev": prev_total,
"curr": curr_total,
"delta": curr_total - prev_total,
}
for group_ko in ("자산", "부채"):
prev_items = section_items(prev, group_ko) if prev else {}
curr_items = section_items(curr, group_ko)
all_ids = set(prev_items) | set(curr_items)
for aid in all_ids:
p_item = prev_items.get(aid)
c_item = curr_items.get(aid)
prev_money = p_item["money"] if p_item else 0
curr_money = c_item["money"] if c_item else 0
delta = curr_money - prev_money
if delta == 0 and prev_money == 0 and curr_money == 0:
continue
name = (c_item or p_item)["name"]
result["accounts"].append({
"group": group_ko,
"account_id": aid,
"name": name,
"prev": prev_money,
"curr": curr_money,
"delta": delta,
})
return result
def format_email(report_ym: str, deltas: dict, has_prev: bool) -> tuple[str, str]:
"""메일 제목/본문 반환."""
subject = f"[월간결산] {report_ym[:4]}{int(report_ym[5:])}월 자산 변동"
lines: list[str] = []
if not has_prev:
lines.append(f"# {subject}\n")
lines.append("첫 스냅샷을 저장했습니다. 다음달 1일부터 전월 대비 비교가 시작됩니다.\n")
lines.append(f"## 현재 스냅샷 ({deltas['title']})\n")
for ko in ("자산", "부채", "자본"):
lines.append(f"- **{ko} 합계:** {deltas['totals'][ko]['curr']:,}")
return subject, "\n".join(lines) + "\n"
nw = deltas["totals"]["자본"]
asset = deltas["totals"]["자산"]
liab = deltas["totals"]["부채"]
lines.append(f"# {subject}\n")
lines.append(f"섹션: {deltas['title']}\n")
lines.append("## 합계 변동\n")
lines.append(f"- **순자산:** {nw['prev']:,}원 → {nw['curr']:,}원 (**{fmt_won(nw['delta'])}**)")
lines.append(f"- 자산: {asset['prev']:,}원 → {asset['curr']:,}원 ({fmt_won(asset['delta'])})")
lines.append(f"- 부채: {liab['prev']:,}원 → {liab['curr']:,}원 ({fmt_won(liab['delta'])})")
lines.append("")
for group_ko in ("자산", "부채"):
rows = [a for a in deltas["accounts"] if a["group"] == group_ko]
rows.sort(key=lambda x: abs(x["delta"]), reverse=True)
if not rows:
continue
lines.append(f"## {group_ko} 계정별 변동\n")
for a in rows:
tag = ""
if a["prev"] == 0 and a["curr"] != 0:
tag = " · 신규"
elif a["curr"] == 0 and a["prev"] != 0:
tag = " · 청산"
lines.append(
f"- **{a['name']}**: {a['prev']:,}원 → {a['curr']:,}"
f"({fmt_won(a['delta'])}){tag}"
)
lines.append("")
return subject, "\n".join(lines).rstrip() + "\n"
def format_telegram(report_ym: str, deltas: dict, has_prev: bool) -> str:
month_label = f"{report_ym[:4]}{int(report_ym[5:])}"
if not has_prev:
return (
f"📊 {month_label} 결산\n"
f"첫 스냅샷 저장 완료 — 다음달부터 비교 시작합니다."
)
nw = deltas["totals"]["자본"]
pct = (nw["delta"] / nw["prev"] * 100) if nw["prev"] else 0.0
pct_str = f"{pct:+.2f}%" if nw["prev"] else "N/A"
lines = [
f"📊 {month_label} 결산",
f"순자산 {nw['prev']:,}{nw['curr']:,}원 ({fmt_won(nw['delta'])}, {pct_str})",
]
movers = [a for a in deltas["accounts"] if abs(a["delta"]) >= TOP_THRESHOLD]
movers.sort(key=lambda x: abs(x["delta"]), reverse=True)
movers = movers[:TOP_MAX]
if movers:
lines.append("")
lines.append("주요 변동:")
for a in movers:
arrow = "" if a["delta"] > 0 else ""
tag = ""
if a["prev"] == 0:
tag = " (신규)"
elif a["curr"] == 0:
tag = " (청산)"
lines.append(f"{arrow} {a['name']}{tag}: {fmt_won(a['delta'])}")
else:
lines.append("")
lines.append(f"±{TOP_THRESHOLD//10000}만원 이상 변동 없음")
return "\n".join(lines)
def send_email(subject: str, body: str) -> None:
cmd = ["gog", "gmail", "send", "--to", EMAIL_RECIPIENT, "--subject", subject, "--body-file", "-"]
p = subprocess.run(cmd, input=body, text=True, capture_output=True, timeout=60)
if p.returncode != 0:
raise RuntimeError(f"메일 발송 실패: {p.stderr.strip() or p.stdout.strip()}")
def send_telegram(text: str) -> None:
cfg = json.loads(CONFIG_PATH.read_text())
acct = cfg["channels"]["telegram"]["accounts"][TELEGRAM_ACCOUNT]
token = acct["botToken"]
chat_ids = acct.get("allowFrom") or []
if not chat_ids:
raise RuntimeError("골디 텔레그램 allowFrom 비어있음")
url = f"https://api.telegram.org/bot{token}/sendMessage"
for chat_id in chat_ids:
payload = {
"chat_id": chat_id,
"text": text[:4000],
"disable_web_page_preview": "true",
}
data = urllib.parse.urlencode(payload).encode()
req = urllib.request.Request(url, data=data, method="POST")
with urllib.request.urlopen(req, timeout=15) as r:
if r.status != 200:
body = r.read().decode("utf-8", errors="replace")
raise RuntimeError(f"텔레그램 HTTP {r.status}: {body[:200]}")
def format_inbox_telegram_line(summary: dict) -> str:
"""텔레그램용 인박스 한 줄. reconcile 결과만 요약, 실패 건수 있으면 표시."""
parts: list[str] = []
for r in summary.get("reconcile", {}).get("securities_balance", []):
action = r.get("action")
delta = r.get("delta") or 0
if action == "aligned":
parts.append("증권 일치")
elif action == "journaled":
sign = "차익" if delta > 0 else "차손"
parts.append(f"증권 평가{sign} {abs(delta):,}원 분개")
elif action == "noise":
parts.append(f"증권 차액 {delta:+,}원 (노이즈)")
elif action == "rejected":
parts.append(f"⚠️ 증권 차액 {delta:+,}원 거부")
elif action == "journal_failed":
parts.append("⚠️ 증권 분개 실패")
elif action == "skipped":
parts.append("증권 reconcile skip")
# rejected/journal_failed 는 위 reconcile 라인에서 이미 표시됨 — 중복 카운트 방지
reconcile_failed = sum(
1 for r in summary.get("reconcile", {}).get("securities_balance", [])
if r.get("action") in ("rejected", "journal_failed")
)
other_failed = max(0, len(summary.get("failed", [])) - reconcile_failed)
if other_failed:
parts.append(f"인박스 검증실패 {other_failed}")
backlog = summary.get("failed_backlog", 0)
if backlog >= inbox_handler.FAILED_BACKLOG_THRESHOLD:
parts.append(f"⚠️ failed/ 적체 {backlog}")
if not parts:
return ""
return "📨 " + " · ".join(parts)
def prev_month_key(ym: str) -> str:
y, m = int(ym[:4]), int(ym[5:])
if m == 1:
return f"{y-1:04d}-12"
return f"{y:04d}-{m-1:02d}"
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true", help="전송·저장 없이 본문만 출력")
ap.add_argument("--no-send", action="store_true", help="전송 생략, 스냅샷만 저장")
ap.add_argument("--as-of", help="기준일 (YYYY-MM-DD). 기본: 오늘")
args = ap.parse_args()
as_of_str = args.as_of or datetime.now(KST).strftime("%Y-%m-%d")
snapshot_ym = as_of_str[:7] # YYYY-MM (스냅샷 시점의 월)
report_ym = prev_month_key(snapshot_ym) # 결산 대상: 직전 달
# 인박스 reconcile 먼저 — securities_balance 분개가 후잉 잔액을 바꾸므로
# fetch_balance 이전에 처리해야 결산이 분개 후 스냅샷을 본다.
# --dry-run 또는 --no-send 시 webhook·텔레그램 자가알림 모두 차단 (안전 디폴트).
inbox_dry = args.dry_run or args.no_send
try:
inbox_summary = inbox_handler.process_inbox(dry_run=inbox_dry)
except Exception as e:
print(f"⚠️ 인박스 처리 예외 (결산은 계속): {e}", file=sys.stderr)
inbox_summary = {
"processed": [], "failed": [{"file": "(handler)", "reason": str(e)}],
"reconcile": {}, "gc_removed": [], "failed_backlog": 0,
}
# cron 로그용 GC·적체 진단 한 줄 (메일·텔레그램은 깨끗 유지)
n_gc = len(inbox_summary.get("gc_removed", []))
n_backlog = inbox_summary.get("failed_backlog", 0)
n_proc = len(inbox_summary.get("processed", []))
n_fail = len(inbox_summary.get("failed", []))
suffix = " [dry-run]" if inbox_dry else ""
print(
f"인박스: processed={n_proc} failed={n_fail} "
f"gc_removed={n_gc} failed_backlog={n_backlog}{suffix}",
file=sys.stderr,
)
current = fetch_balance(as_of_str)
if not current.get("sections"):
print("error: 후잉 섹션 없음", file=sys.stderr)
return 2
curr_sec = current["sections"][0] # 단일 섹션 전제
snapshots = load_snapshots()
prev_entry = snapshots.get(report_ym)
prev_sec = None
if prev_entry and prev_entry.get("sections"):
prev_sec = prev_entry["sections"][0]
deltas = compute_section_deltas(prev_sec or {}, curr_sec)
has_prev = prev_sec is not None
subject, email_body = format_email(report_ym, deltas, has_prev)
tg_body = format_telegram(report_ym, deltas, has_prev)
inbox_block = inbox_handler.format_summary(inbox_summary)
if inbox_block:
email_body = email_body.rstrip() + "\n\n" + inbox_block
# 텔레그램은 한 줄 요약만 — 자세한 건 메일에서 확인
tg_inbox_line = format_inbox_telegram_line(inbox_summary)
if tg_inbox_line:
tg_body = tg_body + "\n\n" + tg_inbox_line
if args.dry_run:
print("=" * 60)
print(f"SUBJECT: {subject}")
print("=" * 60)
print(email_body)
print("=" * 60)
print("TELEGRAM:")
print("=" * 60)
print(tg_body)
return 0
if not args.no_send:
try:
send_email(subject, email_body)
except Exception as e:
print(f"⚠️ 메일 발송 실패: {e}", file=sys.stderr)
return 1
try:
send_telegram(tg_body)
except Exception as e:
print(f"⚠️ 텔레그램 발송 실패: {e}", file=sys.stderr)
return 1
snapshots[snapshot_ym] = current
save_snapshots(snapshots)
if has_prev:
nw_delta = deltas["totals"]["자본"]["delta"]
tail = "메일+텔레그램 전송 완료" if not args.no_send else "저장만 수행"
print(f"✅ 월간결산 {report_ym}: 순자산 {fmt_won(nw_delta)}, {tail}")
else:
tail = "메일+텔레그램 전송 완료" if not args.no_send else "저장만 수행"
print(f"✅ 월간결산 {report_ym}: 첫 스냅샷 저장, {tail}")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,369 @@
# whooing-sync
iMessage에 들어오는 카드/은행 결제 알림을 후잉(whooing.com) 웹훅으로 자동 전송한다.
## When to use
- 사용자가 "가계부 동기화" / "후잉 동기화" / "결제내역 정리" 요청할 때
- launchd가 매시 0/15/30/45분 자동 호출 (기본 운용 — **OpenClaw cron 아님**, 아래 "스케줄러" 참고)
- 매핑되지 않은 새 발신번호가 발견됐을 때 보고
## How
```bash
python3 /Users/snowoyh/.openclaw/agents/budget/workspace/skills/whooing-sync/scripts/whooing_sync.py
```
기본 동작:
1. `credentials/whooing.json`에서 webhook URL 로드 (없으면 종료)
2. `state/whooing_account_map.json``confirmed: true` 발신번호만 대상으로 imsg history 조회
3. `state/whooing_synced.json``last_message_at` 이후 메시지만 처리
4. 각 메시지 원문을 `message=<원문>` 형태로 후잉 웹훅에 POST
5. 후잉 응답 200이면 `whooing_synced.json` 업데이트, 아니면 `whooing_failures.json`에 기록
6. `confirmed: false`이거나 매핑 없는 발신번호 메시지가 들어오면 `whooing_failures.json``unmapped` 섹션에 1회 기록 (반복 알림 방지)
## Output
마지막 줄에 한 줄 요약 출력:
```
✅ 후잉 동기화: transfer N건, structured N건, raw M건, 실패 K건 (last=2026-04-23T12:34:56+09:00)
```
launchd/cron 이 이 줄을 그대로 결과로 받는다.
### 텔레그램 알림 (골디)
`notify.py` 를 통해 관리자 텔레그램(openclaw.json `channels.telegram.accounts.budget`)으로 두 종류 알림이 발송된다:
- **실패 알림** — 후잉 웹훅이 거절(HTTP 에러, 본문 `fail` / `Error :`)한 건. 실패 1건당 1메시지, 4건 이상이면 앞 3건 + 초과분 요약. `_format_sync_failure()` 포맷.
- **raw 폴백 알림** — 후잉은 200 받았지만 structured 매칭 실패로 raw 모드로 넘어간 건. sync 사이클당 **1메시지**(최대 3건 나열, 초과 시 카운트). parser / carrier_to_account / merchant_map 보완 신호. `_format_raw_fallback()` 포맷.
같은 SMS 1건이 두 알림에 동시에 걸리는 일은 없다 (`if ok / else` mutually exclusive). 한 사이클에서 서로 다른 SMS 들이 각각 raw · 실패로 나뉘면 두 메시지가 함께 간다.
`--dry-run` 시에는 둘 다 발송 안 함.
## 스케줄러 (launchd)
15분 주기 자동 실행은 **launchd**가 담당. 이전엔 OpenClaw cron 의 `후잉 가계부 동기화` (agentTurn) 잡을 썼으나, 매 실행마다 LLM 세션 부팅해서 토큰 낭비가 커 (월 ~70M 토큰 추정) launchd 로 전환됨. 그 OpenClaw cron 잡은 2026-04-24 에 삭제됨 — **다시 만들지 말 것**.
- plist: `~/Library/LaunchAgents/ai.openclaw.budget.whooing-sync.plist`
- label: `ai.openclaw.budget.whooing-sync`
- 스케줄: StartCalendarInterval 매시 0/15/30/45분
- 로그: `/Users/snowoyh/.openclaw/logs/whooing-sync.log` (stdout), `.err.log` (stderr)
- PATH: plist 에 `/opt/homebrew/bin` 주입 (`imsg` 해석용)
제어 커맨드:
```bash
# 수동 실행 (1회)
launchctl kickstart -p gui/$(id -u)/ai.openclaw.budget.whooing-sync
# 상태 확인
launchctl print gui/$(id -u)/ai.openclaw.budget.whooing-sync
# 실시간 로그
tail -f /Users/snowoyh/.openclaw/logs/whooing-sync.log
# 언로드 / 재로드
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/ai.openclaw.budget.whooing-sync.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.openclaw.budget.whooing-sync.plist
```
## 전제조건: Full Disk Access (FDA)
launchd 컨텍스트에서 `imsg``~/Library/Messages/chat.db` 를 읽으려면 FDA 허용이 필요. 터미널에서 직접 돌릴 땐 터미널 앱의 FDA 를 자식 프로세스가 상속받지만, launchd 는 상속 안 됨.
- 등록 대상: `/opt/homebrew/bin/imsg`
- 경로: 시스템 설정 → 개인정보 보호 및 보안 → 전체 디스크 접근 권한 → `+` 로 추가 → 토글 ON
**FDA 누락 증상**: stderr 로그에
```
❌ imsg chats 실행 실패: Expecting value: line 1 column 1 (char 0)
```
가 뜨고, stdout 은 `🟢 새 결제 메시지 없음` 으로 조용히 끝남. 오류처럼 보이지 않아 놓치기 쉬움. 맥 이전 / imsg 재설치 시 FDA 재등록 필요.
## 페어 매칭 (자기 계좌 간 이체)
같은 금액의 입↔출 SMS 가 5분 이내 쌍으로 도착하면 **1건의 자산↔자산 structured 이체**로 합성 POST 한다. 중복 엔트리 방지가 목적.
상수 (`scripts/whooing_sync.py` 상단):
- `PAIR_WINDOW_SECONDS = 300` — 두 SMS created_at 차이 허용 범위
- `HOLD_GRACE_SECONDS = 300` — 페어 없는 입출금이 이 나이 미만이면 hold (다음 cron 재시도)
조건: 양쪽 carrier 모두 `whooing_accounts.json``carrier_to_account` 에 자산 계정명이 매핑돼 있어야 함. 하나라도 비면 개별 처리로 폴백.
출력 예시:
```
🔄 [transfer] 하나→신한은행 2026-04-24T00:53:55Z 200 | 신한은행(효원) ← 하나은행(효원) 10,000원 (이체 하나→신한은행)
```
페어 미성립 케이스:
- 한쪽 은행이 confirmed=false → SMS 수집 안 됨. 메모 키워드("카뱅오픈방효원")가 `merchant_map.contains` 와 맞으면 withdrawal-only structured 로 처리. 아니면 raw.
- 시간창 벗어남 / 단독 입출금 / 외부 송금 → 각자 개별 처리.
### merchant_map 주의사항
`state/whooing_merchant_map.json` exact 룰에 `"방효원": { "left": "기초잔액(효원)" }` 가 등록돼 있다. 페어 미성립 시 이 룰로 폴백해서 **기초잔액을 경유한 잘못된 분개**가 기록될 수 있다. 한쪽 carrier 가 confirmed=false 일 때 구멍이 크다.
- 페어 매칭이 1차 방어선.
- 그래도 오류 기록이 보이면 `whooing_manual.py` 로 역분개하거나 후잉 UI 에서 삭제 후 올바른 이체로 재등록.
- 메모에 목적지 은행 키워드("카뱅", "신한" 등) 가 일관되게 들어오면 `merchant_map.contains` 에 등록해 해결 가능 (현재 미등록).
### 기본 Fallback: 모르는 비용 = 기타비용
`card_approval`, `withdrawal``exact` / `contains` 매칭이 없는 건은 **`기타비용 ← {carrier 자산}`** structured 로 자동 분개된다 (관리자님 지시, 2026-04-24).
- 사람 이름 송금(예: "박영춘", "이지윤")은 exact 룰로 등록하지 말고 default fallback 에 맡긴다. merchant_map 비대화 방지.
- `deposit` 은 default fallback 없음 — rule 없으면 raw 폴백 (수익/이체/환급 구분 위험 때문).
- 기존 contains(예: "스타벅스 → 식비") / exact(예: "방효원 → 기초잔액(효원)") 는 계속 유효. fallback 은 둘 다 miss 일 때만 탄다.
- 결과적으로 자잘한 인명 송금·가맹점 미등록 건은 전부 기타비용으로 자동 분류되고, 분류가 필요한 것만 후잉 UI 에서 사후 조정하거나 merchant_map 에 규칙 추가한다.
### 우선 룰 (whooing_overrides.json)
`build_structured()` 보다 먼저 평가되는 우선순위 룰. 정기결제·시간대 기반 분개 등 default fallback(기타비용) 으로 떨어뜨리면 안 되는 케이스를 JSON 으로 정의한다. 코드 수정 없이 룰 추가/수정/삭제 가능.
파일: `state/whooing_overrides.json`. `whooing_sync.py` 가 매 실행마다 다시 읽어 launchd 재기동 불필요.
#### 룰 평가 흐름
1. `apply_overrides()``rules` 를 위에서 아래로 평가, 첫 매칭 룰의 `post` 를 후잉 structured payload 로 변환
2. 매칭 실패 / `enabled: false` / `left` 누락 / carrier_to_account 미등록 → 다음 룰
3. 모든 룰 매칭 실패 → `build_structured()` 폴백 (기존 merchant_map 룰 → default 기타비용)
#### 룰 스키마
```json
{
"name": "사람이 읽을 이름",
"enabled": true,
"match": { "...": "매처들 (전부 AND)" },
"post": { "left": "...", "right": "...", "item": "...", "memo": "..." },
"note": "선택. 운영 메모"
}
```
#### 매처 (match — 모두 옵션, 지정한 것만 AND 평가)
| 키 | 의미 |
|---|---|
| `kind` | `card_approval` / `card_cancel` / `withdrawal` / `deposit` 중 하나와 정확히 일치 |
| `merchant_contains` | 가맹점 문자열에 해당 부분문자열 포함 |
| `merchant_regex` | 가맹점이 정규식과 매칭 (`re.match`) |
| `merchant_unmapped` | true 시 `merchant_map` exact/contains 에 등록되지 않은 가맹점만 |
| `amount_eq` | 금액 정확 일치 |
| `carrier_in` | carrier 가 리스트 안에 (`hana_bank`, `shinhan_card`, `hyundai_card` 등) |
| `weekday_in` | 거래일 요일이 리스트 안에 (월=0 ~ 일=6) |
| `scheduled_day_of_month` | 결제 예정일(월별 day). `weekend_policy` 와 짝 |
| `weekend_policy` | `exact`(기본, 그 날만) / `next_weekday`(주말이면 [원래일~다음 평일] 윈도우 매칭) |
| `time_kst_between` | KST 시각이 윈도우 안에 (예: `["13:00", "14:30"]`, 양 끝 포함) |
#### post 변수 치환
`left` / `right` / `item` / `memo` 문자열에 `{merchant}`, `{raw}`, `{amount}`, `{carrier_account}` 가 치환된다.
- `right` 미지정 → 자동으로 `{carrier_account}` (해당 카드/통장 자산명)
- `item` 미지정 → SMS merchant 사용
- `memo` 미지정 → SMS 원문(`{raw}`) 사용
- `left` 누락 → 룰 무효 처리 (다음 룰로 이동)
- 모든 문자열은 후잉 제한에 맞춰 200자 truncate
#### 현재 등록된 룰
1. **쿠팡 정기결제** — card_approval · 가맹점 "쿠팡" · 7,890원 · 매월 29일 (`next_weekday` 윈도우, 주말이면 다음 평일까지) → `지식,문화 ← {carrier}` (item=`계정결제_쿠팡`)
2. **평일 점심 인명송금** — withdrawal · carrier ∈ {하나/신한/카뱅} · 가맹점 한글 2~4자 · `merchant_map` 미등록 · 평일 13:00~14:30 KST → `식비 ← {carrier}` (item=`점심식사`, memo=`{merchant}에게 송금 | {raw}`)
3. **퇴직연금 자동이체** — withdrawal · 하나은행 · merchant 포함 "44891006239452" · 100,000원 · 매월 25일 (`next_weekday`) → `하나IRP(효원) ← 하나은행(효원)` (item=`퇴직연금`)
4. **프리드라이프 정기납부** — withdrawal · 하나은행 · merchant 포함 "프리드" · 33,000원 · 매월 25일 (`next_weekday`) → `의료,건강,보험 ← 하나은행(효원)` (item=`프리드라이프`)
5. **우체국보험 정기납부** — withdrawal · 하나은행 · merchant 포함 "우체" · 251,790원 · 매월 25일 (`next_weekday`) → `의료,건강,보험 ← 하나은행(효원)` (item=`보험료`)
3·4·5번은 `amount_eq` 포함 엄격 매칭 — 금액·결제일·채널 어긋나면 default fallback "기타비용" 으로 빠지면서 raw 폴백 텔레그램 알림이 트리거되어 이상 거래 감지에 활용. 보험료 인상 등 정상 변경 시 룰의 `amount_eq` 갱신 필요.
#### 룰 추가 / 수정 / 삭제
- 새 정기결제 등록 — `whooing_overrides.json` `rules` 끝에 객체 추가
- 일시 비활성화 — `enabled: false`
- 우선순위 변경 — 배열 순서 조정 (먼저 매칭되는 룰이 이김)
- 정기결제·자동이체·시간대 분개는 모두 `whooing_overrides.json` 으로. `whooing_merchant_map.json` 에는 일반 가맹점 분류 룰(스타벅스 → 식비 등) + 일회성 / 자체이체 같은 비-정기 분류만 둔다.
## Dry-run
```bash
python3 .../whooing_sync.py --dry-run
```
후잉으로 실제 POST는 하지 않고, 어떤 메시지가 어떤 발신번호에서 잡혀 어디로 갈지 stdout에 표시.
## 직접 입력 (iMessage 없이)
사용자가 "후잉에 직접 등록해줘" / "가계부에 한 건 추가" 요청할 때 `whooing_manual.py` 사용.
```bash
# structured (차트 검증 O)
python3 .../whooing_manual.py --item "스타벅스" --money 5800 --left "식비" --right "신한신용(효원)" [--date 20260423] [--memo "오전 커피"]
# raw (후잉 자체 파서에 위임)
python3 .../whooing_manual.py --message "스타벅스 5800원 신한신용"
# 사전 확인
python3 .../whooing_manual.py --dry-run --item ... --money ...
```
- left/right는 `state/whooing_accounts.json``categories` 에 있는 이름만 허용 (차트 외 이름은 후잉이 거부).
- `whooing_synced.json` 은 건드리지 않음 (SMS 흐름과 독립).
- 실패 시 `whooing_failures.json``source: "manual"` 로 기록.
### 에이전트 대화 프로토콜 (스텝바이스텝)
사용자가 에이전트(클로 또는 골디)를 통해 직접 입력할 때는 **한 번에 하나씩** 묻는다. 한 메시지에 여러 질문을 몰아넣지 않는다. 각 단계에서 사용자가 정보를 먼저 제시했으면 해당 단계는 건너뛴다.
1. **항목** — "어떤 항목이에요? (예: 스타벅스)"
2. **금액** — "얼마인가요? (원)"
3. **결제수단(right)** — 먼저 `whooing_accounts.json``부채`(카드) + `자산`(통장) 목록을 읽어 번호 매긴 후보로 제시. "어느 걸로 결제하셨어요?" 사용자가 모호하게 답하면 가장 가까운 후보로 확인받는다.
4. **카테고리(left)** — 결제면 `비용`, 입금이면 `수익`, 계좌이체면 `자산`/`부채`. 해당 카테고리 목록을 번호로 제시 후 선택. 애매하면 추측하지 말고 묻는다.
5. **날짜** — 기본 오늘(KST). "오늘로 할까요, 다른 날짜예요?" 짧게만.
6. **메모** — "메모 있으세요? (없으면 스킵)"
7. **확인 후 실행**`--dry-run` 없이 `whooing_manual.py` structured 모드로 실제 POST. 결과 한 줄로 보고.
원칙:
- 차트에 없는 계정명으로 절대 추측 POST 금지. 모르면 반드시 재질문.
- 사용자가 이미 한 문장에 "스타벅스 5800원 신한카드로" 다 말했다면, 카테고리만 확인하고 바로 실행.
- 카드 취소/환불이면 structured 대신 `--message` raw 모드로 원문 보내 후잉이 상쇄하게 한다.
## 잔액 조회 (whooing_balance.py)
관리자님이 "잔액 확인", "후잉 잔액", "자산 얼마야", "가계부 현황", "순자산 얼마야", "○○카드 얼마 썼어", "○○계좌 잔고" 같은 요청을 하면 이걸로 조회한다.
```bash
# 기본 (오늘 기준, 모든 섹션)
python3 /Users/snowoyh/.openclaw/agents/budget/workspace/skills/whooing-sync/scripts/whooing_balance.py
# 특정 날짜 기준
python3 .../whooing_balance.py --as-of 2026-03-31
# 특정 섹션만 (멀티섹션 사용 시)
python3 .../whooing_balance.py --section-id s128867
# 구조화 JSON (필터링·가공 필요 시)
python3 .../whooing_balance.py --json
```
### 동작
1. `credentials/whooing.json``api` 블록(`app_id`/`token`/`signature`)으로 `X-API-KEY` 헤더 생성 (nounce+timestamp는 매 호출마다 자동).
2. `sections.json` → 섹션 목록 조회
3. `accounts.json?section_id=...` → account_id→이름 매핑 로드
4. `bs.json?section_id=...&start_date=19000101&end_date=<오늘>` → 자산/부채/자본 스냅샷
5. 마크다운으로 카테고리별 합계 + 계정별 금액을 금액 내림차순으로 출력 (0원 계정은 생략)
### 출력 형식
```
## 후잉 잔액 (기준일 2026-04-23)
### [s128867] 효원
- **자산 합계:** 352,136,595원
- 롯데캐슬: 115,810,000원
- ...
- **부채 합계:** 1,339,391원
- ...
- **자본 합계:** 350,797,204원
```
관리자님께 보고할 때는 이 stdout을 그대로 쓰거나, 질문 맥락에 맞게 발췌해서 한 줄 요약으로 축약한다 (예: "순자산 350,797,204원 · 자산 3.52억 · 부채 1,339,391원").
### 특정 계정만 보고 싶을 때
`--json` 으로 돌려서 파이썬/jq로 필터한다.
```bash
python3 .../whooing_balance.py --json \
| python3 -c 'import sys,json; d=json.load(sys.stdin); [print(f"{it[\"name\"]}: {it[\"money\"]:,}원") for s in d["sections"] for it in s["groups"]["자산"]["items"] if "신한" in it["name"]]'
```
### 원칙
- 잔액은 후잉에 실제 등록된 값이다. 의심스러우면 `--as-of` 로 다른 날짜 찍어 비교.
- 외부(메일/텔레그램)로 잔액 데이터를 내보내려면 관리자님 허락 먼저.
- 호출은 읽기 전용이라 dry-run 개념 없음. 바로 실행해도 된다.
- 실패 시 대부분 크리덴셜 문제 → `credentials/whooing.json``api` 블록 확인하고 보고.
## 가희 잔액 리마인더 & 자동분개 (gahee_reminder.py)
`whooing_sync.main()` 끝에서 `gahee_reminder.run(webhook_url, post_to_whooing, dry_run)` 가 매 사이클 호출된다. 별도 launchd plist 없이 whooing-sync 사이클(매 15분)에 piggyback.
### 흐름
1. **발신 게이트** — KST 기준 `day >= send_day_of_month` (catch-up 정책) 이고 시각이 `send_hour_kst` 이상이며 `last_sent_month` ≠ 이번 달이면 1회 발신. 25일에 Mac이 꺼져 있었어도 26~월말 사이 켜면 그 시점에 발신. 다음 달로 넘어가면 포기.
2. **응답 폴링**`imsg chats --json` → 가희 chat 찾기 → `imsg history --chat-id X --start <last_processed_message_at> --attachments --json`. 가희 발신(is_from_me≠true) 메시지만 처리.
3. **1차 스캔** (즉시 워터마크 갱신):
- **이미지 첨부 있음** → 자동분개 X, 골디 텔레그램 알림만 ("이미지 답신 — 직접 처리 부탁")
- **빈 메시지·우리 발신** → 스킵
- **가희 텍스트** → 따로 모아둠 (분개 대상 후보)
4. **2차 처리 — 가희 텍스트가 여러 통이면 마지막만 분개**:
- 가희가 수정·재발송했을 때 첫 메시지로 잘못 분개되는 사고 방지. 이전 N-1통은 워터마크만 갱신, 별도 알림으로 스킵 사실 보고.
- **페어 0건** → 자동분개 X, 골디 텔레그램 알림 ("포맷 오류"). 워터마크 갱신 (재처리 무의미).
- **페어 1건 이상** → 합계 계산 → 후잉 `가희주머니` 현재 잔고 조회 → 차액 분개:
- `diff > 0`: `가희주머니 ← 가희비밀주머니_수익` (수익)
- `diff < 0`: `기타비용 ← 가희주머니` (비용)
- `diff == 0`: 분개 생략, 알림만
5. **분개 실패 시 워터마크 미갱신** — 후잉 API 일시 장애(503 등) 시 워터마크를 갱신하지 않아 다음 사이클(15분 후)에 같은 메시지가 다시 polling되어 재시도. 같은 사이클에 발송된 이미지·이전 텍스트 알림은 다음 사이클에 중복 발송될 수 있음 (실무상 허용 — 후잉 다운은 드물고 회복 빠름).
6. **완료 알림** — 골디 텔레그램으로 합계·차액·분개 방향 보고.
### state 파일
`state/gahee_reminder.json`:
```json
{
"send_day_of_month": 25,
"send_hour_kst": 10,
"message_template": "가계부 업데이트 날이에요. 계좌잔액 보내주시면 자동으로 반영됩니다.",
"last_sent_month": null,
"last_processed_message_at": null,
"whooing_account_name": "가희주머니",
"income_account": "가희비밀주머니_수익",
"expense_account": "기타비용"
}
```
- `last_sent_month` = "YYYY-MM" 발신 직후 기록. 같은 달에 재발신 안 함.
- `last_processed_message_at` = imsg history `--start` 의 ISO 워터마크. 처리한 메시지의 created_at 최댓값.
- 발신 문구·트리거 일자·계정명 변경은 모두 이 JSON 직접 편집. 코드 재배포 불필요. whooing-sync 가 매 사이클 다시 읽음.
### 수신자 설정
`/Users/snowoyh/.openclaw/credentials/gahee_imessage.json`:
```json
{
"handle": "+821055595428",
"display": "010-5559-5428",
"normalized": "01055595428"
}
```
- `handle``imsg send --to` 에 그대로 사용. 국가코드 포함 E.164.
- `normalized` 는 chat identifier 매칭용 (양 끝 어떤 형식이든 `_normalize()` 가 같은 값으로 정규화).
### 파싱 규칙
정규식 `([가-힣A-Za-z][가-힣A-Za-z0-9()\s]{0,18}?)\s*[:]\s*([\d,]+)``라벨 : 금액` 페어 추출. 풀와이드 콜론(``)·줄바꿈 모두 OK. 같은 라벨 중복 시 마지막 값 사용.
**안전장치 없음 (관리자님 결정 2026-05-21)** — 차액 임계치·금액 제한 검증 없이 무조건 분개. 자유 자연어 답신("국민 통장에 42995 있어요" 류)은 라벨이 이상하게 잡힐 수 있으나, 라벨이 어떻든 합계 자체는 보존되므로 분개 금액은 의도와 일치한다. 메모란에 원본 라벨이 그대로 박혀 사후 추적 가능.
### 분개 메모 형식
```
[자동] 가희 잔액 갱신 | 국민:42,995 신한:4,161,585 ... | 합계 15,349,787 (이전 25,372,598)
```
200자 truncate. 후잉 UI 거래내역에서 그대로 검색 가능.
### 트러블슈팅
- **첫 발신 후 응답이 안 잡힘** — `imsg chats --json` 으로 `+821055595428` chat 이 존재하는지 확인. 발신이 실패했으면 chat 자체가 안 생긴다.
- **계속 같은 메시지가 다시 분개됨** — `last_processed_message_at` 가 갱신 안 되고 있다는 뜻. dry-run 으로 호출되고 있는지 (`args.dry_run` true 면 state 저장 안 함) 확인.
- **이번 달 다시 발신하고 싶다** — `state/gahee_reminder.json``last_sent_month``null` 로 되돌리고 다음 사이클 대기.
- **발신·폴링 비활성화** — `state/gahee_reminder.json` 자체를 삭제하면 `run()` 이 즉시 return.
@@ -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