diff --git a/backend/python/app/api/admin/im_bot_admin.py b/backend/python/app/api/admin/im_bot_admin.py new file mode 100644 index 0000000..3f3e699 --- /dev/null +++ b/backend/python/app/api/admin/im_bot_admin.py @@ -0,0 +1,952 @@ +""" +Phase 4: IM Bot Integration - Admin API Module +im_bot_admin.py + +Provides admin management for IM bot channels, routing rules, +LangBot gateway config, statistics, and message monitoring. +""" +import uuid +import json +import hashlib +import hmac +import time +from datetime import datetime, timedelta +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import text + +from ...database import SessionLocal +from ...auth import get_current_user + +router = APIRouter() + + +# ============================================================ +# Helper Functions +# ============================================================ + +def _execute_raw(db: Session, sql_text, params=None): + """Execute raw SQL and return results""" + result = db.execute(sql_text, params or {}) + if result.returns_rows: + return result.fetchall() + db.commit() + return result + + +def _get_platform_info(platform: str) -> dict: + """Get platform display info and config schema""" + platforms = { + "feishu": { + "name": "飞书", + "icon": "feishu", + "color": "#3370FF", + "credentials": ["app_id", "app_secret", "verification_token"], + "webhook_fields": ["encrypt_key"], + "docs_url": "https://open.feishu.cn/document/home/index", + }, + "wechat": { + "name": "微信公众号", + "icon": "wechat", + "color": "#07C160", + "credentials": ["app_id", "app_secret", "token"], + "webhook_fields": ["encoding_aes_key"], + "docs_url": "https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Overview.html", + }, + "personal_wechat": { + "name": "个人微信", + "icon": "wechat-personal", + "color": "#07C160", + "credentials": ["qr_code", "clawbot_token"], + "webhook_fields": [], + "docs_url": "", + "scan_auth": True, + }, + "wecom": { + "name": "企业微信", + "icon": "wecom", + "color": "#2F80ED", + "credentials": ["corp_id", "agent_id", "secret", "token"], + "webhook_fields": ["encoding_aes_key"], + "docs_url": "https://developer.work.weixin.qq.com/document/", + }, + "dingtalk": { + "name": "钉钉", + "icon": "dingtalk", + "color": "#0089FF", + "credentials": ["app_key", "app_secret"], + "webhook_fields": ["aes_key"], + "docs_url": "https://open.dingtalk.com/document/", + }, + "telegram": { + "name": "Telegram", + "icon": "telegram", + "color": "#0088CC", + "credentials": ["bot_token"], + "webhook_fields": [], + "docs_url": "https://core.telegram.org/bots/api", + }, + "whatsapp": { + "name": "WhatsApp", + "icon": "whatsapp", + "color": "#25D366", + "credentials": ["phone_number_id", "access_token"], + "webhook_fields": ["verify_token"], + "docs_url": "https://developers.facebook.com/docs/whatsapp", + }, + "slack": { + "name": "Slack", + "icon": "slack", + "color": "#4A154B", + "credentials": ["bot_token", "signing_secret"], + "webhook_fields": [], + "docs_url": "https://api.slack.com/docs", + }, + } + return platforms.get(platform, {"name": platform, "icon": "default", "color": "#666"}) + + +def _row_to_dict(row) -> dict: + """Convert SQLAlchemy row to dict""" + if row is None: + return None + d = {} + for col in row._mapping.keys(): + val = row._mapping[col] + if isinstance(val, datetime): + val = val.isoformat() + d[col] = val + return d + + +def _rows_to_dict(rows) -> list: + """Convert multiple rows to list of dicts""" + return [_row_to_dict(r) for r in rows] if rows else [] + + +# ============================================================ +# Dashboard & Stats +# ============================================================ + +@router.get("/stats") +async def get_im_bot_stats(user=Depends(get_current_user)): + """Get overall IM bot statistics""" + db = SessionLocal() + try: + # Total channels by platform + rows = _execute_raw(db, text(""" + SELECT platform, COUNT(*) as count, + SUM(CASE WHEN status='connected' THEN 1 ELSE 0 END) as active_count + FROM channels + WHERE is_active = 1 + GROUP BY platform + """)) + platform_stats = {} + total_channels = 0 + total_active = 0 + for r in rows: + d = _row_to_dict(r) + platform_stats[d['platform']] = d + total_channels += d['count'] + total_active += d['active_count'] + + # Today's messages + today = datetime.now().strftime('%Y-%m-%d') + rows = _execute_raw(db, text(""" + SELECT COALESCE(SUM(messages_in), 0) as msg_in, + COALESCE(SUM(messages_out), 0) as msg_out, + COALESCE(SUM(unique_users), 0) as users, + COALESCE(SUM(error_count), 0) as errors + FROM im_bot_daily_stats + WHERE stat_date = :today + """), {"today": today}) + today_stats = _row_to_dict(rows[0]) if rows else {"msg_in": 0, "msg_out": 0, "users": 0, "errors": 0} + + # Total routing rules + routes_count = _execute_raw(db, text("SELECT COUNT(*) as cnt FROM im_bot_routes WHERE is_active=1"))[0]._mapping['cnt'] + + # LangBot status + langbot_row = _execute_raw(db, text("SELECT status FROM im_bot_langbot_config ORDER BY id LIMIT 1")) + langbot_status = _row_to_dict(langbot_row[0])['status'] if langbot_row else 'inactive' + + return { + "success": True, + "data": { + "total_channels": total_channels, + "active_channels": total_active, + "platform_breakdown": platform_stats, + "today_messages_in": today_stats.get('msg_in', 0), + "today_messages_out": today_stats.get('msg_out', 0), + "today_unique_users": today_stats.get('users', 0), + "today_errors": today_stats.get('errors', 0), + "active_routes": routes_count, + "langbot_status": langbot_status, + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Stats error: {str(e)}") + finally: + db.close() + + +@router.get("/dashboard") +async def get_dashboard(user=Depends(get_current_user)): + """Get dashboard data with charts""" + db = SessionLocal() + try: + # 7-day message trend + rows = _execute_raw(db, text(""" + SELECT stat_date, + SUM(messages_in) as msg_in, + SUM(messages_out) as msg_out, + SUM(unique_users) as users + FROM im_bot_daily_stats + WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) + GROUP BY stat_date + ORDER BY stat_date + """)) + trend = _rows_to_dict(rows) + + # Channel list with latest stats + rows = _execute_raw(db, text(""" + SELECT c.id, c.name, c.platform, c.status, c.is_active, + c.last_active, c.default_agent_id, + COALESCE(s.msg_count, 0) as msg_count + FROM channels c + LEFT JOIN ( + SELECT channel_id, SUM(messages_in + messages_out) as msg_count + FROM im_bot_daily_stats + WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) + GROUP BY channel_id + ) s ON c.id = s.channel_id + ORDER BY c.created_at DESC + LIMIT 20 + """)) + channels = [] + for r in rows: + d = _row_to_dict(r) + d['platform_info'] = _get_platform_info(d['platform']) + channels.append(d) + + # Platform distribution + rows = _execute_raw(db, text(""" + SELECT platform, COUNT(*) as count + FROM channels WHERE is_active = 1 + GROUP BY platform + """)) + distribution = _rows_to_dict(rows) + + return { + "success": True, + "data": { + "message_trend": trend, + "recent_channels": channels, + "platform_distribution": distribution, + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Dashboard error: {str(e)}") + finally: + db.close() + + +# ============================================================ +# Channel Management +# ============================================================ + +@router.get("/channels") +async def list_channels( + platform: Optional[str] = Query(None), + status: Optional[str] = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + user=Depends(get_current_user) +): + """List all IM bot channels (admin view)""" + db = SessionLocal() + try: + where_clauses = ["1=1"] + params = {} + if platform: + where_clauses.append("c.platform = :platform") + params['platform'] = platform + if status: + where_clauses.append("c.status = :status") + params['status'] = status + + where_sql = " AND ".join(where_clauses) + offset = (page - 1) * page_size + + # Count + count_sql = f"SELECT COUNT(*) as cnt FROM channels c WHERE {where_sql}" + total = _execute_raw(db, text(count_sql), params)[0]._mapping['cnt'] + + # Query + sql = f""" + SELECT c.*, m.name as agent_name + FROM channels c + LEFT JOIN market_apps m ON c.default_agent_id = m.id + WHERE {where_sql} + ORDER BY c.created_at DESC + LIMIT :limit OFFSET :offset + """ + params['limit'] = page_size + params['offset'] = offset + rows = _execute_raw(db, text(sql), params) + + channels = [] + for r in rows: + d = _row_to_dict(r) + d['platform_info'] = _get_platform_info(d['platform']) + channels.append(d) + + return { + "success": True, + "data": { + "items": channels, + "total": total, + "page": page, + "page_size": page_size, + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"List error: {str(e)}") + finally: + db.close() + + +@router.get("/channels/{channel_id}") +async def get_channel_detail(channel_id: str, user=Depends(get_current_user)): + """Get channel detail with routing rules and recent messages""" + db = SessionLocal() + try: + # Channel + rows = _execute_raw(db, text(""" + SELECT c.*, m.name as agent_name + FROM channels c + LEFT JOIN market_apps m ON c.default_agent_id = m.id + WHERE c.id = :cid + """), {"cid": channel_id}) + if not rows: + raise HTTPException(status_code=404, detail="Channel not found") + channel = _row_to_dict(rows[0]) + channel['platform_info'] = _get_platform_info(channel['platform']) + + # Routing rules + routes = _execute_raw(db, text(""" + SELECT r.*, m.name as agent_name + FROM im_bot_routes r + LEFT JOIN market_apps m ON r.agent_id = m.id + WHERE r.channel_id = :cid + ORDER BY r.priority DESC + """), {"cid": channel_id}) + channel['routes'] = _rows_to_dict(routes) + + # Recent messages (from channel_messages table) + msgs = _execute_raw(db, text(""" + SELECT * FROM channel_messages + WHERE channel_id = :cid + ORDER BY created_at DESC + LIMIT 20 + """), {"cid": channel_id}) + channel['recent_messages'] = _rows_to_dict(msgs) + + # 7-day stats + stats = _execute_raw(db, text(""" + SELECT stat_date, messages_in, messages_out, unique_users, error_count + FROM im_bot_daily_stats + WHERE channel_id = :cid + AND stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) + ORDER BY stat_date + """), {"cid": channel_id}) + channel['stats_7d'] = _rows_to_dict(stats) + + return {"success": True, "data": channel} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Detail error: {str(e)}") + finally: + db.close() + + +@router.post("/channels/{channel_id}/test") +async def test_channel(channel_id: str, user=Depends(get_current_user)): + """Test channel connectivity""" + db = SessionLocal() + try: + rows = _execute_raw(db, text("SELECT * FROM channels WHERE id = :cid"), {"cid": channel_id}) + if not rows: + raise HTTPException(status_code=404, detail="Channel not found") + channel = _row_to_dict(rows[0]) + + # Simulate connectivity test based on platform + platform = channel['platform'] + creds = channel.get('credential', {}) + if isinstance(creds, str): + creds = json.loads(creds) if creds else {} + + result = { + "channel_id": channel_id, + "platform": platform, + "tested_at": datetime.now().isoformat(), + "checks": [], + "passed": True, + } + + if platform == 'feishu': + result['checks'].append({"name": "App ID", "passed": bool(creds.get('app_id'))}) + result['checks'].append({"name": "App Secret", "passed": bool(creds.get('app_secret'))}) + result['checks'].append({"name": "Verification Token", "passed": bool(creds.get('verification_token'))}) + elif platform in ('wechat', 'wecom'): + result['checks'].append({"name": "App ID", "passed": bool(creds.get('app_id') or creds.get('corp_id'))}) + result['checks'].append({"name": "Secret", "passed": bool(creds.get('app_secret') or creds.get('secret'))}) + elif platform == 'dingtalk': + result['checks'].append({"name": "App Key", "passed": bool(creds.get('app_key'))}) + result['checks'].append({"name": "App Secret", "passed": bool(creds.get('app_secret'))}) + elif platform == 'telegram': + result['checks'].append({"name": "Bot Token", "passed": bool(creds.get('bot_token'))}) + else: + result['checks'].append({"name": "Credentials", "passed": len(creds) > 0}) + + result['passed'] = all(c['passed'] for c in result['checks']) + result['webhook_configured'] = bool(channel.get('webhook_url')) + + return {"success": True, "data": result} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Test error: {str(e)}") + finally: + db.close() + + +@router.post("/channels/{channel_id}/activate") +async def activate_channel(channel_id: str, user=Depends(get_current_user)): + """Activate a channel""" + db = SessionLocal() + try: + _execute_raw(db, text( + "UPDATE channels SET is_active=1, status='connected' WHERE id=:cid" + ), {"cid": channel_id}) + return {"success": True, "message": "Channel activated"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +@router.post("/channels/{channel_id}/deactivate") +async def deactivate_channel(channel_id: str, user=Depends(get_current_user)): + """Deactivate a channel""" + db = SessionLocal() + try: + _execute_raw(db, text( + "UPDATE channels SET is_active=0, status='disconnected' WHERE id=:cid" + ), {"cid": channel_id}) + return {"success": True, "message": "Channel deactivated"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +# ============================================================ +# Routing Rules +# ============================================================ + +@router.get("/routes") +async def list_routes( + channel_id: Optional[str] = Query(None), + user=Depends(get_current_user) +): + """List routing rules""" + db = SessionLocal() + try: + where = "1=1" + params = {} + if channel_id: + where = "r.channel_id = :cid" + params['cid'] = channel_id + + rows = _execute_raw(db, text(f""" + SELECT r.*, c.name as channel_name, c.platform, + m.name as agent_name, m2.name as fallback_agent_name + FROM im_bot_routes r + LEFT JOIN channels c ON r.channel_id = c.id + LEFT JOIN market_apps m ON r.agent_id = m.id + LEFT JOIN market_apps m2 ON r.fallback_agent_id = m2.id + WHERE {where} + ORDER BY r.priority DESC, r.created_at DESC + """), params) + return {"success": True, "data": _rows_to_dict(rows)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +@router.post("/routes") +async def create_route( + channel_id: str = Query(...), + agent_id: str = Query(...), + route_name: str = Query("default"), + match_type: str = Query("all"), + match_pattern: Optional[str] = Query(None), + priority: int = Query(0), + fallback_agent_id: Optional[str] = Query(None), + user=Depends(get_current_user) +): + """Create a routing rule""" + db = SessionLocal() + try: + route_id = str(uuid.uuid4()) + _execute_raw(db, text(""" + INSERT INTO im_bot_routes + (id, channel_id, agent_id, route_name, match_type, match_pattern, priority, fallback_agent_id) + VALUES (:id, :cid, :aid, :name, :mt, :mp, :pri, :faid) + """), { + "id": route_id, "cid": channel_id, "aid": agent_id, + "name": route_name, "mt": match_type, "mp": match_pattern, + "pri": priority, "faid": fallback_agent_id, + }) + return {"success": True, "data": {"id": route_id}} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +@router.put("/routes/{route_id}") +async def update_route( + route_id: str, + agent_id: Optional[str] = Query(None), + route_name: Optional[str] = Query(None), + match_type: Optional[str] = Query(None), + match_pattern: Optional[str] = Query(None), + priority: Optional[int] = Query(None), + fallback_agent_id: Optional[str] = Query(None), + is_active: Optional[int] = Query(None), + user=Depends(get_current_user) +): + """Update a routing rule""" + db = SessionLocal() + try: + updates = [] + params = {"id": route_id} + for field, val in [("agent_id", agent_id), ("route_name", route_name), + ("match_type", match_type), ("match_pattern", match_pattern), + ("priority", priority), ("fallback_agent_id", fallback_agent_id), + ("is_active", is_active)]: + if val is not None: + updates.append(f"{field} = :{field}") + params[field] = val + if not updates: + raise HTTPException(status_code=400, detail="No fields to update") + sql = f"UPDATE im_bot_routes SET {', '.join(updates)} WHERE id = :id" + _execute_raw(db, text(sql), params) + return {"success": True, "message": "Route updated"} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +@router.delete("/routes/{route_id}") +async def delete_route(route_id: str, user=Depends(get_current_user)): + """Delete a routing rule""" + db = SessionLocal() + try: + _execute_raw(db, text("DELETE FROM im_bot_routes WHERE id = :id"), {"id": route_id}) + return {"success": True, "message": "Route deleted"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +# ============================================================ +# LangBot Gateway +# ============================================================ + +@router.get("/langbot") +async def get_langbot_config(user=Depends(get_current_user)): + """Get LangBot gateway configuration""" + db = SessionLocal() + try: + rows = _execute_raw(db, text("SELECT * FROM im_bot_langbot_config ORDER BY id")) + return {"success": True, "data": _rows_to_dict(rows)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +@router.put("/langbot") +async def update_langbot_config( + config_id: int = Query(...), + name: Optional[str] = Query(None), + gateway_url: Optional[str] = Query(None), + api_key: Optional[str] = Query(None), + webhook_secret: Optional[str] = Query(None), + bot_uuid: Optional[str] = Query(None), + callback_url: Optional[str] = Query(None), + sync_channels: Optional[int] = Query(None), + status: Optional[str] = Query(None), + user=Depends(get_current_user) +): + """Update LangBot configuration""" + db = SessionLocal() + try: + updates = [] + params = {"id": config_id} + for field, val in [("name", name), ("gateway_url", gateway_url), + ("api_key", api_key), ("webhook_secret", webhook_secret), + ("bot_uuid", bot_uuid), ("callback_url", callback_url), + ("sync_channels", sync_channels), ("status", status)]: + if val is not None: + updates.append(f"{field} = :{field}") + params[field] = val + if not updates: + raise HTTPException(status_code=400, detail="No fields to update") + sql = f"UPDATE im_bot_langbot_config SET {', '.join(updates)} WHERE id = :id" + _execute_raw(db, text(sql), params) + return {"success": True, "message": "LangBot config updated"} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +@router.post("/langbot/test") +async def test_langbot( + gateway_url: str = Query(...), + api_key: Optional[str] = Query(None), + user=Depends(get_current_user) +): + """Test LangBot gateway connectivity""" + import urllib.request + import urllib.error + + result = { + "gateway_url": gateway_url, + "tested_at": datetime.now().isoformat(), + "reachable": False, + "api_version": None, + "error": None, + } + + try: + url = gateway_url.rstrip('/') + '/api/v1/pipelines' + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + req = urllib.request.Request(url, headers=headers, method='GET') + with urllib.request.urlopen(req, timeout=10) as resp: + result['reachable'] = True + result['status_code'] = resp.status + body = resp.read().decode('utf-8', errors='replace') + try: + data = json.loads(body) + result['api_version'] = data.get('version', 'unknown') + except: + result['api_version'] = 'unknown' + except urllib.error.HTTPError as e: + result['reachable'] = True + result['status_code'] = e.code + result['error'] = f"HTTP {e.code}" + except Exception as e: + result['error'] = str(e) + + return {"success": True, "data": result} + + +# ============================================================ +# Platforms & Webhooks +# ============================================================ + +@router.get("/platforms") +async def list_platforms(user=Depends(get_current_user)): + """List all supported IM platforms with config schemas""" + platforms = [] + for key in ["feishu", "wechat", "personal_wechat", "wecom", "dingtalk", + "telegram", "whatsapp", "slack"]: + info = _get_platform_info(key) + info['key'] = key + platforms.append(info) + + # Count channels per platform + db = SessionLocal() + try: + rows = _execute_raw(db, text(""" + SELECT platform, COUNT(*) as count, + SUM(CASE WHEN status='connected' THEN 1 ELSE 0 END) as active + FROM channels + GROUP BY platform + """)) + counts = {r._mapping['platform']: {"count": r._mapping['count'], "active": r._mapping['active']} for r in rows} + for p in platforms: + c = counts.get(p['key'], {"count": 0, "active": 0}) + p['channel_count'] = c['count'] + p['active_count'] = c['active'] + + return {"success": True, "data": platforms} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +@router.get("/webhook-url/{platform}") +async def get_webhook_url(platform: str, user=Depends(get_current_user)): + """Generate webhook URL for a platform""" + if platform not in ["feishu", "wechat", "personal_wechat", "wecom", "dingtalk", + "telegram", "whatsapp", "slack"]: + raise HTTPException(status_code=400, detail=f"Unsupported platform: {platform}") + + # Generate a webhook secret for verification + secret = hashlib.sha256(f"{platform}:{time.time()}".encode()).hexdigest()[:32] + webhook_url = f"/app/api/channels/webhook/{platform}" + + return { + "success": True, + "data": { + "platform": platform, + "platform_name": _get_platform_info(platform)['name'], + "webhook_url": webhook_url, + "full_url": f"http://210.16.161.218:12888{webhook_url}", + "secret": secret, + "instructions": _get_webhook_instructions(platform), + } + } + + +def _get_webhook_instructions(platform: str) -> list: + """Get setup instructions for each platform""" + guides = { + "feishu": [ + "1. 登录飞书开放平台,创建企业自建应用", + "2. 在「事件订阅」页面配置请求地址为上述 Webhook URL", + "3. 填入 App ID、App Secret 和 Verification Token", + "4. 订阅消息事件: im.message.receive_v1", + "5. 发布应用版本并等待管理员审批", + ], + "wechat": [ + "1. 登录微信公众平台,进入「设置与开发」", + "2. 在「基本配置」中填写服务器配置", + "3. URL 填入上述 Webhook URL", + "4. 填入 Token 和 EncodingAESKey", + "5. 启用服务器配置", + ], + "personal_wechat": [ + "1. 点击「扫码授权」按钮,使用手机微信扫码", + "2. 等待授权完成后,频道状态将变为「已连接」", + "3. 个人微信通过 ClawBot 协议接入,无需 Webhook 配置", + "4. 注意:个人微信接入存在封号风险,请谨慎使用", + ], + "wecom": [ + "1. 登录企业微信管理后台,创建自建应用", + "2. 在「接收消息」页面设置 API 接收", + "3. URL 填入上述 Webhook URL", + "4. 填入 Corp ID、Agent ID、Secret、Token 和 EncodingAESKey", + "5. 设置可信 IP 白名单", + ], + "dingtalk": [ + "1. 登录钉钉开放平台,创建企业内部应用", + "2. 在「事件订阅」中配置 HTTP 模式", + "3. 请求地址填入上述 Webhook URL", + "4. 填入 App Key 和 App Secret", + "5. 订阅消息接收事件", + ], + "telegram": [ + "1. 通过 @BotFather 创建 Bot,获取 Bot Token", + "2. 调用 Telegram API 设置 Webhook: setWebhook?url=<上述URL>", + "3. 填入 Bot Token", + ], + "whatsapp": [ + "1. 在 Meta for Developers 创建 WhatsApp Business 应用", + "2. 配置 Webhook 验证 Token", + "3. Webhook URL 填入上述地址", + "4. 订阅 messages 事件", + "5. 填入 Phone Number ID 和 Access Token", + ], + "slack": [ + "1. 在 api.slack.com 创建 Slack App", + "2. 启用 Event Subscriptions", + "3. Request URL 填入上述 Webhook URL", + "4. 订阅 message.im 事件", + "5. 填入 Bot Token 和 Signing Secret", + ], + } + return guides.get(platform, ["请参考平台官方文档进行配置"]) + + +# ============================================================ +# Messages & Logs +# ============================================================ + +@router.get("/messages") +async def list_messages( + channel_id: Optional[str] = Query(None), + direction: Optional[str] = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + user=Depends(get_current_user) +): + """List messages across all channels""" + db = SessionLocal() + try: + where = ["1=1"] + params = {} + if channel_id: + where.append("channel_id = :cid") + params['cid'] = channel_id + if direction: + where.append("direction = :dir") + params['dir'] = direction + + where_sql = " AND ".join(where) + offset = (page - 1) * page_size + + count = _execute_raw(db, text(f"SELECT COUNT(*) as cnt FROM channel_messages WHERE {where_sql}"), params)[0]._mapping['cnt'] + + rows = _execute_raw(db, text(f""" + SELECT cm.*, c.name as channel_name, c.platform + FROM channel_messages cm + LEFT JOIN channels c ON cm.channel_id = c.id + WHERE {where_sql} + ORDER BY cm.created_at DESC + LIMIT :limit OFFSET :offset + """), {**params, "limit": page_size, "offset": offset}) + + messages = [] + for r in rows: + d = _row_to_dict(r) + d['platform_info'] = _get_platform_info(d.get('platform', '')) if d.get('platform') else None + messages.append(d) + + return { + "success": True, + "data": { + "items": messages, + "total": count, + "page": page, + "page_size": page_size, + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +@router.get("/logs") +async def get_activity_logs( + channel_id: Optional[str] = Query(None), + limit: int = Query(100, ge=1, le=500), + user=Depends(get_current_user) +): + """Get recent activity logs""" + db = SessionLocal() + try: + where = "" + params = {"limit": limit} + if channel_id: + where = "WHERE channel_id = :cid" + params['cid'] = channel_id + + rows = _execute_raw(db, text(f""" + SELECT stat_date, channel_id, + messages_in, messages_out, unique_users, + avg_response_time_ms, error_count, tokens_used + FROM im_bot_daily_stats + {where} + ORDER BY stat_date DESC, channel_id + LIMIT :limit + """), params) + + return {"success": True, "data": _rows_to_dict(rows)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + finally: + db.close() + + +# ============================================================ +# Quick Setup Wizard +# ============================================================ + +@router.post("/quick-setup") +async def quick_setup( + platform: str = Query(...), + name: str = Query(...), + agent_id: Optional[str] = Query(None), + credentials: str = Query(None), + user=Depends(get_current_user) +): + """Quick setup wizard - create and configure a channel in one step""" + db = SessionLocal() + try: + if platform not in ["feishu", "wechat", "personal_wechat", "wecom", "dingtalk", + "telegram", "whatsapp", "slack"]: + raise HTTPException(status_code=400, detail=f"Unsupported platform: {platform}") + + channel_id = str(uuid.uuid4()) + webhook_url = f"/app/api/channels/webhook/{platform}" + + cred_json = None + if credentials: + try: + cred_json = json.loads(credentials) + except: + cred_json = {"raw": credentials} + + _execute_raw(db, text(""" + INSERT INTO channels (id, owner_id, name, platform, webhook_url, + credential, default_agent_id, status, is_active) + VALUES (:id, :owner, :name, :platform, :webhook, + :cred, :agent, 'pending', 1) + """), { + "id": channel_id, + "owner": "admin-001", + "name": name, + "platform": platform, + "webhook": webhook_url, + "cred": json.dumps(cred_json) if cred_json else None, + "agent": agent_id, + }) + + # Create default routing rule if agent specified + if agent_id: + route_id = str(uuid.uuid4()) + _execute_raw(db, text(""" + INSERT INTO im_bot_routes (id, channel_id, agent_id, route_name, match_type, priority) + VALUES (:rid, :cid, :aid, 'default', 'all', 0) + """), {"rid": route_id, "cid": channel_id, "aid": agent_id}) + + platform_info = _get_platform_info(platform) + + return { + "success": True, + "data": { + "channel_id": channel_id, + "webhook_url": f"http://210.16.161.218:12888{webhook_url}", + "platform": platform, + "platform_name": platform_info['name'], + "instructions": _get_webhook_instructions(platform), + "next_steps": "请按照上述说明在平台配置 Webhook,然后返回此页面点击「测试连接」", + } + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Quick setup error: {str(e)}") + finally: + db.close() diff --git a/backend/python/main.py b/backend/python/main.py index 4f73222..5f97193 100644 --- a/backend/python/main.py +++ b/backend/python/main.py @@ -34,7 +34,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution -from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin +from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin from app.api.admin.developer_tools_admin import router as developer_tools_router from app.api.admin.mcp_market_admin import router as mcp_market_router from app.api.admin import asset_admin @@ -434,6 +434,7 @@ app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=[ app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"]) app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"]) app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"]) +app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"]) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3e6d5da..6ba6c26 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -39,6 +39,7 @@ import AdminAgents from '@/pages/admin/Agents'; import AdminCodeExec from '@/pages/admin/CodeExec';; import DistributionBuilder from '@/pages/admin/DistributionBuilder'; import PrivacySettings from '@/pages/admin/PrivacySettings'; +import ImBots from '@/pages/admin/ImBots'; const App: React.FC = () => { const [user, setUser] = useState(null); @@ -120,6 +121,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> } /> diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 6544160..fabcfaf 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -814,4 +814,23 @@ export const evolutionAPI = { getPrivacyUsageStats: () => get('/admin/privacy/usage/stats'), getBridgeConfig: () => get('/admin/privacy/bridge-config'), + // IM Bot Management (Phase 4) + getImBotStats: () => get('/admin/im-bot/stats'), + getImBotDashboard: () => get('/admin/im-bot/dashboard'), + getImBotChannels: (params?: string) => get(`/admin/im-bot/channels${params || ''}`), + getImBotChannelDetail: (id: string) => get(`/admin/im-bot/channels/${id}`), + testImBotChannel: (id: string) => post(`/admin/im-bot/channels/${id}/test`), + activateImBotChannel: (id: string) => post(`/admin/im-bot/channels/${id}/activate`), + deactivateImBotChannel: (id: string) => post(`/admin/im-bot/channels/${id}/deactivate`), + getImBotRoutes: (params?: string) => get(`/admin/im-bot/routes${params || ''}`), + createImBotRoute: (params: string) => post(`/admin/im-bot/routes${params}`), + deleteImBotRoute: (id: string) => del(`/admin/im-bot/routes/${id}`), + getLangbotConfig: () => get('/admin/im-bot/langbot'), + updateLangbotConfig: (params: string) => post(`/admin/im-bot/langbot${params}`), + testLangbot: (params: string) => post(`/admin/im-bot/langbot/test${params}`), + getImBotPlatforms: () => get('/admin/im-bot/platforms'), + getImBotWebhookUrl: (platform: string) => get(`/admin/im-bot/webhook-url/${platform}`), + getImBotMessages: (params?: string) => get(`/admin/im-bot/messages${params || ''}`), + imBotQuickSetup: (params: string) => post(`/admin/im-bot/quick-setup${params}`), + }; diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index 2c3289e..057badc 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -20,6 +20,7 @@ const adminNavItems = [ { path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' }, { path: '/admin/distributions', icon: 'distro', label: '发行版构建' }, { path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' }, + { path: '/admin/im-bots', icon: 'imbot', label: 'IM 机器人' }, ]; const adminIconPaths: Record = { diff --git a/frontend/src/pages/admin/ImBots.tsx b/frontend/src/pages/admin/ImBots.tsx new file mode 100644 index 0000000..54b2af9 --- /dev/null +++ b/frontend/src/pages/admin/ImBots.tsx @@ -0,0 +1,1268 @@ +// @ts-nocheck +import React, { useState, useEffect, useCallback } from 'react'; +import { + adminAPI, +} from '@/api'; + +// Platform icons (simple SVG representations) +const platformIcons: Record = { + feishu: 'Feishu', + wechat: 'WeChat', + personal_wechat: 'WeChat', + wecom: 'WeCom', + dingtalk: 'DingTalk', + telegram: 'Telegram', + whatsapp: 'WhatsApp', + slack: 'Slack', +}; + +const platformColors: Record = { + feishu: '#3370FF', + wechat: '#07C160', + personal_wechat: '#07C160', + wecom: '#2F80ED', + dingtalk: '#0089FF', + telegram: '#0088CC', + whatsapp: '#25D366', + slack: '#4A154B', +}; + +const statusColors: Record = { + connected: '#10b981', + pending: '#f59e0b', + disconnected: '#6b7280', +}; + +type Tab = 'dashboard' | 'channels' | 'routes' | 'langbot' | 'messages' | 'setup'; + +const ImBots: React.FC = () => { + const [activeTab, setActiveTab] = useState('dashboard'); + const [loading, setLoading] = useState(false); + const [stats, setStats] = useState(null); + const [dashboard, setDashboard] = useState(null); + const [channels, setChannels] = useState([]); + const [channelsTotal, setChannelsTotal] = useState(0); + const [channelsPage, setChannelsPage] = useState(1); + const [filterPlatform, setFilterPlatform] = useState(''); + const [filterStatus, setFilterStatus] = useState(''); + const [routes, setRoutes] = useState([]); + const [langbotConfig, setLangbotConfig] = useState([]); + const [messages, setMessages] = useState([]); + const [messagesTotal, setMessagesTotal] = useState(0); + const [messagesPage, setMessagesPage] = useState(1); + const [platforms, setPlatforms] = useState([]); + const [selectedChannel, setSelectedChannel] = useState(null); + const [showChannelDetail, setShowChannelDetail] = useState(false); + const [showSetupModal, setShowSetupModal] = useState(false); + const [showRouteModal, setShowRouteModal] = useState(false); + const [showWebhookModal, setShowWebhookModal] = useState(false); + const [webhookInfo, setWebhookInfo] = useState(null); + const [agents, setAgents] = useState([]); + const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null); + + // Route form state + const [routeForm, setRouteForm] = useState({ + channel_id: '', + agent_id: '', + match_type: 'all', + match_pattern: '', + priority: 0, + }); + + // Setup form state + const [setupForm, setSetupForm] = useState({ + platform: 'feishu', + name: '', + agent_id: '', + credentials: '', + }); + + // LangBot form state + const [langbotForm, setLangbotForm] = useState({ + id: 1, + gateway_url: '', + api_key: '', + webhook_secret: '', + bot_uuid: '', + callback_url: '', + sync_channels: 0, + status: 'inactive', + }); + + const showToast = (msg: string, type: 'success' | 'error' = 'success') => { + setToast({ msg, type }); + setTimeout(() => setToast(null), 3000); + }; + + // Load agents for dropdowns + const loadAgents = useCallback(async () => { + try { + const res = await adminAPI.getAIApps('?type=agent&page_size=100'); + if (res.data?.success) { + setAgents(res.data.data?.items || []); + } + } catch (e) { + // silently fail + } + }, []); + + // Load stats + const loadStats = useCallback(async () => { + try { + const res = await adminAPI.getImBotStats(); + if (res.data?.success) setStats(res.data.data); + } catch (e) { + console.error('Stats error:', e); + } + }, []); + + // Load dashboard + const loadDashboard = useCallback(async () => { + try { + const res = await adminAPI.getImBotDashboard(); + if (res.data?.success) setDashboard(res.data.data); + } catch (e) { + console.error('Dashboard error:', e); + } + }, []); + + // Load channels + const loadChannels = useCallback(async () => { + setLoading(true); + try { + let endpoint = `?page=${channelsPage}&page_size=20`; + if (filterPlatform) endpoint += `&platform=${filterPlatform}`; + if (filterStatus) endpoint += `&status=${filterStatus}`; + const res = await adminAPI.getImBotChannels(endpoint); + if (res.data?.success) { + setChannels(res.data.data?.items || []); + setChannelsTotal(res.data.data?.total || 0); + } + } catch (e) { + showToast('Failed to load channels', 'error'); + } finally { + setLoading(false); + } + }, [channelsPage, filterPlatform, filterStatus]); + + // Load routes + const loadRoutes = useCallback(async () => { + try { + const res = await adminAPI.getImBotRoutes(); + if (res.data?.success) setRoutes(res.data.data || []); + } catch (e) { + showToast('Failed to load routes', 'error'); + } + }, []); + + // Load LangBot config + const loadLangbot = useCallback(async () => { + try { + const res = await adminAPI.getLangbotConfig(); + if (res.data?.success) { + const configs = res.data.data || []; + setLangbotConfig(configs); + if (configs.length > 0) { + setLangbotForm({ ...langbotForm, ...configs[0] }); + } + } + } catch (e) { + showToast('Failed to load LangBot config', 'error'); + } + }, []); + + // Load messages + const loadMessages = useCallback(async () => { + try { + const res = await adminAPI.getImBotMessages(`?page=${messagesPage}&page_size=50`); + if (res.data?.success) { + setMessages(res.data.data?.items || []); + setMessagesTotal(res.data.data?.total || 0); + } + } catch (e) { + showToast('Failed to load messages', 'error'); + } + }, [messagesPage]); + + // Load platforms + const loadPlatforms = useCallback(async () => { + try { + const res = await adminAPI.getImBotPlatforms(); + if (res.data?.success) setPlatforms(res.data.data || []); + } catch (e) { + // silently fail + } + }, []); + + // Initial load + useEffect(() => { + loadAgents(); + loadStats(); + loadPlatforms(); + }, []); + + // Tab-specific loads + useEffect(() => { + if (activeTab === 'dashboard') { + loadDashboard(); + loadStats(); + } else if (activeTab === 'channels') { + loadChannels(); + } else if (activeTab === 'routes') { + loadRoutes(); + loadChannels(); + } else if (activeTab === 'langbot') { + loadLangbot(); + } else if (activeTab === 'messages') { + loadMessages(); + } + }, [activeTab]); + + // Handle channel test + const handleTestChannel = async (channelId: string) => { + try { + const res = await adminAPI.testImBotChannel(channelId); + if (res.data?.success) { + const data = res.data.data; + if (data.passed) { + showToast('Channel test passed!'); + } else { + showToast(`Test failed: ${data.checks.filter((c: any) => !c.passed).map((c: any) => c.name).join(', ')}`, 'error'); + } + } + } catch (e) { + showToast('Test failed', 'error'); + } + }; + + // Handle channel activate/deactivate + const handleToggleChannel = async (channelId: string, currentActive: boolean) => { + try { + if (currentActive) { + await adminAPI.deactivateImBotChannel(channelId); + showToast('Channel deactivated'); + } else { + await adminAPI.activateImBotChannel(channelId); + showToast('Channel activated'); + } + loadChannels(); + loadStats(); + } catch (e) { + showToast('Operation failed', 'error'); + } + }; + + // Handle channel detail + const handleViewChannel = async (channelId: string) => { + try { + const res = await adminAPI.getImBotChannelDetail(channelId); + if (res.data?.success) { + setSelectedChannel(res.data.data); + setShowChannelDetail(true); + } + } catch (e) { + showToast('Failed to load channel detail', 'error'); + } + }; + + // Handle webhook URL + const handleGetWebhookUrl = async (platform: string) => { + try { + const res = await adminAPI.getImBotWebhookUrl(platform); + if (res.data?.success) { + setWebhookInfo(res.data.data); + setShowWebhookModal(true); + } + } catch (e) { + showToast('Failed to get webhook URL', 'error'); + } + }; + + // Handle create route + const handleCreateRoute = async () => { + if (!routeForm.channel_id || !routeForm.agent_id) { + showToast('Please select channel and agent', 'error'); + return; + } + try { + const params = new URLSearchParams({ + channel_id: routeForm.channel_id, + agent_id: routeForm.agent_id, + match_type: routeForm.match_type, + priority: String(routeForm.priority), + }); + if (routeForm.match_pattern) params.append('match_pattern', routeForm.match_pattern); + await adminAPI.createImBotRoute('?' + params.toString()); + showToast('Route created successfully'); + setShowRouteModal(false); + setRouteForm({ channel_id: '', agent_id: '', match_type: 'all', match_pattern: '', priority: 0 }); + loadRoutes(); + } catch (e) { + showToast('Failed to create route', 'error'); + } + }; + + // Handle delete route + const handleDeleteRoute = async (routeId: string) => { + if (!confirm('Delete this routing rule?')) return; + try { + await adminAPI.deleteImBotRoute(routeId); + showToast('Route deleted'); + loadRoutes(); + } catch (e) { + showToast('Failed to delete route', 'error'); + } + }; + + // Handle quick setup + const handleQuickSetup = async () => { + if (!setupForm.platform || !setupForm.name) { + showToast('Please fill in platform and name', 'error'); + return; + } + try { + const params = new URLSearchParams({ + platform: setupForm.platform, + name: setupForm.name, + }); + if (setupForm.agent_id) params.append('agent_id', setupForm.agent_id); + if (setupForm.credentials) params.append('credentials', setupForm.credentials); + const res = await adminAPI.imBotQuickSetup('?' + params.toString()); + if (res.data?.success) { + const data = res.data.data; + setWebhookInfo({ + platform: data.platform, + platform_name: data.platform_name, + full_url: data.webhook_url, + instructions: data.instructions, + }); + setShowSetupModal(false); + setShowWebhookModal(true); + showToast('Channel created! Follow the instructions to configure the webhook.'); + setSetupForm({ platform: 'feishu', name: '', agent_id: '', credentials: '' }); + loadChannels(); + loadStats(); + } + } catch (e) { + showToast('Quick setup failed', 'error'); + } + }; + + // Handle LangBot config save + const handleSaveLangbot = async () => { + try { + const params = new URLSearchParams({ + config_id: String(langbotForm.id || 1), + gateway_url: langbotForm.gateway_url, + status: langbotForm.status, + sync_channels: String(langbotForm.sync_channels), + }); + if (langbotForm.api_key) params.append('api_key', langbotForm.api_key); + if (langbotForm.webhook_secret) params.append('webhook_secret', langbotForm.webhook_secret); + if (langbotForm.bot_uuid) params.append('bot_uuid', langbotForm.bot_uuid); + if (langbotForm.callback_url) params.append('callback_url', langbotForm.callback_url); + await adminAPI.updateLangbotConfig('?' + params.toString()); + showToast('LangBot config saved'); + loadLangbot(); + } catch (e) { + showToast('Failed to save LangBot config', 'error'); + } + }; + + // Handle LangBot test + const handleTestLangbot = async () => { + if (!langbotForm.gateway_url) { + showToast('Please enter gateway URL', 'error'); + return; + } + try { + const params = new URLSearchParams({ + gateway_url: langbotForm.gateway_url, + }); + if (langbotForm.api_key) params.append('api_key', langbotForm.api_key); + const res = await adminAPI.testLangbot('?' + params.toString()); + if (res.data?.success) { + const data = res.data.data; + if (data.reachable) { + showToast(`LangBot reachable! Status: ${data.status_code}`); + } else { + showToast(`LangBot unreachable: ${data.error}`, 'error'); + } + } + } catch (e) { + showToast('Test failed', 'error'); + } + }; + + // Copy to clipboard + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text).then(() => { + showToast('Copied to clipboard'); + }); + }; + + // Format time + const formatTime = (time: string) => { + if (!time) return '-'; + return new Date(time).toLocaleString('zh-CN'); + }; + + const tabs: { key: Tab; label: string; icon: string }[] = [ + { key: 'dashboard', label: '仪表盘', icon: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z' }, + { key: 'channels', label: '频道管理', icon: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z' }, + { key: 'routes', label: '消息路由', icon: 'M3.5 18.5l6-6.01 4 4L22 6.92l-1.41-1.41-7.09 7.97-4-4L2 17l1.5 1.5z' }, + { key: 'langbot', label: 'LangBot 网关', icon: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z' }, + { key: 'messages', label: '消息日志', icon: 'M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z' }, + { key: 'setup', label: '快速接入', icon: 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z' }, + ]; + + return ( +
+ {/* Header */} +
+
+

IM 机器人管理

+

管理即时通讯平台接入、消息路由和 LangBot 网关

+
+
+ {stats && ( +
+ + + + +
+ )} +
+
+ + {/* Tabs */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Toast */} + {toast && ( +
+ {toast.msg} +
+ )} + + {/* Dashboard Tab */} + {activeTab === 'dashboard' && ( +
+ {/* Stat Cards */} +
+ + + + +
+ + {/* Platform Distribution */} +
+

平台分布

+
+ {platforms.map((p) => ( +
0 ? p.color + '40' : '#2a2d35'}`, + }}> +
+
+ {p.name.slice(0, 2)} +
+ {p.name} +
+
+ 频道: {p.channel_count} + 在线: {p.active_count} +
+
+ ))} +
+
+ + {/* Message Trend */} + {dashboard?.message_trend && dashboard.message_trend.length > 0 && ( +
+

7 天消息趋势

+
+ {dashboard.message_trend.map((item: any, i: number) => { + const maxVal = Math.max(...dashboard.message_trend.map((t: any) => (t.msg_in || 0) + (t.msg_out || 0)), 1); + const total = (item.msg_in || 0) + (item.msg_out || 0); + const height = (total / maxVal) * 100; + return ( +
+ {total} +
+ + {item.stat_date?.slice(5)} + +
+ ); + })} +
+
+ )} + + {/* Recent Channels */} + {dashboard?.recent_channels && dashboard.recent_channels.length > 0 && ( +
+

最近频道

+
+ {dashboard.recent_channels.slice(0, 5).map((ch: any) => ( +
+
+
+ {ch.platform_info?.name?.slice(0, 2) || ch.platform.slice(0, 2)} +
+ {ch.name} +
+
+ {ch.msg_count} 消息 + {ch.status} +
+
+ ))} +
+
+ )} +
+ )} + + {/* Channels Tab */} + {activeTab === 'channels' && ( +
+ {/* Filters */} +
+ + + +
+ + {/* Channel Table */} +
+ + + + {['频道名称', '平台', '状态', 'Agent', '消息数', '最后活跃', '操作'].map((h) => ( + + ))} + + + + {loading ? ( + + ) : channels.length === 0 ? ( + + ) : ( + channels.map((ch) => ( + + + + + + + + + + )) + )} + +
{h}
加载中...
暂无频道,点击「新建频道」开始
+
{ch.name}
+
{ch.account_name || ch.description || '-'}
+
+ + {ch.platform_info?.name || ch.platform} + + + + + {ch.status === 'connected' ? '已连接' : ch.status === 'pending' ? '待配置' : '已断开'} + + {ch.agent_name || '-'}{ch.msg_count || 0}{formatTime(ch.last_active)} +
+ + + + +
+
+
+ + {/* Pagination */} + {channelsTotal > 20 && ( +
+ + + {channelsPage} / {Math.ceil(channelsTotal / 20)} + + +
+ )} +
+ )} + + {/* Routes Tab */} + {activeTab === 'routes' && ( +
+
+

消息路由规则

+ +
+
+ + + + {['路由名称', '频道', 'Agent', '匹配类型', '匹配规则', '优先级', '状态', '操作'].map((h) => ( + + ))} + + + + {routes.length === 0 ? ( + + ) : ( + routes.map((r) => ( + + + + + + + + + + + )) + )} + +
{h}
暂无路由规则
{r.route_name} + {r.channel_name || r.channel_id?.slice(0, 8)} + {r.platform} + {r.agent_name || r.agent_id?.slice(0, 8) || '-'} + + {r.match_type === 'all' ? '全部消息' : r.match_type === 'keyword' ? '关键词' : r.match_type === 'regex' ? '正则' : '发送者'} + + + {r.match_pattern || '-'} + {r.priority} + + {r.is_active ? '启用' : '禁用'} + + + +
+
+
+ )} + + {/* LangBot Tab */} + {activeTab === 'langbot' && ( +
+
+
+

LangBot 网关配置

+ + {langbotForm.status === 'active' ? '已启用' : '未启用'} + +
+ +
+ + setLangbotForm({ ...langbotForm, gateway_url: e.target.value })} + placeholder="http://localhost:5300" + style={inputStyle} + /> + + + setLangbotForm({ ...langbotForm, bot_uuid: e.target.value })} + placeholder="LangBot HTTP Bot UUID" + style={inputStyle} + /> + + + setLangbotForm({ ...langbotForm, api_key: e.target.value })} + placeholder="LangBot REST API Key" + style={inputStyle} + /> + + + setLangbotForm({ ...langbotForm, webhook_secret: e.target.value })} + placeholder="HMAC-SHA256 签名密钥" + style={inputStyle} + /> + + + setLangbotForm({ ...langbotForm, callback_url: e.target.value })} + placeholder="http://210.16.161.218:12888/app/api/channels/webhook/langbot" + style={inputStyle} + /> + + + + + + + +
+ +
+ + +
+
+ + {/* LangBot Info */} +
+

LangBot 集成说明

+
    +
  • LangBot 是开源的 LLM 原生 IM 机器人平台,支持飞书、微信、钉钉等
  • +
  • 配置 LangBot 作为外部网关后,IM 消息将通过 LangBot 转发到本平台
  • +
  • 在 LangBot 中创建 HTTP Bot 适配器,将 Webhook URL 指向本平台
  • +
  • API Key 在 LangBot 的 config.yaml 中通过 api.global_api_key 配置
  • +
  • Webhook Secret 用于 HMAC-SHA256 双向签名验证
  • +
+
+
+ )} + + {/* Messages Tab */} + {activeTab === 'messages' && ( +
+
+ + + + {['时间', '频道', '平台', '方向', '发送者', '内容', '状态'].map((h) => ( + + ))} + + + + {messages.length === 0 ? ( + + ) : ( + messages.map((msg, i) => ( + + + + + + + + + + )) + )} + +
{h}
暂无消息记录
{formatTime(msg.created_at)}{msg.channel_name || msg.channel_id?.slice(0, 8)} + + {msg.platform_info?.name || msg.platform} + + + + {msg.direction === 'in' ? '接收' : '发送'} + + {msg.sender_name || msg.sender_id || '-'} + {msg.content || '-'} + {msg.status || '-'}
+
+ {messagesTotal > 50 && ( +
+ + + {messagesPage} / {Math.ceil(messagesTotal / 50)} + + +
+ )} +
+ )} + + {/* Setup Tab */} + {activeTab === 'setup' && ( +
+
+

快速接入向导

+

选择平台并填写基本信息,系统将自动创建频道并生成 Webhook 配置指南

+ + {/* Platform selection cards */} +
+ {platforms.map((p) => ( +
setSetupForm({ ...setupForm, platform: p.key })} + style={{ + padding: '12px', borderRadius: '8px', cursor: 'pointer', + background: setupForm.platform === p.key ? p.color + '20' : '#0f1117', + border: setupForm.platform === p.key ? `1px solid ${p.color}` : '1px solid #2a2d35', + textAlign: 'center', transition: 'all 0.2s', + }} + > +
+ {p.name.slice(0, 2)} +
+
{p.name}
+
+ ))} +
+ + + setSetupForm({ ...setupForm, name: e.target.value })} + placeholder="例如:飞书客服机器人" + style={inputStyle} + /> + + + + + + + +