ForcePilot/backend/server/routers/channels_router.py
Kris 88588c3740 feat: 实现多渠道网关核心功能
1. 新增多渠道配置项,支持多渠道网关管理
2. 添加用户来源字段,区分本地用户与渠道用户
3. 实现WebSocket聊天路由与Slack Webhook处理
4. 新增渠道消息记录与统计相关存储模型与仓库
5. 实现多渠道管理API与Webhook分发路由
6. 初始化渠道管理器并集成到应用生命周期
7. 添加第三方依赖包支持多渠道SDK
8. 优化鉴权逻辑,排除渠道用户的常规鉴权
2026-05-12 00:56:00 +08:00

756 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
}
)