1. 新增多渠道配置项,支持多渠道网关管理 2. 添加用户来源字段,区分本地用户与渠道用户 3. 实现WebSocket聊天路由与Slack Webhook处理 4. 新增渠道消息记录与统计相关存储模型与仓库 5. 实现多渠道管理API与Webhook分发路由 6. 初始化渠道管理器并集成到应用生命周期 7. 添加第三方依赖包支持多渠道SDK 8. 优化鉴权逻辑,排除渠道用户的常规鉴权
756 lines
26 KiB
Python
756 lines
26 KiB
Python
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,
|
||
}
|
||
)
|