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
This commit is contained in:
cft0808
2026-03-26 21:52:28 +08:00
parent 482e0c404f
commit 332ef07fc9
4 changed files with 115 additions and 44 deletions

View File

@@ -816,12 +816,23 @@
</div>
</div>
<div class="sub-section">
<div class="sub-sec-title">🔔 飞书推送</div>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
<input id="feishu-webhook" class="sub-input" placeholder="飞书 Webhook URL留空则不推送" style="flex:1">
<button class="btn btn-p" onclick="saveSubConfig()" style="font-size:12px;padding:6px 14px">保存全部配置</button>
<div class="sub-sec-title">🔔 消息推送</div>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:8px">
<select id="notification-channel" class="sub-input" style="max-width:160px">
<option value="feishu">💬 飞书 Feishu</option>
<option value="wecom">📱 企业微信</option>
<option value="telegram">✈️ Telegram</option>
<option value="discord">🎮 Discord</option>
<option value="slack">💬 Slack</option>
<option value="webhook">🔗 通用 Webhook</option>
</select>
<label style="font-size:12px;display:flex;align-items:center;gap:4px">
<input type="checkbox" id="notification-enabled" checked> 启用推送
</label>
</div>
<div style="font-size:11px;color:var(--muted);margin-top:6px">采集完成后自动推送简报链接到飞书群。<a href="https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot" target="_blank" style="color:var(--acc)">如何创建自定义机器人?</a></div>
<input id="notification-webhook" class="sub-input" placeholder="Webhook URL留空则不推送" style="width:100%;margin-bottom:6px">
<button class="btn btn-p" onclick="saveSubConfig()" style="font-size:12px;padding:6px 14px">保存全部配置</button>
<div style="font-size:11px;color:var(--muted);margin-top:6px">采集完成后自动推送简报链接到对应渠道。</div>
</div>
<div id="sub-status" style="font-size:12px;margin-top:8px;display:none"></div>
</div>
@@ -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=>`<option value="${c}">${c}</option>`).join('') + '<option value="__new__">+ 新分类…</option>';
// 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{

View File

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

View File

@@ -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

View File

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