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