Files
openclaw/agents/budget/workspace/skills/monthly-settlement/scripts/inbox_handler.py
T
hyowons fed3526b20 Initial commit: OpenClaw 워크스페이스 버전관리 시작
설정·스크립트·스킬·문서·큐레이션 메모리 추적.
시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)·
백업(clobbered/bak)·dream 캐시는 .gitignore로 제외.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:39:41 +09:00

463 lines
18 KiB
Python

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