ForcePilot/backend/package/yuxi/storage/postgres/models_channels.py
Kris fb754d82f1 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: 日志配置更新
2026-05-21 12:08:03 +08:00

463 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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