from __future__ import annotations from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from yuxi.channels.manager import channel_manager from yuxi.channels.registry import BUILTIN_ADAPTERS from yuxi.storage.postgres.models_business import User from yuxi.storage.postgres.models_channels import ChannelThreadMapping, ChannelUserMapping from yuxi.utils.logging_config import logger from server.utils.auth_middleware import get_admin_user, get_db channels = APIRouter(prefix="/channels", tags=["channels"]) def _make_response(data: Any = None, code: int = 0, message: str = "ok") -> dict: return {"code": code, "data": data, "message": message} async def _check_rate_limit(user_id: str, key: str, max_req: int, window: int) -> None: rate_key = f"{key}:{user_id}" allowed = await channel_manager.check_rate_limit(rate_key, max_req, window) if not allowed: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="请求频率超限,请稍后再试", ) @channels.get("/status") async def list_channels_status(current_user: User = Depends(get_admin_user)): status_data = await channel_manager.get_channel_status() return _make_response(data=status_data) @channels.get("/actions") async def list_channels_for_action( action: str | None = Query(None, description="要查询的 MessageAction,如 pin/edit/send"), current_user: User = Depends(get_admin_user), ): from yuxi.channels.message_actions import ActionRegistry, MessageAction if action: try: msg_action = MessageAction(action) except ValueError: return _make_response( code=-1, message=f"Unknown action: {action}. Valid actions: {[a.value for a in MessageAction]}", ) channels = ActionRegistry.list_channels_for_action(msg_action) return _make_response(data={"action": action, "channels": channels}) all_channels = ActionRegistry.list_all() result = {} for cid, caps in all_channels.items(): result[cid] = { "supported_actions": caps.supported_actions(), "chat_types": caps.chat_types, } return _make_response(data=result) @channels.get("/{channel_id}/actions") async def get_channel_actions( channel_id: str, current_user: User = Depends(get_admin_user), ): from yuxi.channels.message_actions import ActionRegistry declarations = ActionRegistry.list_all_declarations(channel_id) if declarations: actions_data = [ { "action": decl.action.value, "status": decl.status.value, "reason": decl.reason, "impl": decl.impl, } for decl in declarations.values() ] return _make_response(data={"channel_id": channel_id, "actions": actions_data}) caps = ActionRegistry.get(channel_id) if caps is None: raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' is not registered") supported = caps.supported_actions() return _make_response( data={ "channel_id": channel_id, "actions": [ {"action": a, "status": "supported", "reason": "", "impl": ""} for a in supported ], } ) @channels.get("/{channel_id}/status") async def get_channel_status( channel_id: str, current_user: User = Depends(get_admin_user), ): status_data = await channel_manager.get_channel_status(channel_id) if status_data.get("status") == "not_found": raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' is not registered") return _make_response(data=status_data) @channels.post("/{channel_id}/start") async def start_channel( channel_id: str, current_user: User = Depends(get_admin_user), ): await _check_rate_limit(str(current_user.id), "channel_start", 1, 1) registered_ids = set(channel_manager._registry.list_channels()) | set(BUILTIN_ADAPTERS.keys()) if channel_id not in registered_ids: return _make_response(code=-1, message=f"Channel '{channel_id}' is not registered") if channel_id in channel_manager._adapters: return _make_response(code=409, message=f"Channel '{channel_id}' is already running") try: await channel_manager.start_channel(channel_id) return _make_response( data={"channel_id": channel_id, "status": "connecting"}, message="Channel start initiated", ) except ValueError as e: return _make_response(code=-1, message=str(e)) except Exception as e: logger.error(f"Failed to start channel {channel_id}: {e}") return _make_response(code=-1, message=f"Failed to start channel: {e}") @channels.post("/{channel_id}/stop") async def stop_channel( channel_id: str, current_user: User = Depends(get_admin_user), ): await _check_rate_limit(str(current_user.id), "channel_stop", 1, 1) if channel_id not in channel_manager._adapters: return _make_response(code=409, message=f"Channel '{channel_id}' is not running") await channel_manager.stop_channel(channel_id) return _make_response( data={"channel_id": channel_id, "status": "disconnected"}, message="Channel stopped", ) @channels.post("/{channel_id}/restart") async def restart_channel( channel_id: str, current_user: User = Depends(get_admin_user), ): await _check_rate_limit(str(current_user.id), "channel_restart", 1, 1) registered_ids = set(channel_manager._registry.list_channels()) | set(BUILTIN_ADAPTERS.keys()) if channel_id not in registered_ids: return _make_response(code=-1, message=f"Channel '{channel_id}' is not registered") try: await channel_manager.restart_channel(channel_id) return _make_response( data={"channel_id": channel_id, "status": "connecting"}, message="Channel restart initiated", ) except Exception as e: logger.error(f"Failed to restart channel {channel_id}: {e}") return _make_response(code=-1, message=f"Failed to restart channel: {e}") @channels.put("/{channel_id}/config") async def update_channel_config( channel_id: str, config_updates: dict[str, Any], current_user: User = Depends(get_admin_user), ): registered_ids = set(channel_manager._registry.list_channels()) | set(BUILTIN_ADAPTERS.keys()) if channel_id not in registered_ids: return _make_response(code=-1, message=f"Channel '{channel_id}' is not registered") try: result = await channel_manager.update_channel_config(channel_id, config_updates) return _make_response( data=result, message="Configuration updated. Restart channel to apply changes.", ) except Exception as e: return _make_response(code=-1, message=str(e)) @channels.post("/{channel_id}/test") async def test_channel( channel_id: str, current_user: User = Depends(get_admin_user), ): registered_ids = set(channel_manager._registry.list_channels()) | set(BUILTIN_ADAPTERS.keys()) if channel_id not in registered_ids: return _make_response(code=-1, message=f"Channel '{channel_id}' is not registered") result = await channel_manager.test_channel(channel_id) return _make_response(data=result) @channels.get("/{channel_id}/stats") async def get_channel_stats( channel_id: str, days: int = Query(7, ge=1, le=90), period: str | None = Query(None, description="时间范围: 1d / 7d / 30d"), granularity: str = Query("day"), current_user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db), ): from datetime import timedelta from sqlalchemy import func, select from yuxi.storage.postgres.models_channels import ChannelMsgRecord from yuxi.utils.datetime_utils import utc_now_naive if period: period_map = {"1d": 1, "7d": 7, "30d": 30} days = period_map.get(period, 7) registered_ids = set(channel_manager._registry.list_channels()) | set(BUILTIN_ADAPTERS.keys()) if channel_id not in registered_ids: return _make_response(code=-1, message=f"Channel '{channel_id}' is not registered") now = utc_now_naive() since = now - timedelta(days=days) today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) stats_base = select( func.count().label("total"), func.sum( func.cast( (ChannelMsgRecord.status == "success").cast(func.Integer), func.Integer, ) ).label("success_count"), func.sum( func.cast( (ChannelMsgRecord.status == "error").cast(func.Integer), func.Integer, ) ).label("error_count"), func.avg(ChannelMsgRecord.response_time_ms).label("avg_response_ms"), func.percentile_cont(0.5).within_group(ChannelMsgRecord.response_time_ms).label("p50_ms"), func.percentile_cont(0.95).within_group(ChannelMsgRecord.response_time_ms).label("p95_ms"), func.percentile_cont(0.99).within_group(ChannelMsgRecord.response_time_ms).label("p99_ms"), ).where( ChannelMsgRecord.channel_id == channel_id, ChannelMsgRecord.created_at >= since, ) result = await db.execute(stats_base) row = result.one() total = row.total or 0 success = row.success_count or 0 error = row.error_count or 0 today_result = await db.execute( select(func.count()).where( ChannelMsgRecord.channel_id == channel_id, ChannelMsgRecord.created_at >= today_start, ) ) today_count = today_result.scalar() or 0 active_users_result = await db.execute( select(func.count(func.distinct(ChannelMsgRecord.sender_user_id))).where( ChannelMsgRecord.channel_id == channel_id, ChannelMsgRecord.created_at >= since, ) ) active_users = active_users_result.scalar() or 0 summary = { "today_count": today_count, "total_count": total, "active_users": active_users, "success_count": int(success), "error_count": int(error), "success_rate": round(success / total, 3) if total > 0 else 0, "avg_latency": round(row.avg_response_ms, 1) if row.avg_response_ms else 0, "p50_latency": round(row.p50_ms, 1) if row.p50_ms else 0, "p95_latency": round(row.p95_ms, 1) if row.p95_ms else 0, "p99_latency": round(row.p99_ms, 1) if row.p99_ms else 0, } daily_trend: list[dict] = [] daily_success_rate: list[dict] = [] if granularity == "day": daily_query = select( func.date(ChannelMsgRecord.created_at).label("date"), func.count().label("count"), func.avg(ChannelMsgRecord.response_time_ms).label("avg_ms"), ).where( ChannelMsgRecord.channel_id == channel_id, ChannelMsgRecord.created_at >= since, ).group_by(func.date(ChannelMsgRecord.created_at)).order_by(func.date(ChannelMsgRecord.created_at)) daily_result = await db.execute(daily_query) daily_trend = [ {"date": str(r.date), "count": r.count, "avg_ms": round(r.avg_ms, 1) if r.avg_ms else 0} for r in daily_result.all() ] success_rate_query = select( func.date(ChannelMsgRecord.created_at).label("date"), func.count().label("total"), func.sum( func.cast( (ChannelMsgRecord.status == "success").cast(func.Integer), func.Integer, ) ).label("success"), ).where( ChannelMsgRecord.channel_id == channel_id, ChannelMsgRecord.created_at >= since, ).group_by(func.date(ChannelMsgRecord.created_at)).order_by(func.date(ChannelMsgRecord.created_at)) success_rate_result = await db.execute(success_rate_query) daily_success_rate = [ { "date": str(r.date), "success_rate": round(r.success / r.total * 100, 1) if r.total > 0 else 0, "total": r.total, } for r in success_rate_result.all() ] type_dist_result = await db.execute( select(ChannelMsgRecord.chat_type, func.count().label("cnt")) .where(ChannelMsgRecord.channel_id == channel_id, ChannelMsgRecord.created_at >= since) .group_by(ChannelMsgRecord.chat_type) ) chat_type_distribution = {r.chat_type: r.cnt for r in type_dist_result.all()} msg_type_result = await db.execute( select(ChannelMsgRecord.content_type, func.count().label("cnt")) .where(ChannelMsgRecord.channel_id == channel_id, ChannelMsgRecord.created_at >= since) .group_by(ChannelMsgRecord.content_type) ) message_type_distribution = {r.content_type: r.cnt for r in msg_type_result.all()} return _make_response( data={ "channel_id": channel_id, "period": { "from": since.strftime("%Y-%m-%dT%H:%M:%SZ"), "to": now.strftime("%Y-%m-%dT%H:%M:%SZ"), }, "summary": summary, "daily_trend": daily_trend, "daily_success_rate": daily_success_rate, "chat_type_distribution": chat_type_distribution, "message_type_distribution": message_type_distribution, } ) @channels.get("/{channel_id}/users/{user_id}") async def get_channel_user_mapping( channel_id: str, user_id: str, current_user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db), ): result = await db.execute( select(ChannelUserMapping).where( ChannelUserMapping.channel_id == channel_id, ChannelUserMapping.channel_user_id == user_id, ) ) mapping = result.scalar_one_or_none() if not mapping: raise HTTPException(status_code=404, detail="User mapping not found") thread_result = await db.execute( select(ChannelThreadMapping).where( ChannelThreadMapping.channel_id == channel_id, ChannelThreadMapping.internal_user_id == mapping.internal_user_id, ).order_by(ChannelThreadMapping.last_active_at.desc()).limit(10) ) recent_chats = [ { "chat_id": t.channel_chat_id, "thread_id": t.thread_id, "last_active": t.last_active_at.strftime("%Y-%m-%dT%H:%M:%SZ") if t.last_active_at else None, } for t in thread_result.all() ] return _make_response( data={ "channel_id": channel_id, "channel_user_id": user_id, "internal_user_id": mapping.internal_user_id, "created_at": mapping.created_at.strftime("%Y-%m-%dT%H:%M:%SZ") if mapping.created_at else None, "recent_chats": recent_chats, } ) @channels.get("/{channel_id}/messages") async def get_channel_messages( channel_id: str, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), status: str | None = Query(None), content_type: str | None = Query(None), date_from: str | None = Query(None), date_to: str | None = Query(None), search: str | None = Query(None), current_user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db), ): from datetime import datetime from sqlalchemy import func, select, desc from yuxi.storage.postgres.models_channels import ChannelMsgRecord conditions = [ChannelMsgRecord.channel_id == channel_id] if status: conditions.append(ChannelMsgRecord.status == status) if content_type: conditions.append(ChannelMsgRecord.content_type == content_type) if date_from: try: dt_from = datetime.strptime(date_from, "%Y-%m-%d") conditions.append(ChannelMsgRecord.created_at >= dt_from) except ValueError: pass if date_to: try: dt_to = datetime.strptime(date_to, "%Y-%m-%d") conditions.append(ChannelMsgRecord.created_at < dt_to) except ValueError: pass if search: conditions.append(ChannelMsgRecord.content_preview.ilike(f"%{search}%")) count_query = select(func.count()).where(*conditions) total_result = await db.execute(count_query) total = total_result.scalar() or 0 query = ( select(ChannelMsgRecord) .where(*conditions) .order_by(desc(ChannelMsgRecord.created_at)) .offset((page - 1) * page_size) .limit(page_size) ) result = await db.execute(query) rows = result.scalars().all() messages = [ { "id": r.id, "created_at": r.created_at.strftime("%Y-%m-%dT%H:%M:%SZ") if r.created_at else None, "sender_user_id": r.sender_user_id, "content_preview": r.content_preview, "content_type": r.content_type, "status": r.status, "response_time_ms": r.response_time_ms, "reply_content_preview": r.reply_content_preview, "error_message": r.error_message, "chat_id": r.chat_id, "chat_type": r.chat_type, } for r in rows ] return _make_response(data={"messages": messages, "total": total}) @channels.get("/{channel_id}/messages/export") async def export_channel_messages( channel_id: str, status: str | None = Query(None), content_type: str | None = Query(None), date_from: str | None = Query(None), date_to: str | None = Query(None), search: str | None = Query(None), current_user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db), ): import csv from datetime import datetime from io import StringIO from fastapi.responses import StreamingResponse from yuxi.storage.postgres.models_channels import ChannelMsgRecord conditions = [ChannelMsgRecord.channel_id == channel_id] if status: conditions.append(ChannelMsgRecord.status == status) if content_type: conditions.append(ChannelMsgRecord.content_type == content_type) if date_from: try: dt_from = datetime.strptime(date_from, "%Y-%m-%d") conditions.append(ChannelMsgRecord.created_at >= dt_from) except ValueError: pass if date_to: try: dt_to = datetime.strptime(date_to, "%Y-%m-%d") conditions.append(ChannelMsgRecord.created_at < dt_to) except ValueError: pass if search: conditions.append(ChannelMsgRecord.content_preview.ilike(f"%{search}%")) query = select(ChannelMsgRecord).where(*conditions).order_by(ChannelMsgRecord.created_at.desc()).limit(10000) result = await db.execute(query) rows = result.scalars().all() output = StringIO() writer = csv.writer(output) writer.writerow([ "id", "created_at", "sender_user_id", "content_preview", "content_type", "status", "response_time_ms", "reply_content_preview", "error_message", "chat_id", "chat_type" ]) for r in rows: writer.writerow([ r.id, r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else "", r.sender_user_id, r.content_preview, r.content_type, r.status, r.response_time_ms or "", r.reply_content_preview or "", r.error_message or "", r.chat_id, r.chat_type, ]) output.seek(0) return StreamingResponse( iter([output.getvalue()]), media_type="text/csv; charset=utf-8-sig", headers={"Content-Disposition": f"attachment; filename=messages_{channel_id}.csv"}, ) @channels.get("/{channel_id}/policy") async def get_channel_policy( channel_id: str, current_user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db), ): from yuxi.storage.postgres.models_channels import ChannelPolicyConfig result = await db.execute( select(ChannelPolicyConfig).where(ChannelPolicyConfig.channel_id == channel_id) ) policy = result.scalar_one_or_none() if not policy: return _make_response( data={ "channel_id": channel_id, "group_chat_mode": "all", "whitelist_ids": [], "welcome_message": "", "work_hours_start": "09:00", "work_hours_end": "18:00", "timezone_offset": 8, "off_hours_reply": "", } ) return _make_response(data=policy.to_dict()) @channels.put("/{channel_id}/policy") async def update_channel_policy( channel_id: str, policy_updates: dict[str, Any], current_user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db), ): from yuxi.storage.postgres.models_channels import ChannelPolicyConfig from yuxi.utils.datetime_utils import utc_now_naive result = await db.execute( select(ChannelPolicyConfig).where(ChannelPolicyConfig.channel_id == channel_id) ) policy = result.scalar_one_or_none() if policy: for key in [ "group_chat_mode", "whitelist_ids", "welcome_message", "work_hours_start", "work_hours_end", "timezone_offset", "off_hours_reply", ]: if key in policy_updates: setattr(policy, key, policy_updates[key]) policy.updated_at = utc_now_naive() else: policy = ChannelPolicyConfig( channel_id=channel_id, **{k: v for k, v in policy_updates.items() if k in [ "group_chat_mode", "whitelist_ids", "welcome_message", "work_hours_start", "work_hours_end", "timezone_offset", "off_hours_reply", ]} ) db.add(policy) await db.commit() await db.refresh(policy) return _make_response(data=policy.to_dict(), message="策略配置已保存") @channels.get("/{channel_id}/routing") async def get_channel_routing( channel_id: str, current_user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db), ): from yuxi.storage.postgres.models_channels import ChannelRoutingRule result = await db.execute( select(ChannelRoutingRule) .where(ChannelRoutingRule.channel_id == channel_id) .order_by(ChannelRoutingRule.priority.desc()) ) rules = result.scalars().all() return _make_response(data={"channel_id": channel_id, "routes": [r.to_dict() for r in rules]}) @channels.put("/{channel_id}/routing") async def update_channel_routing( channel_id: str, routing_updates: list[dict[str, Any]], current_user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db), ): from yuxi.storage.postgres.models_channels import ChannelRoutingRule await db.execute( select(ChannelRoutingRule).where(ChannelRoutingRule.channel_id == channel_id) ) existing = (await db.execute( select(ChannelRoutingRule).where(ChannelRoutingRule.channel_id == channel_id) )).scalars().all() for rule in existing: await db.delete(rule) for i, item in enumerate(routing_updates): rule = ChannelRoutingRule( channel_id=channel_id, command=item.get("command", ""), agent_config_id=item.get("agent_config_id", 0), priority=item.get("priority", len(routing_updates) - i), ) db.add(rule) await db.commit() return _make_response(data={"channel_id": channel_id, "routes": routing_updates}, message="路由规则已保存") @channels.get("/{channel_id}/config-schema") async def get_channel_config_schema( channel_id: str, current_user: User = Depends(get_admin_user), ): from yuxi.channels.registry import BUILTIN_ADAPTERS all_adapters = {**BUILTIN_ADAPTERS} for cid, adapter_cls in all_adapters.items(): if cid == channel_id: try: default_config = adapter_cls.default_config() if hasattr(adapter_cls, "default_config") else {} except Exception: default_config = {} fields = [] for key, value in default_config.items(): field_type = "text" if "token" in key or "secret" in key or "key" in key: field_type = "password" elif isinstance(value, bool): field_type = "switch" elif isinstance(value, (int, float)): field_type = "number" is_credential = any(k in key for k in ("token", "secret", "key", "app_id")) fields.append({ "key": key, "label": key.replace("_", " ").title(), "type": field_type, "default": value, "required": "token" in key or "secret" in key, "group": "credentials" if is_credential else "general", }) fields.append({ "key": "agent_config_id", "label": "默认 Agent", "type": "agent_select", "required": False, "group": "routing", }) return _make_response(data={ "channel_type": channel_id, "fields": fields, "groups": [ {"key": "credentials", "label": "认证凭据"}, {"key": "general", "label": "通用设置"}, {"key": "routing", "label": "路由配置"}, ], }) return _make_response(data={"channel_type": channel_id, "fields": [], "groups": []}) @channels.get("/{channel_id}/chats/{chat_id}") async def get_channel_chat_mapping( channel_id: str, chat_id: str, current_user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db), ): result = await db.execute( select(ChannelThreadMapping).where( ChannelThreadMapping.channel_id == channel_id, ChannelThreadMapping.channel_chat_id == chat_id, ).order_by(ChannelThreadMapping.last_active_at.desc()).limit(1) ) mapping = result.scalar_one_or_none() if not mapping: raise HTTPException(status_code=404, detail="Chat mapping not found") return _make_response( data={ "channel_id": channel_id, "channel_chat_id": chat_id, "thread_id": mapping.thread_id, "agent_id": mapping.agent_id, "created_at": mapping.created_at.strftime("%Y-%m-%dT%H:%M:%SZ") if mapping.created_at else None, "last_active_at": mapping.last_active_at.strftime("%Y-%m-%dT%H:%M:%SZ") if mapping.last_active_at else None, } )