2026-05-12 00:56:00 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
import csv
|
|
|
|
|
|
import codecs
|
|
|
|
|
|
import json
|
|
|
|
|
|
import re
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from io import StringIO
|
|
|
|
|
|
from typing import Any, Literal
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, status
|
|
|
|
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
from sqlalchemy import asc, desc, func, select
|
|
|
|
|
|
from sqlalchemy.exc import DBAPIError, IntegrityError
|
2026-05-12 00:56:00 +08:00
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
2026-05-14 02:10:52 +08:00
|
|
|
|
from starlette.responses import JSONResponse
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
2026-05-13 16:51:23 +08:00
|
|
|
|
from yuxi.channels.manager import get_channel_manager
|
2026-05-14 02:10:52 +08:00
|
|
|
|
from yuxi.channels.services.plugin_state_store import PluginStateStore, PostgresPluginStateStore
|
|
|
|
|
|
from yuxi.channels.exceptions import (
|
|
|
|
|
|
ChannelAuthenticationError,
|
|
|
|
|
|
ChannelException,
|
|
|
|
|
|
ChannelNotConnectedError,
|
|
|
|
|
|
ChannelRateLimitError,
|
|
|
|
|
|
ChannelTimeoutError,
|
|
|
|
|
|
)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
from yuxi.storage.postgres.models_business import User
|
2026-05-14 02:10:52 +08:00
|
|
|
|
from yuxi.storage.postgres.models_channels import (
|
|
|
|
|
|
ChannelMsgRecord,
|
|
|
|
|
|
ChannelPolicyConfig,
|
|
|
|
|
|
ChannelThreadMapping,
|
|
|
|
|
|
ChannelUserMapping,
|
|
|
|
|
|
)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
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}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
class UpdateStateEntryBody(BaseModel):
|
|
|
|
|
|
value: Any = Field(..., description="状态值(必填)")
|
|
|
|
|
|
ttl_seconds: int | None = Field(None, gt=0, description="过期时间(秒),必须为正整数")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 00:56:00 +08:00
|
|
|
|
async def _check_rate_limit(user_id: str, key: str, max_req: int, window: int) -> None:
|
|
|
|
|
|
rate_key = f"{key}:{user_id}"
|
2026-05-13 16:51:23 +08:00
|
|
|
|
allowed = await get_channel_manager().check_rate_limit(rate_key, max_req, window)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
if not allowed:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
|
|
|
|
detail="请求频率超限,请稍后再试",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-14 09:24:50 +08:00
|
|
|
|
async def _handle_channel_operation_exceptions(channel_id: str, operation):
|
|
|
|
|
|
try:
|
|
|
|
|
|
return await operation
|
|
|
|
|
|
except ChannelAuthenticationError as e:
|
|
|
|
|
|
raise HTTPException(status_code=401, detail=f"凭证无效: {e}")
|
|
|
|
|
|
except ChannelNotConnectedError as e:
|
|
|
|
|
|
raise HTTPException(status_code=503, detail=f"渠道不可达: {e}")
|
|
|
|
|
|
except ChannelTimeoutError as e:
|
|
|
|
|
|
raise HTTPException(status_code=504, detail=f"连接超时: {e}")
|
|
|
|
|
|
except ChannelRateLimitError as e:
|
|
|
|
|
|
raise HTTPException(status_code=429, detail=f"上游限流: {e}")
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Failed to execute channel operation for {channel_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"Failed to execute channel operation: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
_POLICY_FIELDS = [
|
|
|
|
|
|
"group_chat_mode",
|
|
|
|
|
|
"whitelist_ids",
|
|
|
|
|
|
"welcome_message",
|
|
|
|
|
|
"work_hours_start",
|
|
|
|
|
|
"work_hours_end",
|
|
|
|
|
|
"timezone_offset",
|
|
|
|
|
|
"off_hours_reply",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
_POLICY_DEFAULTS = {
|
|
|
|
|
|
"group_chat_mode": "mention_only",
|
|
|
|
|
|
"whitelist_ids": [],
|
|
|
|
|
|
"welcome_message": "",
|
|
|
|
|
|
"work_hours_start": "09:00",
|
|
|
|
|
|
"work_hours_end": "18:00",
|
|
|
|
|
|
"timezone_offset": 8,
|
|
|
|
|
|
"off_hours_reply": "",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ChannelPolicyUpdate(BaseModel):
|
|
|
|
|
|
group_chat_mode: Literal["mention_only", "all", "whitelist", "blacklist"] | None = None
|
|
|
|
|
|
whitelist_ids: list[str] | None = None
|
|
|
|
|
|
welcome_message: str | None = Field(None, max_length=500)
|
|
|
|
|
|
work_hours_start: str | None = Field(None, pattern=r"^\d{2}:\d{2}$")
|
|
|
|
|
|
work_hours_end: str | None = Field(None, pattern=r"^\d{2}:\d{2}$")
|
|
|
|
|
|
timezone_offset: int | None = Field(None, ge=-12, le=14)
|
|
|
|
|
|
off_hours_reply: str | None = Field(None, max_length=500)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_adapter(channel_id: str):
|
|
|
|
|
|
adapter = get_channel_manager()._adapters.get(channel_id)
|
|
|
|
|
|
if adapter is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' not found or not running")
|
|
|
|
|
|
return adapter
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _check_channel_rate_limit(channel_id: str, user_id: str, action: str = "message") -> None:
|
|
|
|
|
|
result = await get_channel_manager().check_per_channel_rate_limit(channel_id, user_id, action)
|
|
|
|
|
|
if not result.allowed:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
|
|
|
|
detail=f"请求频率超限,请在 {result.retry_after_seconds} 秒后重试",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
VALID_MESSAGE_STATUSES = frozenset({"processing", "success", "error", "timeout"})
|
|
|
|
|
|
VALID_CONTENT_TYPES = frozenset({"text", "image", "file", "voice", "video", "location", "event"})
|
|
|
|
|
|
MIN_SEARCH_LENGTH = 2
|
|
|
|
|
|
MAX_SEARCH_LENGTH = 100
|
|
|
|
|
|
|
|
|
|
|
|
SORTABLE_FIELDS = frozenset({"created_at", "response_time_ms", "id"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _escape_like(value: str) -> str:
|
|
|
|
|
|
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_message_conditions(
|
|
|
|
|
|
channel_id: str,
|
|
|
|
|
|
status_value: str | None = None,
|
|
|
|
|
|
content_type: str | None = None,
|
|
|
|
|
|
date_from: str | None = None,
|
|
|
|
|
|
date_to: str | None = None,
|
|
|
|
|
|
search: str | None = None,
|
|
|
|
|
|
) -> list:
|
|
|
|
|
|
conditions = [ChannelMsgRecord.channel_id == channel_id]
|
|
|
|
|
|
|
|
|
|
|
|
if status_value:
|
|
|
|
|
|
if status_value not in VALID_MESSAGE_STATUSES:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=f"无效的 status 值: '{status_value}',可选: {', '.join(sorted(VALID_MESSAGE_STATUSES))}",
|
|
|
|
|
|
)
|
|
|
|
|
|
conditions.append(ChannelMsgRecord.status == status_value)
|
|
|
|
|
|
|
|
|
|
|
|
if content_type:
|
|
|
|
|
|
if content_type not in VALID_CONTENT_TYPES:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=f"无效的 content_type 值: '{content_type}',可选: {', '.join(sorted(VALID_CONTENT_TYPES))}",
|
|
|
|
|
|
)
|
|
|
|
|
|
conditions.append(ChannelMsgRecord.content_type == content_type)
|
|
|
|
|
|
|
|
|
|
|
|
dt_from = None
|
|
|
|
|
|
dt_to = None
|
|
|
|
|
|
|
|
|
|
|
|
if date_from:
|
|
|
|
|
|
try:
|
|
|
|
|
|
dt_from = datetime.strptime(date_from, "%Y-%m-%d")
|
|
|
|
|
|
conditions.append(ChannelMsgRecord.created_at >= dt_from)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=f"日期格式无效: '{date_from}',正确格式: YYYY-MM-DD",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if date_to:
|
|
|
|
|
|
try:
|
|
|
|
|
|
dt_to = datetime.strptime(date_to, "%Y-%m-%d")
|
|
|
|
|
|
conditions.append(ChannelMsgRecord.created_at < dt_to)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=f"日期格式无效: '{date_to}',正确格式: YYYY-MM-DD",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if dt_from is not None and dt_to is not None:
|
|
|
|
|
|
if dt_from >= dt_to:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=f"date_from ({date_from}) 必须早于 date_to ({date_to})",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if search:
|
|
|
|
|
|
search = search.strip()
|
|
|
|
|
|
if len(search) < MIN_SEARCH_LENGTH:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=f"搜索关键词至少 {MIN_SEARCH_LENGTH} 个字符",
|
|
|
|
|
|
)
|
|
|
|
|
|
if len(search) > MAX_SEARCH_LENGTH:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=f"搜索关键词最多 {MAX_SEARCH_LENGTH} 个字符",
|
|
|
|
|
|
)
|
|
|
|
|
|
escaped = _escape_like(search)
|
|
|
|
|
|
conditions.append(ChannelMsgRecord.content_preview.ilike(f"%{escaped}%", escape="\\"))
|
|
|
|
|
|
|
|
|
|
|
|
return conditions
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_CSV_RISKY_PREFIXES = frozenset(("=", "+", "-", "@"))
|
|
|
|
|
|
|
|
|
|
|
|
_CSV_HEADER = [
|
|
|
|
|
|
"id",
|
|
|
|
|
|
"channel_type",
|
|
|
|
|
|
"message_id",
|
|
|
|
|
|
"chat_id",
|
|
|
|
|
|
"chat_type",
|
|
|
|
|
|
"content_type",
|
|
|
|
|
|
"sender_user_id",
|
|
|
|
|
|
"content_preview",
|
|
|
|
|
|
"reply_to_message_id",
|
|
|
|
|
|
"agent_config_id",
|
|
|
|
|
|
"status",
|
|
|
|
|
|
"error_message",
|
|
|
|
|
|
"response_time_ms",
|
|
|
|
|
|
"reply_message_id",
|
|
|
|
|
|
"reply_content_preview",
|
|
|
|
|
|
"created_at",
|
|
|
|
|
|
"replied_at",
|
|
|
|
|
|
"extra_metadata",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sanitize_csv_cell(value: str | None) -> str:
|
|
|
|
|
|
if not value:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
if value[0] in _CSV_RISKY_PREFIXES:
|
|
|
|
|
|
return f"'{value}"
|
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _UTF8SigEncoder:
|
|
|
|
|
|
def __init__(self, generator):
|
|
|
|
|
|
self._gen = generator
|
|
|
|
|
|
self._sent_bom = False
|
|
|
|
|
|
|
|
|
|
|
|
def __iter__(self):
|
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
|
|
def __next__(self):
|
|
|
|
|
|
if not self._sent_bom:
|
|
|
|
|
|
self._sent_bom = True
|
|
|
|
|
|
return codecs.BOM_UTF8
|
|
|
|
|
|
return next(self._gen)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _csv_line_generator(rows):
|
|
|
|
|
|
output = StringIO()
|
|
|
|
|
|
writer = csv.writer(output)
|
|
|
|
|
|
|
|
|
|
|
|
writer.writerow(_CSV_HEADER)
|
|
|
|
|
|
yield output.getvalue()
|
|
|
|
|
|
output.truncate(0)
|
|
|
|
|
|
output.seek(0)
|
|
|
|
|
|
|
|
|
|
|
|
for r in rows:
|
|
|
|
|
|
writer.writerow(
|
|
|
|
|
|
[
|
|
|
|
|
|
r.id,
|
|
|
|
|
|
r.channel_type or "",
|
|
|
|
|
|
r.message_id or "",
|
|
|
|
|
|
r.chat_id,
|
|
|
|
|
|
r.chat_type,
|
|
|
|
|
|
r.content_type,
|
|
|
|
|
|
_sanitize_csv_cell(r.sender_user_id),
|
|
|
|
|
|
_sanitize_csv_cell(r.content_preview),
|
|
|
|
|
|
r.reply_to_message_id or "",
|
|
|
|
|
|
r.agent_config_id or "",
|
|
|
|
|
|
r.status,
|
|
|
|
|
|
_sanitize_csv_cell(r.error_message),
|
|
|
|
|
|
r.response_time_ms or "",
|
|
|
|
|
|
r.reply_message_id or "",
|
|
|
|
|
|
_sanitize_csv_cell(r.reply_content_preview),
|
|
|
|
|
|
r.created_at.strftime("%Y-%m-%dT%H:%M:%SZ") if r.created_at else "",
|
|
|
|
|
|
r.replied_at.strftime("%Y-%m-%dT%H:%M:%SZ") if r.replied_at else "",
|
|
|
|
|
|
json.dumps(r.extra_metadata, ensure_ascii=False) if r.extra_metadata else "",
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
yield output.getvalue()
|
|
|
|
|
|
output.truncate(0)
|
|
|
|
|
|
output.seek(0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_nostr_adapter(channel_id: str):
|
|
|
|
|
|
adapter = _get_adapter(channel_id)
|
|
|
|
|
|
profile_api = getattr(adapter, "_profile_api", None)
|
|
|
|
|
|
if profile_api is None:
|
|
|
|
|
|
raise HTTPException(status_code=501, detail=f"渠道 '{channel_id}' 不支持 Profile 操作")
|
|
|
|
|
|
return adapter, profile_api
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_state_store() -> PluginStateStore:
|
|
|
|
|
|
return PostgresPluginStateStore()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@channels.get("/nostr/profile")
|
|
|
|
|
|
async def get_nostr_profile(
|
|
|
|
|
|
channel_id: str = Query(..., description="Nostr 渠道 ID"),
|
|
|
|
|
|
account_id: str = Query("default"),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
):
|
|
|
|
|
|
adapter, profile_api = _get_nostr_adapter(channel_id)
|
|
|
|
|
|
profile = await profile_api.get_profile(account_id)
|
|
|
|
|
|
return _make_response(data=profile)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@channels.post("/nostr/profile/publish")
|
|
|
|
|
|
async def publish_nostr_profile(
|
|
|
|
|
|
channel_id: str = Query(..., description="Nostr 渠道 ID"),
|
|
|
|
|
|
profile_data: dict = Body(..., description="Profile 数据"),
|
|
|
|
|
|
account_id: str = Body("default"),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
):
|
|
|
|
|
|
adapter, profile_api = _get_nostr_adapter(channel_id)
|
|
|
|
|
|
result = await profile_api.publish_profile(profile_data, account_id)
|
|
|
|
|
|
return _make_response(data=result)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@channels.get("/nostr/profile/import")
|
|
|
|
|
|
async def import_nostr_profile(
|
|
|
|
|
|
pubkey: str = Query(..., description="目标 Nostr 公钥 (hex)"),
|
|
|
|
|
|
channel_id: str = Query(..., description="Nostr 渠道 ID"),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
):
|
|
|
|
|
|
adapter, profile_api = _get_nostr_adapter(channel_id)
|
|
|
|
|
|
result = await profile_api.import_profile(pubkey)
|
|
|
|
|
|
return _make_response(data=result)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@channels.post("/nostr/profile/merge")
|
|
|
|
|
|
async def merge_nostr_profile(
|
|
|
|
|
|
channel_id: str = Query(..., description="Nostr 渠道 ID"),
|
|
|
|
|
|
body: dict = Body(...),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
):
|
|
|
|
|
|
local_data = body.get("local", {})
|
|
|
|
|
|
imported_pubkey = body.get("imported_pubkey", "")
|
|
|
|
|
|
adapter, profile_api = _get_nostr_adapter(channel_id)
|
|
|
|
|
|
result = await profile_api.merge_profile(local_data, imported_pubkey)
|
|
|
|
|
|
return _make_response(data=result)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 00:56:00 +08:00
|
|
|
|
@channels.get("/status")
|
|
|
|
|
|
async def list_channels_status(current_user: User = Depends(get_admin_user)):
|
2026-05-13 16:51:23 +08:00
|
|
|
|
status_data = await get_channel_manager().get_channel_status()
|
2026-05-14 02:10:52 +08:00
|
|
|
|
meta = status_data.get("_meta", {})
|
|
|
|
|
|
code = 0
|
|
|
|
|
|
message = "ok"
|
|
|
|
|
|
if meta.get("failed", 0) > 0:
|
|
|
|
|
|
code = 2001
|
|
|
|
|
|
message = f"部分渠道状态获取失败 ({meta['failed']}/{meta['total_channels']})"
|
|
|
|
|
|
return _make_response(code=code, data=status_data, message=message)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 16:51:23 +08:00
|
|
|
|
@channels.post("/register")
|
|
|
|
|
|
async def register_channel(
|
|
|
|
|
|
body: dict[str, Any],
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
):
|
|
|
|
|
|
channel_type = body.get("channel_type")
|
|
|
|
|
|
if not channel_type:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="channel_type is required")
|
|
|
|
|
|
|
|
|
|
|
|
config = body.get("config", {})
|
|
|
|
|
|
manager = get_channel_manager()
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
await manager.register_channel(channel_type, config)
|
|
|
|
|
|
return _make_response(
|
|
|
|
|
|
data={"channel_id": channel_type, "registered": True},
|
|
|
|
|
|
message="渠道注册成功",
|
|
|
|
|
|
)
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 00:56:00 +08:00
|
|
|
|
@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,
|
2026-05-13 16:51:23 +08:00
|
|
|
|
"actions": [{"action": a, "status": "supported", "reason": "", "impl": ""} for a in supported],
|
2026-05-12 00:56:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@channels.get("/{channel_id}/status")
|
|
|
|
|
|
async def get_channel_status(
|
2026-05-14 02:10:52 +08:00
|
|
|
|
channel_id: str = Path(
|
|
|
|
|
|
...,
|
|
|
|
|
|
min_length=1,
|
|
|
|
|
|
max_length=64,
|
|
|
|
|
|
pattern=r"^[a-zA-Z0-9_-]+$",
|
|
|
|
|
|
description="渠道标识符",
|
|
|
|
|
|
),
|
|
|
|
|
|
include_stats: bool = Query(False),
|
2026-05-12 00:56:00 +08:00
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
):
|
2026-05-14 02:10:52 +08:00
|
|
|
|
await _check_rate_limit(str(current_user.id), "channel_status", 30, 60)
|
|
|
|
|
|
status_data = await get_channel_manager().get_channel_status(channel_id, include_stats=include_stats)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
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),
|
|
|
|
|
|
):
|
2026-05-13 16:51:23 +08:00
|
|
|
|
await _check_rate_limit(str(current_user.id), "channel_start", 3, 3)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
2026-05-13 16:51:23 +08:00
|
|
|
|
manager = get_channel_manager()
|
|
|
|
|
|
if not manager.is_registered(channel_id):
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' is not registered")
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
config = manager._channels_config.get(channel_id, {})
|
|
|
|
|
|
if not config.get("enabled", False):
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=f"Channel '{channel_id}' is disabled in configuration",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-13 16:51:23 +08:00
|
|
|
|
if manager.is_running(channel_id):
|
2026-05-12 00:56:00 +08:00
|
|
|
|
return _make_response(code=409, message=f"Channel '{channel_id}' is already running")
|
|
|
|
|
|
|
2026-05-14 09:24:50 +08:00
|
|
|
|
await _handle_channel_operation_exceptions(channel_id, manager.start_channel(channel_id))
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
adapter = manager._adapters.get(channel_id)
|
|
|
|
|
|
current_status = manager._adapter_status(adapter) if adapter else "connecting"
|
|
|
|
|
|
|
|
|
|
|
|
response_data = {"channel_id": channel_id, "status": current_status}
|
|
|
|
|
|
if adapter:
|
|
|
|
|
|
try:
|
|
|
|
|
|
snapshot = adapter.snapshot()
|
|
|
|
|
|
response_data.update(
|
|
|
|
|
|
{k: v for k, v in snapshot.items() if k in ("channel_type", "display_name", "capabilities", "health")}
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logger.warning(f"Failed to get snapshot for {channel_id}")
|
|
|
|
|
|
|
|
|
|
|
|
return _make_response(data=response_data, message="Channel start initiated")
|
|
|
|
|
|
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
|
|
|
|
|
@channels.post("/{channel_id}/stop")
|
|
|
|
|
|
async def stop_channel(
|
|
|
|
|
|
channel_id: str,
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
):
|
2026-05-13 16:51:23 +08:00
|
|
|
|
await _check_rate_limit(str(current_user.id), "channel_stop", 3, 3)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
2026-05-13 16:51:23 +08:00
|
|
|
|
manager = get_channel_manager()
|
|
|
|
|
|
if not manager.is_running(channel_id):
|
2026-05-12 00:56:00 +08:00
|
|
|
|
return _make_response(code=409, message=f"Channel '{channel_id}' is not running")
|
|
|
|
|
|
|
2026-05-13 16:51:23 +08:00
|
|
|
|
await manager.stop_channel(channel_id)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
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),
|
|
|
|
|
|
):
|
2026-05-13 16:51:23 +08:00
|
|
|
|
await _check_rate_limit(str(current_user.id), "channel_restart", 3, 3)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
2026-05-13 16:51:23 +08:00
|
|
|
|
manager = get_channel_manager()
|
|
|
|
|
|
if not manager.is_registered(channel_id):
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' is not registered")
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
config = manager._channels_config.get(channel_id, {})
|
|
|
|
|
|
if not config.get("enabled", False):
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=f"Channel '{channel_id}' is disabled in configuration",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if not manager.is_running(channel_id):
|
|
|
|
|
|
raise HTTPException(status_code=409, detail=f"Channel '{channel_id}' is not running")
|
|
|
|
|
|
|
2026-05-14 09:24:50 +08:00
|
|
|
|
await _handle_channel_operation_exceptions(channel_id, manager.restart_channel(channel_id))
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
adapter = manager._adapters.get(channel_id)
|
|
|
|
|
|
current_status = manager._adapter_status(adapter) if adapter else "connecting"
|
|
|
|
|
|
return _make_response(
|
|
|
|
|
|
data={"channel_id": channel_id, "status": current_status},
|
|
|
|
|
|
message="Channel restart initiated",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
|
|
|
|
|
@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),
|
|
|
|
|
|
):
|
2026-05-14 02:10:52 +08:00
|
|
|
|
await _check_rate_limit(str(current_user.id), "channel_config", 5, 10)
|
|
|
|
|
|
|
2026-05-13 16:51:23 +08:00
|
|
|
|
manager = get_channel_manager()
|
|
|
|
|
|
if not manager.is_registered(channel_id):
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' is not registered")
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
|
|
|
|
|
try:
|
2026-05-14 02:10:52 +08:00
|
|
|
|
result = await manager.update_channel_config(channel_id, config_updates, user_id=str(current_user.id))
|
|
|
|
|
|
|
|
|
|
|
|
if result["adapter_status"] == "stopped":
|
|
|
|
|
|
message = "配置已保存。启动渠道后生效。"
|
|
|
|
|
|
elif result.get("needs_restart"):
|
|
|
|
|
|
message = "配置已更新。建议重启渠道以确保所有内部状态同步。"
|
|
|
|
|
|
else:
|
|
|
|
|
|
message = "配置已实时生效。"
|
|
|
|
|
|
|
|
|
|
|
|
return _make_response(data=result, message=message)
|
|
|
|
|
|
except ValueError as e:
|
2026-05-13 16:51:23 +08:00
|
|
|
|
raise HTTPException(status_code=400, detail=str(e))
|
2026-05-14 02:10:52 +08:00
|
|
|
|
except ChannelException as e:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Failed to update config for channel {channel_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"Failed to update channel config: {e}")
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@channels.post("/{channel_id}/test")
|
|
|
|
|
|
async def test_channel(
|
|
|
|
|
|
channel_id: str,
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
):
|
2026-05-13 16:51:23 +08:00
|
|
|
|
manager = get_channel_manager()
|
|
|
|
|
|
if not manager.is_registered(channel_id):
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' is not registered")
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
2026-05-13 16:51:23 +08:00
|
|
|
|
result = await manager.test_channel(channel_id)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
from sqlalchemy import Integer
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-05-13 16:51:23 +08:00
|
|
|
|
manager = get_channel_manager()
|
|
|
|
|
|
if not manager.is_registered(channel_id):
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' is not registered")
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
|
|
|
|
|
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(
|
2026-05-13 18:05:12 +08:00
|
|
|
|
(ChannelMsgRecord.status == "success").cast(Integer),
|
2026-05-12 00:56:00 +08:00
|
|
|
|
).label("success_count"),
|
|
|
|
|
|
func.sum(
|
2026-05-13 18:05:12 +08:00
|
|
|
|
(ChannelMsgRecord.status == "error").cast(Integer),
|
2026-05-12 00:56:00 +08:00
|
|
|
|
).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":
|
2026-05-13 16:51:23 +08:00
|
|
|
|
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))
|
|
|
|
|
|
)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-05-13 16:51:23 +08:00
|
|
|
|
success_rate_query = (
|
|
|
|
|
|
select(
|
|
|
|
|
|
func.date(ChannelMsgRecord.created_at).label("date"),
|
|
|
|
|
|
func.count().label("total"),
|
|
|
|
|
|
func.sum(
|
2026-05-13 18:05:12 +08:00
|
|
|
|
(ChannelMsgRecord.status == "success").cast(Integer),
|
2026-05-13 16:51:23 +08:00
|
|
|
|
).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))
|
|
|
|
|
|
)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
|
|
|
|
|
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(
|
2026-05-13 16:51:23 +08:00
|
|
|
|
select(ChannelThreadMapping)
|
|
|
|
|
|
.where(
|
2026-05-12 00:56:00 +08:00
|
|
|
|
ChannelThreadMapping.channel_id == channel_id,
|
|
|
|
|
|
ChannelThreadMapping.internal_user_id == mapping.internal_user_id,
|
2026-05-13 16:51:23 +08:00
|
|
|
|
)
|
|
|
|
|
|
.order_by(ChannelThreadMapping.last_active_at.desc())
|
|
|
|
|
|
.limit(10)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
)
|
|
|
|
|
|
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),
|
2026-05-14 02:10:52 +08:00
|
|
|
|
sort_by: str = Query("created_at"),
|
|
|
|
|
|
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
|
2026-05-12 00:56:00 +08:00
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
2026-05-14 02:10:52 +08:00
|
|
|
|
manager = get_channel_manager()
|
|
|
|
|
|
if not manager.is_registered(channel_id):
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=404,
|
|
|
|
|
|
detail=f"渠道 '{channel_id}' 未注册",
|
|
|
|
|
|
)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
await _check_channel_rate_limit(
|
|
|
|
|
|
channel_id=channel_id,
|
|
|
|
|
|
user_id=str(current_user.id),
|
|
|
|
|
|
)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
conditions = _build_message_conditions(
|
|
|
|
|
|
channel_id=channel_id,
|
|
|
|
|
|
status_value=status,
|
|
|
|
|
|
content_type=content_type,
|
|
|
|
|
|
date_from=date_from,
|
|
|
|
|
|
date_to=date_to,
|
|
|
|
|
|
search=search,
|
|
|
|
|
|
)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
count_query = select(func.count()).select_from(ChannelMsgRecord).where(*conditions)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
total_result = await db.execute(count_query)
|
|
|
|
|
|
total = total_result.scalar() or 0
|
|
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
sort_column = getattr(ChannelMsgRecord, sort_by, None)
|
|
|
|
|
|
if sort_column is None or sort_by not in SORTABLE_FIELDS:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=f"不支持的排序字段: '{sort_by}',可选: {', '.join(sorted(SORTABLE_FIELDS))}",
|
|
|
|
|
|
)
|
|
|
|
|
|
order_func = desc if sort_order == "desc" else asc
|
|
|
|
|
|
|
2026-05-12 00:56:00 +08:00
|
|
|
|
query = (
|
|
|
|
|
|
select(ChannelMsgRecord)
|
|
|
|
|
|
.where(*conditions)
|
2026-05-14 02:10:52 +08:00
|
|
|
|
.order_by(order_func(sort_column))
|
2026-05-12 00:56:00 +08:00
|
|
|
|
.offset((page - 1) * page_size)
|
|
|
|
|
|
.limit(page_size)
|
|
|
|
|
|
)
|
|
|
|
|
|
result = await db.execute(query)
|
|
|
|
|
|
rows = result.scalars().all()
|
|
|
|
|
|
messages = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": r.id,
|
2026-05-14 02:10:52 +08:00
|
|
|
|
"message_id": r.message_id,
|
|
|
|
|
|
"channel_type": r.channel_type,
|
2026-05-12 00:56:00 +08:00
|
|
|
|
"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,
|
2026-05-14 02:10:52 +08:00
|
|
|
|
"reply_message_id": r.reply_message_id,
|
|
|
|
|
|
"replied_at": r.replied_at.strftime("%Y-%m-%dT%H:%M:%SZ") if r.replied_at else None,
|
2026-05-12 00:56:00 +08:00
|
|
|
|
"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),
|
|
|
|
|
|
):
|
2026-05-14 02:10:52 +08:00
|
|
|
|
await _check_rate_limit(str(current_user.id), "channel_messages_export", 5, 60)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
manager = get_channel_manager()
|
|
|
|
|
|
if not manager.is_registered(channel_id):
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=404,
|
|
|
|
|
|
detail=f"渠道 '{channel_id}' 未注册",
|
|
|
|
|
|
)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
conditions = _build_message_conditions(
|
|
|
|
|
|
channel_id=channel_id,
|
|
|
|
|
|
status_value=status,
|
|
|
|
|
|
content_type=content_type,
|
|
|
|
|
|
date_from=date_from,
|
|
|
|
|
|
date_to=date_to,
|
|
|
|
|
|
search=search,
|
|
|
|
|
|
)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
try:
|
|
|
|
|
|
count_query = select(func.count()).select_from(ChannelMsgRecord).where(*conditions)
|
|
|
|
|
|
total = (await db.execute(count_query)).scalar() or 0
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
query = select(ChannelMsgRecord).where(*conditions).order_by(ChannelMsgRecord.created_at.desc()).limit(10000)
|
|
|
|
|
|
result = await db.execute(query)
|
|
|
|
|
|
rows = result.scalars().all()
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
headers = {
|
|
|
|
|
|
"Content-Disposition": f"attachment; filename=messages_{channel_id}.csv",
|
|
|
|
|
|
"X-Total-Count": str(total),
|
|
|
|
|
|
}
|
|
|
|
|
|
if total > 10000:
|
|
|
|
|
|
headers["X-Truncated"] = "true"
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
return StreamingResponse(
|
|
|
|
|
|
_UTF8SigEncoder(_csv_line_generator(rows)),
|
|
|
|
|
|
media_type="text/csv; charset=utf-8",
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logger.exception(f"Failed to export messages for channel {channel_id}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="导出消息失败,请稍后重试")
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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),
|
|
|
|
|
|
):
|
2026-05-14 02:10:52 +08:00
|
|
|
|
manager = get_channel_manager()
|
|
|
|
|
|
if not manager.is_registered(channel_id):
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' is not registered")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = await db.execute(select(ChannelPolicyConfig).where(ChannelPolicyConfig.channel_id == channel_id))
|
|
|
|
|
|
policy = result.scalar_one_or_none()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logger.error(f"Failed to query policy for channel {channel_id}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="查询策略配置失败")
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
|
|
|
|
|
if not policy:
|
2026-05-14 02:10:52 +08:00
|
|
|
|
return _make_response(data={"channel_id": channel_id, **_POLICY_DEFAULTS})
|
2026-05-12 00:56:00 +08:00
|
|
|
|
return _make_response(data=policy.to_dict())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@channels.put("/{channel_id}/policy")
|
|
|
|
|
|
async def update_channel_policy(
|
|
|
|
|
|
channel_id: str,
|
2026-05-14 02:10:52 +08:00
|
|
|
|
policy_updates: ChannelPolicyUpdate,
|
2026-05-12 00:56:00 +08:00
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
2026-05-14 02:10:52 +08:00
|
|
|
|
await _check_rate_limit(str(current_user.id), "channel_policy_update", 5, 5)
|
|
|
|
|
|
|
|
|
|
|
|
updates_dict = policy_updates.model_dump(exclude_none=True)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
2026-05-14 09:24:50 +08:00
|
|
|
|
try:
|
|
|
|
|
|
result = await db.execute(select(ChannelPolicyConfig).where(ChannelPolicyConfig.channel_id == channel_id))
|
|
|
|
|
|
policy = result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
|
|
if policy:
|
|
|
|
|
|
for key in _POLICY_FIELDS:
|
|
|
|
|
|
if key in updates_dict:
|
|
|
|
|
|
setattr(policy, key, updates_dict[key])
|
|
|
|
|
|
else:
|
|
|
|
|
|
policy_data = {k: v for k, v in updates_dict.items() if k in _POLICY_FIELDS}
|
|
|
|
|
|
policy = ChannelPolicyConfig(channel_id=channel_id, **policy_data)
|
|
|
|
|
|
db.add(policy)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
2026-05-14 09:24:50 +08:00
|
|
|
|
await db.commit()
|
|
|
|
|
|
await db.refresh(policy)
|
|
|
|
|
|
except IntegrityError:
|
|
|
|
|
|
await db.rollback()
|
|
|
|
|
|
raise HTTPException(status_code=409, detail="策略配置写入冲突,请重试")
|
|
|
|
|
|
except DBAPIError as e:
|
|
|
|
|
|
logger.error(f"数据库操作失败 (channel={channel_id}): {e}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="数据库操作失败,请稍后重试")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"更新策略配置失败 (channel={channel_id}): {e}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="更新策略配置失败")
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
|
):
|
2026-05-14 02:10:52 +08:00
|
|
|
|
manager = get_channel_manager()
|
|
|
|
|
|
if not manager.is_registered(channel_id):
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"渠道 '{channel_id}' 不存在")
|
|
|
|
|
|
|
2026-05-12 00:56:00 +08:00
|
|
|
|
from yuxi.storage.postgres.models_channels import ChannelRoutingRule
|
|
|
|
|
|
|
2026-05-14 09:24:50 +08:00
|
|
|
|
try:
|
|
|
|
|
|
result = await db.execute(select(ChannelRoutingRule).where(ChannelRoutingRule.channel_id == channel_id))
|
|
|
|
|
|
rules = result.scalars().all()
|
|
|
|
|
|
except DBAPIError as e:
|
|
|
|
|
|
logger.error(f"查询路由规则失败 (channel={channel_id}): {e}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="数据库操作失败,请稍后重试")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"查询路由规则失败 (channel={channel_id}): {e}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="查询路由规则失败")
|
|
|
|
|
|
|
2026-05-12 00:56:00 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
manager = get_channel_manager()
|
|
|
|
|
|
if not manager.is_registered(channel_id):
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"渠道 '{channel_id}' 不存在")
|
|
|
|
|
|
|
|
|
|
|
|
if not routing_updates:
|
|
|
|
|
|
raise HTTPException(status_code=422, detail="不允许清空全部路由规则")
|
|
|
|
|
|
|
|
|
|
|
|
from yuxi.repositories.agent_config_repository import AgentConfigRepository
|
|
|
|
|
|
|
|
|
|
|
|
agent_repo = AgentConfigRepository(db)
|
|
|
|
|
|
for item in routing_updates:
|
|
|
|
|
|
aid = str(item.get("agent_config_id", "0"))
|
|
|
|
|
|
try:
|
|
|
|
|
|
aid_int = int(aid)
|
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
|
raise HTTPException(status_code=422, detail=f"Agent 配置 ID '{aid}' 无效")
|
|
|
|
|
|
config = await agent_repo.get_by_id(aid_int)
|
|
|
|
|
|
if config is None:
|
|
|
|
|
|
raise HTTPException(status_code=422, detail=f"Agent 配置 '{aid}' 不存在")
|
|
|
|
|
|
|
2026-05-13 16:51:23 +08:00
|
|
|
|
existing = (
|
|
|
|
|
|
(await db.execute(select(ChannelRoutingRule).where(ChannelRoutingRule.channel_id == channel_id)))
|
|
|
|
|
|
.scalars()
|
|
|
|
|
|
.all()
|
2026-05-12 00:56:00 +08:00
|
|
|
|
)
|
|
|
|
|
|
for rule in existing:
|
|
|
|
|
|
await db.delete(rule)
|
|
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
for item in routing_updates:
|
2026-05-12 00:56:00 +08:00
|
|
|
|
rule = ChannelRoutingRule(
|
|
|
|
|
|
channel_id=channel_id,
|
|
|
|
|
|
command=item.get("command", ""),
|
2026-05-13 16:51:23 +08:00
|
|
|
|
agent_config_id=str(item.get("agent_config_id", "0")),
|
2026-05-12 00:56:00 +08:00
|
|
|
|
)
|
|
|
|
|
|
db.add(rule)
|
|
|
|
|
|
|
2026-05-14 09:24:50 +08:00
|
|
|
|
try:
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
except IntegrityError:
|
|
|
|
|
|
await db.rollback()
|
|
|
|
|
|
raise HTTPException(status_code=409, detail="路由规则写入冲突,请重试")
|
|
|
|
|
|
except DBAPIError as e:
|
|
|
|
|
|
logger.error(f"数据库操作失败 (channel={channel_id}): {e}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="数据库操作失败,请稍后重试")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"保存路由规则失败 (channel={channel_id}): {e}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="保存路由规则失败")
|
2026-05-14 02:10:52 +08:00
|
|
|
|
|
2026-05-14 09:24:50 +08:00
|
|
|
|
try:
|
|
|
|
|
|
result = await db.execute(select(ChannelRoutingRule).where(ChannelRoutingRule.channel_id == channel_id))
|
|
|
|
|
|
saved_rules = result.scalars().all()
|
|
|
|
|
|
except DBAPIError as e:
|
|
|
|
|
|
logger.error(f"查询路由规则失败 (channel={channel_id}): {e}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="数据库操作失败,请稍后重试")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"查询路由规则失败 (channel={channel_id}): {e}", exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="查询路由规则失败")
|
2026-05-14 02:10:52 +08:00
|
|
|
|
return _make_response(
|
|
|
|
|
|
data={"channel_id": channel_id, "routes": [r.to_dict() for r in saved_rules]},
|
|
|
|
|
|
message="路由规则已保存",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ALL_CONFIG_GROUPS = {
|
|
|
|
|
|
"credentials": {"key": "credentials", "label": "认证凭据"},
|
|
|
|
|
|
"general": {"key": "general", "label": "通用设置"},
|
|
|
|
|
|
"routing": {"key": "routing", "label": "路由配置"},
|
|
|
|
|
|
"advanced": {"key": "advanced", "label": "高级设置"},
|
|
|
|
|
|
}
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@channels.get("/{channel_id}/config-schema")
|
|
|
|
|
|
async def get_channel_config_schema(
|
|
|
|
|
|
channel_id: str,
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
):
|
2026-05-14 02:10:52 +08:00
|
|
|
|
if not re.match(r"^[a-z0-9][a-z0-9_-]{0,30}$", channel_id):
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Invalid channel_id: '{channel_id}'")
|
2026-05-13 16:51:23 +08:00
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
await _check_rate_limit(str(current_user.id), "config_schema", 10, 5)
|
|
|
|
|
|
|
|
|
|
|
|
manager = get_channel_manager()
|
|
|
|
|
|
adapter_cls = manager._registry.get(channel_id)
|
|
|
|
|
|
if adapter_cls is None:
|
|
|
|
|
|
return _make_response(data={"channel_type": channel_id, "fields": [], "groups": []})
|
|
|
|
|
|
|
|
|
|
|
|
schema = adapter_cls.get_config_schema()
|
|
|
|
|
|
|
|
|
|
|
|
if not schema:
|
|
|
|
|
|
logger.info(f"渠道 {channel_id} 未定义 config_schema,返回仅含 agent_config_id 的基本 Schema")
|
|
|
|
|
|
|
|
|
|
|
|
fields = []
|
|
|
|
|
|
used_groups: set[str] = set()
|
|
|
|
|
|
|
|
|
|
|
|
for key, schema_def in schema.items():
|
|
|
|
|
|
field_type = schema_def.get("type", "text")
|
|
|
|
|
|
group = schema_def.get("group", "general")
|
|
|
|
|
|
used_groups.add(group)
|
|
|
|
|
|
|
|
|
|
|
|
field_entry: dict[str, Any] = {
|
|
|
|
|
|
"key": key,
|
|
|
|
|
|
"label": schema_def.get("label") or key.replace("_", " ").title(),
|
|
|
|
|
|
"type": field_type,
|
|
|
|
|
|
"required": schema_def.get("required", False),
|
|
|
|
|
|
"group": group,
|
|
|
|
|
|
}
|
|
|
|
|
|
if "default" in schema_def:
|
|
|
|
|
|
field_entry["default"] = schema_def["default"]
|
|
|
|
|
|
if "enum" in schema_def:
|
|
|
|
|
|
field_entry["enum"] = schema_def["enum"]
|
|
|
|
|
|
if schema_def.get("secret"):
|
|
|
|
|
|
field_entry["secret"] = True
|
|
|
|
|
|
|
|
|
|
|
|
fields.append(field_entry)
|
|
|
|
|
|
|
|
|
|
|
|
fields.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"key": "agent_config_id",
|
|
|
|
|
|
"label": "默认 Agent",
|
|
|
|
|
|
"type": "agent_select",
|
|
|
|
|
|
"required": False,
|
|
|
|
|
|
"group": "routing",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
used_groups.add("routing")
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
groups = [ALL_CONFIG_GROUPS[g] for g in sorted(used_groups) if g in ALL_CONFIG_GROUPS]
|
|
|
|
|
|
|
|
|
|
|
|
return _make_response(
|
|
|
|
|
|
data={
|
|
|
|
|
|
"channel_type": channel_id,
|
|
|
|
|
|
"fields": fields,
|
|
|
|
|
|
"groups": groups,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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(
|
2026-05-13 16:51:23 +08:00
|
|
|
|
select(ChannelThreadMapping)
|
|
|
|
|
|
.where(
|
2026-05-12 00:56:00 +08:00
|
|
|
|
ChannelThreadMapping.channel_id == channel_id,
|
|
|
|
|
|
ChannelThreadMapping.channel_chat_id == chat_id,
|
2026-05-13 16:51:23 +08:00
|
|
|
|
)
|
|
|
|
|
|
.order_by(ChannelThreadMapping.last_active_at.desc())
|
|
|
|
|
|
.limit(1)
|
2026-05-12 00:56:00 +08:00
|
|
|
|
)
|
|
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
2026-05-13 16:51:23 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-14 02:10:52 +08:00
|
|
|
|
@channels.post("/state/cleanup")
|
|
|
|
|
|
async def cleanup_expired_state(
|
|
|
|
|
|
store: PluginStateStore = Depends(get_state_store),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
):
|
|
|
|
|
|
count = await store.cleanup_expired()
|
|
|
|
|
|
return _make_response(data={"cleaned": count}, message=f"清理了 {count} 条过期状态")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@channels.get("/{channel_id}/state")
|
|
|
|
|
|
async def list_channel_state(
|
|
|
|
|
|
channel_id: str,
|
|
|
|
|
|
store: PluginStateStore = Depends(get_state_store),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
):
|
|
|
|
|
|
await _check_rate_limit(str(current_user.id), "state_read", 30, 10)
|
|
|
|
|
|
|
|
|
|
|
|
manager = get_channel_manager()
|
|
|
|
|
|
if not manager.is_registered(channel_id):
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' not found")
|
|
|
|
|
|
|
|
|
|
|
|
all_state = await store.get_all(channel_id)
|
|
|
|
|
|
|
|
|
|
|
|
safe_state = {}
|
|
|
|
|
|
for ns, entries in all_state.items():
|
|
|
|
|
|
if ns == "credentials":
|
|
|
|
|
|
safe_state[ns] = {k: {"redacted": True, "has_credential": True} for k in entries}
|
|
|
|
|
|
else:
|
|
|
|
|
|
safe_state[ns] = entries
|
|
|
|
|
|
|
|
|
|
|
|
return _make_response(data={"channel_id": channel_id, "state": safe_state})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@channels.get("/{channel_id}/state/{namespace}/{key}")
|
|
|
|
|
|
async def get_channel_state_entry(
|
|
|
|
|
|
channel_id: str,
|
|
|
|
|
|
namespace: str = Path(..., min_length=1, max_length=64),
|
|
|
|
|
|
key: str = Path(..., min_length=1, max_length=128),
|
|
|
|
|
|
store: PluginStateStore = Depends(get_state_store),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
):
|
|
|
|
|
|
await _check_rate_limit(str(current_user.id), "state_read", 30, 10)
|
|
|
|
|
|
|
|
|
|
|
|
manager = get_channel_manager()
|
|
|
|
|
|
if not manager.is_registered(channel_id):
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' not found")
|
|
|
|
|
|
|
|
|
|
|
|
value = await store.get(channel_id, key, namespace)
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="State entry not found")
|
|
|
|
|
|
|
|
|
|
|
|
if namespace == "credentials":
|
|
|
|
|
|
return _make_response(
|
|
|
|
|
|
data={
|
|
|
|
|
|
"channel_id": channel_id,
|
|
|
|
|
|
"namespace": namespace,
|
|
|
|
|
|
"key": key,
|
|
|
|
|
|
"redacted": True,
|
|
|
|
|
|
"has_credential": True,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return _make_response(data={"channel_id": channel_id, "namespace": namespace, "key": key, "value": value})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@channels.put("/{channel_id}/state/{namespace}/{key}")
|
|
|
|
|
|
async def update_channel_state_entry(
|
|
|
|
|
|
body: UpdateStateEntryBody,
|
|
|
|
|
|
channel_id: str = Path(..., min_length=1, max_length=32),
|
|
|
|
|
|
namespace: str = Path(..., min_length=1, max_length=64),
|
|
|
|
|
|
key: str = Path(..., min_length=1, max_length=128),
|
|
|
|
|
|
store: PluginStateStore = Depends(get_state_store),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
):
|
|
|
|
|
|
await _check_rate_limit(str(current_user.id), "state_write", 10, 10)
|
|
|
|
|
|
|
|
|
|
|
|
manager = get_channel_manager()
|
|
|
|
|
|
if not manager.is_registered(channel_id):
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' not found")
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"渠道状态写入: user=%s, channel=%s, namespace=%s, key=%s, has_ttl=%s",
|
|
|
|
|
|
current_user.id,
|
|
|
|
|
|
channel_id,
|
|
|
|
|
|
namespace,
|
|
|
|
|
|
key,
|
|
|
|
|
|
body.ttl_seconds is not None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
is_new = await store.set(
|
|
|
|
|
|
channel_id,
|
|
|
|
|
|
key,
|
|
|
|
|
|
body.value,
|
|
|
|
|
|
namespace,
|
|
|
|
|
|
ttl_seconds=body.ttl_seconds,
|
|
|
|
|
|
)
|
|
|
|
|
|
except IntegrityError:
|
|
|
|
|
|
raise HTTPException(status_code=409, detail="状态条目写入冲突,请重试")
|
|
|
|
|
|
except DBAPIError as e:
|
|
|
|
|
|
logger.error(
|
|
|
|
|
|
"更新渠道状态失败 (channel=%s, ns=%s, key=%s): %s",
|
|
|
|
|
|
channel_id,
|
|
|
|
|
|
namespace,
|
|
|
|
|
|
key,
|
|
|
|
|
|
e,
|
|
|
|
|
|
exc_info=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="数据库操作失败,请稍后重试")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error("更新渠道状态失败 (channel=%s): %s", channel_id, e, exc_info=True)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="更新渠道状态失败")
|
|
|
|
|
|
|
|
|
|
|
|
status_code = 201 if is_new else 200
|
|
|
|
|
|
message = "状态已创建" if is_new else "状态已更新"
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
content=_make_response(
|
|
|
|
|
|
data={"channel_id": channel_id, "namespace": namespace, "key": key},
|
|
|
|
|
|
message=message,
|
|
|
|
|
|
),
|
|
|
|
|
|
status_code=status_code,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@channels.delete("/{channel_id}/state/{namespace}/{key}")
|
|
|
|
|
|
async def delete_channel_state_entry(
|
|
|
|
|
|
channel_id: str,
|
|
|
|
|
|
namespace: str = Path(..., min_length=1, max_length=64),
|
|
|
|
|
|
key: str = Path(..., min_length=1, max_length=128),
|
|
|
|
|
|
store: PluginStateStore = Depends(get_state_store),
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
):
|
|
|
|
|
|
await _check_rate_limit(str(current_user.id), "state_write", 10, 10)
|
|
|
|
|
|
|
|
|
|
|
|
manager = get_channel_manager()
|
|
|
|
|
|
if not manager.is_registered(channel_id):
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' not found")
|
|
|
|
|
|
|
|
|
|
|
|
await store.delete(channel_id, key, namespace)
|
|
|
|
|
|
return _make_response(
|
|
|
|
|
|
data={"channel_id": channel_id, "namespace": namespace, "key": key},
|
|
|
|
|
|
message="状态已删除",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-14 09:24:50 +08:00
|
|
|
|
@channels.get("/{channel_id}/credential-status")
|
|
|
|
|
|
async def get_channel_credential_status(
|
|
|
|
|
|
channel_id: str,
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
):
|
|
|
|
|
|
manager = get_channel_manager()
|
|
|
|
|
|
if not manager.is_registered(channel_id):
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' is not registered")
|
|
|
|
|
|
|
|
|
|
|
|
credential = await manager.get_credential_status(channel_id)
|
|
|
|
|
|
return _make_response(data={"channel_id": channel_id, "credential": credential})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@channels.post("/{channel_id}/refresh-credential")
|
|
|
|
|
|
async def refresh_channel_credential(
|
|
|
|
|
|
channel_id: str,
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
):
|
|
|
|
|
|
manager = get_channel_manager()
|
|
|
|
|
|
if not manager.is_registered(channel_id):
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' is not registered")
|
|
|
|
|
|
|
|
|
|
|
|
result = await manager.refresh_credential(channel_id)
|
|
|
|
|
|
return _make_response(data={"channel_id": channel_id, "credential": result})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 16:51:23 +08:00
|
|
|
|
@channels.delete("/{channel_id}")
|
|
|
|
|
|
async def unregister_channel(
|
|
|
|
|
|
channel_id: str,
|
|
|
|
|
|
current_user: User = Depends(get_admin_user),
|
|
|
|
|
|
):
|
2026-05-14 02:10:52 +08:00
|
|
|
|
await _check_rate_limit(str(current_user.id), "channel_unregister", 5, 10)
|
|
|
|
|
|
|
2026-05-13 16:51:23 +08:00
|
|
|
|
manager = get_channel_manager()
|
|
|
|
|
|
if not manager.is_registered(channel_id):
|
2026-05-14 02:10:52 +08:00
|
|
|
|
raise HTTPException(status_code=404, detail=f"Channel '{channel_id}' is not registered")
|
2026-05-13 16:51:23 +08:00
|
|
|
|
|
|
|
|
|
|
await manager.unregister_channel(channel_id)
|
|
|
|
|
|
return _make_response(data={"channel_id": channel_id, "unregistered": True}, message="渠道已注销")
|