From 19afaffbb789c965d30006c7afac08d71d4f745e Mon Sep 17 00:00:00 2001 From: Lee <1633292@qq.com> Date: Mon, 7 Sep 2026 17:50:25 +0800 Subject: [PATCH] feat: add shared notification gateway --- services/notify-gateway/.env.example | 6 + services/notify-gateway/.gitignore | 10 + services/notify-gateway/Dockerfile | 10 + services/notify-gateway/README.md | 46 +++++ services/notify-gateway/compose.yaml | 38 ++++ services/notify-gateway/config.example.yaml | 24 +++ services/notify-gateway/gateway/__init__.py | 0 services/notify-gateway/gateway/__main__.py | 4 + services/notify-gateway/gateway/app.py | 180 ++++++++++++++++++ .../notify-gateway/gateway/delivery_store.py | 48 +++++ services/notify-gateway/notifiers/__init__.py | 3 + services/notify-gateway/notifiers/base.py | 18 ++ services/notify-gateway/notifiers/client.py | 36 ++++ services/notify-gateway/notifiers/factory.py | 51 +++++ .../notify-gateway/notifiers/image_utils.py | 52 +++++ services/notify-gateway/notifiers/qq.py | 37 ++++ services/notify-gateway/notifiers/telegram.py | 72 +++++++ .../notify-gateway/notifiers/wecom_webhook.py | 126 ++++++++++++ services/notify-gateway/requirements.txt | 5 + .../notify-gateway/tests/test_plain_format.py | 32 ++++ 20 files changed, 798 insertions(+) create mode 100644 services/notify-gateway/.env.example create mode 100644 services/notify-gateway/.gitignore create mode 100644 services/notify-gateway/Dockerfile create mode 100644 services/notify-gateway/README.md create mode 100644 services/notify-gateway/compose.yaml create mode 100644 services/notify-gateway/config.example.yaml create mode 100644 services/notify-gateway/gateway/__init__.py create mode 100644 services/notify-gateway/gateway/__main__.py create mode 100644 services/notify-gateway/gateway/app.py create mode 100644 services/notify-gateway/gateway/delivery_store.py create mode 100644 services/notify-gateway/notifiers/__init__.py create mode 100644 services/notify-gateway/notifiers/base.py create mode 100644 services/notify-gateway/notifiers/client.py create mode 100644 services/notify-gateway/notifiers/factory.py create mode 100644 services/notify-gateway/notifiers/image_utils.py create mode 100644 services/notify-gateway/notifiers/qq.py create mode 100644 services/notify-gateway/notifiers/telegram.py create mode 100644 services/notify-gateway/notifiers/wecom_webhook.py create mode 100644 services/notify-gateway/requirements.txt create mode 100644 services/notify-gateway/tests/test_plain_format.py diff --git a/services/notify-gateway/.env.example b/services/notify-gateway/.env.example new file mode 100644 index 0000000..b784db2 --- /dev/null +++ b/services/notify-gateway/.env.example @@ -0,0 +1,6 @@ +TZ=Asia/Singapore +NOTIFY_GATEWAY_TOKEN=replace-with-random-token +TELEGRAM_BOT_TOKEN=replace-with-bot-token +TELEGRAM_NOTIFY_CHAT_ID=replace-with-chat-id +WECOM_WEBHOOK_URLS=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-with-webhook-key +SHARED_NETWORK_NAME=shared-services diff --git a/services/notify-gateway/.gitignore b/services/notify-gateway/.gitignore new file mode 100644 index 0000000..a2ff316 --- /dev/null +++ b/services/notify-gateway/.gitignore @@ -0,0 +1,10 @@ +.env +config/config.yaml +data/ +logs/ +secrets/ +backups/ +__pycache__/ +*.py[cod] +*.db +*.db-* diff --git a/services/notify-gateway/Dockerfile b/services/notify-gateway/Dockerfile new file mode 100644 index 0000000..3eb720d --- /dev/null +++ b/services/notify-gateway/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY gateway ./gateway +COPY notifiers ./notifiers +COPY config.example.yaml ./config.example.yaml +RUN chmod -R a+rX /app/gateway /app/notifiers /app/config.example.yaml +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +CMD ["python", "-m", "gateway"] diff --git a/services/notify-gateway/README.md b/services/notify-gateway/README.md new file mode 100644 index 0000000..537701b --- /dev/null +++ b/services/notify-gateway/README.md @@ -0,0 +1,46 @@ +# notify-gateway + +独立、可复用的 HTTP 通知网关。调用方必须显式指定渠道;Telegram 还必须指定命名 BOT。 +当前支持 Telegram、企业微信群 Webhook,并保留默认关闭的 OneBot QQ 适配器。 + +## API + +```http +POST /send +Authorization: Bearer $NOTIFY_GATEWAY_TOKEN +Content-Type: application/json +``` + +```json +{ + "title": "通知标题", + "text": "正文", + "channels": ["telegram", "wecom_webhook"], + "telegram_bot": "default", + "idempotency_key": "caller:event:123" +} +``` + +可选 `images` 接受最多 4 张 JPEG/PNG Base64 图片,原始数据合计不超过 50 MB: + +```json +{"images":[{"filename":"photo.jpg","content_type":"image/jpeg","data_base64":"..."}]} +``` + +- Telegram:尺寸允许时直接以照片显示,过大时只发送压缩照片预览。 +- 企业微信:尺寸允许时直接显示,过大时只发送不超过平台限制的压缩预览。 +- 网关不再把原图作为文件附件发送,也不接受远程图片 URL。 +- 空标题表示原样纯文本模式,不自动添加 emoji 或分割线。 +- `channels` 必须显式指定且不能为空。 +- 请求 Telegram 时必须提供配置中存在的 `telegram_bot`。 +- 建议始终提供稳定的 `idempotency_key`,成功渠道在重试时会跳过。 +- `GET /health` 无需鉴权。 + +## 启动 + +1. 复制 `.env.example` 为 `.env` 并填写所需渠道变量。 +2. 复制 `config.example.yaml` 为 `config/config.yaml`,按需启用渠道和命名 BOT。 +3. 创建共享网络:`docker network create shared-services`,或设置 `SHARED_NETWORK_NAME`。 +4. 运行 `docker compose up -d --build`。 + +Python 调用客户端见 `notifiers/client.py`。 diff --git a/services/notify-gateway/compose.yaml b/services/notify-gateway/compose.yaml new file mode 100644 index 0000000..ecffccc --- /dev/null +++ b/services/notify-gateway/compose.yaml @@ -0,0 +1,38 @@ +services: + notify-gateway: + build: . + image: notify-gateway:local + container_name: notify-gateway + restart: unless-stopped + environment: + NOTIFY_GATEWAY_CONFIG: /app/config/config.yaml + NOTIFY_GATEWAY_TOKEN: ${NOTIFY_GATEWAY_TOKEN:?set NOTIFY_GATEWAY_TOKEN in .env} + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} + TELEGRAM_NOTIFY_CHAT_ID: ${TELEGRAM_NOTIFY_CHAT_ID:-} + WECOM_WEBHOOK_URLS: ${WECOM_WEBHOOK_URLS:-} + TZ: ${TZ:-Asia/Singapore} + volumes: + - ./config:/app/config:ro + - ./data:/app/data + - ./logs:/app/logs + ports: + - "127.0.0.1:8770:8770" + networks: + - class-monitor + - shared-services + read_only: true + tmpfs: + - /tmp:size=16m,mode=1777 + user: "10000:10000" + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + +networks: + class-monitor: + external: true + name: class-monitor + shared-services: + external: true + name: ${SHARED_NETWORK_NAME:-shared-services} diff --git a/services/notify-gateway/config.example.yaml b/services/notify-gateway/config.example.yaml new file mode 100644 index 0000000..7c0bb2b --- /dev/null +++ b/services/notify-gateway/config.example.yaml @@ -0,0 +1,24 @@ +timezone: Asia/Singapore +listen_host: 0.0.0.0 +listen_port: 8770 +state_db: /app/data/deliveries.db +api_token: "" +channels: + telegram: + enabled: true + bots: + default: + token_env: TELEGRAM_BOT_TOKEN + chat_ids_env: TELEGRAM_NOTIFY_CHAT_ID + # lottery: + # token_env: TELEGRAM_LOTTERY_BOT_TOKEN + # chat_ids_env: TELEGRAM_LOTTERY_CHAT_ID + wecom_webhook: + enabled: false + # WECOM_WEBHOOK_URLS: semicolon-separated, one URL per internal group. + urls: [] + qq: + enabled: false + onebot_http: "" + access_token: "" + targets: [] diff --git a/services/notify-gateway/gateway/__init__.py b/services/notify-gateway/gateway/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/notify-gateway/gateway/__main__.py b/services/notify-gateway/gateway/__main__.py new file mode 100644 index 0000000..37c2a2d --- /dev/null +++ b/services/notify-gateway/gateway/__main__.py @@ -0,0 +1,4 @@ +from .app import main + +if __name__ == "__main__": + main() diff --git a/services/notify-gateway/gateway/app.py b/services/notify-gateway/gateway/app.py new file mode 100644 index 0000000..62c7bf5 --- /dev/null +++ b/services/notify-gateway/gateway/app.py @@ -0,0 +1,180 @@ +import asyncio +import base64 +import hashlib +import hmac +import logging +import os +from pathlib import Path + +import yaml +from aiohttp import web + +from notifiers.base import ImageAttachment +from notifiers.factory import build_notifier +from .delivery_store import DeliveryStore + +LOG = logging.getLogger("notify-gateway") +MAX_IMAGES = 4 +MAX_IMAGE_BYTES = 50_000_000 +MAX_TOTAL_IMAGE_BYTES = 50_000_000 + + +def parse_images(raw: object) -> list[ImageAttachment]: + if raw in (None, []): + return [] + if not isinstance(raw, list) or len(raw) > MAX_IMAGES: + raise web.HTTPBadRequest(reason="images must be a list with at most 4 items") + images: list[ImageAttachment] = [] + total_bytes = 0 + for item in raw: + if not isinstance(item, dict): + raise web.HTTPBadRequest(reason="invalid image item") + content_type = str(item.get("content_type") or "") + if content_type not in {"image/jpeg", "image/png"}: + raise web.HTTPBadRequest(reason="unsupported image content type") + try: + data = base64.b64decode(str(item.get("data_base64") or ""), validate=True) + except Exception: + raise web.HTTPBadRequest(reason="invalid image base64") + magic_ok = ( + content_type == "image/jpeg" and data.startswith(bytes.fromhex("ffd8ff")) + ) or ( + content_type == "image/png" + and data.startswith(bytes.fromhex("89504e470d0a1a0a")) + ) + if not magic_ok or not data or len(data) > MAX_IMAGE_BYTES: + raise web.HTTPBadRequest(reason="invalid image data or size") + total_bytes += len(data) + if total_bytes > MAX_TOTAL_IMAGE_BYTES: + raise web.HTTPBadRequest(reason="total image size exceeds 50 MB") + filename = Path(str(item.get("filename") or "image")).name + images.append(ImageAttachment(filename, content_type, data)) + return images + + +class Gateway: + def __init__(self, cfg: dict): + self.cfg = cfg + self.expected = os.getenv("NOTIFY_GATEWAY_TOKEN", "") or str(cfg.get("api_token", "")) + self.channels_cfg = cfg.get("channels") or {} + self.notifiers = {} + self.telegram_bots = {} + for name, channel_cfg in self.channels_cfg.items(): + if not channel_cfg.get("enabled"): + continue + if name == "telegram": + bots_cfg = channel_cfg.get("bots") or {} + if not isinstance(bots_cfg, dict) or not bots_cfg: + raise RuntimeError("Telegram bots are not configured") + self.telegram_bots = { + bot_name: build_notifier("telegram", bot_cfg) + for bot_name, bot_cfg in bots_cfg.items() + } + else: + self.notifiers[name] = build_notifier(name, channel_cfg) + self.store = DeliveryStore(str(cfg.get("state_db", "/app/data/deliveries.db"))) + + def _check(self, request: web.Request) -> None: + if not self.expected: + raise web.HTTPForbidden(reason="gateway token not configured") + auth = request.headers.get("Authorization", "") + if not hmac.compare_digest(auth, f"Bearer {self.expected}"): + raise web.HTTPUnauthorized() + + async def health(self, request: web.Request): + channels = { + name: (bool(self.telegram_bots) if name == "telegram" else name in self.notifiers) + for name in self.channels_cfg + } + return web.json_response({ + "ok": True, + "channels": channels, + "telegram_bots": sorted(self.telegram_bots), + }) + + async def send(self, request: web.Request): + self._check(request) + try: + data = await request.json() + except Exception: + raise web.HTTPBadRequest(reason="invalid json") + title = str(data.get("title", "")) + text = str(data.get("text", "")) + images = parse_images(data.get("images")) + wanted = data.get("channels") + if not text and not title and not images: + raise web.HTTPBadRequest(reason="empty message") + if not isinstance(wanted, list) or not wanted or not all(isinstance(x, str) and x for x in wanted): + raise web.HTTPBadRequest(reason="channels must explicitly select at least one channel") + if len(set(wanted)) != len(wanted): + raise web.HTTPBadRequest(reason="channels must not contain duplicates") + if len(title) > 500 or len(text) > 20000: + raise web.HTTPRequestEntityTooLarge(max_size=20500, actual_size=len(title) + len(text)) + telegram_bot = data.get("telegram_bot") + if "telegram" in wanted: + if not isinstance(telegram_bot, str) or not telegram_bot.strip(): + raise web.HTTPBadRequest(reason="telegram_bot is required for Telegram delivery") + telegram_bot = telegram_bot.strip() + if telegram_bot not in self.telegram_bots: + raise web.HTTPBadRequest(reason="unknown telegram_bot") + elif telegram_bot is not None: + raise web.HTTPBadRequest(reason="telegram_bot is only valid with the Telegram channel") + key = str(data.get("idempotency_key") or "").strip() + if not key: + digest_input = bytearray((title + "\0" + text).encode("utf-8")) + for image in images: + digest_input.extend(b"\0") + digest_input.extend(hashlib.sha256(image.data).digest()) + digest = hashlib.sha256(digest_input).hexdigest() + key = f"content:{digest}" + if len(key) > 200: + raise web.HTTPBadRequest(reason="idempotency_key too long") + results: dict[str, str] = {} + for name in wanted: + notifier = self.telegram_bots.get(telegram_bot) if name == "telegram" else self.notifiers.get(name) + if not notifier: + results[name] = "disabled" + continue + delivery_target = f"telegram:{telegram_bot}" if name == "telegram" else name + if await self.store.delivered(key, delivery_target): + results[name] = "already_delivered" + continue + try: + await notifier.send(title, text, images) + await self.store.record(key, delivery_target, True) + results[name] = "ok" + except Exception as exc: + LOG.exception("gateway send failed channel=%s", name) + await self.store.record(key, delivery_target, False, repr(exc)) + results[name] = "error" + ok = bool(results) and all(v in {"ok", "already_delivered"} for v in results.values()) + return web.json_response({"ok": ok, "results": results}) + + async def run_app(self): + await self.store.init() + site = web.Application(client_max_size=70 * 1024 * 1024) + site.router.add_get("/health", self.health) + site.router.add_post("/send", self.send) + runner = web.AppRunner(site) + await runner.setup() + await web.TCPSite(runner, self.cfg.get("listen_host", "0.0.0.0"), + int(self.cfg.get("listen_port", 8770))).start() + LOG.info("notify-gateway ready channels=%s", + [k for k, v in (self.cfg.get("channels") or {}).items() if v.get("enabled")]) + await asyncio.Event().wait() + + +def load_config(): + with open(os.environ.get("NOTIFY_GATEWAY_CONFIG", "/app/config/config.yaml"), + encoding="utf-8") as f: + return yaml.safe_load(f) + + +def main(): + Path("/app/logs").mkdir(parents=True, exist_ok=True) + logging.basicConfig(level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s") + # httpx logs full request URLs; Telegram and WeCom put credentials in URLs. + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) + asyncio.run(Gateway(load_config()).run_app()) diff --git a/services/notify-gateway/gateway/delivery_store.py b/services/notify-gateway/gateway/delivery_store.py new file mode 100644 index 0000000..6e31685 --- /dev/null +++ b/services/notify-gateway/gateway/delivery_store.py @@ -0,0 +1,48 @@ +import aiosqlite + + +SCHEMA = """ +PRAGMA journal_mode=WAL; +PRAGMA synchronous=FULL; +CREATE TABLE IF NOT EXISTS deliveries ( + idempotency_key TEXT NOT NULL, + channel TEXT NOT NULL, + delivered INTEGER NOT NULL DEFAULT 0, + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (idempotency_key, channel) +); +""" + + +class DeliveryStore: + def __init__(self, path: str) -> None: + self.path = path + + async def init(self) -> None: + async with aiosqlite.connect(self.path) as db: + await db.executescript(SCHEMA) + await db.commit() + + async def delivered(self, key: str, channel: str) -> bool: + async with aiosqlite.connect(self.path) as db: + row = await (await db.execute( + "SELECT delivered FROM deliveries WHERE idempotency_key=? AND channel=?", + (key, channel), + )).fetchone() + return bool(row and row[0]) + + async def record(self, key: str, channel: str, ok: bool, error: str = "") -> None: + async with aiosqlite.connect(self.path) as db: + await db.execute( + """INSERT INTO deliveries(idempotency_key,channel,delivered,attempts,last_error) + VALUES(?,?,?,?,?) + ON CONFLICT(idempotency_key,channel) DO UPDATE SET + delivered=MAX(deliveries.delivered, excluded.delivered), + attempts=deliveries.attempts+1, + last_error=CASE WHEN excluded.delivered=1 THEN NULL ELSE excluded.last_error END, + updated_at=CURRENT_TIMESTAMP""", + (key, channel, int(ok), 1, error[:500] or None), + ) + await db.commit() diff --git a/services/notify-gateway/notifiers/__init__.py b/services/notify-gateway/notifiers/__init__.py new file mode 100644 index 0000000..a92e761 --- /dev/null +++ b/services/notify-gateway/notifiers/__init__.py @@ -0,0 +1,3 @@ +from .factory import build_notifier, build_notifiers + +__all__ = ["build_notifier", "build_notifiers"] diff --git a/services/notify-gateway/notifiers/base.py b/services/notify-gateway/notifiers/base.py new file mode 100644 index 0000000..37f8f72 --- /dev/null +++ b/services/notify-gateway/notifiers/base.py @@ -0,0 +1,18 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ImageAttachment: + filename: str + content_type: str + data: bytes + + +class Notifier(ABC): + name = "base" + + @abstractmethod + async def send(self, title: str, text: str, + images: list[ImageAttachment] | None = None) -> None: + """Deliver one notification or raise so the caller can retry.""" diff --git a/services/notify-gateway/notifiers/client.py b/services/notify-gateway/notifiers/client.py new file mode 100644 index 0000000..deb52b5 --- /dev/null +++ b/services/notify-gateway/notifiers/client.py @@ -0,0 +1,36 @@ +"""HTTP client for external programs calling notify-gateway.""" +import httpx + + +class NotifyClient: + def __init__(self, base_url: str, token: str, timeout: float = 20.0) -> None: + self.base_url = base_url.rstrip("/") + self.token = token + self.timeout = timeout + + async def send(self, title: str, text: str, channels: list[str], + telegram_bot: str = "", + idempotency_key: str = "", + images: list[dict] | None = None) -> dict: + if not channels: + raise ValueError("channels must explicitly select at least one channel") + if "telegram" in channels and not telegram_bot.strip(): + raise ValueError("telegram_bot is required for Telegram delivery") + payload = {"title": title, "text": text, "channels": channels} + if "telegram" in channels: + payload["telegram_bot"] = telegram_bot.strip() + if images: + payload["images"] = images + if idempotency_key: + payload["idempotency_key"] = idempotency_key + async with httpx.AsyncClient(timeout=self.timeout, trust_env=False) as client: + r = await client.post( + f"{self.base_url}/send", + headers={"Authorization": f"Bearer {self.token}"}, + json=payload, + ) + r.raise_for_status() + data = r.json() + if not data.get("ok"): + raise RuntimeError(f"notify-gateway delivery incomplete: {data.get('results', {})}") + return data diff --git a/services/notify-gateway/notifiers/factory.py b/services/notify-gateway/notifiers/factory.py new file mode 100644 index 0000000..b6514d9 --- /dev/null +++ b/services/notify-gateway/notifiers/factory.py @@ -0,0 +1,51 @@ +from .base import ImageAttachment, Notifier +from .qq import QQNotifier +from .telegram import TelegramNotifier +from .wecom_webhook import WecomWebhookNotifier + + +def build_notifier(name: str, cfg: dict | None = None) -> Notifier: + cfg = cfg or {} + if name == "telegram": + return TelegramNotifier( + token=cfg.get("token", ""), + chat_ids=cfg.get("chat_ids"), + token_env=cfg.get("token_env", "TELEGRAM_BOT_TOKEN"), + chat_ids_env=cfg.get("chat_ids_env", "TELEGRAM_NOTIFY_CHAT_ID"), + ) + if name == "wecom_webhook": + return WecomWebhookNotifier(urls=cfg.get("urls")) + if name == "qq": + return QQNotifier( + onebot_http=cfg.get("onebot_http", ""), + access_token=cfg.get("access_token", ""), + targets=cfg.get("targets", [])) + raise ValueError(f"Unknown notification channel: {name}") + + +def build_notifiers(names: list[str], channels_cfg: dict | None = None) -> "MultiNotifier": + channels_cfg = channels_cfg or {} + return MultiNotifier([build_notifier(n, channels_cfg.get(n, {})) for n in names]) + + +class MultiNotifier(Notifier): + """Fan-out to N channels; raises only if ALL fail.""" + + name = "multi" + + def __init__(self, notifiers: list[Notifier]): + self.notifiers = notifiers + + async def send(self, title: str, text: str, + images: list[ImageAttachment] | None = None) -> None: + errors: dict[str, str] = {} + for n in self.notifiers: + try: + await n.send(title, text, images) + except Exception as exc: + errors[n.name] = repr(exc) + if errors and len(errors) == len(self.notifiers): + raise RuntimeError(f"All notifiers failed: {errors}") + if errors: + import logging + logging.getLogger("notify-gateway").warning("partial notify failure: %s", errors) diff --git a/services/notify-gateway/notifiers/image_utils.py b/services/notify-gateway/notifiers/image_utils.py new file mode 100644 index 0000000..696e493 --- /dev/null +++ b/services/notify-gateway/notifiers/image_utils.py @@ -0,0 +1,52 @@ +import io +from pathlib import Path + +from PIL import Image, ImageOps + +from .base import ImageAttachment + + +INLINE_IMAGE_MAX_BYTES = 2_000_000 +PREVIEW_TARGET_BYTES = 1_900_000 +WECOM_FILE_MAX_BYTES = 20_000_000 +TELEGRAM_PHOTO_MAX_BYTES = 10_000_000 +TELEGRAM_FILE_MAX_BYTES = 50_000_000 + + +def compressed_preview(image: ImageAttachment) -> ImageAttachment: + if len(image.data) <= INLINE_IMAGE_MAX_BYTES: + return image + + with Image.open(io.BytesIO(image.data)) as source: + source.load() + working = ImageOps.exif_transpose(source) + if working.mode in {"RGBA", "LA"}: + background = Image.new("RGB", working.size, "white") + alpha = working.getchannel("A") + background.paste(working.convert("RGB"), mask=alpha) + working = background + elif working.mode != "RGB": + working = working.convert("RGB") + + current = working + while True: + for quality in (90, 82, 74, 66, 58, 50, 42, 34): + output = io.BytesIO() + current.save( + output, + format="JPEG", + quality=quality, + optimize=True, + progressive=True, + ) + data = output.getvalue() + if len(data) <= PREVIEW_TARGET_BYTES: + name = f"{Path(image.filename).stem}-preview.jpg" + return ImageAttachment(name, "image/jpeg", data) + width, height = current.size + if min(width, height) <= 128: + raise RuntimeError("image cannot be compressed below preview limit") + current = current.resize( + (max(1, int(width * 0.8)), max(1, int(height * 0.8))), + Image.Resampling.LANCZOS, + ) diff --git a/services/notify-gateway/notifiers/qq.py b/services/notify-gateway/notifiers/qq.py new file mode 100644 index 0000000..cd2f04c --- /dev/null +++ b/services/notify-gateway/notifiers/qq.py @@ -0,0 +1,37 @@ +"""QQ notifier (reserved). Transport: NapCat OneBot HTTP API.""" +import os + +import httpx + +from .base import ImageAttachment, Notifier + + +class QQNotifier(Notifier): + name = "qq" + + def __init__(self, onebot_http: str = '', access_token: str = '', + targets: list[dict] | None = None) -> None: + self.onebot_http = (onebot_http or os.getenv("QQ_ONEBOT_HTTP", "")).rstrip("/") + self.access_token = access_token or os.getenv("ONEBOT_ACCESS_TOKEN", "") + self.targets = targets if targets is not None else [] + if not self.onebot_http: + raise RuntimeError("QQ OneBot HTTP endpoint not configured (reserved)") + + async def send(self, title: str, text: str, + images: list[ImageAttachment] | None = None) -> None: + if not self.targets: + raise RuntimeError("QQ targets not configured (reserved)") + headers = {} + if self.access_token: + headers["Authorization"] = f"Bearer {self.access_token}" + body = f"{title}\n{text}" if title else text + async with httpx.AsyncClient(timeout=20, trust_env=False) as client: + for t in self.targets: + if t.get("group_id"): + await client.post(f"{self.onebot_http}/send_group_msg", + headers=headers, + json={"group_id": t["group_id"], "message": body}) + elif t.get("user_id"): + await client.post(f"{self.onebot_http}/send_private_msg", + headers=headers, + json={"user_id": t["user_id"], "message": body}) diff --git a/services/notify-gateway/notifiers/telegram.py b/services/notify-gateway/notifiers/telegram.py new file mode 100644 index 0000000..16ff2c6 --- /dev/null +++ b/services/notify-gateway/notifiers/telegram.py @@ -0,0 +1,72 @@ +import html +import os + +import httpx + +from .base import ImageAttachment, Notifier +from .image_utils import ( + TELEGRAM_PHOTO_MAX_BYTES, + compressed_preview, +) + + +def esc(value: object) -> str: + return html.escape(str(value or "")) + + +class TelegramNotifier(Notifier): + name = "telegram" + + def __init__(self, token: str = '', chat_ids: list[str] | None = None, + token_env: str = "TELEGRAM_BOT_TOKEN", + chat_ids_env: str = "TELEGRAM_NOTIFY_CHAT_ID") -> None: + self.token = token or os.environ.get(token_env, "") + raw = "" + if chat_ids: + raw = ",".join(chat_ids) + else: + raw = os.getenv(chat_ids_env) or ( + os.getenv("TELEGRAM_HOME_CHANNEL") if chat_ids_env == "TELEGRAM_NOTIFY_CHAT_ID" else "" + ) or "" + # FIX vs old code: fan-out to ALL chat ids, not just split(',',1)[0]. + self.chat_ids = [c.strip() for c in raw.split(",") if c.strip()] + if not self.token: + raise RuntimeError("Telegram token is not configured") + if not self.chat_ids: + raise RuntimeError("Telegram notification target is not configured") + self.proxy = os.getenv("TELEGRAM_PROXY") or None + + async def send(self, title: str, text: str, + images: list[ImageAttachment] | None = None) -> None: + body = f"{esc(title)}\n{text}" if title else text + failures = 0 + async with httpx.AsyncClient(timeout=90, proxy=self.proxy, trust_env=False) as client: + for chat_id in self.chat_ids: + try: + response = await client.post( + f"https://api.telegram.org/bot{self.token}/sendMessage", + json={"chat_id": chat_id, "text": body[:4096], "parse_mode": "HTML", "disable_web_page_preview": True}, + ) + ok = response.status_code < 400 and response.json().get("ok") is True + if ok: + for image in images or []: + upload = ( + image if len(image.data) <= TELEGRAM_PHOTO_MAX_BYTES + else compressed_preview(image) + ) + response = await client.post( + f"https://api.telegram.org/bot{self.token}/sendPhoto", + data={"chat_id": chat_id}, + files={"photo": ( + upload.filename, upload.data, upload.content_type + )}, + ) + if response.status_code >= 400 or response.json().get("ok") is not True: + ok = False + break + except Exception: + ok = False + if not ok: + failures += 1 + if failures: + raise RuntimeError(f"Telegram delivery failed for {failures} target(s)") diff --git a/services/notify-gateway/notifiers/wecom_webhook.py b/services/notify-gateway/notifiers/wecom_webhook.py new file mode 100644 index 0000000..1d3ad85 --- /dev/null +++ b/services/notify-gateway/notifiers/wecom_webhook.py @@ -0,0 +1,126 @@ +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)") diff --git a/services/notify-gateway/requirements.txt b/services/notify-gateway/requirements.txt new file mode 100644 index 0000000..ae481e4 --- /dev/null +++ b/services/notify-gateway/requirements.txt @@ -0,0 +1,5 @@ +aiohttp==3.12.15 +aiosqlite==0.21.0 +httpx==0.28.1 +Pillow==12.3.0 +PyYAML==6.0.2 diff --git a/services/notify-gateway/tests/test_plain_format.py b/services/notify-gateway/tests/test_plain_format.py new file mode 100644 index 0000000..0a6c355 --- /dev/null +++ b/services/notify-gateway/tests/test_plain_format.py @@ -0,0 +1,32 @@ +import inspect + +from notifiers.telegram import TelegramNotifier +from notifiers.wecom_webhook import WecomWebhookNotifier, _format_plain_text + + +def test_titleless_message_is_plain_and_undecorated(): + text = "【小学群】 发送人:家长\n摘要:发送了一张图片\n准备物品:课外书" + result = _format_plain_text("", text) + assert result == text + assert "🔔" not in result + assert "📌" not in result + assert "━━━━━━━━━━━━" not in result + + +def test_titled_messages_keep_existing_decoration(): + result = _format_plain_text("系统通知", "发送人:测试") + assert result.startswith("🔔 系统通知\n━━━━━━━━━━━━") + assert "👤 发送人:测试" in result + + +def test_original_image_file_attachments_are_disabled(): + telegram_source = inspect.getsource(TelegramNotifier.send) + wecom_source = inspect.getsource(WecomWebhookNotifier.send) + assert "sendDocument" not in telegram_source + assert "upload_media" not in wecom_source + assert '"msgtype": "file"' not in wecom_source + + +for name in sorted(value for value in globals() if value.startswith("test_")): + globals()[name]() + print(f"{name}=PASS")