ForcePilot/backend/package/yuxi/channel/gateway/rpc_handlers.py
Kris ecd3c90e80 feat(channel/gateway): 新增完整网关通道模块
新增设备身份管理、认证限流、并发通道、Webhook路由、RBAC权限控制、SSE/轮询降级等全套网关通道功能,包含:
1. 设备身份生成与签名验证
2. 设备令牌认证与速率限制
3. 内存+数据库双重设备注册表
4. 并发通道限流管理
5. Webhook安全处理与路由
6. RBAC权限校验系统
7. OpenAI API兼容适配层
8. Tailscale认证支持
9. HTTP轮询降级机制
2026-05-21 10:26:33 +08:00

1445 lines
52 KiB
Python

import json
import logging
import uuid
from collections.abc import AsyncGenerator
from sqlalchemy import select
from yuxi.channel.gateway.protocol import (
GatewayErrorCode,
GatewayRpcMethod,
RpcEvent,
RpcRequest,
RpcResponse,
)
from yuxi.channel.gateway.rpc_dispatcher import rpc_dispatcher
from yuxi.channel.gateway.validation import (
ProbeParams,
SendMessageParams,
StartAccountParams,
StopAccountParams,
validate_params,
)
from yuxi.channel.runtime.manager import gateway
from yuxi.storage.postgres.manager import pg_manager
logger = logging.getLogger(__name__)
def _is_placeholder_user(user_id: str | None) -> bool:
if not user_id:
return True
return user_id in ("shared", "bootstrap") or user_id.startswith(("tailscale:", "device:", "proxy:"))
async def _resolve_caller_user(request: RpcRequest, db):
from yuxi.storage.postgres.models_business import User
user_id = request.caller_user_id
if user_id and not _is_placeholder_user(user_id):
try:
int_id = int(user_id)
result = await db.execute(select(User).where(User.id == int_id))
user = result.scalar_one_or_none()
if user:
return user
except (ValueError, TypeError):
pass
try:
from sqlalchemy import func as sa_func
result = await db.execute(select(User).where(sa_func.cast(User.id, str) == user_id))
user = result.scalar_one_or_none()
if user:
return user
except Exception:
pass
result = await db.execute(select(User).limit(1))
user = result.scalar_one_or_none()
if user:
logger.warning(
"RPC handler using fallback user %s for caller_user_id=%s method=%s",
user.id,
user_id,
request.method,
)
return user
async def _handle_health(request: RpcRequest) -> RpcResponse:
report = gateway.get_health()
event_loop = None
try:
from yuxi.channel.monitoring.event_loop_monitor import event_loop_monitor
loop_health = event_loop_monitor.health()
loop_stats = event_loop_monitor.stats()
event_loop = {
"degraded": loop_health.degraded,
"reasons": loop_health.reasons,
"interval_ms": loop_health.interval_ms,
"delay": {
"p50_ms": loop_stats.p50_ms,
"p90_ms": loop_stats.p90_ms,
"p99_ms": loop_stats.p99_ms,
"p999_ms": loop_stats.p999_ms,
"max_ms": loop_stats.max_ms,
"min_ms": loop_stats.min_ms,
"avg_ms": loop_stats.avg_ms,
},
"utilization": loop_stats.utilization,
"cpu_core_ratio": loop_health.cpu_core_ratio,
"samples": loop_stats.samples,
"warnings": loop_stats.warnings,
}
except Exception:
logger.exception("Failed to collect event loop health")
return RpcResponse(
id=request.id,
ok=True,
result={
"status": report.status,
"summary": {
"total": report.summary.total,
"running": report.summary.running,
"stopped": report.summary.stopped,
"degraded": report.summary.degraded,
"unhealthy": report.summary.unhealthy,
"unknown": report.summary.unknown,
"connected": report.summary.connected,
"total_active_runs": report.summary.total_active_runs,
},
"channels": report.channels,
"event_loop": event_loop,
},
)
async def _handle_channels_list(request: RpcRequest) -> RpcResponse:
snapshots = gateway.get_all_snapshots()
return RpcResponse(
id=request.id,
ok=True,
result={"channels": [s.to_dict() for s in snapshots.values()]},
)
async def _handle_channels_status(request: RpcRequest) -> RpcResponse:
channel_type = request.params.get("channel_type") if request.params else None
account_id = request.params.get("account_id", "default") if request.params else "default"
if channel_type:
snapshot = gateway.get_snapshot(channel_type, account_id)
if snapshot is None:
return RpcResponse(
id=request.id,
ok=False,
error_code=None,
error_message=f"渠道 {channel_type}:{account_id} 未找到",
)
return RpcResponse(id=request.id, ok=True, result=snapshot.to_dict())
snapshots = gateway.get_all_snapshots()
return RpcResponse(
id=request.id,
ok=True,
result={"channels": [s.to_dict() for s in snapshots.values()]},
)
@validate_params(ProbeParams)
async def _handle_channels_probe(request: RpcRequest) -> RpcResponse:
validated = request._validated_params
snapshot = gateway.get_snapshot(validated.channel_type, validated.account_id)
if snapshot is None:
return RpcResponse(
id=request.id,
ok=False,
error_code=None,
error_message=f"渠道 {validated.channel_type}:{validated.account_id} 未找到",
)
return RpcResponse(
id=request.id,
ok=True,
result={
"channel_type": snapshot.channel_type,
"account_id": snapshot.account_id,
"state": snapshot.state.value,
"connected": snapshot.connected,
"configured": snapshot.configured,
"enabled": snapshot.enabled,
"healthy": snapshot.health_state.value if snapshot.health_state else "unknown",
"last_connected_at": snapshot.last_connected_at,
"last_disconnect": snapshot.last_disconnect.to_dict() if snapshot.last_disconnect else None,
"last_error": snapshot.last_error,
},
)
@validate_params(StartAccountParams)
async def _handle_channels_start(request: RpcRequest) -> RpcResponse:
validated = request._validated_params
channel_type = validated.channel_type
account_id = validated.account_id
if channel_type:
snapshot = await gateway.start_channel(channel_type, account_id)
return RpcResponse(id=request.id, ok=True, result=snapshot.to_dict())
await gateway.start_all()
snapshots = gateway.get_all_snapshots()
return RpcResponse(
id=request.id,
ok=True,
result={"channels": [s.to_dict() for s in snapshots.values()]},
)
@validate_params(StopAccountParams)
async def _handle_channels_stop(request: RpcRequest) -> RpcResponse:
validated = request._validated_params
channel_type = validated.channel_type
account_id = validated.account_id
if channel_type:
snapshot = await gateway.stop_channel(channel_type, account_id)
return RpcResponse(id=request.id, ok=True, result=snapshot.to_dict())
await gateway.stop_all()
return RpcResponse(id=request.id, ok=True, result={"stopped": True})
async def _handle_channels_restart(request: RpcRequest) -> RpcResponse:
channel_type = request.params.get("channel_type") if request.params else None
account_id = request.params.get("account_id", "default") if request.params else "default"
if not channel_type:
return RpcResponse(
id=request.id,
ok=False,
error_code=None,
error_message="restart 需要指定 channel_type",
)
await gateway.stop_channel(channel_type, account_id)
snapshot = await gateway.start_channel(channel_type, account_id)
return RpcResponse(id=request.id, ok=True, result=snapshot.to_dict())
async def _handle_channels_configure(request: RpcRequest) -> RpcResponse:
params = request.params or {}
channel_type = params.get("channel_type")
account_id = params.get("account_id", "default")
if not channel_type:
return RpcResponse(id=request.id, ok=False, error_message="缺少 channel_type 参数")
try:
snapshot = gateway.get_snapshot(channel_type, account_id)
if snapshot is None:
return RpcResponse(
id=request.id,
ok=False,
error_message=f"渠道 {channel_type}:{account_id} 未找到",
)
config_update = params.get("config", {})
if config_update:
from yuxi.channel.config.reloader import reload_channel_config
await reload_channel_config(channel_type, account_id, config_update)
return RpcResponse(
id=request.id,
ok=True,
result={"channel_type": channel_type, "account_id": account_id, "configured": True},
)
except Exception:
logger.exception("channels.configure failed")
return RpcResponse(
id=request.id,
ok=False,
error_code=GatewayErrorCode.INTERNAL_ERROR,
error_message="channels.configure 执行异常",
)
async def _handle_chat_send(request: RpcRequest) -> RpcResponse:
params = request.params or {}
query = params.get("query", "")
agent_config_id = params.get("agent_config_id")
thread_id = params.get("thread_id")
request_id = str(uuid.uuid4())
if not query:
return RpcResponse(id=request.id, ok=False, error_message="缺少 query 参数")
if not agent_config_id:
return RpcResponse(id=request.id, ok=False, error_message="缺少 agent_config_id 参数")
try:
from yuxi.services.chat_service import agent_chat
async with pg_manager.get_async_session_context() as db:
current_user = await _resolve_caller_user(request, db)
meta = {
"source": "gateway_rpc",
"channel_type": params.get("channel_type", "rpc"),
"account_id": params.get("account_id", "default"),
"request_id": request_id,
}
reply = await agent_chat(
query=query,
agent_config_id=int(agent_config_id),
thread_id=thread_id,
meta=meta,
image_content=params.get("image_content"),
current_user=current_user,
db=db,
)
return RpcResponse(
id=request.id,
ok=True,
result={
"response": reply.get("response", ""),
"thread_id": reply.get("thread_id", thread_id),
"status": reply.get("status", "finished"),
"request_id": request_id,
},
)
except Exception:
logger.exception("chat.send failed")
return RpcResponse(
id=request.id,
ok=False,
error_code=GatewayErrorCode.INTERNAL_ERROR,
error_message="chat.send 执行异常",
)
async def _handle_chat_stream(request: RpcRequest) -> AsyncGenerator[RpcEvent, None]:
params = request.params or {}
query = params.get("query", "")
agent_config_id = params.get("agent_config_id")
thread_id = params.get("thread_id")
request_id = str(uuid.uuid4())
if not query or not agent_config_id:
yield RpcResponse(id=request.id, ok=False, error_message="缺少 query 或 agent_config_id 参数")
return
try:
from yuxi.services.chat_service import stream_agent_chat
async with pg_manager.get_async_session_context() as db:
current_user = await _resolve_caller_user(request, db)
meta = {
"source": "gateway_rpc",
"channel_type": params.get("channel_type", "rpc"),
"account_id": params.get("account_id", "default"),
"peer_id": params.get("peer_id", ""),
"request_id": request_id,
}
async for chunk in stream_agent_chat(
query=query,
agent_config_id=int(agent_config_id),
thread_id=thread_id,
meta=meta,
image_content=params.get("image_content"),
current_user=current_user,
db=db,
):
try:
data = json.loads(chunk.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
continue
yield RpcEvent(
event="chat.chunk",
data={
"id": request.id,
"status": data.get("status", ""),
"response": data.get("response", ""),
"thread_id": data.get("thread_id", thread_id),
"request_id": request_id,
},
)
yield RpcResponse(id=request.id, ok=True, result={"status": "finished", "request_id": request_id})
except Exception:
logger.exception("chat.stream failed")
yield RpcResponse(
id=request.id,
ok=False,
error_code=GatewayErrorCode.INTERNAL_ERROR,
error_message="chat.stream 执行异常",
)
async def _handle_chat_history(request: RpcRequest) -> RpcResponse:
params = request.params or {}
thread_id = params.get("thread_id")
limit = params.get("limit", 50)
offset = params.get("offset", 0)
if not thread_id:
return RpcResponse(id=request.id, ok=False, error_message="缺少 thread_id 参数")
try:
from yuxi.repositories.conversation_repository import ConversationRepository
async with pg_manager.get_async_session_context() as db:
conv_repo = ConversationRepository(db)
messages = await conv_repo.get_messages_by_thread_id(thread_id, limit=limit, offset=offset)
return RpcResponse(
id=request.id,
ok=True,
result={
"thread_id": thread_id,
"messages": [
{
"id": m.id,
"role": m.role,
"content": m.content,
"message_type": m.message_type,
"created_at": m.created_at.isoformat() if m.created_at else None,
}
for m in messages
],
},
)
except Exception:
logger.exception("chat.history failed")
return RpcResponse(
id=request.id,
ok=False,
error_code=GatewayErrorCode.INTERNAL_ERROR,
error_message="chat.history 执行异常",
)
async def _handle_chat_cancel(request: RpcRequest) -> RpcResponse:
params = request.params or {}
run_id = params.get("run_id")
thread_id = params.get("thread_id")
if not run_id and not thread_id:
return RpcResponse(id=request.id, ok=False, error_message="缺少 run_id 或 thread_id 参数")
try:
async with pg_manager.get_async_session_context() as db:
current_user = await _resolve_caller_user(request, db)
current_user_id = str(current_user.id) if current_user else None
if run_id and current_user_id:
from yuxi.services.agent_run_service import cancel_agent_run_view
async with pg_manager.get_async_session_context() as db:
await cancel_agent_run_view(
run_id=run_id,
current_user_id=current_user_id,
db=db,
)
elif thread_id and current_user_id:
from yuxi.services.agent_run_service import get_active_run_by_thread
async with pg_manager.get_async_session_context() as db:
active = await get_active_run_by_thread(
thread_id=thread_id,
current_user_id=current_user_id,
db=db,
)
if active and active.get("run_id"):
from yuxi.services.agent_run_service import cancel_agent_run_view
await cancel_agent_run_view(
run_id=active["run_id"],
current_user_id=current_user_id,
db=db,
)
return RpcResponse(id=request.id, ok=True, result={"cancelled": True})
except Exception:
logger.exception("chat.cancel failed")
return RpcResponse(
id=request.id,
ok=False,
error_code=GatewayErrorCode.INTERNAL_ERROR,
error_message="chat.cancel 执行异常",
)
async def _handle_sessions_list(request: RpcRequest) -> RpcResponse:
params = request.params or {}
limit = params.get("limit", 50)
offset = params.get("offset", 0)
try:
from yuxi.repositories.conversation_repository import ConversationRepository
async with pg_manager.get_async_session_context() as db:
conv_repo = ConversationRepository(db)
conversations = await conv_repo.list_conversations(limit=limit, offset=offset)
return RpcResponse(
id=request.id,
ok=True,
result={
"sessions": [
{
"id": conv.thread_id,
"title": conv.title,
"created_at": conv.created_at.isoformat() if conv.created_at else None,
"updated_at": conv.updated_at.isoformat() if conv.updated_at else None,
}
for conv in conversations
],
},
)
except Exception:
logger.exception("sessions.list failed")
return RpcResponse(
id=request.id,
ok=False,
error_code=GatewayErrorCode.INTERNAL_ERROR,
error_message="sessions.list 执行异常",
)
async def _handle_sessions_history(request: RpcRequest) -> RpcResponse:
params = request.params or {}
thread_id = params.get("thread_id")
limit = params.get("limit", 100)
offset = params.get("offset", 0)
if not thread_id:
return RpcResponse(id=request.id, ok=False, error_message="缺少 thread_id 参数")
try:
from yuxi.repositories.conversation_repository import ConversationRepository
async with pg_manager.get_async_session_context() as db:
conv_repo = ConversationRepository(db)
messages = await conv_repo.get_messages_by_thread_id(thread_id, limit=limit, offset=offset)
return RpcResponse(
id=request.id,
ok=True,
result={
"thread_id": thread_id,
"messages": [
{
"id": m.id,
"role": m.role,
"content": m.content,
"message_type": m.message_type,
"created_at": m.created_at.isoformat() if m.created_at else None,
}
for m in messages
],
},
)
except Exception:
logger.exception("sessions.history failed")
return RpcResponse(
id=request.id,
ok=False,
error_code=GatewayErrorCode.INTERNAL_ERROR,
error_message="sessions.history 执行异常",
)
async def _handle_plugins_list(request: RpcRequest) -> RpcResponse:
from yuxi.channel.plugins.registry import ChannelPluginRegistry
plugins = ChannelPluginRegistry.list_all()
return RpcResponse(
id=request.id,
ok=True,
result={
"plugins": [
{
"id": p.id,
"name": p.name,
"order": p.order,
}
for p in plugins
],
},
)
async def _handle_plugins_install(request: RpcRequest) -> RpcResponse:
return RpcResponse(
id=request.id,
ok=True,
result={"message": "plugins.install: 插件动态安装需要通过 Plugin SDK 完成,当前仅支持内置渠道"},
)
async def _handle_system_config(request: RpcRequest) -> RpcResponse:
snapshots = gateway.get_all_snapshots()
channel_configs = {
channel_type: {
"account_count": len(snapshot.accounts) if hasattr(snapshot, "accounts") else 1,
"status": snapshot.status,
}
for channel_type, snapshot in snapshots.items()
}
return RpcResponse(id=request.id, ok=True, result={"channels": channel_configs})
async def _handle_system_version(request: RpcRequest) -> RpcResponse:
try:
from yuxi import __version__ as yuxi_version
except ImportError:
yuxi_version = "unknown"
return RpcResponse(
id=request.id,
ok=True,
result={"version": yuxi_version, "platform": "yuxi"},
)
async def _handle_system_log_tail(request: RpcRequest) -> RpcResponse:
params = request.params or {}
lines = params.get("lines", 50)
return RpcResponse(
id=request.id,
ok=True,
result={
"message": f"system.log.tail: 请求尾部 {lines} 行日志,请通过 HTTP API /api/logs 获取",
"hint": "use GET /api/logs?tail={lines} instead",
},
)
async def _handle_pairing_list(request: RpcRequest) -> RpcResponse:
params = request.params or {}
channel_type = params.get("channel_type")
account_id = params.get("account_id", "default")
if not channel_type:
return RpcResponse(id=request.id, ok=False, error_message="缺少 channel_type 参数")
try:
from yuxi.channel.security.pairing import PairingManager
manager = PairingManager()
pending = await manager.list_pending(channel_type, account_id)
return RpcResponse(
id=request.id,
ok=True,
result={
"pending": [
{
"channel_type": r.channel_type,
"peer_id": r.peer_id,
"account_id": r.account_id,
"code": r.code,
"created_at": r.created_at,
}
for r in pending
],
},
)
except Exception:
logger.exception("pairing.list failed")
return RpcResponse(
id=request.id,
ok=False,
error_code=GatewayErrorCode.INTERNAL_ERROR,
error_message="pairing.list 执行异常",
)
async def _handle_pairing_approve(request: RpcRequest) -> RpcResponse:
params = request.params or {}
channel_type = params.get("channel_type")
peer_id = params.get("peer_id")
account_id = params.get("account_id", "default")
if not channel_type or not peer_id:
return RpcResponse(id=request.id, ok=False, error_message="缺少 channel_type 或 peer_id 参数")
try:
from yuxi.channel.security.allowlist import get_allowlist_checker
from yuxi.channel.security.pairing import PairingManager
allowlist = await get_allowlist_checker()
manager = PairingManager(allowlist=allowlist)
await manager.approve(channel_type, peer_id, account_id)
return RpcResponse(
id=request.id,
ok=True,
result={"approved": True, "peer_id": peer_id, "channel_type": channel_type},
)
except Exception:
logger.exception("pairing.approve failed")
return RpcResponse(
id=request.id,
ok=False,
error_code=GatewayErrorCode.INTERNAL_ERROR,
error_message="pairing.approve 执行异常",
)
async def _handle_pairing_reject(request: RpcRequest) -> RpcResponse:
params = request.params or {}
channel_type = params.get("channel_type")
peer_id = params.get("peer_id")
account_id = params.get("account_id", "default")
if not channel_type or not peer_id:
return RpcResponse(id=request.id, ok=False, error_message="缺少 channel_type 或 peer_id 参数")
try:
from yuxi.channel.security.allowlist import get_allowlist_checker
from yuxi.channel.security.pairing import PairingManager
allowlist = await get_allowlist_checker()
manager = PairingManager(allowlist=allowlist)
await manager.reject(channel_type, peer_id, account_id)
return RpcResponse(
id=request.id,
ok=True,
result={"rejected": True, "peer_id": peer_id, "channel_type": channel_type},
)
except Exception:
logger.exception("pairing.reject failed")
return RpcResponse(
id=request.id,
ok=False,
error_code=GatewayErrorCode.INTERNAL_ERROR,
error_message="pairing.reject 执行异常",
)
async def _handle_allowlist_get(request: RpcRequest) -> RpcResponse:
params = request.params or {}
channel_type = params.get("channel_type")
if not channel_type:
return RpcResponse(id=request.id, ok=False, error_message="缺少 channel_type 参数")
try:
from yuxi.channel.security.allowlist import get_allowlist_checker
checker = await get_allowlist_checker()
await checker.ensure_loaded(channel_type)
result = {
"channel_type": channel_type,
"dm_policy": checker.dm_policy.value,
"group_policy": checker.group_policy.value,
"dm_entries": checker.list_entries(channel_type, "dm"),
"group_entries": checker.list_entries(channel_type, "group"),
"allow_from_entries": checker.list_entries(channel_type, "allow_from"),
}
return RpcResponse(id=request.id, ok=True, result=result)
except Exception:
logger.exception("allowlist.get failed")
return RpcResponse(
id=request.id,
ok=False,
error_code=GatewayErrorCode.INTERNAL_ERROR,
error_message="allowlist.get 执行异常",
)
async def _handle_allowlist_add(request: RpcRequest) -> RpcResponse:
params = request.params or {}
channel_type = params.get("channel_type")
list_type = params.get("list_type", "dm")
entry = params.get("entry")
if not channel_type or not entry:
return RpcResponse(id=request.id, ok=False, error_message="缺少 channel_type 或 entry 参数")
try:
from yuxi.channel.security.allowlist import get_allowlist_checker
checker = await get_allowlist_checker()
await checker.ensure_loaded(channel_type)
await checker.add_entry(channel_type, list_type, entry)
return RpcResponse(
id=request.id,
ok=True,
result={"message": f"{entry} 添加到 {channel_type}{list_type} 白名单", "added": True},
)
except Exception:
logger.exception("allowlist.add failed")
return RpcResponse(
id=request.id,
ok=False,
error_code=GatewayErrorCode.INTERNAL_ERROR,
error_message="allowlist.add 执行异常",
)
async def _handle_allowlist_remove(request: RpcRequest) -> RpcResponse:
params = request.params or {}
channel_type = params.get("channel_type")
list_type = params.get("list_type", "dm")
entry = params.get("entry")
if not channel_type or not entry:
return RpcResponse(id=request.id, ok=False, error_message="缺少 channel_type 或 entry 参数")
try:
from yuxi.channel.security.allowlist import get_allowlist_checker
checker = await get_allowlist_checker()
await checker.ensure_loaded(channel_type)
await checker.remove_entry(channel_type, list_type, entry)
return RpcResponse(
id=request.id,
ok=True,
result={"message": f"{channel_type}{list_type} 白名单移除 {entry}", "removed": True},
)
except Exception:
logger.exception("allowlist.remove failed")
return RpcResponse(
id=request.id,
ok=False,
error_code=GatewayErrorCode.INTERNAL_ERROR,
error_message="allowlist.remove 执行异常",
)
async def _handle_cron_list(request: RpcRequest) -> RpcResponse:
from yuxi.channel.cron.engine import get_cron_engine
engine = get_cron_engine()
jobs = engine.list_jobs()
return RpcResponse(
id=request.id,
ok=True,
result={"jobs": [j.to_dict() for j in jobs]},
)
async def _handle_cron_create(request: RpcRequest) -> RpcResponse:
params = request.params or {}
job_id = params.get("id")
name = params.get("name", "")
cron_expr = params.get("cron_expr", "*/5 * * * *")
handler_ref = params.get("handler", "")
if not job_id:
return RpcResponse(id=request.id, ok=False, error_message="缺少 id 参数")
from yuxi.channel.cron.engine import CronJob, get_cron_engine
from yuxi.channel.cron.handler_registry import register_handler
from yuxi.channel.cron.types import CronDeliveryTarget, CronFailureAlert, ScheduleKind
engine = get_cron_engine()
schedule_kind_raw = params.get("schedule_kind", "cron")
try:
schedule_kind = ScheduleKind(schedule_kind_raw)
except ValueError:
schedule_kind = ScheduleKind.CRON
schedule_value = params.get("schedule_value", cron_expr)
schedule_tz = params.get("schedule_tz")
stagger_ms = params.get("stagger_ms", 0)
every_ms = params.get("every_ms")
anchor_ms = params.get("anchor_ms")
max_runs = params.get("max_runs", 0)
delete_after_run = params.get("delete_after_run", False)
delivery_raw = params.get("delivery", [])
delivery = [CronDeliveryTarget.from_dict(t) for t in delivery_raw if isinstance(t, dict)]
failure_alert = CronFailureAlert.from_dict(params.get("failure_alert"))
handler_name = params.get("handler_name", handler_ref or job_id)
handler = _build_cron_handler(handler_ref, params.get("handler_args", {}))
try:
job = CronJob(
id=job_id,
name=name,
handler_name=handler_name,
handler=handler,
handler_args=params.get("handler_args", {}),
schedule_kind=schedule_kind,
schedule_value=schedule_value,
schedule_tz=schedule_tz,
stagger_ms=stagger_ms,
every_ms=every_ms,
anchor_ms=anchor_ms,
max_runs=max_runs,
delete_after_run=delete_after_run,
delivery=delivery,
failure_alert=failure_alert,
)
engine.add_job(job)
if handler_ref:
register_handler(handler_name, handler)
return RpcResponse(
id=request.id,
ok=True,
result={"job": job.to_dict()},
)
except Exception as e:
return RpcResponse(id=request.id, ok=False, error_message=str(e))
async def _handle_cron_delete(request: RpcRequest) -> RpcResponse:
params = request.params or {}
job_id = params.get("id")
if not job_id:
return RpcResponse(id=request.id, ok=False, error_message="缺少 id 参数")
from yuxi.channel.cron.engine import get_cron_engine
engine = get_cron_engine()
engine.remove_job(job_id)
return RpcResponse(id=request.id, ok=True, result={"deleted": True})
async def _handle_cron_update(request: RpcRequest) -> RpcResponse:
params = dict(request.params or {})
job_id = params.pop("id", None)
if not job_id:
return RpcResponse(id=request.id, ok=False, error_message="缺少 id 参数")
from yuxi.channel.cron.engine import get_cron_engine
engine = get_cron_engine()
job = engine.update_job(job_id, **params)
if job is None:
return RpcResponse(id=request.id, ok=False, error_message=f"任务 {job_id} 不存在")
return RpcResponse(id=request.id, ok=True, result={"job": job.to_dict()})
async def _handle_cron_force_run(request: RpcRequest) -> RpcResponse:
params = request.params or {}
job_id = params.get("id")
if not job_id:
return RpcResponse(id=request.id, ok=False, error_message="缺少 id 参数")
from yuxi.channel.cron.engine import get_cron_engine
engine = get_cron_engine()
result = await engine.force_run(job_id)
return RpcResponse(id=request.id, ok=True, result=result)
async def _handle_cron_pause(request: RpcRequest) -> RpcResponse:
params = request.params or {}
job_id = params.get("id")
if not job_id:
return RpcResponse(id=request.id, ok=False, error_message="缺少 id 参数")
from yuxi.channel.cron.engine import get_cron_engine
engine = get_cron_engine()
engine.pause_job(job_id)
return RpcResponse(id=request.id, ok=True, result={"paused": True})
async def _handle_cron_resume(request: RpcRequest) -> RpcResponse:
params = request.params or {}
job_id = params.get("id")
if not job_id:
return RpcResponse(id=request.id, ok=False, error_message="缺少 id 参数")
from yuxi.channel.cron.engine import get_cron_engine
engine = get_cron_engine()
engine.resume_job(job_id)
return RpcResponse(id=request.id, ok=True, result={"resumed": True})
async def _handle_cron_diagnostics(request: RpcRequest) -> RpcResponse:
params = request.params or {}
job_id = params.get("id")
limit = int(params.get("limit", 50))
offset = int(params.get("offset", 0))
from yuxi.channel.cron.engine import get_cron_engine
engine = get_cron_engine()
result = engine.read_diagnostics(job_id=job_id, limit=limit, offset=offset)
return RpcResponse(id=request.id, ok=True, result=result)
async def _handle_cron_run_log(request: RpcRequest) -> RpcResponse:
params = request.params or {}
job_id = params.get("id")
if not job_id:
return RpcResponse(id=request.id, ok=False, error_message="缺少 id 参数")
limit = int(params.get("limit", 50))
offset = int(params.get("offset", 0))
from yuxi.channel.cron.engine import get_cron_engine
engine = get_cron_engine()
result = engine.read_run_log(job_id=job_id, limit=limit, offset=offset)
return RpcResponse(id=request.id, ok=True, result=result)
def _build_cron_handler(handler_ref: str, args: dict):
if handler_ref == "health_check":
async def _health_check(**kwargs):
from yuxi.channel.runtime.manager import gateway
report = gateway.get_health()
return {
"status": report.status,
"summary": {
"total": report.summary.total,
"running": report.summary.running,
},
}
return _health_check
if handler_ref == "channel_heartbeat":
async def _heartbeat(**kwargs):
channel_type = kwargs.get("channel_type", "")
from yuxi.channel.protocols import HeartbeatProtocol
from yuxi.channel.plugins.registry import ChannelPluginRegistry
plugin = ChannelPluginRegistry.get(channel_type)
if plugin is not None and isinstance(plugin, HeartbeatProtocol):
ok = await plugin.on_ping({})
return {"ok": ok, "channel_type": channel_type}
return {"ok": False, "error": f"Channel {channel_type} not found"}
return _heartbeat
async def _noop(**kwargs):
return {"message": f"noop handler: {handler_ref}"}
return _noop
async def _handle_agent_tools_list(request: RpcRequest) -> RpcResponse:
params = request.params or {}
channel_type = params.get("channel_type")
if not channel_type:
return RpcResponse(id=request.id, ok=False, error_message="缺少 channel_type 参数")
from yuxi.channel.message.bridge import AgentBridge
bridge = AgentBridge(lambda **kw: _empty_stream(), None)
tools = bridge.collect_channel_tools(channel_type)
return RpcResponse(id=request.id, ok=True, result={"tools": tools})
async def _handle_agent_tools_execute(request: RpcRequest) -> RpcResponse:
params = request.params or {}
channel_type = params.get("channel_type")
tool_name = params.get("tool_name")
tool_params = params.get("params", {})
context = params.get("context", {})
if not channel_type or not tool_name:
return RpcResponse(id=request.id, ok=False, error_message="缺少 channel_type 或 tool_name 参数")
from yuxi.channel.message.bridge import AgentBridge
bridge = AgentBridge(lambda **kw: _empty_stream(), None)
result = await bridge.execute_channel_tool(channel_type, tool_name, tool_params, context)
return RpcResponse(id=request.id, ok=True, result=result)
async def _empty_stream():
if False:
yield b""
async def _handle_sessions_create(request: RpcRequest) -> RpcResponse:
params = request.params or {}
agent_id = params.get("agent_id", "default")
title = params.get("title", "新对话")
thread_id = params.get("thread_id")
user_id = params.get("user_id")
try:
from yuxi.repositories.conversation_repository import ConversationRepository
async with pg_manager.get_async_session_context() as db:
conv_repo = ConversationRepository(db)
if not user_id:
current_user = await _resolve_caller_user(request, db)
user_id = str(current_user.id) if current_user else "system"
conversation = await conv_repo.create_conversation(
user_id=user_id,
agent_id=agent_id,
title=title,
thread_id=thread_id,
metadata=params.get("metadata"),
)
return RpcResponse(
id=request.id,
ok=True,
result={
"key": conversation.thread_id,
"session_id": conversation.thread_id,
"title": conversation.title,
"agent_id": agent_id,
"created_at": conversation.created_at.isoformat() if conversation.created_at else None,
},
)
except Exception:
logger.exception("sessions.create failed")
return RpcResponse(
id=request.id,
ok=False,
error_code=GatewayErrorCode.INTERNAL_ERROR,
error_message="sessions.create 执行异常",
)
async def _handle_sessions_delete(request: RpcRequest) -> RpcResponse:
params = request.params or {}
thread_id = params.get("thread_id")
if not thread_id:
return RpcResponse(id=request.id, ok=False, error_message="缺少 thread_id 参数")
try:
from yuxi.repositories.conversation_repository import ConversationRepository
async with pg_manager.get_async_session_context() as db:
conv_repo = ConversationRepository(db)
success = await conv_repo.delete_conversation(thread_id, soft_delete=True)
return RpcResponse(
id=request.id,
ok=True,
result={"key": thread_id, "deleted": success},
)
except Exception:
logger.exception("sessions.delete failed")
return RpcResponse(
id=request.id,
ok=False,
error_code=GatewayErrorCode.INTERNAL_ERROR,
error_message="sessions.delete 执行异常",
)
async def _handle_config_get(request: RpcRequest) -> RpcResponse:
params = request.params or {}
channel_type = params.get("channel_type")
try:
from yuxi.repositories.channel_config_repo import ChannelConfigRepository
repo = ChannelConfigRepository()
if channel_type:
configs = await repo.list_by_type(channel_type)
else:
configs = await repo.list_all()
return RpcResponse(
id=request.id,
ok=True,
result={
"configs": [
c.to_dict()
if hasattr(c, "to_dict")
else {"id": str(c.id), "channel_type": c.channel_type, "enabled": c.enabled}
for c in configs
],
},
)
except Exception:
logger.exception("config.get failed")
return RpcResponse(
id=request.id,
ok=False,
error_code=GatewayErrorCode.INTERNAL_ERROR,
error_message="config.get 执行异常",
)
async def _handle_config_set(request: RpcRequest) -> RpcResponse:
params = request.params or {}
config_id = params.get("config_id")
channel_type = params.get("channel_type")
config_data = params.get("config", {})
if not config_id and not channel_type:
return RpcResponse(id=request.id, ok=False, error_message="缺少 config_id 或 channel_type 参数")
try:
from yuxi.repositories.channel_config_repo import ChannelConfigRepository
repo = ChannelConfigRepository()
if config_id:
updated = await repo.update(config_id, config_data)
affected_channel_type = updated.channel_type if updated else channel_type
affected_account_id = config_id
else:
configs = await repo.list_by_type(channel_type)
if not configs:
return RpcResponse(
id=request.id,
ok=False,
error_message=f"渠道 {channel_type} 未找到配置",
)
updated = await repo.update(str(configs[0].id), config_data)
affected_channel_type = channel_type
affected_account_id = str(configs[0].id) if configs else None
result = RpcResponse(
id=request.id,
ok=True,
result={
"updated": True,
"config": updated.to_dict() if updated and hasattr(updated, "to_dict") else None,
},
)
if updated and affected_channel_type and affected_account_id:
try:
from yuxi.channel.config.reloader import reload_channel_config
await reload_channel_config(affected_channel_type, affected_account_id, config_data)
except Exception:
logger.exception(
"config.set: reload_channel_config failed for %s:%s",
affected_channel_type,
affected_account_id,
)
return result
except Exception:
logger.exception("config.set failed")
return RpcResponse(
id=request.id,
ok=False,
error_code=GatewayErrorCode.INTERNAL_ERROR,
error_message="config.set 执行异常",
)
@validate_params(SendMessageParams)
async def _handle_message_send(request: RpcRequest) -> RpcResponse:
validated = request._validated_params
channel_type = validated.channel_type
target_id = validated.target_id
content = validated.text
account_id = validated.account_id
reply_to_id = request.params.get("reply_to_id") if request.params else None
thread_id = request.params.get("thread_id") if request.params else None
try:
from yuxi.channel.protocols import OutboundProtocol
from yuxi.channel.plugins.registry import ChannelPluginRegistry
plugin = ChannelPluginRegistry.get(channel_type)
if plugin is None:
return RpcResponse(
id=request.id,
ok=False,
error_message=f"渠道 {channel_type} 未注册",
)
if not isinstance(plugin, OutboundProtocol):
return RpcResponse(
id=request.id,
ok=False,
error_message=f"渠道 {channel_type} 不支持出站消息发送",
)
await plugin.send_text(
target_id=target_id,
content=content,
reply_to_id=reply_to_id,
thread_id=thread_id,
account_id=account_id,
)
return RpcResponse(
id=request.id,
ok=True,
result={
"channel": channel_type,
"to": target_id,
"sent": True,
},
)
except Exception:
logger.exception("message.send failed")
return RpcResponse(
id=request.id,
ok=False,
error_code=GatewayErrorCode.INTERNAL_ERROR,
error_message="message.send 执行异常",
)
async def _handle_message_action(request: RpcRequest) -> RpcResponse:
params = request.params or {}
channel_type = params.get("channel_type")
action = params.get("action", "")
action_params = params.get("params", {})
context = params.get("context", {})
if not channel_type:
return RpcResponse(id=request.id, ok=False, error_message="缺少 channel_type 参数")
if not action:
return RpcResponse(id=request.id, ok=False, error_message="缺少 action 参数")
try:
from yuxi.channel.sdk.actions.dispatch import (
DispatchContext,
dispatch_message_action,
)
ctx = DispatchContext(
channel_type=channel_type,
action=action,
params=action_params,
account_id=params.get("account_id", "default"),
requester_sender_id=context.get("sender_id"),
session_key=context.get("session_key"),
agent_id=context.get("agent_id"),
)
result = await dispatch_message_action(ctx)
return RpcResponse(
id=request.id,
ok=result["success"],
result={
"channel": channel_type,
"action": action,
"handled": result["success"],
"message": result.get("message", ""),
"data": result.get("data", {}),
} if result["success"] else None,
error_message=result.get("message") if not result["success"] else None,
)
except Exception:
logger.exception("message.action failed")
return RpcResponse(
id=request.id,
ok=False,
error_code=GatewayErrorCode.INTERNAL_ERROR,
error_message="message.action 执行异常",
)
async def _handle_identity_links_list(request: RpcRequest) -> RpcResponse:
from yuxi.channel.security.identity_link import IdentityLinkResolver
resolver = IdentityLinkResolver()
await resolver.ensure_loaded()
return RpcResponse(id=request.id, ok=True, result={
"links": resolver.list_links(),
})
async def _handle_identity_links_add(request: RpcRequest) -> RpcResponse:
params = request.params or {}
identity = params.get("identity")
channel_type = params.get("channel_type")
peer_id = params.get("peer_id")
if not all([identity, channel_type, peer_id]):
return RpcResponse(id=request.id, ok=False, error_message="缺少 identity、channel_type 或 peer_id 参数")
from yuxi.channel.security.identity_link import IdentityLinkResolver
resolver = IdentityLinkResolver()
await resolver.ensure_loaded()
await resolver.add_link(identity, channel_type, peer_id)
return RpcResponse(id=request.id, ok=True, result={"added": True})
async def _handle_identity_links_remove(request: RpcRequest) -> RpcResponse:
params = request.params or {}
channel_type = params.get("channel_type")
peer_id = params.get("peer_id")
if not channel_type or not peer_id:
return RpcResponse(id=request.id, ok=False, error_message="缺少 channel_type 或 peer_id 参数")
from yuxi.channel.security.identity_link import IdentityLinkResolver
resolver = IdentityLinkResolver()
await resolver.ensure_loaded()
await resolver.remove_link(channel_type, peer_id)
return RpcResponse(id=request.id, ok=True, result={"removed": True})
def register_all_handlers() -> None:
rpc_dispatcher.register(GatewayRpcMethod.SYSTEM_HEALTH, _handle_health)
rpc_dispatcher.register(GatewayRpcMethod.SYSTEM_CONFIG, _handle_system_config)
rpc_dispatcher.register(GatewayRpcMethod.SYSTEM_VERSION, _handle_system_version)
rpc_dispatcher.register(GatewayRpcMethod.SYSTEM_LOG_TAIL, _handle_system_log_tail)
rpc_dispatcher.register(GatewayRpcMethod.CHANNELS_LIST, _handle_channels_list)
rpc_dispatcher.register(GatewayRpcMethod.CHANNELS_STATUS, _handle_channels_status)
rpc_dispatcher.register(GatewayRpcMethod.CHANNELS_PROBE, _handle_channels_probe)
rpc_dispatcher.register(GatewayRpcMethod.CHANNELS_START, _handle_channels_start)
rpc_dispatcher.register(GatewayRpcMethod.CHANNELS_STOP, _handle_channels_stop)
rpc_dispatcher.register(GatewayRpcMethod.CHANNELS_RESTART, _handle_channels_restart)
rpc_dispatcher.register(GatewayRpcMethod.CHANNELS_CONFIGURE, _handle_channels_configure)
rpc_dispatcher.register(GatewayRpcMethod.CHAT_SEND, _handle_chat_send)
rpc_dispatcher.register_stream(GatewayRpcMethod.CHAT_STREAM, _handle_chat_stream)
rpc_dispatcher.register(GatewayRpcMethod.CHAT_HISTORY, _handle_chat_history)
rpc_dispatcher.register(GatewayRpcMethod.CHAT_CANCEL, _handle_chat_cancel)
rpc_dispatcher.register(GatewayRpcMethod.SESSIONS_LIST, _handle_sessions_list)
rpc_dispatcher.register(GatewayRpcMethod.SESSIONS_HISTORY, _handle_sessions_history)
rpc_dispatcher.register(GatewayRpcMethod.PLUGINS_LIST, _handle_plugins_list)
rpc_dispatcher.register(GatewayRpcMethod.PLUGINS_INSTALL, _handle_plugins_install)
rpc_dispatcher.register(GatewayRpcMethod.PAIRING_LIST, _handle_pairing_list)
rpc_dispatcher.register(GatewayRpcMethod.PAIRING_APPROVE, _handle_pairing_approve)
rpc_dispatcher.register(GatewayRpcMethod.PAIRING_REJECT, _handle_pairing_reject)
rpc_dispatcher.register(GatewayRpcMethod.ALLOWLIST_GET, _handle_allowlist_get)
rpc_dispatcher.register(GatewayRpcMethod.ALLOWLIST_ADD, _handle_allowlist_add)
rpc_dispatcher.register(GatewayRpcMethod.ALLOWLIST_REMOVE, _handle_allowlist_remove)
rpc_dispatcher.register(GatewayRpcMethod.CRON_LIST, _handle_cron_list)
rpc_dispatcher.register(GatewayRpcMethod.CRON_CREATE, _handle_cron_create)
rpc_dispatcher.register(GatewayRpcMethod.CRON_UPDATE, _handle_cron_update)
rpc_dispatcher.register(GatewayRpcMethod.CRON_DELETE, _handle_cron_delete)
rpc_dispatcher.register(GatewayRpcMethod.CRON_FORCE_RUN, _handle_cron_force_run)
rpc_dispatcher.register(GatewayRpcMethod.CRON_PAUSE, _handle_cron_pause)
rpc_dispatcher.register(GatewayRpcMethod.CRON_RESUME, _handle_cron_resume)
rpc_dispatcher.register(GatewayRpcMethod.CRON_DIAGNOSTICS, _handle_cron_diagnostics)
rpc_dispatcher.register(GatewayRpcMethod.CRON_RUN_LOG, _handle_cron_run_log)
rpc_dispatcher.register(GatewayRpcMethod.AGENT_TOOLS_LIST, _handle_agent_tools_list)
rpc_dispatcher.register(GatewayRpcMethod.AGENT_TOOLS_EXECUTE, _handle_agent_tools_execute)
rpc_dispatcher.register(GatewayRpcMethod.SESSIONS_CREATE, _handle_sessions_create)
rpc_dispatcher.register(GatewayRpcMethod.SESSIONS_DELETE, _handle_sessions_delete)
rpc_dispatcher.register(GatewayRpcMethod.CONFIG_GET, _handle_config_get)
rpc_dispatcher.register(GatewayRpcMethod.CONFIG_SET, _handle_config_set)
rpc_dispatcher.register(GatewayRpcMethod.MESSAGE_SEND, _handle_message_send)
rpc_dispatcher.register(GatewayRpcMethod.MESSAGE_ACTION, _handle_message_action)
rpc_dispatcher.register(GatewayRpcMethod.IDENTITY_LINKS_LIST, _handle_identity_links_list)
rpc_dispatcher.register(GatewayRpcMethod.IDENTITY_LINKS_ADD, _handle_identity_links_add)
rpc_dispatcher.register(GatewayRpcMethod.IDENTITY_LINKS_REMOVE, _handle_identity_links_remove)
rpc_dispatcher.add_alias("health", GatewayRpcMethod.SYSTEM_HEALTH)
rpc_dispatcher.add_alias("config", GatewayRpcMethod.SYSTEM_CONFIG)
rpc_dispatcher.add_alias("version", GatewayRpcMethod.SYSTEM_VERSION)
rpc_dispatcher.add_alias("chat.message", GatewayRpcMethod.CHAT_SEND)
rpc_dispatcher.add_alias("channels", GatewayRpcMethod.CHANNELS_LIST)
rpc_dispatcher.add_alias("channels.start", GatewayRpcMethod.CHANNELS_START)
rpc_dispatcher.add_alias("channels.stop", GatewayRpcMethod.CHANNELS_STOP)
rpc_dispatcher.add_alias("channels.restart", GatewayRpcMethod.CHANNELS_RESTART)
rpc_dispatcher.add_alias("sessions", GatewayRpcMethod.SESSIONS_LIST)
logger.info("Gateway RPC handlers registered: %s", rpc_dispatcher.list_methods())