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

181 lines
7.8 KiB
Python

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