"""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]