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)