ForcePilot/backend/package/yuxi/channel/session/manager.py
Kris bab30f2715
Some checks failed
Deploy VitePress site to Pages / build (push) Has been cancelled
Ruff Format Check / Ruff Format & Lint (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
feat:0715
2026-07-15 12:30:58 +08:00

209 lines
8.1 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 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