trace/services/notify-gateway/notifiers/wecom_webhook.py
Lee 19afaffbb7
All checks were successful
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Successful in 3m22s
feat: add shared notification gateway
2026-09-07 17:50:25 +08:00

127 lines
4.5 KiB
Python

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"<br\s*/?>", "\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)")