ForcePilot/backend/package/yuxi/channel/session/manager.py

209 lines
8.1 KiB
Python
Raw Normal View History

2026-07-15 12:30:58 +08:00
"""渠道会话管理器"""
import uuid
from hashlib import md5
from sqlalchemy import select, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.channel.plugins.protocol import (
BindingRoute,
ChannelPlugin,
InboundMessage,
SessionConversationRef,
)
from yuxi.channel.routing.router import BindingRouter
from yuxi.channel.session.user_mapping import build_channel_user_uid
from yuxi.repositories.channel_session_repository import ChannelSessionRepository
from yuxi.repositories.conversation_repository import ConversationRepository
from yuxi.storage.postgres.model_channel import ChannelSession
from yuxi.storage.postgres.models_business import User
class SessionManager:
def __init__(
self,
binding_router: BindingRouter,
) -> None:
self.router = binding_router
async def resolve(
self,
config: dict,
plugin: ChannelPlugin,
inbound: InboundMessage,
db_session: AsyncSession,
) -> tuple[ChannelSession, BindingRoute | None]:
"""解析渠道会话,返回会话与首次解析出的路由(新建会话时)。
若会话已存在则路由为 None由调用方按需复用缓存或重新解析
"""
# 1. 插件自定义解析
ref = plugin.resolve_session_conversation(
inbound.chat_type,
inbound.peer_id or inbound.sender_id or "",
)
# 2. 通用回退
if ref is None:
ref = self._fallback_resolve(plugin, inbound)
# 3. 解析插件级回复与线程策略
reply_to_mode = plugin.resolve_reply_to_mode(config, inbound)
auto_thread_id = plugin.resolve_auto_thread_id(config, inbound)
if reply_to_mode is not None:
ref.channel_metadata["reply_to_mode"] = reply_to_mode
if auto_thread_id:
if auto_thread_id not in ref.parent_conversation_candidates:
ref.parent_conversation_candidates.append(auto_thread_id)
ref.channel_metadata["auto_thread_id"] = auto_thread_id
if ref.parent_conversation_candidates:
ref.channel_metadata["parent_conversation_candidates"] = ref.parent_conversation_candidates
session_repo = ChannelSessionRepository(db_session)
# 4. Postgres 下对 session_key 加事务级咨询锁,串行化新建会话,避免并发冲突
await self._acquire_session_key_lock(db_session, ref.session_key)
# 5. 在锁保护下重新查询已有 ChannelSession
session = await session_repo.get_by_session_key(ref.session_key)
if session is not None:
updates: dict = {}
if reply_to_mode is not None:
updates["reply_to_mode"] = reply_to_mode
if auto_thread_id:
updates["auto_thread_id"] = auto_thread_id
if ref.parent_conversation_candidates:
updates["parent_conversation_candidates"] = ref.parent_conversation_candidates
if updates:
session.update_channel_metadata(updates)
await db_session.flush()
return session, None
# 6. 先确定 agent_id必须在创建 Conversation 前)
route = await self.router.resolve_runtime(config, plugin, ref, db_session)
agent_id = route.agent_id or config.get("default_agent_id")
if not agent_id:
raise ValueError(f"Unable to resolve agent_id for session {ref.session_key}")
# 7. 创建/获取账户级虚拟用户(复用同一事务)
channel_user = await self._get_or_create_channel_user(
channel_type=config["channel_type"],
account_id=config["account_id"],
db_session=db_session,
)
# 8. 创建 Conversation
channel_metadata = {
"account_id": config["account_id"],
"sender_id": inbound.sender_id,
"sender_name": inbound.sender_name,
}
conversation_repo = ConversationRepository(db_session)
conversation = await conversation_repo.create_conversation(
uid=channel_user.uid,
agent_id=agent_id,
thread_id=self._session_key_to_thread_id(ref.session_key),
metadata={
"channel_type": config["channel_type"],
"channel_session_id": ref.session_key,
"channel_metadata": channel_metadata,
},
channel_type=config["channel_type"],
channel_session_id=ref.session_key,
channel_metadata=channel_metadata,
auto_commit=False,
)
# 9. 创建 ChannelSession非 Postgres 环境仍可能冲突,兜底重取并丢弃当前路由)
try:
session = await session_repo.create(
session_key=ref.session_key,
channel_type=config["channel_type"],
account_id=config["account_id"],
chat_type=inbound.chat_type,
channel_sender_id=inbound.sender_id,
conversation_id=conversation.id,
channel_metadata=ref.channel_metadata,
auto_commit=False,
)
except IntegrityError:
await db_session.rollback()
session = await session_repo.get_by_session_key(ref.session_key)
if session is None:
raise
await db_session.refresh(session)
return session, None
return session, route
async def _acquire_session_key_lock(self, db_session: AsyncSession, session_key: str) -> None:
"""在 Postgres 下对 session_key 加事务级咨询锁,避免并发新建同一会话。
Postgres 方言不执行任何操作依赖外层 IntegrityError 兜底
"""
bind = db_session.bind
if bind is None or bind.dialect.name != "postgresql":
return
lock_id = self._session_key_lock_id(session_key)
await db_session.execute(text("SELECT pg_advisory_xact_lock(:lock_id)").bindparams(lock_id=lock_id))
def _session_key_lock_id(self, session_key: str) -> int:
"""把 session_key 映射为 64 位有符号整数,供 pg_advisory_xact_lock 使用。"""
return int(md5(session_key.encode()).hexdigest()[:15], 16)
async def record_message(
self,
session: ChannelSession,
inbound: InboundMessage,
db_session: AsyncSession,
) -> None:
"""更新 ChannelSession 最后消息时间等轻量元数据。"""
session_repo = ChannelSessionRepository(db_session)
await session_repo.update_last_message_at(
session,
channel_message_id=inbound.channel_message_id,
)
async def _get_or_create_channel_user(
self,
channel_type: str,
account_id: str,
db_session: AsyncSession,
) -> User:
uid = build_channel_user_uid(channel_type, account_id)
result = await db_session.execute(select(User).where(User.uid == uid))
user = result.scalar_one_or_none()
if user is not None:
return user
user = User(
uid=uid,
username=f"{channel_type}:{account_id}",
password_hash="",
role="user",
is_channel_user=True,
channel_type=channel_type,
)
db_session.add(user)
try:
await db_session.flush()
except IntegrityError:
await db_session.rollback()
result = await db_session.execute(select(User).where(User.uid == uid))
user = result.scalar_one_or_none()
if user is None:
raise
return user
def _fallback_resolve(self, plugin: ChannelPlugin, inbound: InboundMessage) -> SessionConversationRef:
return SessionConversationRef(
session_key=plugin.parse_session_key(inbound),
chat_type=inbound.chat_type,
channel_sender_id=inbound.sender_id,
channel_metadata={"fallback": True},
)
def _session_key_to_thread_id(self, session_key: str) -> str:
return uuid.uuid5(uuid.NAMESPACE_OID, session_key).hex