Files
openclaw/agents/budget/workspace/skills/whooing-sync/SKILL.md
T
hyowons 549545bde6 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:10:57 +09:00

21 KiB
Raw Blame History

whooing-sync

iMessage에 들어오는 카드/은행 결제 알림을 후잉(whooing.com) 웹훅으로 자동 전송한다.

When to use

  • 사용자가 "가계부 동기화" / "후잉 동기화" / "결제내역 정리" 요청할 때
  • launchd가 매시 0/15/30/45분 자동 호출 (기본 운용 — OpenClaw cron 아님, 아래 "스케줄러" 참고)
  • 매핑되지 않은 새 발신번호가 발견됐을 때 보고

How

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.jsonconfirmed: true 발신번호만 대상으로 imsg history 조회
  3. state/whooing_synced.jsonlast_message_at 이후 메시지만 처리
  4. 각 메시지 원문을 message=<원문> 형태로 후잉 웹훅에 POST
  5. 후잉 응답 200이면 whooing_synced.json 업데이트, 아니면 whooing_failures.json에 기록
  6. confirmed: false이거나 매핑 없는 발신번호 메시지가 들어오면 whooing_failures.jsonunmapped 섹션에 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 해석용)

제어 커맨드:

# 수동 실행 (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.jsoncarrier_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, withdrawalexact / 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 기타비용)

룰 스키마

{
  "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 ∈ {하나/신한/카뱅} · 가맹점 한글 24자 · merchant_map 미등록 · 평일 13:0014: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

python3 .../whooing_sync.py --dry-run

후잉으로 실제 POST는 하지 않고, 어떤 메시지가 어떤 발신번호에서 잡혀 어디로 갈지 stdout에 표시.

직접 입력 (iMessage 없이)

사용자가 "후잉에 직접 등록해줘" / "가계부에 한 건 추가" 요청할 때 whooing_manual.py 사용.

# 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.jsoncategories 에 있는 이름만 허용 (차트 외 이름은 후잉이 거부).
  • whooing_synced.json 은 건드리지 않음 (SMS 흐름과 독립).
  • 실패 시 whooing_failures.jsonsource: "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)

관리자님이 "잔액 확인", "후잉 잔액", "자산 얼마야", "가계부 현황", "순자산 얼마야", "○○카드 얼마 썼어", "○○계좌 잔고" 같은 요청을 하면 이걸로 조회한다.

# 기본 (오늘 기준, 모든 섹션)
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.jsonapi 블록(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로 필터한다.

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.jsonapi 블록 확인하고 보고.

가희 잔액 리마인더 & 자동분개 (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:

{
  "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:

{
  "handle": "+821055595428",
  "display": "010-5559-5428",
  "normalized": "01055595428"
}
  • handleimsg 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.jsonlast_sent_monthnull 로 되돌리고 다음 사이클 대기.
  • 발신·폴링 비활성화state/gahee_reminder.json 자체를 삭제하면 run() 이 즉시 return.