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