diff --git a/admin/dashboard/blueprints/trendradar_webhook.py b/admin/dashboard/blueprints/trendradar_webhook.py new file mode 100644 index 0000000..94796c5 --- /dev/null +++ b/admin/dashboard/blueprints/trendradar_webhook.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +""" +TrendRadar Webhook 适配蓝图。 + +设计目标: +1. 提供一个无需登录态的公开 webhook 接口,供 TrendRadar 推送热点内容; +2. 兼容 TrendRadar Generic Webhook 常见字段(title/content)以及常见别名; +3. 将接收到的内容转发到配置的微信群,支持按 payload 指定目标群(可开关)。 +""" +import asyncio +import json +import threading +from typing import Any, Dict, List, Tuple + +from flask import Blueprint, current_app, jsonify, request +from loguru import logger + + +# 独立 webhook 路由,避免和后台管理接口混在一起。 +trendradar_webhook_bp = Blueprint("trendradar_webhook", __name__, url_prefix="/webhook") + + +# 使用独立事件循环在线程中发送消息,避免阻塞 Flask 请求线程。 +_shared_loop = None +_loop_lock = threading.Lock() + + +def _get_or_create_loop() -> asyncio.AbstractEventLoop: + """获取或创建共享事件循环。""" + global _shared_loop + with _loop_lock: + if _shared_loop is None: + _shared_loop = asyncio.new_event_loop() + + def _run_loop(): + asyncio.set_event_loop(_shared_loop) + _shared_loop.run_forever() + + t = threading.Thread(target=_run_loop, daemon=True, name="trendradar_webhook_loop") + t.start() + return _shared_loop + + +def _parse_payload() -> Dict[str, Any]: + """解析请求负载,优先 JSON,兼容 form 与原始文本。""" + payload = request.get_json(silent=True) + if isinstance(payload, dict): + return payload + + if request.form: + return {k: v for k, v in request.form.items()} + + raw_text = (request.get_data(cache=False, as_text=True) or "").strip() + if not raw_text: + return {} + try: + data = json.loads(raw_text) + if isinstance(data, dict): + return data + except Exception: + pass + return {"raw_text": raw_text} + + +def _extract_title_content(payload: Dict[str, Any]) -> Tuple[str, str]: + """提取标题与正文,兼容多种字段写法。""" + title = str( + payload.get("title") + or payload.get("subject") + or (payload.get("data") or {}).get("title") + or "TrendRadar 热点推送" + ).strip() + + content = str( + payload.get("content") + or payload.get("text") + or payload.get("message") + or payload.get("summary") + or (payload.get("data") or {}).get("content") + or "" + ).strip() + + # 若未提取到标准字段,保底输出完整 payload,避免静默丢消息。 + if not content: + content = json.dumps(payload, ensure_ascii=False, indent=2) + return title, content + + +def _extract_target_groups(cfg: Dict[str, Any], payload: Dict[str, Any]) -> List[str]: + """提取目标群列表。 + + 优先级: + 1. 当 allow_payload_target_groups = true 时,允许 payload 覆盖目标群; + 2. 否则使用配置 default_group_ids。 + """ + targets: List[str] = [] + allow_payload = bool(cfg.get("allow_payload_target_groups", False)) + + if allow_payload: + raw_payload_targets = ( + payload.get("target_group_ids") + or payload.get("group_ids") + or payload.get("target_group_id") + or payload.get("group_id") + or payload.get("roomid") + ) + if isinstance(raw_payload_targets, str): + targets.extend([x.strip() for x in raw_payload_targets.split(",") if x.strip()]) + elif isinstance(raw_payload_targets, list): + targets.extend([str(x).strip() for x in raw_payload_targets if str(x).strip()]) + + if not targets: + raw_cfg_targets = cfg.get("default_group_ids", []) + if isinstance(raw_cfg_targets, str): + targets.extend([x.strip() for x in raw_cfg_targets.split(",") if x.strip()]) + elif isinstance(raw_cfg_targets, list): + targets.extend([str(x).strip() for x in raw_cfg_targets if str(x).strip()]) + + # 去重并保持顺序。 + seen = set() + result: List[str] = [] + for gid in targets: + if gid in seen: + continue + seen.add(gid) + result.append(gid) + return result + + +def _build_wechat_text(title: str, content: str, payload: Dict[str, Any]) -> str: + """构造转发到微信群的文本。""" + source = str(payload.get("source") or payload.get("platform") or "TrendRadar").strip() + lines = [ + "📡 TrendRadar 热点播报", + f"来源:{source}", + f"标题:{title}", + "", + content, + ] + return "\n".join(lines).strip() + + +def _check_token(cfg: Dict[str, Any], payload: Dict[str, Any]) -> bool: + """校验 webhook token。""" + expected = str(cfg.get("token", "") or "").strip() + if not expected: + return True + + provided = str( + request.headers.get("X-Webhook-Token") + or request.args.get("token") + or payload.get("token") + or "" + ).strip() + return provided == expected + + +@trendradar_webhook_bp.route("/trendradar", methods=["POST"]) +def trendradar_webhook(): + """TrendRadar Webhook 接口。""" + try: + dashboard_server = current_app.dashboard_server + cfg = (dashboard_server.config or {}).get("trendradar_webhook", {}) + if not bool(cfg.get("enabled", False)): + return jsonify({"success": False, "message": "trendradar webhook 未启用"}), 403 + + payload = _parse_payload() + if not _check_token(cfg, payload): + return jsonify({"success": False, "message": "token 校验失败"}), 401 + + title, content = _extract_title_content(payload) + target_groups = _extract_target_groups(cfg, payload) + if not target_groups: + return jsonify({"success": False, "message": "未配置目标群"}), 400 + + text = _build_wechat_text(title, content, payload) + loop = _get_or_create_loop() + sent_groups: List[str] = [] + failed_groups: Dict[str, str] = {} + + async def _send_once(group_id: str): + # sender 传空字符串即可,保持与现有插件调用风格一致。 + await dashboard_server.client.send_text_message(group_id, text, "") + + timeout_seconds = int(cfg.get("send_timeout_seconds", 20)) + for group_id in target_groups: + try: + fut = asyncio.run_coroutine_threadsafe(_send_once(group_id), loop) + fut.result(timeout=max(timeout_seconds, 5)) + sent_groups.append(group_id) + except Exception as e: + failed_groups[group_id] = str(e) + + logger.info( + f"[TrendRadarWebhook] 接收推送: title={title}, targets={target_groups}, " + f"sent={len(sent_groups)}, failed={len(failed_groups)}" + ) + return jsonify( + { + "success": len(failed_groups) == 0, + "title": title, + "sent_groups": sent_groups, + "failed_groups": failed_groups, + } + ) + except Exception as e: + logger.error(f"[TrendRadarWebhook] 处理失败: {e}") + return jsonify({"success": False, "message": str(e)}), 500 + diff --git a/admin/dashboard/config.toml b/admin/dashboard/config.toml index 85d25b5..ce250f3 100644 --- a/admin/dashboard/config.toml +++ b/admin/dashboard/config.toml @@ -5,3 +5,15 @@ port = 8888 [auth] username = "admin" password = "admin123" + +[trendradar_webhook] +# 是否启用 TrendRadar webhook 适配接口 +enabled = false +# 固定 token(建议配置),支持请求头 X-Webhook-Token / query token / payload.token 三种传法 +token = "replace_with_strong_token" +# 默认推送目标群(可配置多个) +default_group_ids = [] +# 是否允许 payload 覆盖目标群(开启后可通过 target_group_ids/group_id 指定) +allow_payload_target_groups = false +# 单群发送超时(秒) +send_timeout_seconds = 20 diff --git a/admin/dashboard/server.py b/admin/dashboard/server.py index a8061f0..bfae6c7 100644 --- a/admin/dashboard/server.py +++ b/admin/dashboard/server.py @@ -158,6 +158,7 @@ class DashboardServer: from admin.dashboard.blueprints.system_jobs import system_jobs_bp from admin.dashboard.blueprints.plugin_schedules import plugin_schedules_bp from admin.dashboard.blueprints.group_plugin_config import group_plugin_config_bp + from admin.dashboard.blueprints.trendradar_webhook import trendradar_webhook_bp # 在app.register_blueprint部分添加 app.register_blueprint(virtual_group_bp, url_prefix='/virtual_group') @@ -175,6 +176,7 @@ class DashboardServer: app.register_blueprint(system_jobs_bp) app.register_blueprint(plugin_schedules_bp) app.register_blueprint(group_plugin_config_bp) + app.register_blueprint(trendradar_webhook_bp) self.LOG.info("所有蓝图已注册") diff --git a/plugins/trendradar_webhook/README.md b/plugins/trendradar_webhook/README.md new file mode 100644 index 0000000..573c483 --- /dev/null +++ b/plugins/trendradar_webhook/README.md @@ -0,0 +1,59 @@ +# TrendRadar Webhook 适配说明 + +## 1. 接口地址 + +在 ABOT 侧新增了 webhook 入口: + +`POST /webhook/trendradar` + +示例: + +`http://<你的ABOT地址>:8888/webhook/trendradar` + +## 2. ABOT 配置 + +编辑 [admin/dashboard/config.toml](/D:/learn/abot/admin/dashboard/config.toml): + +```toml +[trendradar_webhook] +enabled = true +token = "your_secure_token" +default_group_ids = ["xxxx@chatroom"] +allow_payload_target_groups = false +send_timeout_seconds = 20 +``` + +## 3. TrendRadar 配置(Generic Webhook) + +在 TrendRadar 里设置: + +1. `NOTIFICATION_PROVIDER=generic_webhook` +2. `GENERIC_WEBHOOK_URL=http://<你的ABOT地址>:8888/webhook/trendradar` +3. `GENERIC_WEBHOOK_METHOD=POST` +4. `GENERIC_WEBHOOK_HEADERS={"Content-Type":"application/json"}` +5. `GENERIC_WEBHOOK_TEMPLATE={"token":"your_secure_token","title":"{title}","content":"{content}","source":"TrendRadar"}` + +## 4. 可选:让 TrendRadar 指定目标群 + +若你希望不同热点推送到不同群: + +1. ABOT 配置 `allow_payload_target_groups = true` +2. TrendRadar 模板加目标群字段: + +```json +{ + "token": "your_secure_token", + "title": "{title}", + "content": "{content}", + "target_group_ids": ["xxxx@chatroom","yyyy@chatroom"] +} +``` + +## 5. 返回结果 + +接口返回 JSON,包含: + +1. `success` +2. `sent_groups` +3. `failed_groups` +