""" Phase 4: IM Bot Integration - Admin API Module im_bot_admin.py Provides admin management for IM bot channels, routing rules, statistics, message monitoring, and native IM platform adapters (e.g. 个人微信 WeKnora/Clawbot) that connect directly to each IM platform's official open API — no third-party gateway required. """ import uuid import json import hashlib import hmac import time import threading import requests 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'] 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, } } 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() # ============================================================ # 个人微信 (iLink 协议 / 微信 ClawBot 插件) 原生适配器 # 直接对接腾讯官方 iLink Bot HTTP API (ilinkai.weixin.qq.com) # 扫码登录免 AppID;消息收发走 Bearer bot_token + X-WECHAT-UIN # ============================================================ ILINK_BASE = "https://ilinkai.weixin.qq.com" _wechat_receiver_threads = {} def _wechat_headers(bot_token: str) -> dict: """iLink 协议请求头:Bearer bot_token + 随机 X-WECHAT-UIN 防重放""" import base64, random uin = base64.b64encode(str(random.randint(0, 2 ** 32 - 1)).encode("utf-8")).decode("utf-8") return { "Content-Type": "application/json", "Authorization": f"Bearer {bot_token}", "AuthorizationType": "ilink_bot_token", "X-WECHAT-UIN": uin, } def _wechat_row(db): rows = _execute_raw(db, text("SELECT * FROM im_bot_wechat_account WHERE user_id IS NULL ORDER BY id LIMIT 1")) return rows[0] if rows else None def _wechat_mask(row) -> dict: d = _row_to_dict(row) if d.get("bot_token"): bt = d["bot_token"] d["bot_token"] = ("***" + bt[-6:]) if len(bt) > 6 else "***" return d def _qihua_reply(text: str) -> str: """调用七虎AI 生成回复(复用 channels 的真实 LLM 调用)。 自行创建 DB 会话,避免上层线程无可用 session 时崩溃。""" db = None try: from app.api.channels import _call_real_llm db = SessionLocal() return _call_real_llm(db, None, text, "个人微信") except Exception as e: return f"(AI 回复生成失败:{e})" finally: if db: db.close() def _wechat_get_updates(baseurl: str, bot_token: str, cursor: str): """长轮询收消息,返回 (msgs, new_cursor)。最多 hold 35s。""" try: resp = requests.post( baseurl.rstrip("/") + "/ilink/bot/getupdates", headers=_wechat_headers(bot_token), json={"get_updates_buf": cursor, "base_info": {"channel_version": "1.0.2"}}, timeout=40, ) if not resp.ok: return [], cursor j = resp.json() if resp.content else {} return j.get("msgs", []) or [], j.get("get_updates_buf", cursor) except Exception: return [], cursor def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_token: str, text: str) -> bool: """发送文本回复(必须带 context_token 关联到正确的对话窗口)""" try: resp = requests.post( baseurl.rstrip("/") + "/ilink/bot/sendmessage", headers=_wechat_headers(bot_token), json={"msg": { "to_user_id": to_user_id, "message_type": 2, "message_state": 2, "context_token": context_token, "item_list": [{"type": 1, "text_item": {"text": text}}], }}, timeout=20, ) return resp.ok except Exception: return False def _wechat_extract_text(msg: dict) -> str: parts = [] for it in (msg.get("item_list") or []): if it.get("type") == 1 and it.get("text_item", {}).get("text"): parts.append(it["text_item"]["text"]) return "\n".join(parts) def _wechat_receiver_loop(account_id: int): """后台接收线程:长轮询个人微信消息 → 七虎AI 回复 → 发送。 底层 iLink 协议直连 ilinkai.weixin.qq.com,无需 AppID/SecretKey。""" cursor = "" while True: db = SessionLocal() try: row = _wechat_row(db) if not row: return d = _row_to_dict(row) if d.get("status") != "connected" or not d.get("bot_token"): return bot_token = d["bot_token"] baseurl = d.get("baseurl") or ILINK_BASE msgs, cursor = _wechat_get_updates(baseurl, bot_token, cursor) for m in msgs: if m.get("message_type") not in (1, None): continue # 只处理用户消息(2=bot 自己发出的) content = _wechat_extract_text(m) if not content: continue reply = _qihua_reply(content) _wechat_send_message( baseurl, bot_token, m.get("from_user_id", ""), m.get("context_token", ""), reply, ) time.sleep(1) except Exception: time.sleep(5) finally: db.close() def _start_wechat_receiver(account_id: int): if account_id in _wechat_receiver_threads and _wechat_receiver_threads[account_id].is_alive(): return t = threading.Thread(target=_wechat_receiver_loop, args=(account_id,), daemon=True) t.start() _wechat_receiver_threads[account_id] = t @router.get("/wechat") async def get_wechat_config(user=Depends(get_current_user)): """获取个人微信(Clawbot)适配器配置(敏感字段脱敏)""" db = SessionLocal() try: row = _wechat_row(db) if not row: return {"success": True, "data": None} return {"success": True, "data": _wechat_mask(row)} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) finally: db.close() # (POST /wechat 凭证保存端点已移除:iLink 扫码登录无需 AppID/SecretKey) @router.post("/wechat/qrcode") async def wechat_qrcode(user=Depends(get_current_user)): """获取个人微信扫码登录二维码(iLink 协议,免 AppID)""" try: resp = requests.get( ILINK_BASE + "/ilink/bot/get_bot_qrcode", params={"bot_type": 3}, timeout=20, ) if not resp.ok: return {"success": False, "error": f"获取二维码失败 HTTP {resp.status_code}"} j = resp.json() if resp.content else {} return {"success": True, "data": { "qrcode": j.get("qrcode"), "qrcode_img_content": j.get("qrcode_img_content"), }} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.post("/wechat/qrcode/status") async def wechat_qrcode_status(qrcode: str = Query(...), user=Depends(get_current_user)): """轮询扫码状态;confirmed 时保存 bot_token 并启动接收线程""" try: resp = requests.get( ILINK_BASE + "/ilink/bot/get_qrcode_status", params={"qrcode": qrcode}, timeout=40, ) if not resp.ok: return {"success": False, "error": f"查询状态失败 HTTP {resp.status_code}"} j = resp.json() if resp.content else {} data = j.get("data", j) if isinstance(j, dict) else j status = data.get("status") if status == "confirmed": # 兼容两种返回结构:顶层 或 credentials 嵌套 creds = data.get("credentials", {}) or {} bot_token = data.get("bot_token") or creds.get("bot_token") baseurl = data.get("baseurl") or creds.get("baseurl") or ILINK_BASE ilink_bot_id = data.get("ilink_bot_id") or creds.get("ilink_bot_id") db = SessionLocal() try: row = _wechat_row(db) if not row: _execute_raw(db, text("""INSERT INTO im_bot_wechat_account (bot_token, ilink_bot_id, baseurl, status, connected_at, created_at, updated_at) VALUES (:bot_token, :ilink_bot_id, :baseurl, 'connected', :connected_at, :created_at, :updated_at)"""), {"bot_token": bot_token, "ilink_bot_id": ilink_bot_id, "baseurl": baseurl, "connected_at": datetime.now(), "created_at": datetime.now(), "updated_at": datetime.now()}) else: _execute_raw(db, text("""UPDATE im_bot_wechat_account SET bot_token=:bot_token, ilink_bot_id=:ilink_bot_id, baseurl=:baseurl, status='connected', connected_at=:connected_at, updated_at=:updated_at WHERE id=:id"""), {"bot_token": bot_token, "ilink_bot_id": ilink_bot_id, "baseurl": baseurl, "connected_at": datetime.now(), "updated_at": datetime.now(), "id": _row_to_dict(row)["id"]}) row = _wechat_row(db) aid = _row_to_dict(row)["id"] if row else 1 _start_wechat_receiver(aid) finally: db.close() return {"success": True, "data": {"status": "confirmed", "connected": True}} return {"success": True, "data": {"status": status, "connected": False}} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.get("/wechat/status") async def wechat_status(user=Depends(get_current_user)): """个人微信连接状态""" db = SessionLocal() try: row = _wechat_row(db) if not row: return {"success": True, "data": {"connected": False, "configured": False}} d = _row_to_dict(row) return {"success": True, "data": { "connected": d.get("status") == "connected", "configured": True, "baseurl": d.get("baseurl"), "ilink_bot_id": d.get("ilink_bot_id"), "connected_at": str(d.get("connected_at")) if d.get("connected_at") else None, }} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) finally: db.close() @router.post("/wechat/disconnect") async def wechat_disconnect(user=Depends(get_current_user)): """断开个人微信连接(清除 bot_token)""" db = SessionLocal() try: row = _wechat_row(db) if row: _execute_raw(db, text("UPDATE im_bot_wechat_account SET bot_token=NULL, status='disconnected', updated_at=:updated_at WHERE id=:id"), {"updated_at": datetime.now(), "id": _row_to_dict(row)["id"]}) return {"success": True, "message": "已断开个人微信连接"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) finally: db.close() # ============================================================ # 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()