import base64 import hashlib import html import os import re from urllib.parse import parse_qs, urlparse import httpx from .base import ImageAttachment, Notifier from .image_utils import compressed_preview def _truncate_utf8(value: str, limit: int) -> str: data = value.encode("utf-8") if len(data) <= limit: return value return data[:limit - 3].decode("utf-8", errors="ignore") + "..." def _format_plain_text(title: str, text: str) -> str: plain = re.sub(r"", "\n", text, flags=re.IGNORECASE) plain = re.sub(r"<[^>]+>", "", plain) plain = html.unescape(plain) if not title: return plain.strip() field_icons = { "ε‘ι€δΊΊοΌš": "πŸ‘€", "类别:": "πŸ—‚", "εΎ…θ‘ŒεŠ¨οΌš": "βœ…", "ζˆͺ歒:": "⏰", } section_icons = { "ζ‘˜θ¦οΌš": "πŸ“Œ", "εŽŸε› οΌš": "πŸ“", "εŽŸζ–‡οΌš": "πŸ’¬", } output: list[str] = [] for line in plain.splitlines(): stripped = line.strip() matched = False for label, icon in field_icons.items(): if stripped.startswith(label): output.append(f"{icon} {stripped}") matched = True break if matched: continue for label, icon in section_icons.items(): if stripped.startswith(label): if output and output[-1] != "": output.append("") output.append(f"{icon} {label[:-1]}") value = stripped[len(label):].strip() if value: output.append(value) matched = True break if not matched: output.append(line) body = "\n".join(output).strip() if not title: return body icon = "πŸ”΄" if "重要" in title else "πŸ””" return f"{icon} {title}\n━━━━━━━━━━━━\n\n{body}" if body else f"{icon} {title}" class WecomWebhookNotifier(Notifier): name = "wecom_webhook" def __init__(self, urls: list[str] | None = None) -> None: configured = urls or [] if not configured: configured = [part.strip() for part in os.getenv( "WECOM_WEBHOOK_URLS", "" ).split(";") if part.strip()] self.urls = [self._validate_url(url) for url in configured] if not self.urls: raise RuntimeError("WECOM_WEBHOOK_URLS is not configured") @staticmethod def _validate_url(value: str) -> str: parsed = urlparse(value.strip()) key = parse_qs(parsed.query).get("key", []) if (parsed.scheme != "https" or parsed.hostname != "qyapi.weixin.qq.com" or parsed.path != "/cgi-bin/webhook/send" or not key or not key[0]): raise RuntimeError("invalid Enterprise WeChat webhook URL") return value.strip() async def send(self, title: str, text: str, images: list[ImageAttachment] | None = None) -> None: content = _truncate_utf8(_format_plain_text(title, text), 2048) failures = 0 async with httpx.AsyncClient(timeout=90, trust_env=False) as client: for url in self.urls: try: response = await client.post( url, json={"msgtype": "text", "text": {"content": content}}, ) data = response.json() ok = response.status_code < 400 and data.get("errcode") == 0 if ok: for image in images or []: preview = compressed_preview(image) encoded = base64.b64encode(preview.data).decode("ascii") digest = hashlib.md5(preview.data).hexdigest() response = await client.post( url, json={ "msgtype": "image", "image": {"base64": encoded, "md5": digest}, }, ) data = response.json() if response.status_code >= 400 or data.get("errcode") != 0: ok = False break except Exception: ok = False if not ok: failures += 1 if failures: raise RuntimeError(f"Enterprise WeChat webhook failed for {failures} target(s)")