feat(channel): 添加全渠道网关核心基础架构
新增渠道扩展框架的核心基础层,为所有渠道扩展提供统一的基础设施。 主要变更: - channel/extensions/__init__.py: 渠道扩展包初始化与注册 - channel/extensions/base.py: 渠道插件基类,定义统一接口规范 - channel_service.py: 渠道服务核心业务逻辑 - models_channels.py: 渠道相关数据库模型 - 8 个 channel_*_repo.py: 渠道数据仓库层 - storage/postgres/manager.py: 数据库管理器,新增渠道表 - models_business.py: 业务模型更新 - config/app.py: 应用配置更新 - conversation_repository.py: 会话仓库更新 - oidc_service.py: OIDC 服务更新 - logging_config.py: 日志配置更新
This commit is contained in:
parent
d5e36d33b7
commit
fb754d82f1
61
backend/package/yuxi/channel/extensions/__init__.py
Normal file
61
backend/package/yuxi/channel/extensions/__init__.py
Normal file
@ -0,0 +1,61 @@
|
||||
import logging
|
||||
|
||||
try:
|
||||
from yuxi.channel.extensions import irc # noqa
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from yuxi.channel.extensions import nostr # noqa
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from yuxi.channel.extensions import wecom # noqa
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from yuxi.channel.extensions import activitypub # noqa
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from yuxi.channel.extensions import kuaishou # noqa
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from yuxi.channel.extensions import tencent_im # noqa
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from yuxi.channel.extensions import xiaohongshu # noqa
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
from yuxi.channel.plugins.registry import ChannelPluginRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def init_builtin_channels():
|
||||
logger.info("Initializing built-in channel plugins...")
|
||||
|
||||
from yuxi.channel.plugins.discovery import run_discovery_pipeline
|
||||
|
||||
result = run_discovery_pipeline()
|
||||
logger.info(
|
||||
"Plugin discovery complete: %d discovered, %d skipped, %d errors",
|
||||
sum(1 for d in result.discovered if not d.already_registered),
|
||||
len(result.skipped),
|
||||
len(result.errors),
|
||||
)
|
||||
|
||||
plugins = ChannelPluginRegistry.list_all()
|
||||
logger.info(
|
||||
"Registered %d channel plugins: %s",
|
||||
len(plugins),
|
||||
[p.id for p in plugins],
|
||||
)
|
||||
611
backend/package/yuxi/channel/extensions/base.py
Normal file
611
backend/package/yuxi/channel/extensions/base.py
Normal file
@ -0,0 +1,611 @@
|
||||
from yuxi.channel.capabilities import ChannelCapabilities
|
||||
|
||||
|
||||
class BaseChannelPlugin:
|
||||
"""可选的渠道插件基类,提供默认实现。渠道不强制继承,满足 Protocol 即可。"""
|
||||
|
||||
id: str = ""
|
||||
name: str = ""
|
||||
order: int = 99
|
||||
label: str | None = None
|
||||
aliases: list[str] | None = None
|
||||
resolve_reply_to_mode: str | None = None
|
||||
|
||||
@property
|
||||
def capabilities(self) -> ChannelCapabilities:
|
||||
return ChannelCapabilities()
|
||||
|
||||
async def resolve_account(self, account_id: str) -> dict:
|
||||
return {}
|
||||
|
||||
async def list_accounts(self) -> list[dict]:
|
||||
return []
|
||||
|
||||
def list_account_ids(self, config: dict) -> list[str]:
|
||||
return ["default"]
|
||||
|
||||
def is_configured(self, account: dict) -> bool:
|
||||
return bool(account)
|
||||
|
||||
def is_enabled(self, account: dict) -> bool:
|
||||
return True
|
||||
|
||||
def disabled_reason(self, account: dict) -> str:
|
||||
return ""
|
||||
|
||||
async def resolve_allow_from(self, config: dict, account_id: str) -> list[str] | None:
|
||||
return None
|
||||
|
||||
def describe_account(self, account: dict) -> dict:
|
||||
return {"account_id": account.get("account_id", "")}
|
||||
|
||||
async def start(self, ctx) -> object:
|
||||
pass
|
||||
|
||||
async def stop(self, ctx) -> None:
|
||||
pass
|
||||
|
||||
async def send_text(
|
||||
self,
|
||||
target_id: str,
|
||||
content: str,
|
||||
*,
|
||||
reply_to_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
account_id: str | None = None,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
async def send_media(
|
||||
self,
|
||||
target_id: str,
|
||||
media_url: str,
|
||||
media_type: str,
|
||||
reply_to_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
async def probe(self, account: dict) -> bool:
|
||||
return True
|
||||
|
||||
def build_summary(self, snapshot: object) -> dict:
|
||||
return {}
|
||||
|
||||
async def resolve_thread_id(self, account_id: str, reply_to_id: str | None) -> str | None:
|
||||
return None
|
||||
|
||||
async def send_typing(self, target_id: str, thread_id: str | None = None) -> None:
|
||||
pass
|
||||
|
||||
async def clear_typing(self, target_id: str, thread_id: str | None = None) -> None:
|
||||
pass
|
||||
|
||||
async def check_ready(self, account_id: str) -> bool:
|
||||
return True
|
||||
|
||||
# ── HeartbeatProtocol ────────────────────────────────
|
||||
|
||||
@property
|
||||
def ping_interval_ms(self) -> int:
|
||||
return 30000
|
||||
|
||||
async def on_ping(self, account: dict) -> bool:
|
||||
return True
|
||||
|
||||
async def start_typing(self, target_id: str, account: dict) -> object:
|
||||
return None
|
||||
|
||||
async def stop_typing(self, handle: object) -> None:
|
||||
pass
|
||||
|
||||
async def typing_indicator(
|
||||
self,
|
||||
target_id: str,
|
||||
account: dict,
|
||||
_stop_event,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
# ── AgentPromptProtocol ──────────────────────────────
|
||||
|
||||
def build_system_prompt(self, context) -> str | None:
|
||||
return None
|
||||
|
||||
def build_context_note(self, context) -> str:
|
||||
return ""
|
||||
|
||||
@property
|
||||
def channel_format_instructions(self) -> str | None:
|
||||
return None
|
||||
|
||||
# ── ThreadingProtocol ────────────────────────────────
|
||||
|
||||
def extract_thread_id(self, msg: object) -> str | None:
|
||||
return None
|
||||
|
||||
def resolve_reply_transport(self, msg: object, thread_id: str | None):
|
||||
from yuxi.channel.protocols import ReplyTransport
|
||||
|
||||
return ReplyTransport()
|
||||
|
||||
def get_thread_info(self, thread_id: str):
|
||||
return None
|
||||
|
||||
# ── MessagingProtocol ────────────────────────────────
|
||||
|
||||
def resolve_session(self, msg: object):
|
||||
from yuxi.channel.message.models import PeerKind
|
||||
from yuxi.channel.protocols import SessionResolution
|
||||
|
||||
if hasattr(msg, "sender") and hasattr(msg.sender, "kind"):
|
||||
if msg.sender.kind == PeerKind.DIRECT:
|
||||
return SessionResolution(kind="direct", conversation_id=msg.sender.id)
|
||||
gid = msg.group.id if hasattr(msg, "group") and msg.group and msg.group.id else "unknown"
|
||||
return SessionResolution(kind="group", conversation_id=gid)
|
||||
|
||||
def parse_explicit_target(self, content: str) -> str | None:
|
||||
return None
|
||||
|
||||
def build_reply_payload(
|
||||
self,
|
||||
target_id: str,
|
||||
content: str,
|
||||
*,
|
||||
reply_to_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
session_key: str | None = None,
|
||||
agent_config_id: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
from yuxi.channel.protocols import BoundReplyPayload
|
||||
|
||||
return BoundReplyPayload(target_id=target_id, content=content, reply_to_id=reply_to_id, thread_id=thread_id)
|
||||
|
||||
def build_attachment_refs(self, attachments: list[dict]) -> str:
|
||||
lines = []
|
||||
for att in attachments:
|
||||
ft = att.get("file_type", "file")
|
||||
fp = att.get("file_path", "")
|
||||
label = {"image": "图片", "video": "视频"}.get(ft, "文件")
|
||||
lines.append(f"[{label}: {fp}]")
|
||||
return "\n".join(lines)
|
||||
|
||||
def resolve_focused_binding(self, msg: object, session, current_binding: object) -> dict | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def supported_explicit_targets(self) -> list[str]:
|
||||
return []
|
||||
|
||||
# ── AgentToolProtocol ────────────────────────────────
|
||||
|
||||
def get_agent_tools(self) -> list:
|
||||
return []
|
||||
|
||||
async def execute_agent_tool(self, tool_name: str, params: dict, context: dict) -> dict:
|
||||
return {"success": False, "error": f"Unknown tool: {tool_name}"}
|
||||
|
||||
# ── MessageActionProtocol ────────────────────────────
|
||||
|
||||
@property
|
||||
def actions(self):
|
||||
"""消息动作适配器。子类重写以注册支持的动作。
|
||||
|
||||
用法::
|
||||
|
||||
class MyPlugin(BaseChannelPlugin):
|
||||
@property
|
||||
def actions(self):
|
||||
if self._actions is None:
|
||||
self._actions = MessageActionRegistry()
|
||||
self._actions.register(
|
||||
MessageAction.SEND,
|
||||
self._handle_send,
|
||||
description="发送文本消息",
|
||||
)
|
||||
return self._actions
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_message_actions(self) -> list:
|
||||
if self.actions:
|
||||
return self.actions.get_message_actions()
|
||||
return []
|
||||
|
||||
async def execute_message_action(self, action: str, params: dict, context: dict) -> dict:
|
||||
if self.actions:
|
||||
result = await self.actions.execute_action(action, params, context)
|
||||
return result
|
||||
return {"success": False, "error": f"Unknown action: {action}"}
|
||||
|
||||
# ── SecurityProtocol ────────────────────────────────
|
||||
|
||||
async def check_allowlist(self, peer_id: str, channel_type: str) -> bool:
|
||||
return True
|
||||
|
||||
def resolve_dm_policy(self) -> dict:
|
||||
return {"mode": "open", "allow_from": []}
|
||||
|
||||
# ── PairingProtocol ─────────────────────────────────
|
||||
|
||||
async def generate_code(self, peer_id: str) -> str:
|
||||
return ""
|
||||
|
||||
async def verify_code(self, peer_id: str, code: str) -> bool:
|
||||
return True
|
||||
|
||||
# ── GroupsProtocol ──────────────────────────────────
|
||||
|
||||
async def list_groups(self) -> list[dict]:
|
||||
return []
|
||||
|
||||
# ── MentionsProtocol ────────────────────────────────
|
||||
|
||||
def extract_mentions(self, raw_message: dict) -> list[str]:
|
||||
return []
|
||||
|
||||
# ── LifecycleProtocol ───────────────────────────────
|
||||
|
||||
async def on_config_changed(self, prev_cfg: dict, next_cfg: dict, account_id: str) -> None:
|
||||
pass
|
||||
|
||||
async def on_account_removed(self, account_id: str) -> None:
|
||||
pass
|
||||
|
||||
async def on_retire(self) -> None:
|
||||
pass
|
||||
|
||||
async def run_startup_maintenance(self, cfg: dict) -> None:
|
||||
pass
|
||||
|
||||
# ── ApprovalProtocol ────────────────────────────────
|
||||
|
||||
async def check_approval_required(self, config: dict, action, initiator_peer_id: str) -> bool:
|
||||
approval_cfg = config.get("approval", {})
|
||||
if not approval_cfg.get("enabled", False):
|
||||
return False
|
||||
approvers = approval_cfg.get("approvers", [])
|
||||
if not approvers or initiator_peer_id in approvers:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def create_approval_request(
|
||||
self, config: dict, action, initiator_peer_id: str, description: str, context: dict
|
||||
):
|
||||
from yuxi.channel.protocols import ApprovalRequest
|
||||
|
||||
return ApprovalRequest(
|
||||
id="",
|
||||
channel_type="",
|
||||
account_id="",
|
||||
initiator_peer_id=initiator_peer_id,
|
||||
action=action,
|
||||
description=description,
|
||||
)
|
||||
|
||||
async def send_approval_notification(self, config: dict, request, approver_peer_ids: list[str]) -> bool:
|
||||
return False
|
||||
|
||||
async def check_approval_status(self, config: dict, request_id: str):
|
||||
return None
|
||||
|
||||
def get_approver_ids(self, config: dict) -> list[str]:
|
||||
return config.get("approval", {}).get("approvers", [])
|
||||
|
||||
# ── ConfigSchemaProtocol ────────────────────────────
|
||||
|
||||
def config_schema(self) -> dict:
|
||||
return {}
|
||||
|
||||
# ── SetupProtocol ───────────────────────────────────
|
||||
|
||||
async def setup(self, config: dict) -> bool:
|
||||
return True
|
||||
|
||||
# ── SetupWizardProtocol ─────────────────────────────
|
||||
|
||||
def setup_wizard_steps(self) -> list:
|
||||
return []
|
||||
|
||||
async def validate_wizard_input(self, step_key: str, value: str) -> str | None:
|
||||
return None
|
||||
|
||||
# ── AuthProtocol ────────────────────────────────────
|
||||
|
||||
async def login(self, config: dict) -> dict:
|
||||
return {}
|
||||
|
||||
async def logout(self, config: dict) -> None:
|
||||
pass
|
||||
|
||||
async def refresh_token(self, config: dict) -> dict | None:
|
||||
return None
|
||||
|
||||
async def qr_auth_start(self, config: dict) -> str:
|
||||
return ""
|
||||
|
||||
async def qr_auth_wait(self, config: dict) -> dict | None:
|
||||
return None
|
||||
|
||||
# ── ElevatedProtocol ────────────────────────────────
|
||||
|
||||
async def elevate(self, account: dict, reason: str) -> bool:
|
||||
return True
|
||||
|
||||
async def revoke(self, account: dict) -> None:
|
||||
pass
|
||||
|
||||
@property
|
||||
def is_elevated(self) -> bool:
|
||||
return False
|
||||
|
||||
# ── AllowlistManagementProtocol ─────────────────────
|
||||
|
||||
async def list_allow_entries(self, config: dict, scope: str) -> list[str]:
|
||||
return []
|
||||
|
||||
async def add_allow_entry(self, config: dict, scope: str, entry: str) -> bool:
|
||||
return False
|
||||
|
||||
async def remove_allow_entry(self, config: dict, scope: str, entry: str) -> bool:
|
||||
return False
|
||||
|
||||
# ── ReactionProtocol ─────────────────────────────────
|
||||
|
||||
async def send_reaction(
|
||||
self,
|
||||
target_id: str,
|
||||
message_id: str,
|
||||
emoji: str,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
async def remove_reaction(
|
||||
self,
|
||||
target_id: str,
|
||||
message_id: str,
|
||||
emoji: str,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
async def fetch_reactions(
|
||||
self,
|
||||
target_id: str,
|
||||
message_id: str,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> list[dict]:
|
||||
return []
|
||||
|
||||
# ── WebhookProtocol ──────────────────────────────────
|
||||
|
||||
async def handle_webhook(
|
||||
self,
|
||||
request: object,
|
||||
account_id: str | None = None,
|
||||
) -> object:
|
||||
return None
|
||||
|
||||
def verify_signature(self, body: bytes, signature: str, secret: str) -> bool:
|
||||
return False
|
||||
|
||||
def resolve_webhook_auth_bypass_paths(self) -> list[str]:
|
||||
return []
|
||||
|
||||
# ── InboundHandlerProtocol ───────────────────────────
|
||||
|
||||
async def handle_raw_event(
|
||||
self,
|
||||
event: dict,
|
||||
account: dict,
|
||||
) -> object | None:
|
||||
return None
|
||||
|
||||
def parse_to_unified(
|
||||
self,
|
||||
raw_event: dict,
|
||||
account_id: str,
|
||||
) -> object | None:
|
||||
return None
|
||||
|
||||
def resolve_event_type(self, raw_event: dict) -> str:
|
||||
return "unknown"
|
||||
|
||||
# ── MediaProtocol ────────────────────────────────────
|
||||
|
||||
async def upload_media(
|
||||
self,
|
||||
file_path: str,
|
||||
media_type: str,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> dict:
|
||||
return {}
|
||||
|
||||
async def download_media(
|
||||
self,
|
||||
media_url: str,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> bytes:
|
||||
return b""
|
||||
|
||||
def detect_media_type(self, filename: str, content_type: str = "") -> str:
|
||||
return "file"
|
||||
|
||||
def validate_media_url(self, url: str) -> bool:
|
||||
return False
|
||||
|
||||
def resolve_media_size_limit(self, media_type: str) -> int:
|
||||
return 0
|
||||
|
||||
# ── FormatProtocol ───────────────────────────────────
|
||||
|
||||
def markdown_to_native(self, md_text: str) -> dict | str:
|
||||
return md_text
|
||||
|
||||
def native_to_markdown(self, native_content: dict | str) -> str:
|
||||
if isinstance(native_content, dict):
|
||||
return native_content.get("text", "")
|
||||
return str(native_content)
|
||||
|
||||
# ── DedupeProtocol ───────────────────────────────────
|
||||
|
||||
def is_duplicate(self, key: str) -> bool:
|
||||
return False
|
||||
|
||||
def mark_seen(self, key: str) -> None:
|
||||
pass
|
||||
|
||||
def reset(self) -> None:
|
||||
pass
|
||||
|
||||
@property
|
||||
def ttl_seconds(self) -> int:
|
||||
return 300
|
||||
|
||||
@property
|
||||
def max_entries(self) -> int:
|
||||
return 10000
|
||||
|
||||
# ── CardProtocol ─────────────────────────────────────
|
||||
|
||||
async def send_card(
|
||||
self,
|
||||
target_id: str,
|
||||
card_content: dict,
|
||||
*,
|
||||
reply_to_id: str | None = None,
|
||||
account_id: str | None = None,
|
||||
) -> str | None:
|
||||
return None
|
||||
|
||||
async def update_card(
|
||||
self,
|
||||
message_id: str,
|
||||
card_content: dict,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> bool:
|
||||
return False
|
||||
|
||||
async def edit_card(
|
||||
self,
|
||||
target_id: str,
|
||||
message_id: str,
|
||||
card_content: dict,
|
||||
*,
|
||||
thread_id: str | None = None,
|
||||
account_id: str | None = None,
|
||||
) -> str | None:
|
||||
ok = await self.update_card(message_id, card_content, account_id=account_id)
|
||||
return message_id if ok else None
|
||||
|
||||
def build_text_card(self, text: str, title: str | None = None, **kwargs) -> dict:
|
||||
return {"text": text}
|
||||
|
||||
def supports_card_interactions(self) -> bool:
|
||||
return False
|
||||
|
||||
# ── ErrorHandlingProtocol ────────────────────────────
|
||||
|
||||
def classify_error(self, error: BaseException) -> object:
|
||||
from yuxi.channel.errors import classify_error
|
||||
|
||||
return classify_error(error)
|
||||
|
||||
def is_retryable(self, error: BaseException) -> bool:
|
||||
from yuxi.channel.errors import is_retryable
|
||||
|
||||
return is_retryable(error)
|
||||
|
||||
def should_backoff(self, error: BaseException, attempt: int) -> int:
|
||||
from yuxi.channel.errors import should_backoff
|
||||
|
||||
return should_backoff(error, attempt)
|
||||
|
||||
# ── PollingProtocol ──────────────────────────────────
|
||||
|
||||
poll_interval_seconds: int = 60
|
||||
|
||||
async def poll_once(self, account: dict) -> list[dict]:
|
||||
return []
|
||||
|
||||
async def should_poll(self, account: dict) -> bool:
|
||||
return False
|
||||
|
||||
def get_poll_cursor(self, account_id: str) -> str | None:
|
||||
return None
|
||||
|
||||
def set_poll_cursor(self, account_id: str, cursor: str) -> None:
|
||||
pass
|
||||
|
||||
# ── PinProtocol ──────────────────────────────────────
|
||||
|
||||
async def pin_message(
|
||||
self,
|
||||
message_id: str,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> bool:
|
||||
return False
|
||||
|
||||
async def unpin_message(
|
||||
self,
|
||||
message_id: str,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> bool:
|
||||
return False
|
||||
|
||||
async def list_pins(
|
||||
self,
|
||||
chat_id: str,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> list[dict]:
|
||||
return []
|
||||
|
||||
# ── WebsocketProtocol ────────────────────────────────
|
||||
|
||||
async def connect_ws(self, account: dict) -> object:
|
||||
return None
|
||||
|
||||
async def disconnect_ws(self) -> None:
|
||||
pass
|
||||
|
||||
async def on_ws_message(self, message: str | bytes) -> None:
|
||||
pass
|
||||
|
||||
@property
|
||||
def ws_reconnect_interval_ms(self) -> int:
|
||||
return 5000
|
||||
|
||||
@property
|
||||
def ws_heartbeat_interval_ms(self) -> int:
|
||||
return 30000
|
||||
|
||||
# ── Hooks ───────────────────────────────────────────
|
||||
|
||||
async def on_message_sending(self, msg: object, content: str) -> str | None:
|
||||
return content
|
||||
|
||||
async def on_message_received(self, msg: object) -> object | None:
|
||||
return msg
|
||||
|
||||
async def before_send_attempt(self, target_id: str, content: str) -> None:
|
||||
pass
|
||||
|
||||
async def after_send_success(self, target_id: str, receipt: object) -> None:
|
||||
pass
|
||||
|
||||
async def after_send_failure(self, target_id: str, error: str) -> None:
|
||||
pass
|
||||
|
||||
async def after_commit(self, receipt: object) -> None:
|
||||
pass
|
||||
@ -28,6 +28,50 @@ from yuxi.config.static.models import (
|
||||
from yuxi.utils.logging_config import logger
|
||||
|
||||
|
||||
class DependencyWhitelistEntry(BaseModel):
|
||||
"""依赖白名单条目。"""
|
||||
|
||||
name: str
|
||||
min_version: str | None = None
|
||||
max_version: str | None = None
|
||||
allowed_versions: list[str] | None = None
|
||||
blocked_versions: list[str] | None = None
|
||||
|
||||
|
||||
DEFAULT_DEPENDENCY_WHITELIST: list[DependencyWhitelistEntry] = [
|
||||
DependencyWhitelistEntry(name="aiofiles", min_version="0.8.0"),
|
||||
DependencyWhitelistEntry(name="aiohttp", min_version="3.8.0"),
|
||||
DependencyWhitelistEntry(name="aiosignal", min_version="1.2.0"),
|
||||
DependencyWhitelistEntry(name="asyncpg", min_version="0.28.0"),
|
||||
DependencyWhitelistEntry(name="cachetools", min_version="5.0.0"),
|
||||
DependencyWhitelistEntry(name="cryptography", min_version="41.0.0"),
|
||||
DependencyWhitelistEntry(name="fastapi", min_version="0.100.0"),
|
||||
DependencyWhitelistEntry(name="frozenlist", min_version="1.3.0"),
|
||||
DependencyWhitelistEntry(name="httpx", min_version="0.24.0"),
|
||||
DependencyWhitelistEntry(name="lxml", min_version="4.9.0"),
|
||||
DependencyWhitelistEntry(name="multidict", min_version="6.0.0"),
|
||||
DependencyWhitelistEntry(name="numpy", min_version="1.24.0"),
|
||||
DependencyWhitelistEntry(name="orjson", min_version="3.9.0"),
|
||||
DependencyWhitelistEntry(name="pillow", min_version="10.0.0"),
|
||||
DependencyWhitelistEntry(name="psycopg2-binary", min_version="2.9.0"),
|
||||
DependencyWhitelistEntry(name="pydantic", min_version="2.0.0"),
|
||||
DependencyWhitelistEntry(name="pydantic-core", min_version="2.0.0"),
|
||||
DependencyWhitelistEntry(name="pydantic-settings", min_version="2.0.0"),
|
||||
DependencyWhitelistEntry(name="python-dotenv", min_version="1.0.0"),
|
||||
DependencyWhitelistEntry(name="pyyaml", min_version="6.0"),
|
||||
DependencyWhitelistEntry(name="redis", min_version="4.5.0"),
|
||||
DependencyWhitelistEntry(name="requests", min_version="2.28.0"),
|
||||
DependencyWhitelistEntry(name="sqlalchemy", min_version="2.0.0"),
|
||||
DependencyWhitelistEntry(name="starlette", min_version="0.27.0"),
|
||||
DependencyWhitelistEntry(name="structlog", min_version="23.0.0"),
|
||||
DependencyWhitelistEntry(name="tenacity", min_version="8.0.0"),
|
||||
DependencyWhitelistEntry(name="urllib3", min_version="1.26.0"),
|
||||
DependencyWhitelistEntry(name="uvicorn", min_version="0.22.0"),
|
||||
DependencyWhitelistEntry(name="websockets", min_version="10.0"),
|
||||
DependencyWhitelistEntry(name="yarl", min_version="1.8.0"),
|
||||
]
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
"""应用配置类"""
|
||||
|
||||
@ -84,9 +128,23 @@ class Config(BaseModel):
|
||||
sandbox_max_output_bytes: int = Field(default=262144, description="沙箱最大输出字节数")
|
||||
sandbox_keepalive_interval_seconds: int = Field(default=30, description="沙箱保活间隔(秒)")
|
||||
|
||||
# ============================================================
|
||||
# Media Store 配置
|
||||
# ============================================================
|
||||
media_store_enabled: bool = Field(default=True, description="是否启用媒体本地存储")
|
||||
media_store_max_total_bytes: int = Field(default=500 * 1024 * 1024, description="媒体存储总上限(字节),默认500MB")
|
||||
media_store_max_file_bytes: int = Field(default=10 * 1024 * 1024, description="单文件大小上限(字节),默认10MB")
|
||||
media_store_ttl_seconds: int = Field(default=7 * 24 * 3600, description="媒体文件TTL(秒),默认7天")
|
||||
media_cleanup_interval_seconds: int = Field(default=3600, description="清理扫描间隔(秒),默认1小时")
|
||||
|
||||
# ============================================================
|
||||
# 模型信息(只读,不持久化)
|
||||
# ============================================================
|
||||
dependency_whitelist: list[DependencyWhitelistEntry] = Field(
|
||||
default_factory=lambda: [DependencyWhitelistEntry(**e.model_dump()) for e in DEFAULT_DEPENDENCY_WHITELIST],
|
||||
description="渠道插件依赖白名单",
|
||||
exclude=True,
|
||||
)
|
||||
model_names: dict[str, ChatModelProvider] = Field(
|
||||
default_factory=lambda: DEFAULT_CHAT_MODEL_PROVIDERS.copy(),
|
||||
description="聊天模型提供商配置",
|
||||
|
||||
72
backend/package/yuxi/repositories/channel_allowlist_repo.py
Normal file
72
backend/package/yuxi/repositories/channel_allowlist_repo.py
Normal file
@ -0,0 +1,72 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from yuxi.storage.postgres.manager import pg_manager
|
||||
from yuxi.storage.postgres.models_channels import ChannelAllowlistEntry
|
||||
|
||||
|
||||
class ChannelAllowlistRepository:
|
||||
async def list_by_channel(self, channel_id: str, allow_type: str | None = None) -> list[ChannelAllowlistEntry]:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
query = select(ChannelAllowlistEntry).where(
|
||||
ChannelAllowlistEntry.channel_id == channel_id,
|
||||
ChannelAllowlistEntry.enabled == True,
|
||||
)
|
||||
if allow_type:
|
||||
query = query.where(ChannelAllowlistEntry.allow_type == allow_type)
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def add_entry(self, channel_id: str, allow_type: str, allow_id: str) -> ChannelAllowlistEntry | None:
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(
|
||||
select(ChannelAllowlistEntry).where(
|
||||
ChannelAllowlistEntry.channel_id == channel_id,
|
||||
ChannelAllowlistEntry.allow_type == allow_type,
|
||||
ChannelAllowlistEntry.allow_id == allow_id,
|
||||
)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if entry is not None:
|
||||
entry.enabled = True
|
||||
entry.updated_at = now
|
||||
else:
|
||||
entry = ChannelAllowlistEntry(
|
||||
channel_id=channel_id,
|
||||
allow_type=allow_type,
|
||||
allow_id=allow_id,
|
||||
)
|
||||
session.add(entry)
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
async def remove_entry(self, channel_id: str, allow_type: str, allow_id: str) -> bool:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(
|
||||
select(ChannelAllowlistEntry).where(
|
||||
ChannelAllowlistEntry.channel_id == channel_id,
|
||||
ChannelAllowlistEntry.allow_type == allow_type,
|
||||
ChannelAllowlistEntry.allow_id == allow_id,
|
||||
)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if entry is None:
|
||||
return False
|
||||
entry.enabled = False
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def delete_entry(self, channel_id: str, allow_type: str, allow_id: str) -> bool:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(
|
||||
delete(ChannelAllowlistEntry).where(
|
||||
ChannelAllowlistEntry.channel_id == channel_id,
|
||||
ChannelAllowlistEntry.allow_type == allow_type,
|
||||
ChannelAllowlistEntry.allow_id == allow_id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
94
backend/package/yuxi/repositories/channel_binding_repo.py
Normal file
94
backend/package/yuxi/repositories/channel_binding_repo.py
Normal file
@ -0,0 +1,94 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from yuxi.channel.routing.models import BindingMatch, PeerConstraint, PeerKind, RouteBinding
|
||||
from yuxi.storage.postgres.manager import pg_manager
|
||||
from yuxi.storage.postgres.models_channels import ChannelBinding
|
||||
|
||||
|
||||
def to_route_binding(binding: ChannelBinding) -> RouteBinding:
|
||||
peer_constraint = None
|
||||
if binding.peer_kind and binding.peer_id:
|
||||
peer_constraint = PeerConstraint(
|
||||
kind=PeerKind(binding.peer_kind),
|
||||
peer_id=binding.peer_id,
|
||||
)
|
||||
|
||||
match = BindingMatch(
|
||||
channel=binding.channel_type,
|
||||
channel_config_id=binding.channel_config_id,
|
||||
account_id=binding.account_id,
|
||||
peer=peer_constraint,
|
||||
guild_id=binding.guild_id,
|
||||
team_id=binding.team_id,
|
||||
roles=list(binding.roles or []),
|
||||
)
|
||||
|
||||
return RouteBinding(
|
||||
agent_config_id=binding.agent_config_id,
|
||||
match=match,
|
||||
dm_scope=binding.dm_scope,
|
||||
priority=binding.priority,
|
||||
session_dm_scope=binding.session_dm_scope,
|
||||
)
|
||||
|
||||
|
||||
class ChannelBindingRepository:
|
||||
async def get_by_id(self, binding_id: str) -> ChannelBinding | None:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(ChannelBinding).where(ChannelBinding.id == binding_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_by_agent(self, agent_config_id: int) -> list[ChannelBinding]:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(
|
||||
select(ChannelBinding)
|
||||
.where(ChannelBinding.agent_config_id == agent_config_id)
|
||||
.order_by(ChannelBinding.priority.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_all(self) -> list[ChannelBinding]:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(ChannelBinding).order_by(ChannelBinding.priority.desc()))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_by_channel_type(self, channel_type: str) -> list[ChannelBinding]:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(
|
||||
select(ChannelBinding)
|
||||
.where(ChannelBinding.channel_type == channel_type)
|
||||
.order_by(ChannelBinding.priority.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, data: dict[str, Any]) -> ChannelBinding:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
binding = ChannelBinding(**data)
|
||||
session.add(binding)
|
||||
await session.commit()
|
||||
await session.refresh(binding)
|
||||
return binding
|
||||
|
||||
async def update(self, binding_id: str, data: dict[str, Any]) -> ChannelBinding | None:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(ChannelBinding).where(ChannelBinding.id == binding_id))
|
||||
binding = result.scalar_one_or_none()
|
||||
if binding is None:
|
||||
return None
|
||||
for key, value in data.items():
|
||||
if key != "id":
|
||||
setattr(binding, key, value)
|
||||
await session.commit()
|
||||
await session.refresh(binding)
|
||||
return binding
|
||||
|
||||
async def delete(self, binding_id: str) -> bool:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(ChannelBinding).where(ChannelBinding.id == binding_id))
|
||||
binding = result.scalar_one_or_none()
|
||||
if binding is None:
|
||||
return False
|
||||
await session.delete(binding)
|
||||
return True
|
||||
67
backend/package/yuxi/repositories/channel_config_repo.py
Normal file
67
backend/package/yuxi/repositories/channel_config_repo.py
Normal file
@ -0,0 +1,67 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from yuxi.storage.postgres.manager import pg_manager
|
||||
from yuxi.storage.postgres.models_channels import ChannelConfig
|
||||
|
||||
|
||||
class ChannelConfigRepository:
|
||||
async def get_by_id(self, config_id: str) -> ChannelConfig | None:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(ChannelConfig).where(ChannelConfig.id == config_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_by_type(self, channel_type: str) -> list[ChannelConfig]:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(
|
||||
select(ChannelConfig)
|
||||
.where(ChannelConfig.channel_type == channel_type)
|
||||
.order_by(ChannelConfig.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_enabled(self, channel_type: str | None = None) -> list[ChannelConfig]:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
query = select(ChannelConfig).where(ChannelConfig.enabled.is_(True))
|
||||
if channel_type:
|
||||
query = query.where(ChannelConfig.channel_type == channel_type)
|
||||
query = query.order_by(ChannelConfig.created_at.desc())
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_all(self) -> list[ChannelConfig]:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(ChannelConfig).order_by(ChannelConfig.created_at.desc()))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, data: dict[str, Any]) -> ChannelConfig:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
config = ChannelConfig(**data)
|
||||
session.add(config)
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
return config
|
||||
|
||||
async def update(self, config_id: str, data: dict[str, Any]) -> ChannelConfig | None:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(ChannelConfig).where(ChannelConfig.id == config_id))
|
||||
config = result.scalar_one_or_none()
|
||||
if config is None:
|
||||
return None
|
||||
for key, value in data.items():
|
||||
if key != "id":
|
||||
setattr(config, key, value)
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
return config
|
||||
|
||||
async def delete(self, config_id: str) -> bool:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(ChannelConfig).where(ChannelConfig.id == config_id))
|
||||
config = result.scalar_one_or_none()
|
||||
if config is None:
|
||||
return False
|
||||
await session.delete(config)
|
||||
await session.commit()
|
||||
return True
|
||||
@ -0,0 +1,74 @@
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from yuxi.storage.postgres.manager import pg_manager
|
||||
from yuxi.storage.postgres.models_channels import DeviceIdentity
|
||||
|
||||
|
||||
class DeviceIdentityRepository:
|
||||
async def get_by_device_id(self, device_id: str) -> DeviceIdentity | None:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(DeviceIdentity).where(DeviceIdentity.device_id == device_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, data: dict[str, Any]) -> DeviceIdentity:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
record = DeviceIdentity(**data)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
return record
|
||||
|
||||
async def update(self, device_id: str, data: dict[str, Any]) -> DeviceIdentity | None:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(DeviceIdentity).where(DeviceIdentity.device_id == device_id))
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return None
|
||||
for key, value in data.items():
|
||||
setattr(record, key, value)
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
return record
|
||||
|
||||
async def mark_used(self, device_id: str) -> bool:
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(DeviceIdentity).where(DeviceIdentity.device_id == device_id))
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return False
|
||||
record.last_used_at = now
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def revoke(self, device_id: str) -> bool:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(DeviceIdentity).where(DeviceIdentity.device_id == device_id))
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return False
|
||||
record.status = "revoked"
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def list_active(self) -> list[DeviceIdentity]:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(
|
||||
select(DeviceIdentity)
|
||||
.where(DeviceIdentity.status == "active")
|
||||
.order_by(DeviceIdentity.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def delete(self, device_id: str) -> bool:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(DeviceIdentity).where(DeviceIdentity.device_id == device_id))
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return False
|
||||
await session.delete(record)
|
||||
await session.commit()
|
||||
return True
|
||||
@ -0,0 +1,47 @@
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from yuxi.storage.postgres.manager import pg_manager
|
||||
from yuxi.storage.postgres.models_channels import ChannelIdentityLink
|
||||
|
||||
|
||||
class ChannelIdentityLinkRepository:
|
||||
|
||||
async def find_all(self) -> dict[str, list[str]]:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(ChannelIdentityLink))
|
||||
rows = result.scalars().all()
|
||||
links: dict[str, list[str]] = {}
|
||||
for row in rows:
|
||||
links.setdefault(row.identity, []).append(f"{row.channel_type}:{row.peer_id}")
|
||||
return links
|
||||
|
||||
async def find_by_identity(self, identity: str) -> list[str]:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(
|
||||
select(ChannelIdentityLink).where(ChannelIdentityLink.identity == identity)
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
return [f"{row.channel_type}:{row.peer_id}" for row in rows]
|
||||
|
||||
async def add_link(self, identity: str, channel_type: str, peer_id: str) -> None:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
link = ChannelIdentityLink(identity=identity, channel_type=channel_type, peer_id=peer_id)
|
||||
session.add(link)
|
||||
await session.commit()
|
||||
|
||||
async def remove_link(self, channel_type: str, peer_id: str) -> None:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
await session.execute(
|
||||
delete(ChannelIdentityLink).where(
|
||||
ChannelIdentityLink.channel_type == channel_type,
|
||||
ChannelIdentityLink.peer_id == peer_id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def remove_identity(self, identity: str) -> None:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
await session.execute(
|
||||
delete(ChannelIdentityLink).where(ChannelIdentityLink.identity == identity)
|
||||
)
|
||||
await session.commit()
|
||||
68
backend/package/yuxi/repositories/channel_msg_record_repo.py
Normal file
68
backend/package/yuxi/repositories/channel_msg_record_repo.py
Normal file
@ -0,0 +1,68 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from yuxi.storage.postgres.manager import pg_manager
|
||||
from yuxi.storage.postgres.models_channels import ChannelMsgRecord
|
||||
from yuxi.utils.datetime_utils import utc_now_naive
|
||||
|
||||
|
||||
class ChannelMsgRecordRepository:
|
||||
async def get_by_id(self, record_id: int) -> ChannelMsgRecord | None:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(ChannelMsgRecord).where(ChannelMsgRecord.id == record_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create_record(self, data: dict[str, Any]) -> ChannelMsgRecord:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
record = ChannelMsgRecord(**data)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
return record
|
||||
|
||||
async def mark_success(
|
||||
self, record_id: int, reply_message_id: str, reply_content_preview: str, response_time_ms: int
|
||||
) -> ChannelMsgRecord | None:
|
||||
now = utc_now_naive()
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(ChannelMsgRecord).where(ChannelMsgRecord.id == record_id))
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return None
|
||||
record.status = "success"
|
||||
record.reply_message_id = reply_message_id
|
||||
record.reply_content_preview = reply_content_preview
|
||||
record.response_time_ms = response_time_ms
|
||||
record.replied_at = now
|
||||
await session.commit()
|
||||
return record
|
||||
|
||||
async def mark_error(
|
||||
self, record_id: int, error_message: str, response_time_ms: int | None = None
|
||||
) -> ChannelMsgRecord | None:
|
||||
now = utc_now_naive()
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(ChannelMsgRecord).where(ChannelMsgRecord.id == record_id))
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return None
|
||||
record.status = "error"
|
||||
record.error_message = error_message
|
||||
record.response_time_ms = response_time_ms
|
||||
record.replied_at = now
|
||||
await session.commit()
|
||||
return record
|
||||
|
||||
async def list_by_chat(self, channel_id: str, chat_id: str, limit: int = 50) -> list[ChannelMsgRecord]:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(
|
||||
select(ChannelMsgRecord)
|
||||
.where(
|
||||
ChannelMsgRecord.channel_id == channel_id,
|
||||
ChannelMsgRecord.chat_id == chat_id,
|
||||
)
|
||||
.order_by(ChannelMsgRecord.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
170
backend/package/yuxi/repositories/channel_pairing_repo.py
Normal file
170
backend/package/yuxi/repositories/channel_pairing_repo.py
Normal file
@ -0,0 +1,170 @@
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from yuxi.storage.postgres.manager import pg_manager
|
||||
from yuxi.storage.postgres.models_channels import ChannelPairingRecord
|
||||
|
||||
|
||||
class ChannelPairingRecordRepository:
|
||||
async def get_by_id(self, record_id: str) -> ChannelPairingRecord | None:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(ChannelPairingRecord).where(ChannelPairingRecord.id == record_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def find_by_channel_peer(
|
||||
self, channel_type: str, peer_id: str, account_id: str = "default", status: str | None = None
|
||||
) -> list[ChannelPairingRecord]:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
query = select(ChannelPairingRecord).where(
|
||||
ChannelPairingRecord.channel_type == channel_type,
|
||||
ChannelPairingRecord.account_id == account_id,
|
||||
ChannelPairingRecord.peer_id == peer_id,
|
||||
)
|
||||
if status:
|
||||
query = query.where(ChannelPairingRecord.status == status)
|
||||
result = await session.execute(query.order_by(ChannelPairingRecord.created_at.desc()))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def find_pending(
|
||||
self, channel_type: str, peer_id: str, account_id: str = "default"
|
||||
) -> list[ChannelPairingRecord]:
|
||||
return await self.find_by_channel_peer(channel_type, peer_id, account_id, status="pending")
|
||||
|
||||
async def find_pending_by_account(self, channel_type: str, account_id: str) -> list[ChannelPairingRecord]:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
result = await session.execute(
|
||||
select(ChannelPairingRecord)
|
||||
.where(
|
||||
ChannelPairingRecord.channel_type == channel_type,
|
||||
ChannelPairingRecord.account_id == account_id,
|
||||
ChannelPairingRecord.status == "pending",
|
||||
ChannelPairingRecord.expires_at > now,
|
||||
)
|
||||
.order_by(ChannelPairingRecord.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def mark_expired(self) -> int:
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(
|
||||
select(ChannelPairingRecord).where(
|
||||
ChannelPairingRecord.status == "pending",
|
||||
ChannelPairingRecord.expires_at < now,
|
||||
)
|
||||
)
|
||||
records = result.scalars().all()
|
||||
for record in records:
|
||||
record.status = "expired"
|
||||
if records:
|
||||
await session.commit()
|
||||
return len(records)
|
||||
|
||||
async def upsert_pending(
|
||||
self,
|
||||
channel_type: str,
|
||||
account_id: str,
|
||||
peer_id: str,
|
||||
code: str,
|
||||
token: str,
|
||||
expires_at: datetime,
|
||||
) -> ChannelPairingRecord:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(
|
||||
select(ChannelPairingRecord).where(
|
||||
ChannelPairingRecord.channel_type == channel_type,
|
||||
ChannelPairingRecord.account_id == account_id,
|
||||
ChannelPairingRecord.peer_id == peer_id,
|
||||
ChannelPairingRecord.status == "pending",
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is not None:
|
||||
record.pairing_code = code
|
||||
record.pairing_token = token
|
||||
record.expires_at = expires_at
|
||||
else:
|
||||
record = ChannelPairingRecord(
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
peer_id=peer_id,
|
||||
pairing_code=code,
|
||||
pairing_token=token,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
return record
|
||||
|
||||
async def create(self, data: dict[str, Any]) -> ChannelPairingRecord:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
record = ChannelPairingRecord(**data)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
return record
|
||||
|
||||
async def update_status(
|
||||
self, record_id: str, status: str, paired_at: datetime | None = None
|
||||
) -> ChannelPairingRecord | None:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(ChannelPairingRecord).where(ChannelPairingRecord.id == record_id))
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return None
|
||||
record.status = status
|
||||
if paired_at is not None:
|
||||
record.paired_at = paired_at
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
return record
|
||||
|
||||
async def approve(self, record_id: str) -> ChannelPairingRecord | None:
|
||||
return await self.update_status(record_id, "paired", datetime.now(UTC).replace(tzinfo=None))
|
||||
|
||||
async def reject(self, record_id: str) -> ChannelPairingRecord | None:
|
||||
return await self.update_status(record_id, "expired")
|
||||
|
||||
async def remove_pending(
|
||||
self, channel_type: str, account_id: str, peer_id: str, target_status: str = "paired"
|
||||
) -> bool:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
result = await session.execute(
|
||||
select(ChannelPairingRecord).where(
|
||||
ChannelPairingRecord.channel_type == channel_type,
|
||||
ChannelPairingRecord.account_id == account_id,
|
||||
ChannelPairingRecord.peer_id == peer_id,
|
||||
ChannelPairingRecord.status == "pending",
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return False
|
||||
record.status = target_status
|
||||
if target_status == "paired":
|
||||
record.paired_at = now
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def delete(self, record_id: str) -> bool:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(ChannelPairingRecord).where(ChannelPairingRecord.id == record_id))
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return False
|
||||
await session.delete(record)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def list_all(self, status: str | None = None) -> list[ChannelPairingRecord]:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
query = select(ChannelPairingRecord)
|
||||
if status:
|
||||
query = query.where(ChannelPairingRecord.status == status)
|
||||
result = await session.execute(query.order_by(ChannelPairingRecord.created_at.desc()))
|
||||
return list(result.scalars().all())
|
||||
106
backend/package/yuxi/repositories/channel_thread_mapping_repo.py
Normal file
106
backend/package/yuxi/repositories/channel_thread_mapping_repo.py
Normal file
@ -0,0 +1,106 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from yuxi.storage.postgres.manager import pg_manager
|
||||
from yuxi.storage.postgres.models_channels import ChannelThreadMapping
|
||||
|
||||
|
||||
class ChannelThreadMappingRepository:
|
||||
async def get_by_id(self, mapping_id: int) -> ChannelThreadMapping | None:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(ChannelThreadMapping).where(ChannelThreadMapping.id == mapping_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def find_by_channel_chat(
|
||||
self, channel_id: str, channel_chat_id: str, internal_user_id: str
|
||||
) -> ChannelThreadMapping | None:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(
|
||||
select(ChannelThreadMapping).where(
|
||||
ChannelThreadMapping.channel_id == channel_id,
|
||||
ChannelThreadMapping.channel_chat_id == channel_chat_id,
|
||||
ChannelThreadMapping.internal_user_id == internal_user_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def find_by_thread_id(self, thread_id: str) -> ChannelThreadMapping | None:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(
|
||||
select(ChannelThreadMapping).where(ChannelThreadMapping.thread_id == thread_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def resolve_thread(
|
||||
self,
|
||||
channel_id: str,
|
||||
channel_chat_id: str,
|
||||
internal_user_id: str,
|
||||
agent_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
) -> ChannelThreadMapping:
|
||||
now = datetime.now()
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(
|
||||
select(ChannelThreadMapping).where(
|
||||
ChannelThreadMapping.channel_id == channel_id,
|
||||
ChannelThreadMapping.channel_chat_id == channel_chat_id,
|
||||
ChannelThreadMapping.internal_user_id == internal_user_id,
|
||||
)
|
||||
)
|
||||
mapping = result.scalar_one_or_none()
|
||||
if mapping is not None:
|
||||
mapping.last_active_at = now
|
||||
mapping.agent_id = agent_id or mapping.agent_id
|
||||
await session.commit()
|
||||
await session.refresh(mapping)
|
||||
return mapping
|
||||
|
||||
mapping = ChannelThreadMapping(
|
||||
channel_id=channel_id,
|
||||
channel_chat_id=channel_chat_id,
|
||||
internal_user_id=internal_user_id,
|
||||
thread_id=thread_id or uuid.uuid4().hex,
|
||||
agent_id=agent_id,
|
||||
last_active_at=now,
|
||||
)
|
||||
session.add(mapping)
|
||||
await session.commit()
|
||||
await session.refresh(mapping)
|
||||
return mapping
|
||||
|
||||
async def touch_active(self, thread_id: str) -> bool:
|
||||
from datetime import datetime
|
||||
|
||||
now = datetime.now()
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(
|
||||
select(ChannelThreadMapping).where(ChannelThreadMapping.thread_id == thread_id)
|
||||
)
|
||||
mapping = result.scalar_one_or_none()
|
||||
if mapping is None:
|
||||
return False
|
||||
mapping.last_active_at = now
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def create(self, data: dict[str, Any]) -> ChannelThreadMapping:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
mapping = ChannelThreadMapping(**data)
|
||||
session.add(mapping)
|
||||
await session.commit()
|
||||
await session.refresh(mapping)
|
||||
return mapping
|
||||
|
||||
async def delete(self, mapping_id: int) -> bool:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(ChannelThreadMapping).where(ChannelThreadMapping.id == mapping_id))
|
||||
mapping = result.scalar_one_or_none()
|
||||
if mapping is None:
|
||||
return False
|
||||
await session.delete(mapping)
|
||||
await session.commit()
|
||||
return True
|
||||
124
backend/package/yuxi/repositories/channel_user_mapping_repo.py
Normal file
124
backend/package/yuxi/repositories/channel_user_mapping_repo.py
Normal file
@ -0,0 +1,124 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from yuxi.storage.postgres.manager import pg_manager
|
||||
from yuxi.storage.postgres.models_channels import ChannelUserMapping
|
||||
|
||||
|
||||
def _make_channel_user_id(channel_id: str, channel_user_id: str) -> str:
|
||||
unique_key = f"{channel_id}:{channel_user_id}"
|
||||
uid_hash = hashlib.sha256(unique_key.encode()).hexdigest()[:16]
|
||||
return f"ch_{uid_hash}"
|
||||
|
||||
|
||||
class ChannelUserMappingRepository:
|
||||
async def get_by_id(self, mapping_id: int) -> ChannelUserMapping | None:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(ChannelUserMapping).where(ChannelUserMapping.id == mapping_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def find_by_channel(self, channel_id: str, channel_user_id: str) -> ChannelUserMapping | None:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(
|
||||
select(ChannelUserMapping).where(
|
||||
ChannelUserMapping.channel_id == channel_id,
|
||||
ChannelUserMapping.channel_user_id == channel_user_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def resolve_user(self, channel_id: str, channel_user_id: str) -> ChannelUserMapping:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(
|
||||
select(ChannelUserMapping).where(
|
||||
ChannelUserMapping.channel_id == channel_id,
|
||||
ChannelUserMapping.channel_user_id == channel_user_id,
|
||||
)
|
||||
)
|
||||
mapping = result.scalar_one_or_none()
|
||||
if mapping is not None:
|
||||
return mapping
|
||||
|
||||
from server.utils.auth_utils import AuthUtils
|
||||
from yuxi.storage.postgres.models_business import User
|
||||
|
||||
user_id = _make_channel_user_id(channel_id, channel_user_id)
|
||||
username = user_id
|
||||
|
||||
existing = await session.execute(select(User.id).where(User.user_id == user_id))
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
username = f"{user_id}_{secrets.token_hex(4)}"
|
||||
|
||||
random_password = secrets.token_urlsafe(32)
|
||||
password_hash = AuthUtils.hash_password(random_password)
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
user_id=user_id,
|
||||
password_hash=password_hash,
|
||||
role="user",
|
||||
source=f"channel:{channel_id}",
|
||||
)
|
||||
session.add(user)
|
||||
try:
|
||||
await session.flush()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
existing_user = await session.execute(select(User).where(User.user_id == user_id))
|
||||
user = existing_user.scalar_one()
|
||||
|
||||
mapping = ChannelUserMapping(
|
||||
channel_id=channel_id,
|
||||
channel_user_id=channel_user_id,
|
||||
internal_user_id=str(user.id),
|
||||
)
|
||||
session.add(mapping)
|
||||
await session.commit()
|
||||
await session.refresh(mapping)
|
||||
return mapping
|
||||
|
||||
async def find_by_internal_user(self, internal_user_id: str) -> list[ChannelUserMapping]:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(
|
||||
select(ChannelUserMapping).where(ChannelUserMapping.internal_user_id == internal_user_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_all(self) -> list[ChannelUserMapping]:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(ChannelUserMapping))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, data: dict[str, Any]) -> ChannelUserMapping:
|
||||
channel_id = data["channel_id"]
|
||||
channel_user_id = data["channel_user_id"]
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(
|
||||
select(ChannelUserMapping).where(
|
||||
ChannelUserMapping.channel_id == channel_id,
|
||||
ChannelUserMapping.channel_user_id == channel_user_id,
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
mapping = ChannelUserMapping(**data)
|
||||
session.add(mapping)
|
||||
await session.commit()
|
||||
await session.refresh(mapping)
|
||||
return mapping
|
||||
|
||||
async def delete(self, mapping_id: int) -> bool:
|
||||
async with pg_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(ChannelUserMapping).where(ChannelUserMapping.id == mapping_id))
|
||||
mapping = result.scalar_one_or_none()
|
||||
if mapping is None:
|
||||
return False
|
||||
await session.delete(mapping)
|
||||
await session.commit()
|
||||
return True
|
||||
@ -4,7 +4,8 @@
|
||||
|
||||
import uuid as uuid_lib
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@ -72,6 +73,52 @@ class ConversationRepository:
|
||||
result = await self.db.execute(select(Conversation).where(Conversation.thread_id == thread_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def upsert_conversation(
|
||||
self,
|
||||
thread_id: str,
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
title: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
update_metadata: dict | None = None,
|
||||
) -> Conversation:
|
||||
metadata = (metadata or {}).copy()
|
||||
metadata.setdefault("attachments", [])
|
||||
|
||||
normalized_title = self._normalize_title(title)
|
||||
|
||||
insert_stmt = insert(Conversation).values(
|
||||
thread_id=thread_id,
|
||||
user_id=str(user_id),
|
||||
agent_id=agent_id,
|
||||
title=normalized_title or "New Conversation",
|
||||
status="active",
|
||||
extra_metadata=metadata,
|
||||
)
|
||||
|
||||
update_values: dict = {"updated_at": utc_now_naive()}
|
||||
if update_metadata is not None:
|
||||
current_meta = Conversation.extra_metadata
|
||||
update_values["extra_metadata"] = current_meta.op("||")(update_metadata)
|
||||
|
||||
upsert_stmt = insert_stmt.on_conflict_do_update(
|
||||
index_elements=["thread_id"],
|
||||
set_=update_values,
|
||||
).returning(Conversation)
|
||||
|
||||
result = await self.db.execute(upsert_stmt)
|
||||
conversation = result.scalar_one()
|
||||
|
||||
stats_stmt = insert(ConversationStats).values(
|
||||
conversation_id=conversation.id,
|
||||
).on_conflict_do_nothing()
|
||||
await self.db.execute(stats_stmt)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(conversation)
|
||||
|
||||
logger.info(f"Upserted conversation: {conversation.thread_id} for user {user_id}")
|
||||
return conversation
|
||||
|
||||
async def _get_conversation_by_id(self, conversation_id: int) -> Conversation | None:
|
||||
result = await self.db.execute(select(Conversation).where(Conversation.id == conversation_id))
|
||||
return result.scalar_one_or_none()
|
||||
@ -285,24 +332,30 @@ class ConversationRepository:
|
||||
metadata: dict | None = None,
|
||||
is_pinned: bool | None = None,
|
||||
) -> Conversation | None:
|
||||
conversation = await self.get_conversation_by_thread_id(thread_id)
|
||||
if not conversation:
|
||||
return None
|
||||
values: dict = {"updated_at": utc_now_naive()}
|
||||
|
||||
normalized_title = self._normalize_title(title)
|
||||
if normalized_title is not None:
|
||||
conversation.title = normalized_title
|
||||
values["title"] = normalized_title
|
||||
if status is not None:
|
||||
conversation.status = status
|
||||
values["status"] = status
|
||||
if is_pinned is not None:
|
||||
conversation.is_pinned = is_pinned
|
||||
|
||||
values["is_pinned"] = is_pinned
|
||||
if metadata is not None:
|
||||
current_metadata = conversation.extra_metadata or {}
|
||||
current_metadata.update(metadata)
|
||||
conversation.extra_metadata = current_metadata
|
||||
values["extra_metadata"] = Conversation.extra_metadata.op("||")(metadata)
|
||||
|
||||
stmt = (
|
||||
update(Conversation)
|
||||
.where(Conversation.thread_id == thread_id)
|
||||
.values(**values)
|
||||
.returning(Conversation)
|
||||
)
|
||||
|
||||
result = await self.db.execute(stmt)
|
||||
conversation = result.scalar_one_or_none()
|
||||
if conversation is None:
|
||||
return None
|
||||
|
||||
conversation.updated_at = utc_now_naive()
|
||||
await self.db.commit()
|
||||
await self.db.refresh(conversation)
|
||||
|
||||
@ -461,3 +514,25 @@ class ConversationRepository:
|
||||
metadata["attachments"] = new_attachments
|
||||
await self._save_metadata(conversation, metadata)
|
||||
return True
|
||||
|
||||
async def get_conversations_by_metadata_key(self, key: str, value: str) -> list[Conversation]:
|
||||
result = await self.db.execute(
|
||||
select(Conversation)
|
||||
.where(Conversation.extra_metadata[key].astext == value)
|
||||
.order_by(Conversation.updated_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_conversations_by_channel(
|
||||
self, channel_type: str, account_id: str | None = None
|
||||
) -> list[Conversation]:
|
||||
conditions = [Conversation.extra_metadata["channel_type"].astext == channel_type]
|
||||
if account_id:
|
||||
conditions.append(Conversation.extra_metadata["account_id"].astext == account_id)
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Conversation)
|
||||
.where(*conditions)
|
||||
.order_by(Conversation.updated_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
244
backend/package/yuxi/services/channel_service.py
Normal file
244
backend/package/yuxi/services/channel_service.py
Normal file
@ -0,0 +1,244 @@
|
||||
from typing import Any
|
||||
|
||||
from yuxi.channel.runtime.manager import gateway
|
||||
from yuxi.channel.security.pairing import PairingManager
|
||||
from yuxi.repositories.channel_binding_repo import ChannelBindingRepository
|
||||
from yuxi.repositories.channel_config_repo import ChannelConfigRepository
|
||||
from yuxi.repositories.channel_pairing_repo import ChannelPairingRecordRepository
|
||||
from yuxi.repositories.channel_thread_mapping_repo import ChannelThreadMappingRepository
|
||||
from yuxi.repositories.channel_user_mapping_repo import ChannelUserMappingRepository
|
||||
|
||||
_PAIRING_PEER_ID_PREFIX = "admin:pairing"
|
||||
|
||||
_config_repo = ChannelConfigRepository()
|
||||
_binding_repo = ChannelBindingRepository()
|
||||
_pairing_repo = ChannelPairingRecordRepository()
|
||||
_user_mapping_repo = ChannelUserMappingRepository()
|
||||
_thread_mapping_repo = ChannelThreadMappingRepository()
|
||||
|
||||
|
||||
async def list_channel_configs(
|
||||
channel_type: str | None = None,
|
||||
enabled: bool | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
if channel_type and enabled is not None:
|
||||
configs = await _config_repo.list_enabled(channel_type)
|
||||
elif enabled is not None:
|
||||
configs = await _config_repo.list_enabled()
|
||||
elif channel_type:
|
||||
configs = await _config_repo.list_by_type(channel_type)
|
||||
else:
|
||||
configs = await _config_repo.list_all()
|
||||
return [c.to_dict(mask_secrets=True) for c in configs]
|
||||
|
||||
|
||||
async def get_channel_config(config_id: str) -> dict[str, Any] | None:
|
||||
config = await _config_repo.get_by_id(config_id)
|
||||
return config.to_dict(mask_secrets=True) if config else None
|
||||
|
||||
|
||||
async def create_channel_config(data: dict[str, Any]) -> dict[str, Any]:
|
||||
config = await _config_repo.create(data)
|
||||
return config.to_dict(mask_secrets=True)
|
||||
|
||||
|
||||
async def update_channel_config(config_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
config = await _config_repo.update(config_id, data)
|
||||
return config.to_dict(mask_secrets=True) if config else None
|
||||
|
||||
|
||||
async def delete_channel_config(config_id: str) -> bool:
|
||||
return await _config_repo.delete(config_id)
|
||||
|
||||
|
||||
async def list_channel_bindings(agent_config_id: int | None = None) -> list[dict[str, Any]]:
|
||||
if agent_config_id is not None:
|
||||
bindings = await _binding_repo.list_by_agent(agent_config_id)
|
||||
else:
|
||||
bindings = await _binding_repo.list_all()
|
||||
return [b.to_dict() for b in bindings]
|
||||
|
||||
|
||||
async def get_channel_binding(binding_id: str) -> dict[str, Any] | None:
|
||||
binding = await _binding_repo.get_by_id(binding_id)
|
||||
return binding.to_dict() if binding else None
|
||||
|
||||
|
||||
async def create_channel_binding(data: dict[str, Any]) -> dict[str, Any]:
|
||||
binding = await _binding_repo.create(data)
|
||||
return binding.to_dict()
|
||||
|
||||
|
||||
async def update_channel_binding(binding_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
binding = await _binding_repo.update(binding_id, data)
|
||||
return binding.to_dict() if binding else None
|
||||
|
||||
|
||||
async def delete_channel_binding(binding_id: str) -> bool:
|
||||
return await _binding_repo.delete(binding_id)
|
||||
|
||||
|
||||
async def list_channel_bindings_by_type(channel_type: str) -> list[dict[str, Any]]:
|
||||
bindings = await _binding_repo.list_by_channel_type(channel_type)
|
||||
return [b.to_dict() for b in bindings]
|
||||
|
||||
|
||||
async def list_channel_pairing_records(status: str | None = None) -> list[dict[str, Any]]:
|
||||
records = await _pairing_repo.list_all(status=status)
|
||||
return [r.to_dict() for r in records]
|
||||
|
||||
|
||||
async def approve_channel_pairing(record_id: str) -> dict[str, Any] | None:
|
||||
record = await _pairing_repo.approve(record_id)
|
||||
return record.to_dict() if record else None
|
||||
|
||||
|
||||
async def reject_channel_pairing(record_id: str) -> dict[str, Any] | None:
|
||||
record = await _pairing_repo.reject(record_id)
|
||||
return record.to_dict() if record else None
|
||||
|
||||
|
||||
async def get_channel_pairing_record(record_id: str) -> dict[str, Any] | None:
|
||||
record = await _pairing_repo.get_by_id(record_id)
|
||||
return record.to_dict() if record else None
|
||||
|
||||
|
||||
async def delete_channel_pairing_record(record_id: str) -> bool:
|
||||
return await _pairing_repo.delete(record_id)
|
||||
|
||||
|
||||
async def generate_agent_pairing_code(agent_config_id: int) -> dict[str, Any]:
|
||||
bindings = await _binding_repo.list_by_agent(agent_config_id)
|
||||
if not bindings:
|
||||
raise ValueError(f"Agent config {agent_config_id} has no channel bindings")
|
||||
|
||||
binding = bindings[0]
|
||||
mgr = PairingManager()
|
||||
peer_id = f"{_PAIRING_PEER_ID_PREFIX}:{agent_config_id}"
|
||||
|
||||
req = await mgr.upsert_code(
|
||||
channel_type=binding.channel_type,
|
||||
peer_id=peer_id,
|
||||
account_id=binding.account_id or "default",
|
||||
)
|
||||
|
||||
return {
|
||||
"code": req.code,
|
||||
"channel_type": binding.channel_type,
|
||||
"account_id": binding.account_id or "default",
|
||||
"agent_config_id": agent_config_id,
|
||||
"record_id": req._record_id,
|
||||
}
|
||||
|
||||
|
||||
async def list_user_mappings() -> list[dict[str, Any]]:
|
||||
mappings = await _user_mapping_repo.list_all()
|
||||
return [m.to_dict() for m in mappings]
|
||||
|
||||
|
||||
async def get_user_mapping(mapping_id: int) -> dict[str, Any] | None:
|
||||
mapping = await _user_mapping_repo.get_by_id(mapping_id)
|
||||
return mapping.to_dict() if mapping else None
|
||||
|
||||
|
||||
async def find_user_mapping_by_channel(channel_id: str, channel_user_id: str) -> dict[str, Any] | None:
|
||||
mapping = await _user_mapping_repo.find_by_channel(channel_id, channel_user_id)
|
||||
return mapping.to_dict() if mapping else None
|
||||
|
||||
|
||||
async def resolve_channel_user(channel_id: str, channel_user_id: str) -> dict[str, Any]:
|
||||
mapping = await _user_mapping_repo.resolve_user(channel_id, channel_user_id)
|
||||
return mapping.to_dict()
|
||||
|
||||
|
||||
async def find_user_mappings_by_internal_user(internal_user_id: str) -> list[dict[str, Any]]:
|
||||
mappings = await _user_mapping_repo.find_by_internal_user(internal_user_id)
|
||||
return [m.to_dict() for m in mappings]
|
||||
|
||||
|
||||
async def create_user_mapping(data: dict[str, Any]) -> dict[str, Any]:
|
||||
mapping = await _user_mapping_repo.create(data)
|
||||
return mapping.to_dict()
|
||||
|
||||
|
||||
async def delete_user_mapping(mapping_id: int) -> bool:
|
||||
return await _user_mapping_repo.delete(mapping_id)
|
||||
|
||||
|
||||
async def resolve_channel_thread(
|
||||
channel_id: str,
|
||||
channel_chat_id: str,
|
||||
internal_user_id: str,
|
||||
agent_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
mapping = await _thread_mapping_repo.resolve_thread(
|
||||
channel_id,
|
||||
channel_chat_id,
|
||||
internal_user_id,
|
||||
agent_id,
|
||||
)
|
||||
return mapping.to_dict()
|
||||
|
||||
|
||||
async def get_thread_mapping(mapping_id: int) -> dict[str, Any] | None:
|
||||
mapping = await _thread_mapping_repo.get_by_id(mapping_id)
|
||||
return mapping.to_dict() if mapping else None
|
||||
|
||||
|
||||
async def find_thread_mapping_by_channel_chat(
|
||||
channel_id: str,
|
||||
channel_chat_id: str,
|
||||
internal_user_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
mapping = await _thread_mapping_repo.find_by_channel_chat(
|
||||
channel_id,
|
||||
channel_chat_id,
|
||||
internal_user_id,
|
||||
)
|
||||
return mapping.to_dict() if mapping else None
|
||||
|
||||
|
||||
async def find_thread_mapping_by_thread_id(thread_id: str) -> dict[str, Any] | None:
|
||||
mapping = await _thread_mapping_repo.find_by_thread_id(thread_id)
|
||||
return mapping.to_dict() if mapping else None
|
||||
|
||||
|
||||
async def create_thread_mapping(data: dict[str, Any]) -> dict[str, Any]:
|
||||
mapping = await _thread_mapping_repo.create(data)
|
||||
return mapping.to_dict()
|
||||
|
||||
|
||||
async def delete_thread_mapping(mapping_id: int) -> bool:
|
||||
return await _thread_mapping_repo.delete(mapping_id)
|
||||
|
||||
|
||||
async def get_gateway_health() -> dict[str, Any]:
|
||||
report = gateway.get_health()
|
||||
return {
|
||||
"status": report.status,
|
||||
"channels": report.channels,
|
||||
"summary": report.summary,
|
||||
"boot": gateway.get_boot_status().to_dict(),
|
||||
}
|
||||
|
||||
|
||||
async def get_gateway_healthz() -> dict[str, Any]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
async def get_hook_status() -> dict[str, Any]:
|
||||
result = gateway.get_hook_status()
|
||||
return result.to_dict()
|
||||
|
||||
|
||||
async def reload_hooks() -> dict[str, Any]:
|
||||
result = gateway.reload_hooks()
|
||||
return result.to_dict()
|
||||
|
||||
|
||||
async def install_hook(hook_id: str, hook_md_content: str, target: str = "workspace") -> dict[str, Any]:
|
||||
return gateway.install_hook(hook_id, hook_md_content, target)
|
||||
|
||||
|
||||
async def uninstall_hook(source: str, hook_id: str) -> dict[str, Any]:
|
||||
return gateway.uninstall_hook(source, hook_id)
|
||||
@ -590,6 +590,7 @@ async def _create_oidc_binding_placeholder(db, sub: str, target_user: User) -> N
|
||||
password_hash=password_hash,
|
||||
role=target_user.role,
|
||||
department_id=target_user.department_id,
|
||||
source="oidc",
|
||||
is_deleted=1, # 标记为deleted,不参与实际登录
|
||||
last_login=utc_now_naive(),
|
||||
)
|
||||
@ -692,6 +693,7 @@ async def create_oidc_user(db, user_info: dict, department_id: int | None = None
|
||||
"role": oidc_config.default_role,
|
||||
"department_id": department_id,
|
||||
"last_login": utc_now_naive(),
|
||||
"source": "oidc",
|
||||
}
|
||||
)
|
||||
logger.info(f"Created OIDC user: {new_user.username} ({user_id})")
|
||||
@ -880,6 +882,7 @@ async def oidc_callback_handler(code: str, state: str, db, request: Request | No
|
||||
"role": user.role,
|
||||
"department_id": user.department_id,
|
||||
"department_name": department_name,
|
||||
"source": user.source,
|
||||
}
|
||||
|
||||
exchange_code = OIDCUtils.generate_login_code(response_data)
|
||||
|
||||
@ -4,6 +4,7 @@ import json
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import yuxi.storage.postgres.models_channels as _ # 触发 SQLAlchemy Base.metadata 注册
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
@ -249,6 +250,180 @@ class PostgresManager(metaclass=SingletonMeta):
|
||||
for stmt in stmts:
|
||||
await conn.execute(text(stmt))
|
||||
|
||||
async def ensure_channels_schema(self):
|
||||
self._check_initialized()
|
||||
stmts = [
|
||||
"""CREATE TABLE IF NOT EXISTS channel_configs (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
channel_type VARCHAR(50) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
created_by VARCHAR(64),
|
||||
updated_by VARCHAR(64),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
)""",
|
||||
"""CREATE INDEX IF NOT EXISTS idx_channel_configs_type
|
||||
ON channel_configs (channel_type)""",
|
||||
"""CREATE INDEX IF NOT EXISTS idx_channel_configs_type_enabled
|
||||
ON channel_configs (channel_type, enabled)""",
|
||||
"""CREATE TABLE IF NOT EXISTS channel_bindings (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
channel_type VARCHAR(50) NOT NULL,
|
||||
channel_config_id VARCHAR(64) REFERENCES channel_configs(id) ON DELETE SET NULL,
|
||||
account_id VARCHAR(100),
|
||||
peer_kind VARCHAR(20),
|
||||
peer_id VARCHAR(200),
|
||||
agent_config_id INTEGER NOT NULL REFERENCES agent_configs(id) ON DELETE CASCADE,
|
||||
priority INTEGER DEFAULT 0,
|
||||
dm_scope VARCHAR(50) DEFAULT 'per-channel-peer',
|
||||
created_by VARCHAR(64),
|
||||
updated_by VARCHAR(64),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
)""",
|
||||
"""CREATE INDEX IF NOT EXISTS idx_channel_bindings_agent
|
||||
ON channel_bindings (agent_config_id)""",
|
||||
"""CREATE INDEX IF NOT EXISTS idx_channel_bindings_lookup
|
||||
ON channel_bindings (channel_type, account_id, peer_kind, peer_id)""",
|
||||
"""CREATE INDEX IF NOT EXISTS idx_channel_bindings_type
|
||||
ON channel_bindings (channel_type)""",
|
||||
"""CREATE INDEX IF NOT EXISTS idx_channel_bindings_config
|
||||
ON channel_bindings (channel_config_id)""",
|
||||
"""ALTER TABLE channel_bindings
|
||||
ADD COLUMN IF NOT EXISTS guild_id VARCHAR(100)""",
|
||||
"""ALTER TABLE channel_bindings
|
||||
ADD COLUMN IF NOT EXISTS team_id VARCHAR(100)""",
|
||||
"""ALTER TABLE channel_bindings
|
||||
ADD COLUMN IF NOT EXISTS roles JSONB DEFAULT '[]'""",
|
||||
"""ALTER TABLE channel_bindings
|
||||
ADD COLUMN IF NOT EXISTS session_dm_scope VARCHAR(50)""",
|
||||
"""CREATE TABLE IF NOT EXISTS channel_pairing_records (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
channel_type VARCHAR(50) NOT NULL,
|
||||
account_id VARCHAR(100) NOT NULL DEFAULT 'default',
|
||||
peer_id VARCHAR(200) NOT NULL,
|
||||
agent_config_id INTEGER REFERENCES agent_configs(id) ON DELETE SET NULL,
|
||||
pairing_code VARCHAR(8) NOT NULL,
|
||||
pairing_token VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
paired_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
)""",
|
||||
"""CREATE INDEX IF NOT EXISTS idx_channel_pairing_records_lookup
|
||||
ON channel_pairing_records (channel_type, account_id, peer_id, status)""",
|
||||
"""CREATE INDEX IF NOT EXISTS idx_channel_pairing_records_expiry
|
||||
ON channel_pairing_records (expires_at)""",
|
||||
"""CREATE INDEX IF NOT EXISTS idx_channel_pairing_records_agent
|
||||
ON channel_pairing_records (agent_config_id)""",
|
||||
"""CREATE TABLE IF NOT EXISTS channel_user_mappings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
channel_id VARCHAR(32) NOT NULL,
|
||||
channel_user_id VARCHAR(128) NOT NULL,
|
||||
internal_user_id VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
CONSTRAINT uq_channel_user UNIQUE (channel_id, channel_user_id)
|
||||
)""",
|
||||
"""CREATE INDEX IF NOT EXISTS idx_channel_user_internal
|
||||
ON channel_user_mappings (internal_user_id)""",
|
||||
"""CREATE TABLE IF NOT EXISTS channel_thread_mappings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
channel_id VARCHAR(32) NOT NULL,
|
||||
channel_chat_id VARCHAR(128) NOT NULL,
|
||||
internal_user_id VARCHAR(64) NOT NULL,
|
||||
thread_id VARCHAR(64) NOT NULL,
|
||||
agent_id VARCHAR(64),
|
||||
last_active_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
CONSTRAINT uq_channel_thread UNIQUE (channel_id, channel_chat_id, internal_user_id)
|
||||
)""",
|
||||
"""CREATE INDEX IF NOT EXISTS idx_channel_thread_thread
|
||||
ON channel_thread_mappings (thread_id)""",
|
||||
"""CREATE INDEX IF NOT EXISTS idx_channel_thread_active
|
||||
ON channel_thread_mappings (last_active_at)""",
|
||||
"""CREATE TABLE IF NOT EXISTS channel_msg_records (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
channel_id VARCHAR(32) NOT NULL,
|
||||
channel_type VARCHAR(32) NOT NULL,
|
||||
message_id VARCHAR(128) NOT NULL,
|
||||
chat_id VARCHAR(128) NOT NULL,
|
||||
chat_type VARCHAR(32) NOT NULL DEFAULT 'direct',
|
||||
content_type VARCHAR(32) NOT NULL DEFAULT 'text',
|
||||
sender_user_id VARCHAR(128) NOT NULL,
|
||||
content_preview VARCHAR(500) NOT NULL,
|
||||
reply_to_message_id VARCHAR(128),
|
||||
agent_config_id INTEGER,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'processing',
|
||||
error_message VARCHAR(500),
|
||||
response_time_ms INTEGER,
|
||||
reply_message_id VARCHAR(128),
|
||||
reply_content_preview VARCHAR(500),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
replied_at TIMESTAMP WITH TIME ZONE,
|
||||
extra_data JSONB
|
||||
)""",
|
||||
"""CREATE INDEX IF NOT EXISTS idx_msg_records_channel_status
|
||||
ON channel_msg_records (channel_id, status)""",
|
||||
"""CREATE INDEX IF NOT EXISTS idx_msg_records_created
|
||||
ON channel_msg_records (created_at)""",
|
||||
"""CREATE INDEX IF NOT EXISTS idx_msg_records_chat
|
||||
ON channel_msg_records (channel_id, chat_id)""",
|
||||
"""ALTER TABLE IF EXISTS users
|
||||
ADD COLUMN IF NOT EXISTS source VARCHAR(32) NOT NULL DEFAULT 'local'""",
|
||||
"""CREATE TABLE IF NOT EXISTS channel_device_identities (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
device_id VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
public_key_pem TEXT NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
created_by VARCHAR(64),
|
||||
last_used_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
)""",
|
||||
"""CREATE INDEX IF NOT EXISTS idx_channel_device_identities_device_id
|
||||
ON channel_device_identities (device_id)""",
|
||||
"""CREATE INDEX IF NOT EXISTS idx_channel_device_identities_status
|
||||
ON channel_device_identities (status)""",
|
||||
"""CREATE TABLE IF NOT EXISTS channel_identity_links (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
identity VARCHAR(255) NOT NULL,
|
||||
channel_type VARCHAR(64) NOT NULL,
|
||||
peer_id VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
CONSTRAINT uq_identity_link_channel_peer UNIQUE (channel_type, peer_id)
|
||||
)""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_identity_links_identity ON channel_identity_links(identity)",
|
||||
"""CREATE TABLE IF NOT EXISTS pay_notifications (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
notification_id VARCHAR(128) NOT NULL,
|
||||
mch_id VARCHAR(32) NOT NULL,
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
out_trade_no VARCHAR(64) DEFAULT '',
|
||||
transaction_id VARCHAR(64) DEFAULT '',
|
||||
resource_json JSONB DEFAULT '{}',
|
||||
status VARCHAR(32) DEFAULT 'pending',
|
||||
error_message TEXT,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
completed_at TIMESTAMP WITH TIME ZONE,
|
||||
CONSTRAINT uk_pay_notification_id UNIQUE (notification_id)
|
||||
)""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pay_notifications_mch_id ON pay_notifications(mch_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pay_notifications_out_trade_no ON pay_notifications(out_trade_no)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pay_notifications_status ON pay_notifications(status)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_pay_notifications_created_at ON pay_notifications(created_at)",
|
||||
]
|
||||
|
||||
async with self.async_engine.begin() as conn:
|
||||
for stmt in stmts:
|
||||
await conn.execute(text(stmt))
|
||||
|
||||
@property
|
||||
def is_postgresql(self) -> bool:
|
||||
"""检查是否是 PostgreSQL 数据库"""
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
"""PostgreSQL 业务数据模型 - 用户、部门、对话等相关表"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
@ -17,6 +20,7 @@ from sqlalchemy import (
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy.orm import relationship
|
||||
from yuxi.utils.datetime_utils import format_utc_datetime, utc_now_naive
|
||||
|
||||
@ -61,6 +65,9 @@ class User(Base):
|
||||
password_hash = Column(String, nullable=False)
|
||||
role = Column(String, nullable=False, default="user") # 角色: superadmin, admin, user
|
||||
department_id = Column(Integer, ForeignKey("departments.id"), nullable=True) # 部门ID
|
||||
source = Column(
|
||||
String(32), nullable=False, default="local", comment="用户来源: local / channel:telegram / channel:feishu / ..."
|
||||
)
|
||||
created_at = Column(DateTime, default=utc_now_naive)
|
||||
last_login = Column(DateTime, nullable=True)
|
||||
|
||||
@ -91,6 +98,7 @@ class User(Base):
|
||||
"avatar": self.avatar,
|
||||
"role": self.role,
|
||||
"department_id": self.department_id,
|
||||
"source": self.source,
|
||||
"created_at": format_utc_datetime(self.created_at),
|
||||
"last_login": format_utc_datetime(self.last_login),
|
||||
"login_failed_count": self.login_failed_count,
|
||||
@ -543,7 +551,7 @@ class ModelProvider(Base):
|
||||
embedding_models_endpoint = Column(String(200), nullable=True, comment="Embedding 模型列表端点")
|
||||
rerank_models_endpoint = Column(String(200), nullable=True, comment="Rerank 模型列表端点")
|
||||
api_key_env = Column(String(128), nullable=True, comment="API Key 环境变量名")
|
||||
api_key = Column(String(500), nullable=True, comment="直接配置的 API Key")
|
||||
_api_key = Column("api_key", String(1000), nullable=True, comment="直接配置的 API Key (加密存储)")
|
||||
|
||||
capabilities = Column(JSON, nullable=False, default=list, comment="支持能力:chat/embedding/rerank")
|
||||
enabled_models = Column(JSON, nullable=False, default=list, comment="已启用模型配置对象")
|
||||
@ -558,6 +566,34 @@ class ModelProvider(Base):
|
||||
created_at = Column(DateTime, default=utc_now_naive, comment="创建时间")
|
||||
updated_at = Column(DateTime, default=utc_now_naive, onupdate=utc_now_naive, comment="更新时间")
|
||||
|
||||
@hybrid_property
|
||||
def api_key(self) -> str | None:
|
||||
from yuxi.channel.secrets.crypto import CryptoError, decrypt_secret
|
||||
|
||||
if self._api_key:
|
||||
try:
|
||||
return decrypt_secret(self._api_key)
|
||||
except CryptoError:
|
||||
_logger.warning(
|
||||
"Failed to decrypt api_key for provider %s",
|
||||
self.provider_id,
|
||||
)
|
||||
return None
|
||||
return None
|
||||
|
||||
@api_key.setter # type: ignore[no-redef]
|
||||
def api_key(self, value: str | None) -> None:
|
||||
from yuxi.channel.secrets.crypto import CryptoError, encrypt_secret, is_encrypted
|
||||
|
||||
if value and not is_encrypted(value):
|
||||
try:
|
||||
self._api_key = encrypt_secret(value)
|
||||
except CryptoError as e:
|
||||
_logger.error("Failed to encrypt api_key: %s", e)
|
||||
self._api_key = value
|
||||
else:
|
||||
self._api_key = value
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
|
||||
462
backend/package/yuxi/storage/postgres/models_channels.py
Normal file
462
backend/package/yuxi/storage/postgres/models_channels.py
Normal file
@ -0,0 +1,462 @@
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from yuxi.storage.postgres.models_business import Base
|
||||
from yuxi.utils.datetime_utils import format_utc_datetime, utc_now_naive
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_SECRET_FIELD_NAMES = frozenset(
|
||||
{
|
||||
"app_secret",
|
||||
"bot_token",
|
||||
"signing_secret",
|
||||
"encrypt_key",
|
||||
"verification_token",
|
||||
"api_key",
|
||||
"api_secret",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"webhook_token",
|
||||
"client_secret",
|
||||
"secret",
|
||||
"password",
|
||||
"passwd",
|
||||
"auth_token",
|
||||
"app_key",
|
||||
"license_key",
|
||||
"private_key",
|
||||
"webhook_secret",
|
||||
}
|
||||
)
|
||||
|
||||
_SECRET_MARKER = "$secret"
|
||||
|
||||
|
||||
class ChannelConfig(Base):
|
||||
__tablename__ = "channel_configs"
|
||||
|
||||
id = Column(String(64), primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
channel_type = Column(String(50), nullable=False, comment="渠道类型: feishu/dingtalk/wecom_bot/telegram/slack/imessage")
|
||||
name = Column(String(100), nullable=False, comment="渠道账户名称")
|
||||
config = Column(JSON, nullable=False, default=dict, comment="渠道特定配置")
|
||||
enabled = Column(Boolean, nullable=False, default=True, comment="启用状态")
|
||||
created_by = Column(String(64), nullable=True, comment="创建人")
|
||||
updated_by = Column(String(64), nullable=True, comment="更新人")
|
||||
created_at = Column(DateTime, default=utc_now_naive)
|
||||
updated_at = Column(DateTime, default=utc_now_naive, onupdate=utc_now_naive)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_channel_configs_type", "channel_type"),
|
||||
Index("idx_channel_configs_type_enabled", "channel_type", "enabled"),
|
||||
)
|
||||
|
||||
def to_dict(self, mask_secrets: bool = True) -> dict[str, Any]:
|
||||
config = dict(self.config or {})
|
||||
if mask_secrets:
|
||||
_mask_secret_values(config)
|
||||
return {
|
||||
"id": self.id,
|
||||
"channel_type": self.channel_type,
|
||||
"name": self.name,
|
||||
"config": config,
|
||||
"enabled": bool(self.enabled),
|
||||
"created_by": self.created_by,
|
||||
"updated_by": self.updated_by,
|
||||
"created_at": format_utc_datetime(self.created_at),
|
||||
"updated_at": format_utc_datetime(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
class ChannelBinding(Base):
|
||||
__tablename__ = "channel_bindings"
|
||||
|
||||
id = Column(String(64), primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
channel_type = Column(String(50), nullable=False, comment="渠道类型")
|
||||
channel_config_id = Column(
|
||||
String(64),
|
||||
ForeignKey("channel_configs.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
comment="关联 channel_configs; NULL=渠道级通配绑定",
|
||||
)
|
||||
account_id = Column(String(100), nullable=True, comment="渠道账户ID(对端视角), '*'=通配")
|
||||
peer_kind = Column(String(20), nullable=True, comment="direct/group/channel")
|
||||
peer_id = Column(String(200), nullable=True, comment="对端ID, '*'=通配")
|
||||
agent_config_id = Column(
|
||||
Integer, ForeignKey("agent_configs.id", ondelete="CASCADE"), nullable=False, comment="路由目标 AgentConfig"
|
||||
)
|
||||
priority = Column(Integer, nullable=False, default=0, comment="优先级, 数值越大越优先")
|
||||
dm_scope = Column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="per-channel-peer",
|
||||
comment="DM Session 隔离粒度: main/per-peer/per-channel-peer/per-account-channel-peer",
|
||||
)
|
||||
guild_id = Column(String(100), nullable=True, comment="Discord/Slack Guild ID")
|
||||
team_id = Column(String(100), nullable=True, comment="Slack Team ID")
|
||||
roles = Column(JSON, nullable=True, default=list, comment="角色列表,匹配 guild+roles 场景")
|
||||
session_dm_scope = Column(
|
||||
String(50),
|
||||
nullable=True,
|
||||
comment="单条绑定级 DM 隔离策略覆盖(覆盖 agent 级默认值)",
|
||||
)
|
||||
created_by = Column(String(64), nullable=True, comment="创建人")
|
||||
updated_by = Column(String(64), nullable=True, comment="更新人")
|
||||
created_at = Column(DateTime, default=utc_now_naive)
|
||||
updated_at = Column(DateTime, default=utc_now_naive, onupdate=utc_now_naive)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_channel_bindings_agent", "agent_config_id"),
|
||||
Index("idx_channel_bindings_lookup", "channel_type", "account_id", "peer_kind", "peer_id"),
|
||||
Index("idx_channel_bindings_type", "channel_type"),
|
||||
Index("idx_channel_bindings_config", "channel_config_id"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"channel_type": self.channel_type,
|
||||
"channel_config_id": self.channel_config_id,
|
||||
"account_id": self.account_id,
|
||||
"peer_kind": self.peer_kind,
|
||||
"peer_id": self.peer_id,
|
||||
"agent_config_id": self.agent_config_id,
|
||||
"priority": self.priority,
|
||||
"dm_scope": self.dm_scope,
|
||||
"guild_id": self.guild_id,
|
||||
"team_id": self.team_id,
|
||||
"roles": self.roles or [],
|
||||
"session_dm_scope": self.session_dm_scope,
|
||||
"created_by": self.created_by,
|
||||
"updated_by": self.updated_by,
|
||||
"created_at": format_utc_datetime(self.created_at),
|
||||
"updated_at": format_utc_datetime(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
class ChannelPairingRecord(Base):
|
||||
__tablename__ = "channel_pairing_records"
|
||||
|
||||
id = Column(String(64), primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
channel_type = Column(String(50), nullable=False, comment="渠道类型")
|
||||
account_id = Column(String(100), nullable=False, default="default", comment="渠道账户ID")
|
||||
peer_id = Column(String(200), nullable=False, comment="对端用户ID")
|
||||
agent_config_id = Column(
|
||||
Integer,
|
||||
ForeignKey("agent_configs.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
comment="配对归属的 AgentConfig(路由解析后回填)",
|
||||
)
|
||||
pairing_code = Column(String(8), nullable=False, comment="8位配对码")
|
||||
pairing_token = Column(String(64), nullable=False, comment="配对令牌")
|
||||
status = Column(String(20), nullable=False, default="pending", comment="pending/paired/expired")
|
||||
expires_at = Column(DateTime, nullable=False, comment="过期时间")
|
||||
paired_at = Column(DateTime, nullable=True, comment="配对完成时间")
|
||||
created_at = Column(DateTime, default=utc_now_naive)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_channel_pairing_records_lookup", "channel_type", "account_id", "peer_id", "status"),
|
||||
Index("idx_channel_pairing_records_expiry", "expires_at"),
|
||||
Index("idx_channel_pairing_records_agent", "agent_config_id"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"channel_type": self.channel_type,
|
||||
"account_id": self.account_id,
|
||||
"peer_id": self.peer_id,
|
||||
"agent_config_id": self.agent_config_id,
|
||||
"pairing_code": self.pairing_code,
|
||||
"pairing_token": self.pairing_token,
|
||||
"status": self.status,
|
||||
"expires_at": format_utc_datetime(self.expires_at),
|
||||
"paired_at": format_utc_datetime(self.paired_at) if self.paired_at else None,
|
||||
"created_at": format_utc_datetime(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
class ChannelUserMapping(Base):
|
||||
__tablename__ = "channel_user_mappings"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
channel_id = Column(String(32), nullable=False, comment="渠道标识")
|
||||
channel_user_id = Column(String(128), nullable=False, comment="渠道侧用户ID")
|
||||
internal_user_id = Column(String(64), nullable=False, comment="内部用户ID")
|
||||
created_at = Column(DateTime, nullable=False, default=utc_now_naive)
|
||||
updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("channel_id", "channel_user_id", name="uq_channel_user"),
|
||||
Index("idx_channel_user_internal", "internal_user_id"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"channel_id": self.channel_id,
|
||||
"channel_user_id": self.channel_user_id,
|
||||
"internal_user_id": self.internal_user_id,
|
||||
"created_at": format_utc_datetime(self.created_at),
|
||||
"updated_at": format_utc_datetime(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
class ChannelIdentityLink(Base):
|
||||
__tablename__ = "channel_identity_links"
|
||||
|
||||
id = Column(String(64), primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
identity = Column(String(255), nullable=False, index=True, comment="统一身份标识")
|
||||
channel_type = Column(String(64), nullable=False, comment="渠道类型")
|
||||
peer_id = Column(String(255), nullable=False, comment="渠道侧用户/群组ID")
|
||||
created_at = Column(DateTime, default=utc_now_naive)
|
||||
updated_at = Column(DateTime, default=utc_now_naive, onupdate=utc_now_naive)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("channel_type", "peer_id", name="uq_identity_link_channel_peer"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"identity": self.identity,
|
||||
"channel_type": self.channel_type,
|
||||
"peer_id": self.peer_id,
|
||||
"created_at": format_utc_datetime(self.created_at),
|
||||
"updated_at": format_utc_datetime(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
class ChannelAllowlistEntry(Base):
|
||||
__tablename__ = "channel_allowlist_entries"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
channel_id = Column(String(32), nullable=False, comment="渠道标识")
|
||||
allow_type = Column(String(32), nullable=False, default="user", comment="允许类型: user/group")
|
||||
allow_id = Column(String(128), nullable=False, comment="允许的用户ID或群组ID")
|
||||
enabled = Column(Boolean, nullable=False, default=True, comment="是否启用")
|
||||
created_at = Column(DateTime, nullable=False, default=utc_now_naive)
|
||||
updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("channel_id", "allow_type", "allow_id", name="uq_channel_allowlist"),
|
||||
Index("idx_channel_allowlist_channel", "channel_id"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"channel_id": self.channel_id,
|
||||
"allow_type": self.allow_type,
|
||||
"allow_id": self.allow_id,
|
||||
"enabled": self.enabled,
|
||||
"created_at": format_utc_datetime(self.created_at),
|
||||
"updated_at": format_utc_datetime(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
class ChannelThreadMapping(Base):
|
||||
__tablename__ = "channel_thread_mappings"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
channel_id = Column(String(32), nullable=False, comment="渠道标识")
|
||||
channel_chat_id = Column(String(128), nullable=False, comment="渠道侧会话/群组ID")
|
||||
internal_user_id = Column(String(64), nullable=False, comment="内部用户ID")
|
||||
thread_id = Column(String(64), nullable=False, comment="LangGraph thread_id (UUID)")
|
||||
agent_id = Column(String(64), nullable=True, comment="绑定的 Agent ID")
|
||||
last_active_at = Column(DateTime, nullable=False, default=utc_now_naive, comment="最后活跃时间")
|
||||
created_at = Column(DateTime, nullable=False, default=utc_now_naive)
|
||||
updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("channel_id", "channel_chat_id", "internal_user_id", name="uq_channel_thread"),
|
||||
Index("idx_channel_thread_thread", "thread_id"),
|
||||
Index("idx_channel_thread_active", "last_active_at"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"channel_id": self.channel_id,
|
||||
"channel_chat_id": self.channel_chat_id,
|
||||
"internal_user_id": self.internal_user_id,
|
||||
"thread_id": self.thread_id,
|
||||
"agent_id": self.agent_id,
|
||||
"last_active_at": format_utc_datetime(self.last_active_at),
|
||||
"created_at": format_utc_datetime(self.created_at),
|
||||
"updated_at": format_utc_datetime(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
class ChannelMsgRecord(Base):
|
||||
__tablename__ = "channel_msg_records"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
channel_id = Column(String(32), nullable=False, comment="渠道标识")
|
||||
channel_type = Column(String(32), nullable=False, comment="渠道类型枚举值")
|
||||
message_id = Column(String(128), nullable=False, comment="渠道侧消息ID")
|
||||
chat_id = Column(String(128), nullable=False, comment="渠道侧会话ID")
|
||||
chat_type = Column(String(32), nullable=False, default="direct", comment="会话类型: direct/group/thread/forum")
|
||||
content_type = Column(String(32), nullable=False, default="text", comment="消息类型: text/image/file/audio/video")
|
||||
sender_user_id = Column(String(128), nullable=False, comment="发送者渠道用户ID")
|
||||
content_preview = Column(String(500), nullable=False, comment="消息内容预览(截断500字符)")
|
||||
reply_to_message_id = Column(String(128), nullable=True, comment="被回复的消息ID")
|
||||
agent_config_id = Column(Integer, nullable=True, comment="处理消息的 AgentConfig ID")
|
||||
status = Column(String(32), nullable=False, default="processing", comment="processing/success/error/timeout")
|
||||
error_message = Column(String(500), nullable=True, comment="错误信息")
|
||||
response_time_ms = Column(Integer, nullable=True, comment="响应时间(毫秒)")
|
||||
reply_message_id = Column(String(128), nullable=True, comment="回复消息ID")
|
||||
reply_content_preview = Column(String(500), nullable=True, comment="回复内容预览")
|
||||
created_at = Column(DateTime, nullable=False, default=utc_now_naive)
|
||||
replied_at = Column(DateTime, nullable=True, comment="回复时间")
|
||||
extra_data = Column(JSON, nullable=True, comment="扩展元数据")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_msg_records_channel_status", "channel_id", "status"),
|
||||
Index("idx_msg_records_created", "created_at"),
|
||||
Index("idx_msg_records_chat", "channel_id", "chat_id"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"channel_id": self.channel_id,
|
||||
"channel_type": self.channel_type,
|
||||
"message_id": self.message_id,
|
||||
"chat_id": self.chat_id,
|
||||
"chat_type": self.chat_type,
|
||||
"content_type": self.content_type,
|
||||
"sender_user_id": self.sender_user_id,
|
||||
"content_preview": self.content_preview,
|
||||
"reply_to_message_id": self.reply_to_message_id,
|
||||
"agent_config_id": self.agent_config_id,
|
||||
"status": self.status,
|
||||
"error_message": self.error_message,
|
||||
"response_time_ms": self.response_time_ms,
|
||||
"reply_message_id": self.reply_message_id,
|
||||
"reply_content_preview": self.reply_content_preview,
|
||||
"created_at": format_utc_datetime(self.created_at),
|
||||
"replied_at": format_utc_datetime(self.replied_at) if self.replied_at else None,
|
||||
"extra_data": self.extra_data,
|
||||
}
|
||||
|
||||
|
||||
def _mask_secret_values(config: dict, _depth: int = 0) -> None:
|
||||
if _depth > 10:
|
||||
return
|
||||
for key, value in config.items():
|
||||
if isinstance(value, dict):
|
||||
if _SECRET_MARKER in value:
|
||||
inner = dict(value[_SECRET_MARKER])
|
||||
inner["ref"] = "***"
|
||||
config[key] = {_SECRET_MARKER: inner}
|
||||
else:
|
||||
_mask_secret_values(value, _depth + 1)
|
||||
elif key.lower() in _SECRET_FIELD_NAMES and value:
|
||||
config[key] = "***"
|
||||
|
||||
|
||||
def _encrypt_config_sensitive_fields(config: dict, _depth: int = 0) -> dict:
|
||||
if _depth > 10:
|
||||
return config
|
||||
from yuxi.channel.secrets.crypto import encrypt_secret
|
||||
|
||||
result: dict = {}
|
||||
for key, value in config.items():
|
||||
if isinstance(value, dict):
|
||||
if _SECRET_MARKER in value:
|
||||
result[key] = value
|
||||
else:
|
||||
result[key] = _encrypt_config_sensitive_fields(value, _depth + 1)
|
||||
elif isinstance(value, str) and key.lower() in _SECRET_FIELD_NAMES and value:
|
||||
result[key] = encrypt_secret(value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _decrypt_config_sensitive_fields(config: dict, _depth: int = 0) -> dict:
|
||||
if _depth > 10:
|
||||
return config
|
||||
from yuxi.channel.secrets.crypto import CryptoError, decrypt_secret, is_encrypted
|
||||
|
||||
result: dict = {}
|
||||
for key, value in config.items():
|
||||
if isinstance(value, dict):
|
||||
if _SECRET_MARKER in value:
|
||||
result[key] = value
|
||||
else:
|
||||
result[key] = _decrypt_config_sensitive_fields(value, _depth + 1)
|
||||
elif isinstance(value, str) and is_encrypted(value):
|
||||
try:
|
||||
result[key] = decrypt_secret(value)
|
||||
except CryptoError:
|
||||
_logger.warning(
|
||||
"Failed to decrypt field %s in channel config, using redacted",
|
||||
key,
|
||||
)
|
||||
result[key] = "***"
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _install_channel_config_crypto_hooks():
|
||||
from sqlalchemy import event as sa_event
|
||||
|
||||
@sa_event.listens_for(ChannelConfig, "before_insert")
|
||||
@sa_event.listens_for(ChannelConfig, "before_update")
|
||||
def _before_write(_mapper, _connection, target):
|
||||
if target.config and isinstance(target.config, dict):
|
||||
target.config = _encrypt_config_sensitive_fields(dict(target.config))
|
||||
|
||||
@sa_event.listens_for(ChannelConfig, "load")
|
||||
def _after_load(target, _context):
|
||||
if target.config and isinstance(target.config, dict):
|
||||
target.config = _decrypt_config_sensitive_fields(dict(target.config))
|
||||
|
||||
|
||||
_install_channel_config_crypto_hooks()
|
||||
|
||||
|
||||
class DeviceIdentity(Base):
|
||||
__tablename__ = "channel_device_identities"
|
||||
|
||||
id = Column(String(64), primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||
device_id = Column(String(64), unique=True, nullable=False, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
public_key_pem = Column(Text, nullable=False)
|
||||
status = Column(String(20), default="active")
|
||||
created_by = Column(String(64), nullable=True)
|
||||
last_used_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=utc_now_naive)
|
||||
updated_at = Column(DateTime, default=utc_now_naive, onupdate=utc_now_naive)
|
||||
|
||||
__table_args__ = (Index("idx_channel_device_identities_status", "status"),)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"device_id": self.device_id,
|
||||
"name": self.name,
|
||||
"public_key_pem": self.public_key_pem,
|
||||
"status": self.status,
|
||||
"created_by": self.created_by,
|
||||
"last_used_at": format_utc_datetime(self.last_used_at) if self.last_used_at else None,
|
||||
"created_at": format_utc_datetime(self.created_at),
|
||||
"updated_at": format_utc_datetime(self.updated_at),
|
||||
}
|
||||
@ -12,7 +12,7 @@ LOG_FILE = f"{SAVE_DIR}/logs/yuxi-{DATETIME}.log"
|
||||
|
||||
|
||||
class LoguruHandler(logging.Handler):
|
||||
"""将 Python logging 桥接到 loguru 的 handler"""
|
||||
"""将 Python logging 桥接到 loguru 的 handler,自动注入 trace_id"""
|
||||
|
||||
def emit(self, record: logging.LogRecord):
|
||||
level_map = {
|
||||
@ -27,11 +27,18 @@ class LoguruHandler(logging.Handler):
|
||||
msg = self.format(record)
|
||||
except Exception:
|
||||
msg = record.getMessage()
|
||||
trace_id = getattr(record, "trace_id", "-") or "-"
|
||||
msg = f"[{trace_id}] {msg}"
|
||||
loguru_logger.opt(depth=1, exception=record.exc_info).log(level, msg)
|
||||
|
||||
|
||||
def _setup_logging_bridge():
|
||||
"""配置 logging 到 loguru 的桥接,捕获第三方库日志(如 LightRAG)"""
|
||||
"""配置 logging 到 loguru 的桥接,捕获第三方库日志(如 LightRAG),
|
||||
|
||||
同时为 yuxi.channel 模块日志安装 trace_id filter 实现全链路追踪。
|
||||
"""
|
||||
from yuxi.channel.runtime.trace_ctx import TraceIdFilter
|
||||
|
||||
loguru_handler = LoguruHandler()
|
||||
loguru_handler.setLevel(logging.DEBUG)
|
||||
|
||||
@ -51,6 +58,14 @@ def _setup_logging_bridge():
|
||||
lib_logger.setLevel(logging.WARNING)
|
||||
lib_logger.propagate = False
|
||||
|
||||
# 桥接 yuxi.channel 模块日志,并安装 trace_id filter
|
||||
channel_logger = logging.getLogger("yuxi.channel")
|
||||
channel_logger.addHandler(loguru_handler)
|
||||
channel_logger.setLevel(logging.DEBUG)
|
||||
channel_logger.propagate = False
|
||||
trace_filter = TraceIdFilter()
|
||||
channel_logger.addFilter(trace_filter)
|
||||
|
||||
|
||||
def setup_logger(name, level="DEBUG", console=True):
|
||||
"""使用 loguru 设置日志记录器"""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user