auto: 일일 백업 2026-08-04 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -68,5 +68,8 @@
|
||||
- **이미지 답신**: 자동분개 X. 골디 텔레그램 알림만 — 관리자님이 직접 처리하거나 가희님께 텍스트로 다시 요청.
|
||||
- **포맷 오류** (페어 0건): 분개 중단 + 골디 텔레그램 보고. 가희님께 다시 요청 또는 직접 입력.
|
||||
- **완료**: 골디 텔레그램으로 합계·차액·분개 방향 보고.
|
||||
- 상태 파일: `state/gahee_reminder.json` (last_sent_month, last_processed_message_at), 수신자: `credentials/gahee_imessage.json`.
|
||||
- 상태 파일: `state/gahee_reminder.json` (last_sent_month, last_processed_message_at, last_sent_at, reply_alert_for), 수신자: `credentials/gahee_imessage.json`.
|
||||
- 발신 문구 수정은 state 파일 `message_template` 직접 편집 (코드 재배포 불필요).
|
||||
- **발신 경로는 `--service imessage`** (2026-08-03 전환). 이전 `sms` 강제는 이 수신자에게 3전 3패였고(전부 `error=4` 미발송), 살아남은 건은 Messages 가 RCS 로 바꿔준 운이었다. 7/25 미발송이 성공으로 기록돼 7월 잔액이 통째로 누락된 사고의 원인.
|
||||
- **답신 미도착 감시**: 발신 후 2일간 답신 없으면 골디 텔레그램 1회 경고. `imsg send` 는 발송 실패를 알려주지 않아(rc=0) 이게 유일한 안전망이다. 재발신은 자동 X — `python3 gahee_reminder.py --send-now` 로 수동.
|
||||
- ⚠️ `--send-now` 는 **내 세션에서 못 돌린다** (자동화 → 메시지 권한이 Claude Code 에 거부). launchd 임시 oneshot plist 경유로 실행. 상세는 SKILL.md.
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
|
||||
## Scripts (python3)
|
||||
|
||||
- `skills/whooing-sync/scripts/whooing_sync.py` — iMessage 결제문자 → 후잉 웹훅 POST. launchd `ai.openclaw.budget.whooing-sync` 가 매시 0/15/30/45분 실행 (OpenClaw cron 아님). FDA 필수(`/opt/homebrew/bin/imsg`). 페어 매칭 로직 내장 — 자세한 건 `skills/whooing-sync/SKILL.md`.
|
||||
- `skills/whooing-sync/scripts/whooing_sync.py` — iMessage 결제문자 → 후잉 웹훅 POST. launchd `ai.openclaw.budget.whooing-sync` 가 매시 0/15/30/45분 실행 (OpenClaw cron 아님). ⚠️ **on-demand 실행은 스크립트 직접 호출이 아니라 `launchctl kickstart -p gui/$(id -u)/ai.openclaw.budget.whooing-sync` + 로그 확인** — 내 세션은 게이트웨이 node 를 TCC responsible process 로 물고 있어 `imsg` 가 Messages DB 를 못 읽는다. FDA 필수(`/opt/homebrew/bin/imsg`). 페어 매칭 로직 내장 — 자세한 건 `skills/whooing-sync/SKILL.md`.
|
||||
- `skills/whooing-sync/scripts/imsg_cli.py` — `imsg` CLI 공용 래퍼(`run_json`/`chats`). stderr·returncode 를 판정해 FDA 누락을 파싱 에러로 둔갑시키지 않는다. `whooing_sync.py`·`gahee_reminder.py` 가 공유.
|
||||
- `skills/whooing-sync/scripts/whooing_manual.py` — iMessage 없이 한 건 직접 등록. structured(`--item/--money/--left/--right [--date] [--memo]`) 또는 raw(`--message`). structured는 `whooing_accounts.json` 차트 검증 후 POST.
|
||||
- `skills/whooing-sync/scripts/whooing_balance.py` — 후잉 OpenAPI로 자산/부채/자본 잔액 조회. 옵션: `--section-id`, `--as-of YYYY-MM-DD`, `--json`. 크리덴셜은 `credentials/whooing.json`의 `api` 블록(app_id/token/signature).
|
||||
|
||||
|
||||
@@ -10,6 +10,18 @@ iMessage에 들어오는 카드/은행 결제 알림을 후잉(whooing.com) 웹
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -72,18 +84,42 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.openclaw.budget.whooi
|
||||
|
||||
## 전제조건: Full Disk Access (FDA)
|
||||
|
||||
launchd 컨텍스트에서 `imsg` 가 `~/Library/Messages/chat.db` 를 읽으려면 FDA 허용이 필요. 터미널에서 직접 돌릴 땐 터미널 앱의 FDA 를 자식 프로세스가 상속받지만, launchd 는 상속 안 됨.
|
||||
`imsg` 가 `~/Library/Messages/chat.db` 를 읽으려면 FDA 허용이 필요. **판정 주체는 TCC responsible process** — 자기 자신이 아니라 프로세스 체인의 책임 프로세스다.
|
||||
|
||||
- 등록 대상: `/opt/homebrew/bin/imsg`
|
||||
- 경로: 시스템 설정 → 개인정보 보호 및 보안 → 전체 디스크 접근 권한 → `+` 로 추가 → 토글 ON
|
||||
- launchd → `/usr/bin/python3`(플랫폼 바이너리) → `imsg`: 책임이 `imsg` 자신이라 **`/opt/homebrew/bin/imsg` 승인으로 통과**
|
||||
- 터미널 앱 → … → `imsg`: 터미널 앱(iTerm2 등)의 FDA 로 통과
|
||||
- 게이트웨이 node → codex → bash → `imsg`: **게이트웨이 node 바이너리**의 FDA 로 판정됨
|
||||
|
||||
**FDA 누락 증상**: stderr 로그에
|
||||
등록 경로: 시스템 설정 → 개인정보 보호 및 보안 → 전체 디스크 접근 권한 → `+` 로 추가 → 토글 ON
|
||||
|
||||
**FDA 누락 증상**: `imsg` 가 stderr 로 다음을 낸다.
|
||||
|
||||
```
|
||||
❌ imsg chats 실행 실패: Expecting value: line 1 column 1 (char 0)
|
||||
Permission Error: Cannot access Messages database
|
||||
... requires Full Disk Access permission.
|
||||
```
|
||||
|
||||
가 뜨고, stdout 은 `🟢 새 결제 메시지 없음` 으로 조용히 끝남. 오류처럼 보이지 않아 놓치기 쉬움. 맥 이전 / imsg 재설치 시 FDA 재등록 필요.
|
||||
`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 재등록 필요.
|
||||
|
||||
## 페어 매칭 (자기 계좌 간 이체)
|
||||
|
||||
@@ -118,7 +154,9 @@ launchd 컨텍스트에서 `imsg` 가 `~/Library/Messages/chat.db` 를 읽으려
|
||||
|
||||
- 사람 이름 송금(예: "박영춘", "이지윤")은 exact 룰로 등록하지 말고 default fallback 에 맡긴다. merchant_map 비대화 방지.
|
||||
- `deposit` 은 default fallback 없음 — rule 없으면 raw 폴백 (수익/이체/환급 구분 위험 때문).
|
||||
- `card_cancel` 은 **승인의 역분개**로 좌우를 뒤집어 POST 한다 (2026-08-03). 승인이 `{비용 ← 카드}` 이므로 취소는 `{카드 ← 비용}`. 부분취소도 취소 문자 금액 그대로 상쇄되어 맞는다. 이전엔 raw 폴백으로 후잉 자체 파서에 맡겼는데, 결과는 맞았지만 파싱을 외부에 의존하고 raw 폴백 알림이 매번 울렸다. 현대카드·신한카드(`매입취소`/`승인취소`) 공통.
|
||||
- 기존 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)
|
||||
@@ -296,6 +334,9 @@ python3 .../whooing_balance.py --json \
|
||||
### 흐름
|
||||
|
||||
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, 골디 텔레그램 알림만 ("이미지 답신 — 직접 처리 부탁")
|
||||
@@ -322,6 +363,8 @@ python3 .../whooing_balance.py --json \
|
||||
"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": "기타비용"
|
||||
@@ -330,8 +373,29 @@ python3 .../whooing_balance.py --json \
|
||||
|
||||
- `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`:
|
||||
|
||||
@@ -12,13 +12,18 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import imsg_cli
|
||||
import notify
|
||||
import whooing_balance
|
||||
|
||||
@@ -27,6 +32,13 @@ 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회 알린다.
|
||||
# imsg send 는 Messages 에 넘기기만 성공하면 rc=0 이라 발송 실패를 동기적으로 못 잡는다
|
||||
# (2026-07-25 사고: SMS error=4 로 미발송인데 성공 처리돼 그 달 재시도가 막혔다).
|
||||
# 실측 답신 지연은 3시간·38시간 — 2일이면 오탐이 적다.
|
||||
REPLY_GRACE_DAYS = 2
|
||||
CHAT_DB = Path.home() / "Library" / "Messages" / "chat.db"
|
||||
APPLE_EPOCH = 978307200 # chat.db date 는 2001-01-01 기준 ns
|
||||
# 라벨 + 금액 패턴. 라벨은 한글/영문/괄호/공백/숫자 1~20자.
|
||||
# 콜론 구분 입력은 정수 전체를 허용하고, 공백 구분 입력은 천 단위 콤마가 있을 때만 허용한다.
|
||||
# 일반 안내 문자의 "오전 09" 같은 표현을 잔액으로 오인하지 않기 위함.
|
||||
@@ -77,15 +89,11 @@ def _normalize(handle: str) -> str:
|
||||
|
||||
|
||||
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}")
|
||||
rows, err = imsg_cli.chats(IMSG_TIMEOUT)
|
||||
if err:
|
||||
print(f"⚠️ 가희 imsg chats 실패: {err}")
|
||||
return []
|
||||
return rows
|
||||
|
||||
|
||||
def _find_gahee_chat_id(handle_norm: str) -> int | None:
|
||||
@@ -97,26 +105,35 @@ def _find_gahee_chat_id(handle_norm: str) -> int | 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"]
|
||||
args = ["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}")
|
||||
args += ["--start", start_iso]
|
||||
rows, err = imsg_cli.run_json(args, IMSG_TIMEOUT)
|
||||
if err:
|
||||
print(f"⚠️ 가희 imsg history 실패: {err}")
|
||||
return []
|
||||
return rows
|
||||
|
||||
|
||||
def _send_imessage(handle: str, text: str, dry_run: bool) -> bool:
|
||||
"""가희님께 리마인더 발신.
|
||||
|
||||
⚠️ rc=0 은 "Messages 에 넘겼다"는 뜻일 뿐 발송 성공이 아니다. 실제 실패는 그 뒤
|
||||
비동기로 chat.db 의 error 칼럼에 찍힌다 — 여기서는 알 수 없다. 발송 여부 확인은
|
||||
_verify_sent()(FDA 필요, CLI 전용) 또는 _check_reply_overdue()(답신 기반)가 맡는다.
|
||||
|
||||
⚠️ service 는 imessage 다. sms 강제는 이 수신자에게 3전 3패였고(5/25 ×2, 7/25 ×1
|
||||
전부 error=4), 살아남은 2건은 Messages 가 임의로 RCS 로 바꿔준 운이었다. 가희님은
|
||||
iMessage 사용자다(답신 2건 모두 iMessage). 2026-08-03 전환.
|
||||
"""
|
||||
if dry_run:
|
||||
print(f"[dry-run] imsg send --to {handle} --text {text!r} --service sms")
|
||||
print(f"[dry-run] imsg send --to {handle} --text {text!r} --service imessage")
|
||||
return True
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["imsg", "send", "--to", handle, "--text", text,
|
||||
"--service", "sms"],
|
||||
"--service", "imessage"],
|
||||
capture_output=True, text=True, timeout=IMSG_TIMEOUT,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
@@ -128,6 +145,46 @@ def _send_imessage(handle: str, text: str, dry_run: bool) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _utc_z(dt: datetime) -> str:
|
||||
"""imsg --start / state 워터마크와 같은 UTC Z 포맷."""
|
||||
return dt.astimezone(ZoneInfo("UTC")).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _verify_sent(handle: str, after_unix: float, timeout: int = 20):
|
||||
"""chat.db 직접 조회로 실제 발송 여부 확인. 반환 (verdict, detail).
|
||||
|
||||
verdict True=발송됨 / False=실패 / None=확인 불가.
|
||||
chat.db 는 FDA 를 가진 컨텍스트에서만 읽힌다 — launchd(/usr/bin/python3)에선 못 읽으므로
|
||||
확인 불가(None)로 떨어진다. 이 함수는 CLI 전용이고, 자동 경로는 답신 기반 감시를 쓴다.
|
||||
"""
|
||||
digits = re.sub(r"\D", "", handle)[-8:]
|
||||
after_ns = int((after_unix - APPLE_EPOCH) * 1_000_000_000)
|
||||
deadline = time.time() + timeout
|
||||
detail = None
|
||||
while True:
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{CHAT_DB}?mode=ro", uri=True)
|
||||
row = con.execute(
|
||||
"select m.service, m.is_sent, m.error from message m "
|
||||
"join handle h on m.handle_id = h.rowid "
|
||||
"where h.id like ? and m.is_from_me = 1 and m.date >= ? "
|
||||
"order by m.date desc limit 1",
|
||||
(f"%{digits}", after_ns),
|
||||
).fetchone()
|
||||
con.close()
|
||||
except Exception as e:
|
||||
return None, f"chat.db 조회 불가 ({type(e).__name__}: {e})"
|
||||
if row:
|
||||
detail = f"service={row[0]} is_sent={row[1]} error={row[2]}"
|
||||
if row[2]:
|
||||
return False, detail
|
||||
if row[1]:
|
||||
return True, detail
|
||||
if time.time() >= deadline:
|
||||
return None, detail or "발신 기록이 아직 안 보임"
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def _parse_balance_text(text: str) -> dict[str, int]:
|
||||
"""가희 답신 텍스트에서 '라벨 : 금액' 페어 추출. 빈 dict면 포맷 오류.
|
||||
|
||||
@@ -234,6 +291,9 @@ def _maybe_send_reminder(state: dict, dry_run: bool) -> bool:
|
||||
|
||||
if not dry_run:
|
||||
state["last_sent_month"] = month_tag
|
||||
# 답신 감시 시작점. 발송 성공 여부는 여기서 알 수 없으므로 답신으로 확인한다.
|
||||
state["last_sent_at"] = _utc_z(now)
|
||||
state.pop("reply_alert_for", None)
|
||||
_save_json(STATE_FILE, state)
|
||||
notify.send(
|
||||
f"📨 <b>가희 잔액 리마인더 발신</b>\n"
|
||||
@@ -243,6 +303,51 @@ def _maybe_send_reminder(state: dict, dry_run: bool) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _check_reply_overdue(state: dict, dry_run: bool):
|
||||
"""발신 후 REPLY_GRACE_DAYS 가 지나도 답신이 없으면 관리자님께 1회 알린다.
|
||||
|
||||
발송 실패를 직접 감지할 수 없어서(위 _send_imessage 주석) 결과로 대신 본다.
|
||||
2026-07-25 처럼 미발송이 조용히 넘어가는 걸 막는 유일한 안전망이다.
|
||||
|
||||
⚠️ 재발신은 자동으로 하지 않는다 — 가희님이 늦게 답하는 경우 중복 독촉이 되고,
|
||||
자동 발송 트리거를 늘리지 않는다는 방침에도 어긋난다. 알림 받고 --send-now 로 수동.
|
||||
"""
|
||||
sent_at = state.get("last_sent_at")
|
||||
if not sent_at:
|
||||
return
|
||||
try:
|
||||
sent_dt = datetime.fromisoformat(sent_at.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return
|
||||
if _now_kst() - sent_dt < timedelta(days=REPLY_GRACE_DAYS):
|
||||
return
|
||||
if state.get("reply_alert_for") == sent_at:
|
||||
return # 이번 발신 건은 이미 알림
|
||||
|
||||
handle_norm = _normalize(_gahee_handle())
|
||||
chat_id = _find_gahee_chat_id(handle_norm)
|
||||
if chat_id is None:
|
||||
return
|
||||
if any(_msg_from_gahee(m) for m in _imsg_history(chat_id, sent_at)):
|
||||
# 답신 도착 — 감시 종료
|
||||
if not dry_run:
|
||||
state.pop("last_sent_at", None)
|
||||
state.pop("reply_alert_for", None)
|
||||
_save_json(STATE_FILE, state)
|
||||
return
|
||||
|
||||
days = (_now_kst() - sent_dt).days
|
||||
notify.send(
|
||||
f"⚠️ <b>가희 잔액 리마인더 — {days}일째 답신 없음</b>\n"
|
||||
f"발신: {notify.escape_html(sent_at)}\n"
|
||||
f"발송 자체가 실패했을 수 있어요 (imsg 는 발송 실패를 알려주지 않아요).\n"
|
||||
f"재발신: <code>python3 gahee_reminder.py --send-now</code>"
|
||||
)
|
||||
if not dry_run:
|
||||
state["reply_alert_for"] = sent_at
|
||||
_save_json(STATE_FILE, state)
|
||||
|
||||
|
||||
def _msg_text(msg: dict) -> str:
|
||||
"""imsg history 메시지 객체에서 본문 텍스트 추출. 키 변형(text/body) 모두 시도."""
|
||||
for k in ("text", "body", "message", "content"):
|
||||
@@ -402,9 +507,61 @@ def run(webhook_url: str, post_fn, dry_run: bool = False):
|
||||
_maybe_send_reminder(state, dry_run=dry_run)
|
||||
# 발신 직후 같은 사이클에서 폴링은 무의미하지만, 다음 사이클부터 자연스럽게 시작됨.
|
||||
_poll_replies(state, webhook_url, post_fn, dry_run=dry_run)
|
||||
# 발송 실패가 조용히 넘어가지 않게 하는 안전망. 유예 전이면 API 콜 없이 즉시 반환.
|
||||
_check_reply_overdue(state, 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
|
||||
|
||||
|
||||
def send_now(dry_run: bool = False) -> int:
|
||||
"""날짜 게이트를 무시하고 리마인더 1회 발신 (수동용).
|
||||
|
||||
발송 실패로 그 달을 통째로 놓쳤을 때 복구 수단. last_sent_month 는 건드리지 않아
|
||||
정규 발신(매월 25일)이 그대로 살아있다.
|
||||
"""
|
||||
state = _load_json(STATE_FILE, None)
|
||||
if not state:
|
||||
print("❌ 가희 state 파일 없음", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
handle = _gahee_handle()
|
||||
template = state.get("message_template") or "가계부 업데이트 날이에요. 계좌잔액 보내주시면 자동으로 반영됩니다."
|
||||
before = time.time()
|
||||
if not _send_imessage(handle, template, dry_run=dry_run):
|
||||
return 1
|
||||
if dry_run:
|
||||
print(f"[dry-run] 수신자={handle} · last_sent_month 는 그대로 유지")
|
||||
return 0
|
||||
|
||||
state["last_sent_at"] = _utc_z(_now_kst())
|
||||
state.pop("reply_alert_for", None)
|
||||
_save_json(STATE_FILE, state)
|
||||
|
||||
verdict, detail = _verify_sent(handle, before)
|
||||
if verdict is True:
|
||||
print(f"✅ 발송 확인 — {detail}")
|
||||
return 0
|
||||
if verdict is False:
|
||||
print(f"❌ 발송 실패 — {detail}", file=sys.stderr)
|
||||
state.pop("last_sent_at", None)
|
||||
_save_json(STATE_FILE, state)
|
||||
return 1
|
||||
# chat.db 를 못 읽는 컨텍스트이거나 아직 판정 전. 실패로 단정하지 않는다.
|
||||
print(f"⚠️ 발송 확인 불가 — {detail}")
|
||||
print(f" 답신이 {REPLY_GRACE_DAYS}일간 없으면 골디가 텔레그램으로 알립니다.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser(description="가희 잔액 리마인더 (수동 발신용)")
|
||||
ap.add_argument("--send-now", action="store_true",
|
||||
help="날짜 게이트 무시하고 1회 발신. 정규 25일 발신은 유지")
|
||||
ap.add_argument("--dry-run", action="store_true", help="실제 발송 없이 계획만 출력")
|
||||
args = ap.parse_args()
|
||||
if args.send_now:
|
||||
sys.exit(send_now(dry_run=args.dry_run))
|
||||
ap.print_help()
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
"""imsg CLI 호출 공용 래퍼.
|
||||
|
||||
호출부가 stdout 만 JSON 파싱하고 stderr·returncode 를 버리면, FDA(전체 디스크 접근 권한)
|
||||
누락 같은 권한 오류가 `Expecting value: line 1 column 1 (char 0)` 파싱 에러로 둔갑한다.
|
||||
2026-08-03 골디 오진 사고의 원인 — 실제 원인은 homebrew node 업그레이드로 게이트웨이
|
||||
바이너리 경로가 바뀌어 FDA 승인이 무효화된 것이었다. 판정을 한곳에 모아 사유를 그대로 노출한다.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
# imsg 가 권한 거부 시 내는 문구 (imsg 0.5.0 기준).
|
||||
FDA_MARKERS = ("Full Disk Access", "Cannot access Messages database")
|
||||
FDA_HINT = (
|
||||
"Messages DB 접근 거부 — 전체 디스크 접근 권한(FDA) 누락. "
|
||||
"skills/whooing-sync/SKILL.md 의 'Full Disk Access' 섹션 참고"
|
||||
)
|
||||
|
||||
|
||||
# `imsg chats` 기본값은 최근 20건이라, 오래 조용한 발신번호(현대카드 SMS·신한은행 등)가
|
||||
# 목록에서 빠진다. 그 상태로 chat id 캐시를 다시 만들면 해당 carrier 수집이 조용히 끊긴다.
|
||||
# chat.db 전체 대화가 130개 수준이라 넉넉히 잡는다. (2026-08-03 실측으로 발견)
|
||||
CHATS_LIMIT = 200
|
||||
|
||||
|
||||
def _reason(stderr: str, stdout: str, fallback: str) -> str:
|
||||
"""실패 사유 한 줄. FDA 마커는 stdout/stderr 어느 쪽에 있어도 잡는다."""
|
||||
err = (stderr or "").strip()
|
||||
out = (stdout or "").strip()
|
||||
if any(m in err or m in out for m in FDA_MARKERS):
|
||||
return FDA_HINT
|
||||
detail = err or out
|
||||
if detail:
|
||||
return " ".join(detail.split())[:200]
|
||||
return fallback
|
||||
|
||||
|
||||
def run_json(args: list, timeout: int) -> "tuple[list, str | None]":
|
||||
"""imsg 실행 → (rows, error). 성공 시 (list[dict], None), 실패 시 ([], 사유).
|
||||
|
||||
결과 0건과 실패를 구분한다 — 둘 다 빈 리스트지만 실패에만 error 가 붙는다.
|
||||
"""
|
||||
try:
|
||||
p = subprocess.run(
|
||||
["imsg"] + list(args),
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return [], "imsg CLI가 설치되어 있지 않습니다"
|
||||
except subprocess.TimeoutExpired:
|
||||
return [], f"타임아웃 ({timeout}s)"
|
||||
except Exception as e:
|
||||
return [], f"{type(e).__name__}: {e}"
|
||||
|
||||
out = p.stdout or ""
|
||||
if p.returncode != 0:
|
||||
return [], _reason(p.stderr, out, f"rc={p.returncode}")
|
||||
|
||||
if not out.strip():
|
||||
# stderr 만 있고 stdout 이 비면 실패. 둘 다 비면 정상적으로 결과 0건.
|
||||
if (p.stderr or "").strip():
|
||||
return [], _reason(p.stderr, "", "빈 출력")
|
||||
return [], None
|
||||
|
||||
rows = []
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
return [], _reason(p.stderr, line, "JSON 파싱 실패")
|
||||
return rows, None
|
||||
|
||||
|
||||
def chats(timeout: int) -> "tuple[list, str | None]":
|
||||
"""대화 목록 조회 → (rows, error). CHATS_LIMIT 만큼 넓게 훑는다."""
|
||||
return run_json(["chats", "--limit", str(CHATS_LIMIT), "--json"], timeout)
|
||||
@@ -179,15 +179,16 @@ def parse_kakao_bank(raw: str, created_at: str) -> "dict | None":
|
||||
return result
|
||||
|
||||
|
||||
# 현대카드 승인 형식 (취소는 별도 샘플 확보 후 추가):
|
||||
# 현대카드 블루멤버스 승인
|
||||
# 방*원
|
||||
# 5,170원 일시불
|
||||
# 04/25 11:34
|
||||
# 쿠팡
|
||||
# 누적1,466,011원
|
||||
HYUNDAI_CARD_APPROVE_RE = re.compile(
|
||||
r"^\s*현대카드[^\n]*승인\s*\n"
|
||||
# 현대카드 승인/취소 형식 (취소도 헤더 단어만 다르고 본문 구조는 같다):
|
||||
# 현대카드 블루멤버스 승인 현대카드 블루멤버스 취소
|
||||
# 방*원 방*원
|
||||
# 5,170원 일시불 150,000원 일시불
|
||||
# 04/25 11:34 08/02 13:06
|
||||
# 쿠팡 현대가스
|
||||
# 누적1,466,011원 누적4,696,608원
|
||||
# 앞의 `현대카드[^\n]*` 가 greedy 라 "승인취소" 형태도 kind=취소 로 잡힌다.
|
||||
HYUNDAI_CARD_RE = re.compile(
|
||||
r"^\s*현대카드[^\n]*(?P<kind>승인|취소)\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"
|
||||
@@ -198,8 +199,8 @@ HYUNDAI_CARD_APPROVE_RE = re.compile(
|
||||
|
||||
# RCS/MAAP 형식은 한 줄로 들어온다:
|
||||
# [Web발신] 현대카드 블루멤버스 승인 방*원 84,000원 일시불 05/16 11:57 네이버페이 누적820,000원
|
||||
HYUNDAI_CARD_APPROVE_INLINE_RE = re.compile(
|
||||
r"현대카드[^\n]*승인\s+"
|
||||
HYUNDAI_CARD_INLINE_RE = re.compile(
|
||||
r"현대카드[^\n]*(?P<kind>승인|취소)\s+"
|
||||
r"(?P<who>\S+)\s+"
|
||||
r"(?P<amount>[\d,]+)\s*원[^\n]*?"
|
||||
r"\d{2}/\d{2}\s+\d{2}:\d{2}\s+"
|
||||
@@ -211,13 +212,13 @@ HYUNDAI_CARD_APPROVE_INLINE_RE = re.compile(
|
||||
|
||||
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)
|
||||
m = HYUNDAI_CARD_RE.search(text) or HYUNDAI_CARD_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",
|
||||
"kind": "card_cancel" if m.group("kind") == "취소" else "card_approval",
|
||||
"entry_date": _iso_to_kst_date(created_at),
|
||||
"amount": amount,
|
||||
"merchant": merchant,
|
||||
|
||||
@@ -12,7 +12,6 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
@@ -22,6 +21,7 @@ from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import parsers as sms_parsers
|
||||
import imsg_cli
|
||||
import notify
|
||||
import whooing_balance
|
||||
import gahee_reminder
|
||||
@@ -209,7 +209,11 @@ MONEY_AMOUNT_RE = re.compile(r"(?:\d[\d,]*\s*원|(?:입금|출금)\s+\d[\d,]*)")
|
||||
|
||||
# 주유소 카드 결제는 보통 150,000원 선승인 → 실제 주유금액 승인 → 150,000원 승인취소 순서로 온다.
|
||||
# 선승인/취소는 후잉에 기록하지 않고, 실제 결제만 차량유지비/주유비로 남긴다.
|
||||
FUEL_KEYWORDS = ("석유", "주유소", "주유")
|
||||
# "현대가스"·"한경에너"는 상호에 주유/석유가 안 들어가는 충전소라 직접 등록 (2026-08-03 관리자님 확인).
|
||||
# 새 주유소를 만나면 여기와 whooing_merchant_map.json contains **양쪽에** 상호를 추가한다 —
|
||||
# 이 상수는 선승인/취소 스킵, merchant_map 은 실제 주유 결제 분류로 한 쌍이다.
|
||||
# ⚠️ 한경에너는 선승인이 149,900원이라 FUEL_PREAUTH_AMOUNT 정확일치엔 안 걸린다(분류만 적용).
|
||||
FUEL_KEYWORDS = ("석유", "주유소", "주유", "현대가스", "한경에너")
|
||||
FUEL_PREAUTH_AMOUNT = 150_000
|
||||
|
||||
|
||||
@@ -220,7 +224,7 @@ def _is_fuel_text(text: str) -> bool:
|
||||
def _should_skip_fuel_preauth(parsed: dict | None, text: str) -> bool:
|
||||
"""주유소 15만원 선승인/취소 스킵.
|
||||
|
||||
parsed 가 있으면 구조화 필드 기준으로 판단한다. 현대카드 취소처럼 아직 파서가 없는 형식은
|
||||
parsed 가 있으면 구조화 필드 기준으로 판단한다. 파서가 못 읽은 형식은 폴백으로
|
||||
raw 텍스트에서 '취소' + '150,000원' + 주유 키워드 조합만 보수적으로 스킵한다.
|
||||
"""
|
||||
if parsed:
|
||||
@@ -281,39 +285,33 @@ def resolve_chat_ids(carriers):
|
||||
needed = set(carriers.keys())
|
||||
if needed.issubset(cached_keys):
|
||||
return {k: cached[k] for k in needed}
|
||||
try:
|
||||
chats_raw = subprocess.run(
|
||||
["imsg", "chats", "--json"],
|
||||
capture_output=True, text=True, timeout=IMSG_TIMEOUT,
|
||||
)
|
||||
chats = [json.loads(l) for l in chats_raw.stdout.splitlines() if l.strip()]
|
||||
except FileNotFoundError:
|
||||
print("❌ imsg CLI가 설치되어 있지 않습니다.", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
except Exception as e:
|
||||
print(f"❌ imsg chats 실행 실패: {e}", file=sys.stderr)
|
||||
chats, err = imsg_cli.chats(IMSG_TIMEOUT)
|
||||
if err:
|
||||
print(f"❌ imsg chats 실행 실패: {err}", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
result = {}
|
||||
for sender in carriers.keys():
|
||||
ids = [c["id"] for c in chats if c.get("identifier") == sender and c.get("id") is not None]
|
||||
result[sender] = ids
|
||||
# 전 carrier 가 빈 리스트면 조회 자체가 실패한 것으로 본다. 이 상태를 캐시에 저장하면
|
||||
# 이후 모든 실행(launchd 포함)이 캐시에 적중해 조용히 0건 수집을 영구 반복한다.
|
||||
# 일부만 비는 건 정상 — 메시지 이력이 없는 carrier 가 있을 수 있다.
|
||||
if not any(result.values()):
|
||||
print("❌ imsg chats 결과에 매핑된 발신번호가 하나도 없습니다 — 캐시 저장 안 함", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
save_json(CHAT_IDS_CACHE, result)
|
||||
return result
|
||||
|
||||
|
||||
def fetch_messages(chat_id, since):
|
||||
cmd = ["imsg", "history", "--chat-id", str(chat_id), "--limit", str(HISTORY_LIMIT), "--json"]
|
||||
args = ["history", "--chat-id", str(chat_id), "--limit", str(HISTORY_LIMIT), "--json"]
|
||||
if since:
|
||||
cmd += ["--start", since]
|
||||
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 subprocess.TimeoutExpired:
|
||||
print(f"⚠️ chat-id {chat_id} history 타임아웃", file=sys.stderr)
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"⚠️ chat-id {chat_id} history 실패: {e}", file=sys.stderr)
|
||||
args += ["--start", since]
|
||||
rows, err = imsg_cli.run_json(args, IMSG_TIMEOUT)
|
||||
if err:
|
||||
print(f"⚠️ chat-id {chat_id} history 실패: {err}", file=sys.stderr)
|
||||
return []
|
||||
return rows
|
||||
|
||||
|
||||
# --- 룰 엔진 (whooing_overrides.json) -----------------------------------------
|
||||
@@ -518,7 +516,19 @@ def build_structured(parsed, sender_info, accounts, merchant_map):
|
||||
return {**base, "left": "기타비용", "right": carrier_acct}
|
||||
|
||||
if kind == "card_cancel":
|
||||
return None # raw 폴백 (후잉이 원거래 찾아 상쇄)
|
||||
# 승인의 역분개. 승인이 {left: 비용, right: 카드} 이므로 좌우를 뒤집는다.
|
||||
# 부분취소(취소액 != 승인액)도 취소 문자 금액 그대로 상쇄되어 맞는다.
|
||||
if rule:
|
||||
left = rule.get("left")
|
||||
if left:
|
||||
out = {**base, "left": rule.get("right") or carrier_acct, "right": left}
|
||||
if rule.get("item"):
|
||||
out["item"] = rule["item"]
|
||||
return out
|
||||
return None
|
||||
if cat:
|
||||
return {**base, "left": carrier_acct, "right": cat}
|
||||
return {**base, "left": carrier_acct, "right": "기타비용"}
|
||||
|
||||
if kind == "withdrawal":
|
||||
if rule:
|
||||
|
||||
Reference in New Issue
Block a user