From 332ef07fc92fa06cf021bd6a54ebbccb0c633346 Mon Sep 17 00:00:00 2001 From: cft0808 Date: Thu, 26 Mar 2026 21:52:28 +0800 Subject: [PATCH] feat: multi-channel notification push (Phase 3+4) - Integrate channel adapters into dashboard server push_notification() - Add migrate_notification_config() for backward compatibility - Add /api/notification-channels endpoint - Update dashboard UI with multi-channel select - Rename env vars to generic NOTIFICATION_ENABLED/DEFAULT_DISPATCH_CHANNEL - Add env var fallback in sync_agent_config.py Closes #200, Closes #201 --- dashboard/dashboard.html | 41 ++++++++++--- dashboard/server.py | 110 +++++++++++++++++++++++++---------- edict/.env.example | 6 +- scripts/sync_agent_config.py | 2 +- 4 files changed, 115 insertions(+), 44 deletions(-) diff --git a/dashboard/dashboard.html b/dashboard/dashboard.html index ef8336f..dc962ac 100644 --- a/dashboard/dashboard.html +++ b/dashboard/dashboard.html @@ -816,12 +816,23 @@
-
🔔 飞书推送
-
- - +
🔔 消息推送
+
+ +
-
采集完成后自动推送简报链接到飞书群。如何创建自定义机器人?
+ + +
采集完成后自动推送简报链接到对应渠道。
@@ -1972,6 +1983,10 @@ function channelLabel(t){ const now = t.now||''; if(now.includes('feishu/direct')) return {icon:'💬', text:'飞书对话'}; if(now.includes('feishu')) return {icon:'💬', text:'飞书'}; + if(now.includes('wecom')) return {icon:'📱', text:'企业微信'}; + if(now.includes('telegram')) return {icon:'✈️', text:'Telegram'}; + if(now.includes('discord')) return {icon:'🎮', text:'Discord'}; + if(now.includes('slack')) return {icon:'💬', text:'Slack'}; if(now.includes('webchat')) return {icon:'🌐', text:'WebChat'}; if(now.includes('cron')) return {icon:'⏰', text:'定时'}; if(now.includes('direct')) return {icon:'📨', text:'直连'}; @@ -2168,7 +2183,7 @@ let subConfig = null; async function loadSubConfig(){ try{ subConfig = await fetchJ(API+'/morning-config'); } - catch(e){ subConfig = { categories: DEFAULT_CATS.map(c=>({name:c,enabled:true})), keywords:[], custom_feeds:[], feishu_webhook:'' }; } + catch(e){ subConfig = { categories: DEFAULT_CATS.map(c=>({name:c,enabled:true})), keywords:[], custom_feeds:[], notification:{enabled:true,channel:'feishu',webhook:''} }; } } function renderSubConfig(){ @@ -2203,8 +2218,11 @@ function renderSubConfig(){ // Feed category dropdown const sel = document.getElementById('new-feed-cat'); sel.innerHTML = allCats.map(c=>``).join('') + ''; - // Feishu webhook - document.getElementById('feishu-webhook').value = subConfig.feishu_webhook||''; + // Notification + const noti = subConfig.notification || {enabled:true, channel:'feishu', webhook: subConfig.feishu_webhook||''}; + document.getElementById('notification-channel').value = noti.channel || 'feishu'; + document.getElementById('notification-enabled').checked = noti.enabled !== false; + document.getElementById('notification-webhook').value = noti.webhook || ''; } function toggleSubConfig(){ @@ -2270,7 +2288,12 @@ function removeFeed(i){ } async function saveSubConfig(){ - subConfig.feishu_webhook = document.getElementById('feishu-webhook').value.trim(); + if(!subConfig) subConfig = {}; + subConfig.notification = { + enabled: document.getElementById('notification-enabled').checked, + channel: document.getElementById('notification-channel').value, + webhook: document.getElementById('notification-webhook').value.trim() + }; const st = document.getElementById('sub-status'); st.style.display='block'; st.style.color='var(--acc)'; st.textContent='⟳ 保存中…'; try{ diff --git a/dashboard/server.py b/dashboard/server.py index 87b67b2..ba6c352 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -31,6 +31,11 @@ from court_discuss import ( log = logging.getLogger('server') logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(name)s] %(message)s', datefmt='%H:%M:%S') +CHANNELS_DIR = pathlib.Path(__file__).parent.parent / 'edict' / 'backend' / 'app' / 'channels' +if str(CHANNELS_DIR.parent) not in sys.path: + sys.path.insert(0, str(CHANNELS_DIR.parent)) +from channels import get_channel, get_channel_info, CHANNELS as NOTIFICATION_CHANNELS + OCLAW_HOME = pathlib.Path.home() / '.openclaw' MAX_REQUEST_BODY = 1 * 1024 * 1024 # 1 MB ALLOWED_ORIGIN = None # Set via --cors; None means restrict to localhost @@ -476,19 +481,51 @@ def remove_remote_skill(agent_id, skill_name): def _compute_checksum(content: str) -> str: - """计算内容的简单校验和(SHA256 的前16字符)""" import hashlib return hashlib.sha256(content.encode()).hexdigest()[:16] -def push_to_feishu(): - """Push morning brief link to Feishu via webhook.""" - cfg = read_json(DATA / 'morning_brief_config.json', {}) +def migrate_notification_config(): + """自动迁移旧配置 (feishu_webhook) 到新结构 (notification)""" + cfg_path = DATA / 'morning_brief_config.json' + cfg = read_json(cfg_path, {}) + if not cfg: + return + if 'notification' in cfg: + return + if 'feishu_webhook' not in cfg: + return webhook = cfg.get('feishu_webhook', '').strip() + cfg['notification'] = { + 'enabled': bool(webhook), + 'channel': 'feishu', + 'webhook': webhook + } + try: + atomic_json_write(cfg_path, cfg) + log.info('已自动迁移 feishu_webhook 到 notification 配置') + except Exception as e: + log.warning(f'迁移配置失败: {e}') + + +def push_notification(): + """通用消息推送 (支持多渠道)""" + cfg = read_json(DATA / 'morning_brief_config.json', {}) + notification = cfg.get('notification', {}) + if not notification and cfg.get('feishu_webhook'): + notification = {'enabled': True, 'channel': 'feishu', 'webhook': cfg['feishu_webhook']} + if not notification.get('enabled', True): + return + channel_type = notification.get('channel', 'feishu') + webhook = notification.get('webhook', '').strip() if not webhook: return - if not validate_url(webhook, allowed_schemes=('https',), allowed_domains=('open.feishu.cn', 'open.larksuite.com')): - log.warning(f'飞书 Webhook URL 不合法: {webhook}') + channel_cls = get_channel(channel_type) + if not channel_cls: + log.warning(f'未知的通知渠道: {channel_type}') + return + if not channel_cls.validate_webhook(webhook): + log.warning(f'{channel_cls.label} Webhook URL 不合法: {webhook}') return brief = read_json(DATA / 'morning_brief.json', {}) date_str = brief.get('date', '') @@ -501,23 +538,16 @@ def push_to_feishu(): cat_lines.append(f' {cat}: {len(items)} 条') summary = '\n'.join(cat_lines) date_fmt = date_str[:4] + '年' + date_str[4:6] + '月' + date_str[6:] + '日' if len(date_str) == 8 else date_str - payload = json.dumps({ - 'msg_type': 'interactive', - 'card': { - 'header': {'title': {'tag': 'plain_text', 'content': f'📰 天下要闻 · {date_fmt}'}, 'template': 'blue'}, - 'elements': [ - {'tag': 'div', 'text': {'tag': 'lark_md', 'content': f'共 **{total}** 条要闻已更新\n{summary}'}}, - {'tag': 'action', 'actions': [{'tag': 'button', 'text': {'tag': 'plain_text', 'content': '🔗 查看完整简报'}, 'url': 'http://127.0.0.1:7891', 'type': 'primary'}]}, - {'tag': 'note', 'elements': [{'tag': 'plain_text', 'content': f"采集于 {brief.get('generated_at', '')}"}]} - ] - } - }).encode() - try: - req = Request(webhook, data=payload, headers={'Content-Type': 'application/json'}) - resp = urlopen(req, timeout=10) - print(f'[飞书] 推送成功 ({resp.status})') - except Exception as e: - print(f'[飞书] 推送失败: {e}', file=sys.stderr) + title = f'📰 天下要闻 · {date_fmt}' + content = f'共 **{total}** 条要闻已更新\n{summary}' + url = f'http://127.0.0.1:{_DASHBOARD_PORT}' + success = channel_cls.send(webhook, title, content, url) + print(f'[{channel_cls.label}] 推送{"成功" if success else "失败"}') + + +def push_to_feishu(): + """Push morning brief link to Feishu via webhook. (已弃用,使用 push_notification)""" + push_notification() # 旨意标题最低要求 @@ -2155,6 +2185,7 @@ class Handler(BaseHTTPRequestHandler): elif p == '/api/morning-brief': self.send_json(read_json(DATA / 'morning_brief.json', {})) elif p == '/api/morning-config': + migrate_notification_config() self.send_json(read_json(DATA / 'morning_brief_config.json', { 'categories': [ {'name': '政治', 'enabled': True}, @@ -2162,8 +2193,11 @@ class Handler(BaseHTTPRequestHandler): {'name': '经济', 'enabled': True}, {'name': 'AI大模型', 'enabled': True}, ], - 'keywords': [], 'custom_feeds': [], 'feishu_webhook': '', + 'keywords': [], 'custom_feeds': [], + 'notification': {'enabled': True, 'channel': 'feishu', 'webhook': ''}, })) + elif p == '/api/notification-channels': + self.send_json({'ok': True, 'channels': get_channel_info()}) elif p.startswith('/api/morning-brief/'): date = p.split('/')[-1] # 标准化日期格式为 YYYYMMDD(兼容 YYYY-MM-DD 输入) @@ -2237,11 +2271,10 @@ class Handler(BaseHTTPRequestHandler): return if p == '/api/morning-config': - # 字段校验 if not isinstance(body, dict): self.send_json({'ok': False, 'error': '请求体必须是 JSON 对象'}, 400) return - allowed_keys = {'categories', 'keywords', 'custom_feeds', 'feishu_webhook'} + allowed_keys = {'categories', 'keywords', 'custom_feeds', 'notification', 'feishu_webhook'} unknown = set(body.keys()) - allowed_keys if unknown: self.send_json({'ok': False, 'error': f'未知字段: {", ".join(unknown)}'}, 400) @@ -2252,11 +2285,24 @@ class Handler(BaseHTTPRequestHandler): if 'keywords' in body and not isinstance(body['keywords'], list): self.send_json({'ok': False, 'error': 'keywords 必须是数组'}, 400) return - # 飞书 Webhook 校验 - webhook = body.get('feishu_webhook', '').strip() - if webhook and not validate_url(webhook, allowed_schemes=('https',), allowed_domains=('open.feishu.cn', 'open.larksuite.com')): - self.send_json({'ok': False, 'error': '飞书 Webhook URL 无效,仅支持 https://open.feishu.cn 或 open.larksuite.com 域名'}, 400) - return + if 'notification' in body: + noti = body['notification'] + if not isinstance(noti, dict): + self.send_json({'ok': False, 'error': 'notification 必须是对象'}, 400) + return + channel_type = noti.get('channel', 'feishu') + if channel_type not in NOTIFICATION_CHANNELS: + self.send_json({'ok': False, 'error': f'不支持的渠道: {channel_type}'}, 400) + return + webhook = noti.get('webhook', '').strip() + if webhook: + channel_cls = get_channel(channel_type) + if channel_cls and not channel_cls.validate_webhook(webhook): + self.send_json({'ok': False, 'error': f'{channel_cls.label} Webhook URL 无效'}, 400) + return + webhook_legacy = body.get('feishu_webhook', '').strip() + if webhook_legacy and 'notification' not in body: + body['notification'] = {'enabled': True, 'channel': 'feishu', 'webhook': webhook_legacy} cfg_path = DATA / 'morning_brief_config.json' cfg_path.write_text(json.dumps(body, ensure_ascii=False, indent=2)) self.send_json({'ok': True, 'message': '订阅配置已保存'}) @@ -2560,6 +2606,8 @@ def main(): log.info(f'三省六部看板启动 → http://{args.host}:{args.port}') print(f' 按 Ctrl+C 停止') + migrate_notification_config() + # 启动恢复:重新派发上次被 kill 中断的 queued 任务 threading.Timer(3.0, _startup_recover_queued_dispatches).start() diff --git a/edict/.env.example b/edict/.env.example index 5d0fbab..e1bfc66 100644 --- a/edict/.env.example +++ b/edict/.env.example @@ -31,6 +31,6 @@ MAX_DISPATCH_RETRY=3 DISPATCH_TIMEOUT_SEC=300 HEARTBEAT_INTERVAL_SEC=30 -# ── 飞书通知 ── -FEISHU_DELIVER=true -FEISHU_CHANNEL=feishu +# ── 消息通知 ── +NOTIFICATION_ENABLED=true +DEFAULT_DISPATCH_CHANNEL=feishu diff --git a/scripts/sync_agent_config.py b/scripts/sync_agent_config.py index 44892a7..c8d8ac1 100644 --- a/scripts/sync_agent_config.py +++ b/scripts/sync_agent_config.py @@ -182,7 +182,7 @@ def main(): 'generatedAt': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'defaultModel': default_model, 'knownModels': merged_models, - 'dispatchChannel': existing_cfg.get('dispatchChannel', 'feishu'), + 'dispatchChannel': existing_cfg.get('dispatchChannel') or os.getenv('DEFAULT_DISPATCH_CHANNEL', 'feishu'), 'agents': result, } DATA.mkdir(exist_ok=True)