Files
openclaw/agents/budget/workspace/skills/whooing-sync/SKILL.md
T
hyowons c03b3f2659 auto: 일일 백업 2026-08-05 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 02:00:06 +09:00

443 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# whooing-sync
iMessage에 들어오는 카드/은행 결제 알림을 후잉(whooing.com) 웹훅으로 자동 전송한다.
## When to use
- 사용자가 "가계부 동기화" / "후잉 동기화" / "결제내역 정리" 요청할 때
- launchd가 매시 0/15/30/45분 자동 호출 (기본 운용 — **OpenClaw cron 아님**, 아래 "스케줄러" 참고)
- 매핑되지 않은 새 발신번호가 발견됐을 때 보고
## How
**on-demand 실행은 launchd 잡을 앞당겨 돌린다.** 스크립트를 직접 실행하지 말 것 —
에이전트 세션(코덱스)은 게이트웨이 node 를 TCC responsible process 로 물고 있어서
`imsg` 가 Messages DB 를 못 읽는다 (아래 "Full Disk Access" 참고). launchd 잡은 그 제약이 없다.
```bash
launchctl kickstart -p gui/$(id -u)/ai.openclaw.budget.whooing-sync
# 비동기 — 몇 초 뒤 결과 확인
tail -20 /Users/snowoyh/.openclaw/logs/whooing-sync.log
```
스크립트 직접 실행은 **FDA 를 가진 터미널 앱**(iTerm2 등)에서만 동작한다:
```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)
`imsg``~/Library/Messages/chat.db` 를 읽으려면 FDA 허용이 필요. **판정 주체는 TCC responsible process** — 자기 자신이 아니라 프로세스 체인의 책임 프로세스다.
- launchd → `/usr/bin/python3`(플랫폼 바이너리) → `imsg`: 책임이 `imsg` 자신이라 **`/opt/homebrew/bin/imsg` 승인으로 통과**
- 터미널 앱 → … → `imsg`: 터미널 앱(iTerm2 등)의 FDA 로 통과
- 게이트웨이 node → codex → bash → `imsg`: **게이트웨이 node 바이너리**의 FDA 로 판정됨
등록 경로: 시스템 설정 → 개인정보 보호 및 보안 → 전체 디스크 접근 권한 → `+` 로 추가 → 토글 ON
**FDA 누락 증상**: `imsg` 가 stderr 로 다음을 낸다.
```
Permission Error: Cannot access Messages database
... requires Full Disk Access permission.
```
`imsg_cli.run_json()` 이 이걸 잡아 로그에 그대로 옮긴다:
```
⚠️ chat-id 7 history 실패: Messages DB 접근 거부 — 전체 디스크 접근 권한(FDA) 누락. ...
```
### 2026-08-03 사고: homebrew node 업그레이드로 FDA 무효화
node@22 가 2026-07-20 에 `22.22.2``22.23.1` 로 올라가며 **바이너리 경로가 바뀌어** 기존 FDA 승인이 무효화됐다 (시스템 TCC.db 에 22.22.2=허용 / 22.23.1=거부로 남아 있음). 게이트웨이가 7/21 새 바이너리로 재기동되면서 골디의 에이전트 세션에서 `imsg` 가 죽었다. **launchd 잡은 영향 없었다** — 그래서 자동 동기화는 정상인데 골디만 실패하는 그림이 됐고, 골디는 이걸 "문자 수집 실패"로 오진했다.
- 재발 시 확인: `lsof -p $(pgrep -f 'openclaw/dist/index.js gateway') -a -d txt | head -1` 로 실제 node 경로를 뽑아, 그 경로를 FDA 목록에 추가
- 그 다음 게이트웨이 재기동: `launchctl kickstart -k gui/$(id -u)/ai.openclaw.gateway`
- **on-demand 실행을 kickstart 로 바꾼 이유가 이것** — node 업그레이드마다 깨지는 의존을 실행 경로에서 뺐다
당시 오진을 만든 코드 결함 3개는 함께 고쳤다 (2026-08-03):
1. 호출부가 stderr·returncode 를 버려 권한 오류가 `Expecting value: line 1 column 1 (char 0)` 파싱 에러로 둔갑 → `imsg_cli.py` 공용 래퍼로 사유 노출
2. `resolve_chat_ids()` 가 권한 거부 시 **빈 chat id 매핑을 캐시에 저장** → 이후 launchd 포함 전 실행이 캐시 적중으로 조용히 0건 수집을 영구 반복. 이제 전 carrier 가 비면 저장 거부 + exit 3
3. `imsg chats` 기본값이 최근 20건이라 조용한 발신번호(현대카드 SMS·신한은행)가 목록에서 빠짐 → 캐시 재생성 시 그 carrier 수집이 조용히 끊김. `CHATS_LIMIT=200` 으로 전량 조회
맥 이전 / 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 폴백 (수익/이체/환급 구분 위험 때문).
- ⚠️ **후잉은 계정 그룹별로 쓸 수 있는 변이 정해져 있다**`비용(expenses)`**차변(left) 전용**, `수익(income)`은 **대변(right) 전용**이고 `자산·부채`만 양쪽 자유다. 위반하면 `[r_account] => 잘못된 값입니다`(비용을 우측에) 또는 `[l_account] => 잘못된 값입니다`(수익을 좌측에)로 **HTTP 200 + 실패 본문**이 온다. 이 제약을 몰라서 2026-08-03~04 회귀가 났다. 새 분개 방향을 만들 때 반드시 확인할 것.
- ⚠️ **후잉은 취소·환불 문자를 자동 처리하지 않는다.** 공식 FAQ(`/help/faqs/inserting/refund`)가 "기존의 구입 거래를 **찾아서 지워주거나**, 금액을 마이너스로..." 라고 **사용자에게 수동 안내**한다. raw 로 취소 원문을 보내도 원본 승인은 그대로 남는다. 2026-08-04 에 과거 3건(07-04 카카오 월드킹·06-09 현대 쿠팡·06-13 현대 네이버페이)에서 원본이 사라진 걸 보고 "후잉 파서가 지운다"고 판단했는데, **관리자님이 그동안 수동 삭제해 오신 것**이었다. 두 설명 모두 "건수 −1, 순액 일치"라 검산으로는 구분되지 않는다 — 같은 착각을 반복하지 말 것.
- `card_cancel`**원본 승인 거래를 찾아 PUT 으로 감액**한다 (2026-08-04, 관리자님 결정 — 수동 삭제를 없애는 게 목적). `_handle_card_cancel` → `whooing_balance.find_card_entries` 로 같은 카드·같은 상호(memo 토큰 매칭)·금액이 취소액 이상인 거래를 30일 창에서 찾고, **정확히 1건일 때만** `set_entry_money``원금액 취소액` 으로 줄인다. 전액취소면 0원 줄이 남지만 잔액·합계에 영향은 없다.
- ⚠️ **좌우를 뒤집은 역분개로 바꾸지 말 것** — 비용이 대변으로 가서 후잉이 거부한다(위 제약). 2026-08-03 에 이 방식으로 바꿨다가 **첫 취소 문자에서 동기화가 통째로 멈췄다**(`blocked_at` 이 커서를 잠가 뒤의 정상 결제까지 전부 대기).
- ⚠️ **거래 삭제는 API 로 불가능하다** — 후잉 OpenAPI 의 DELETE 는 `section_id` 를 쿼리·본문(urlencoded/JSON)·쿠키·경로 어디에서도 읽지 못한다(2026-08-04 전수 실측, 같은 본문을 PUT 으로 보내면 정상). 0원 줄을 지우려면 후잉 UI 에서 수동으로. 마이너스 분개(후잉 공식 안내안)도 가능하지만 ± 두 줄이 남아 채택하지 않았다.
- **후보가 0건이거나 2건 이상이면 감액하지 않고 raw 폴백**(오감액보다 안전). 이때는 **후잉이 알아서 처리해주지 않으므로 관리자님 수동 작업이 필요하다** — 그래서 폴백 건은 `new_raw` 에 그대로 실어 raw 폴백 텔레그램 알림이 울리게 둔다. 알림을 끄면 수동 작업이 필요한 걸 모르고 지나친다.
- ⚠️ **매칭은 `item` 이 아니라 `memo` 로 한다**`merchant_map` contains 룰이 `item` 을 재작성해서(동행복권→`복권`) item 은 상호와 다를 수 있지만 memo 는 항상 승인 SMS 원문이다.
- ⚠️ **memo 매칭은 토큰 단위**(`_memo_has_merchant`) — 단순 substring 이면 `쿠팡``쿠팡페이주` 승인까지 잡는다. 2026-08-04 에 두 상호가 같은 날 같은 금액(22,700원)으로 실제로 있었다.
- ⚠️ **`card_cancel` 을 내는 파서는 현대카드·신한카드(`매입취소`/`승인취소`) 둘뿐이다.** 카카오뱅크 `카드결제취소``KAKAOBANK_CARD_RE``카드결제\s+` 가 안 맞아 **파싱 자체가 실패**(`kind=None`)하고, 하나·신한은행은 입금/출금만 있다. **이들은 감액 대상이 아니라 raw 로 가고, 후잉도 처리하지 않으므로 관리자님 수동 삭제가 계속 필요하다.** 자동화하려면 `parsers.py` 보강 + 2026-08-03 픽스처 회귀 재실행이 필요하다 — **2026-08-04 관리자님 지시로 "실제 취소 문자가 올 때 처리"로 보류**. 판단 근거는 파싱이 실패해도 `_infer_raw_details` 가 정규식으로 금액·상호를 뽑아 raw 폴백 알림에 실어주기 때문(실측: 카카오 취소 문자 → `2,000원 · (주)월드킹`). 알림을 보고 그때 대응하면 된다.
- **삼성카드는 아예 동기화 대상이 아니다** — 발신번호 `+8215888700``whooing_account_map.json` 에서 `confirmed=False` 라 수집조차 안 되고, 실제 오는 문자는 전부 `(광고)` 이며 후잉 `삼성신용(효원)`(x77) 은 최근 90일 거래 0건이다(2026-08-04 확인). 취소를 기다릴 대상이 아니라, **다시 사용하기 시작하면 승인 문자부터 누락**되니 그때 `confirmed` 등록이 먼저다.
- 기존 contains(예: "스타벅스 → 식비") / exact(예: "방효원 → 기초잔액(효원)") 는 계속 유효. fallback 은 둘 다 miss 일 때만 탄다.
- ⚠️ **주유소는 두 곳을 함께 손대야 한다**`whooing_sync.py``FUEL_KEYWORDS`(150,000원 선승인/취소 스킵)와 `whooing_merchant_map.json` contains 룰(실제 결제 → `차량유지비/주유비`)이 한 쌍이다. 한쪽만 넣으면 선승인이 후잉에 남거나 실제 주유가 기타비용으로 샌다. 상호에 주유/석유가 없는 충전소(현대가스·한경에너)는 상호 자체를 등록. 한경에너는 선승인이 149,900원이라 `FUEL_PREAUTH_AMOUNT`(150,000 정확일치) 스킵엔 안 걸리고 분류만 적용된다.
- 결과적으로 자잘한 인명 송금·가맹점 미등록 건은 전부 기타비용으로 자동 분류되고, 분류가 필요한 것만 후잉 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원 신한카드로" 다 말했다면, 카테고리만 확인하고 바로 실행.
- 카드 취소/환불은 원본 승인 거래를 찾아 감액하는 게 원칙(위 `card_cancel` 항목). 후잉은 취소 문자를 자동 처리해주지 않으니 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~월말 사이 켜면 그 시점에 발신. 다음 달로 넘어가면 포기.
- 발신은 **`--service imessage`** (2026-08-03 전환, 아래 사고 참고). 가희님은 iMessage 사용자다.
- 성공 시 `last_sent_month` 와 함께 **`last_sent_at`** 을 찍는다 — 답신 미도착 감시의 시작점.
1-b. **답신 미도착 감시** (`_check_reply_overdue`) — 발신 후 `REPLY_GRACE_DAYS`(2일)가 지나도 가희님 답신이 없으면 관리자님께 텔레그램 1회. 유예 전이면 API 콜 0으로 즉시 반환하고, 답신이 확인되면 `last_sent_at` 을 지워 감시를 끝낸다. 같은 발신 건에 대한 재알림은 `reply_alert_for` 로 막는다. ⚠️ **재발신은 자동으로 하지 않는다** — 가희님이 늦게 답하는 경우 중복 독촉이 되고, 자동 발송 트리거를 늘리지 않는다는 방침에도 어긋난다.
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,
"last_sent_at": null,
"reply_alert_for": null,
"whooing_account_name": "가희주머니",
"income_account": "가희비밀주머니_수익",
"expense_account": "기타비용"
}
```
- `last_sent_month` = "YYYY-MM" 발신 직후 기록. 같은 달에 재발신 안 함.
- `last_processed_message_at` = imsg history `--start` 의 ISO 워터마크. 처리한 메시지의 created_at 최댓값.
- `last_sent_at` = 발신 시각(UTC Z). 답신 미도착 감시의 기준점. 답신 확인되면 지워진다.
- `reply_alert_for` = 이미 "답신 없음" 알림을 보낸 `last_sent_at` 값. 같은 발신 건 재알림 방지.
- 발신 문구·트리거 일자·계정명 변경은 모두 이 JSON 직접 편집. 코드 재배포 불필요. whooing-sync 가 매 사이클 다시 읽음.
### 수동 발신 (`--send-now`)
발송이 실패해 그 달을 놓쳤을 때의 복구 수단. 날짜 게이트를 무시하고 1회 발신하며 `last_sent_month` 는 건드리지 않아 정규 25일 발신이 살아있다.
```bash
python3 gahee_reminder.py --send-now [--dry-run]
```
⚠️ **에이전트 세션·코디 셸에서 실행하면 20초 타임아웃으로 실패한다.** `imsg send` 는 AppleEvents 로 Messages 를 구동하는데, **자동화 → 메시지** 권한이 `imsg` 와 iTerm2 에만 허용돼 있고 Claude Code 에는 거부로 박혀 있다. launchd 경유로 돌려야 한다 — 임시 oneshot plist 를 만들어 `kickstart` 하고 로그를 읽은 뒤 `bootout` + 삭제한다(2026-08-03 실행 방식).
### 2026-07-25 사고: 미발송이 성공으로 기록됨
7/25 리마인더가 실제로 발송되지 않았는데(`chat.db`: `service=SMS, is_sent=0, error=4`) 시스템은 성공으로 기록했다. 가희주머니 7월 잔액이 안 들어와 8/1 월간 결산에서 빠졌다.
- **왜 실패했나** — `--service sms` 강제가 이 수신자에게 **3전 3패**였다(5/25 ×2, 7/25 ×1 전부 `error=4`). 살아남은 2건(5/25·6/25)은 Messages 가 임의로 **RCS 로 바꿔** 보낸 운이었고, 7월엔 그 폴백이 안 일어났다. 전체 통계로도 iMessage 137/139 성공 vs SMS 실패 3건(전부 이 리마인더).
- **왜 몰랐나** — `imsg send` 는 Messages 에 넘기기만 성공하면 rc=0 이고, 실제 실패는 그 뒤 비동기로 `chat.db``error` 에 찍힌다. 코드가 rc 만 봐서 성공 처리 → 오탐 텔레그램 → `last_sent_month` 갱신 → 그 달 재시도 차단 → 8월엔 catch-up 이 당월만 보므로 7월분 영구 유실.
- **왜 발송 검증을 자동 경로에 안 넣었나** — `is_sent`/`error``chat.db` 에만 있고 `imsg history` JSON 은 노출하지 않는다(실패한 메시지도 정상 조회됨). 직접 sqlite 조회가 유일한 길인데 **launchd 의 `/usr/bin/python3` 는 FDA 가 없어 `authorization denied` 로 막힌다**(2026-08-03 실측). 그래서 자동 경로는 권한과 무관한 답신 기반 감시를 쓰고, 정밀 확인(`_verify_sent`)은 FDA 가 있는 컨텍스트에서 도는 `--send-now` 에만 붙였다.
- `error=4` 의 정확한 의미는 확증 못 함 — 사고 시점 imagent 로그가 이미 롤오프됐다.
### 수신자 설정
`/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.