diff --git a/services/notify-gateway/.env.example b/services/notify-gateway/.env.example deleted file mode 100644 index b784db2..0000000 --- a/services/notify-gateway/.env.example +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index a2ff316..0000000 --- a/services/notify-gateway/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -.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 deleted file mode 100644 index 3eb720d..0000000 --- a/services/notify-gateway/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 537701b..0000000 --- a/services/notify-gateway/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# 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 deleted file mode 100644 index ecffccc..0000000 --- a/services/notify-gateway/compose.yaml +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 7c0bb2b..0000000 --- a/services/notify-gateway/config.example.yaml +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index e69de29..0000000 diff --git a/services/notify-gateway/gateway/__main__.py b/services/notify-gateway/gateway/__main__.py deleted file mode 100644 index 37c2a2d..0000000 --- a/services/notify-gateway/gateway/__main__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .app import main - -if __name__ == "__main__": - main() diff --git a/services/notify-gateway/gateway/app.py b/services/notify-gateway/gateway/app.py deleted file mode 100644 index 62c7bf5..0000000 --- a/services/notify-gateway/gateway/app.py +++ /dev/null @@ -1,180 +0,0 @@ -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 deleted file mode 100644 index 6e31685..0000000 --- a/services/notify-gateway/gateway/delivery_store.py +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index a92e761..0000000 --- a/services/notify-gateway/notifiers/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 37f8f72..0000000 --- a/services/notify-gateway/notifiers/base.py +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index deb52b5..0000000 --- a/services/notify-gateway/notifiers/client.py +++ /dev/null @@ -1,36 +0,0 @@ -"""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 deleted file mode 100644 index b6514d9..0000000 --- a/services/notify-gateway/notifiers/factory.py +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 696e493..0000000 --- a/services/notify-gateway/notifiers/image_utils.py +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index cd2f04c..0000000 --- a/services/notify-gateway/notifiers/qq.py +++ /dev/null @@ -1,37 +0,0 @@ -"""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 deleted file mode 100644 index 16ff2c6..0000000 --- a/services/notify-gateway/notifiers/telegram.py +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index 1d3ad85..0000000 --- a/services/notify-gateway/notifiers/wecom_webhook.py +++ /dev/null @@ -1,126 +0,0 @@ -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 deleted file mode 100644 index ae481e4..0000000 --- a/services/notify-gateway/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 0a6c355..0000000 --- a/services/notify-gateway/tests/test_plain_format.py +++ /dev/null @@ -1,32 +0,0 @@ -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")