Initial commit: OpenClaw 워크스페이스 버전관리 시작
설정·스크립트·스킬·문서·큐레이션 메모리 추적. 시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)· 백업(clobbered/bak)·dream 캐시는 .gitignore로 제외. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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())
|
||||
+378
@@ -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("&", "&").replace("<", "<").replace(">", ">"))
|
||||
|
||||
|
||||
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
Reference in New Issue
Block a user