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