mirror of
https://github.com/cft0808/edict.git
synced 2026-09-03 07:17:16 +08:00
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:
@@ -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{
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user