回退:移除误提交的通知网关
This commit is contained in:
parent
19afaffbb7
commit
509d75ed0b
@ -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
|
|
||||||
10
services/notify-gateway/.gitignore
vendored
10
services/notify-gateway/.gitignore
vendored
@ -1,10 +0,0 @@
|
|||||||
.env
|
|
||||||
config/config.yaml
|
|
||||||
data/
|
|
||||||
logs/
|
|
||||||
secrets/
|
|
||||||
backups/
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*.db
|
|
||||||
*.db-*
|
|
||||||
@ -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"]
|
|
||||||
@ -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`。
|
|
||||||
@ -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}
|
|
||||||
@ -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: []
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
from .app import main
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@ -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())
|
|
||||||
@ -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()
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
from .factory import build_notifier, build_notifiers
|
|
||||||
|
|
||||||
__all__ = ["build_notifier", "build_notifiers"]
|
|
||||||
@ -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."""
|
|
||||||
@ -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
|
|
||||||
@ -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)
|
|
||||||
@ -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,
|
|
||||||
)
|
|
||||||
@ -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})
|
|
||||||
@ -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"<b>{esc(title)}</b>\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)")
|
|
||||||
@ -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"<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)")
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
aiohttp==3.12.15
|
|
||||||
aiosqlite==0.21.0
|
|
||||||
httpx==0.28.1
|
|
||||||
Pillow==12.3.0
|
|
||||||
PyYAML==6.0.2
|
|
||||||
@ -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")
|
|
||||||
Loading…
Reference in New Issue
Block a user