mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-30 04:15:45 +08:00
821 lines
32 KiB
Python
821 lines
32 KiB
Python
# Auto generated by LangBot Plugin SDK.
|
|
# HumanTakeover - 人工接管与人工回复插件
|
|
# Please refer to https://langbot.app/docs/en/plugin/dev/tutor.html for more details.
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
from contextlib import asynccontextmanager
|
|
from copy import deepcopy
|
|
from typing import Any
|
|
|
|
from langbot_plugin.api.definition.plugin import BasePlugin
|
|
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 接管静默超时(秒)默认值:接管后若该时长内无人工响应则自动取消接管
|
|
DEFAULT_TAKEOVER_TIMEOUT = 600
|
|
# 每个会话最多保留的消息条数,避免存储无限膨胀
|
|
MAX_MESSAGES_PER_SESSION = 300
|
|
|
|
# plugin_storage 键名
|
|
STORAGE_KEY_SESSIONS = "ht_sessions"
|
|
STORAGE_KEY_MESSAGES = "ht_messages"
|
|
STORAGE_KEY_SCHEMA = "ht_storage_schema"
|
|
STORAGE_SESSION_PREFIX = "ht_session_v2_"
|
|
# Leave ample room for SDK base64 encoding and the 16 MiB transport envelope.
|
|
MAX_SESSION_STORAGE_BYTES = 8 * 1024 * 1024
|
|
|
|
# Process-cache slot used when no installation binding is available (dedicated
|
|
# placement). It maps to the legacy storage keys so existing dedicated installs
|
|
# keep reading their own rows.
|
|
DEDICATED_SCOPE = "dedicated"
|
|
|
|
# Written by the SDK worker launcher. A shared worker must always carry a trusted
|
|
# invocation binding; without one it must refuse to touch tenant state instead of
|
|
# falling back to a binding-less scope shared by every installation.
|
|
RUNTIME_PROFILE_ENV = "LANGBOT_PLUGIN_RUNTIME_PROFILE"
|
|
|
|
|
|
def installation_scope(binding) -> str:
|
|
"""Return the stable scope token for one installation binding.
|
|
|
|
``runtime_revision`` changes on every worker upgrade, so only the stable
|
|
installation identity triple is used; including it would orphan persisted
|
|
rows and make the revoked binding impossible to release.
|
|
"""
|
|
|
|
return f"{binding.instance_uuid}:{binding.workspace_uuid}:{binding.installation_uuid}"
|
|
|
|
|
|
def scoped_storage_key(scope: str, key: str) -> str:
|
|
"""Return the plugin-storage key for one installation scope.
|
|
|
|
Host rows are keyed by ``[instance, workspace, owner_type, owner, key]`` with
|
|
no installation dimension, so the installation scope has to live in the
|
|
plugin's own key. The binding-less dedicated scope keeps the legacy key.
|
|
"""
|
|
|
|
if scope == DEDICATED_SCOPE:
|
|
return key
|
|
return f"{scope}:{key}"
|
|
|
|
|
|
class _InstallationState:
|
|
"""Process-local sessions/messages belonging to exactly one installation.
|
|
|
|
The same object graph serves every installation of the artifact, so this is
|
|
cached per binding and dropped in ``on_installation_revoked``.
|
|
"""
|
|
|
|
__slots__ = (
|
|
"scope",
|
|
"sessions",
|
|
"messages",
|
|
"lock",
|
|
"loaded",
|
|
"storage_uncertain",
|
|
"load_failed",
|
|
)
|
|
|
|
def __init__(self, scope: str) -> None:
|
|
self.scope = scope
|
|
self.sessions: dict[str, dict[str, Any]] = {}
|
|
self.messages: dict[str, list[dict[str, Any]]] = {}
|
|
self.lock = asyncio.Lock()
|
|
self.loaded = False
|
|
self.storage_uncertain = False
|
|
self.load_failed = False
|
|
|
|
|
|
class HumanTakeover(BasePlugin):
|
|
"""人工接管插件主类。
|
|
|
|
维护所有会话(私聊/群聊)、消息记录与接管状态,并通过 LangBot 的
|
|
plugin_storage(数据库支撑)持久化,保证重启不丢失数据。
|
|
|
|
会话与消息缓存按 installation binding 隔离,并在每次调用时按需加载,因此
|
|
共享对象图上的多个安装不会看到彼此的会话。
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self._states: dict[str, _InstallationState] = {}
|
|
|
|
# ==================== 生命周期 ====================
|
|
|
|
async def initialize(self) -> None:
|
|
"""Process-wide initialization only.
|
|
|
|
Under shared placement this runs once per worker with an empty config and
|
|
no installation context, so tenant state is loaded lazily per invocation
|
|
(and per binding) instead.
|
|
"""
|
|
return None
|
|
|
|
async def on_installation_revoked(self, binding) -> None:
|
|
"""Drop the revoked installation's cache; no Host API is available here."""
|
|
self._states.pop(installation_scope(binding), None)
|
|
|
|
async def destroy(self) -> None:
|
|
self._states.clear()
|
|
|
|
# ==================== 状态作用域 ====================
|
|
|
|
def _current_scope(self) -> str:
|
|
binding = self.get_installation_binding()
|
|
if binding is not None:
|
|
return installation_scope(binding)
|
|
if os.environ.get(RUNTIME_PROFILE_ENV) == "shared":
|
|
raise RuntimeError(
|
|
"shared invocation has no trusted installation binding; "
|
|
"refusing to read or write tenant state"
|
|
)
|
|
return DEDICATED_SCOPE
|
|
|
|
def _current_state(self) -> _InstallationState:
|
|
scope = self._current_scope()
|
|
st = self._states.get(scope)
|
|
if st is None:
|
|
st = _InstallationState(scope)
|
|
self._states[scope] = st
|
|
return st
|
|
|
|
def _check_failure_fence(
|
|
self, st: _InstallationState, *, storage_reconciled: bool = False
|
|
) -> None:
|
|
"""Fail closed while an ambiguous storage failure is pending.
|
|
|
|
Only ``reconcile()`` may lift the barrier: it is the one entry point that
|
|
asserts an operator has established the ambiguous remote outcome settled.
|
|
Ordinary calls must re-check this fence *after* taking the installation
|
|
lock, because a failure can land while they wait for it.
|
|
"""
|
|
if storage_reconciled:
|
|
return
|
|
if st.storage_uncertain:
|
|
raise RuntimeError("HumanTakeover storage requires remote reconciliation")
|
|
if st.load_failed:
|
|
raise RuntimeError("HumanTakeover storage is not initialized")
|
|
|
|
async def _ensure_loaded(
|
|
self, st: _InstallationState, *, storage_reconciled: bool = False
|
|
) -> None:
|
|
"""Load one installation's rows exactly once, honoring failure fences."""
|
|
self._check_failure_fence(st, storage_reconciled=storage_reconciled)
|
|
if st.loaded and not storage_reconciled:
|
|
return
|
|
async with st.lock:
|
|
# A queued loader may have waited while another operation failed; the
|
|
# barrier is re-checked under the lock so a plain load can never clear
|
|
# it and silently re-read storage.
|
|
self._check_failure_fence(st, storage_reconciled=storage_reconciled)
|
|
if st.loaded and not storage_reconciled:
|
|
return
|
|
if storage_reconciled:
|
|
# Explicit reconciliation is the only path that lifts the barrier.
|
|
st.storage_uncertain = False
|
|
st.load_failed = False
|
|
# A (re)load replaces the cache with what storage holds.
|
|
st.sessions = {}
|
|
st.messages = {}
|
|
try:
|
|
await self._finish_operation(self._load_all(st))
|
|
except asyncio.CancelledError:
|
|
# A cancelled load settles remotely but stays retryable.
|
|
raise
|
|
except BaseException:
|
|
st.loaded = False
|
|
st.load_failed = True
|
|
raise
|
|
st.loaded = True
|
|
self._report_unscoped_session_keys(st)
|
|
logger.info(
|
|
"HumanTakeover initialized scope=%s: %d sessions, %d message buckets",
|
|
st.scope,
|
|
len(st.sessions),
|
|
len(st.messages),
|
|
)
|
|
|
|
@classmethod
|
|
def session_key_has_bot_identity(
|
|
cls, session_key: str, session: dict[str, Any]
|
|
) -> bool:
|
|
"""Whether a stored session key was derived from its own bot identity.
|
|
|
|
Rows written before session keys carried the bot identity are left in
|
|
place: a key that already collided across bots cannot be attributed to one
|
|
bot afterwards, so migrating it would re-merge the very histories this
|
|
scoping separates. They stay readable, are never written by another bot's
|
|
event (new events always use a bot-scoped key), and remain replyable only
|
|
while their stored bot uuid is known.
|
|
"""
|
|
bot_uuid = str(session.get("bot_uuid") or "")
|
|
if not bot_uuid:
|
|
return False
|
|
return session_key == cls.make_session_key(
|
|
str(session.get("type") or ""), session.get("target_id") or "", bot_uuid
|
|
)
|
|
|
|
@classmethod
|
|
def _report_unscoped_session_keys(cls, st: _InstallationState) -> None:
|
|
unscoped = [
|
|
key
|
|
for key, session in st.sessions.items()
|
|
if not cls.session_key_has_bot_identity(key, session)
|
|
]
|
|
if unscoped:
|
|
logger.warning(
|
|
"HumanTakeover scope %s kept %d session(s) stored before session "
|
|
"keys carried the bot identity; they are not updated by other bots' "
|
|
"events and their conversation continues under a bot-scoped key",
|
|
st.scope,
|
|
len(unscoped),
|
|
)
|
|
|
|
async def load_state(self) -> None:
|
|
"""Load the invoking installation's state on demand (per invocation)."""
|
|
await self._ensure_loaded(self._current_state())
|
|
|
|
async def reconcile(self) -> None:
|
|
"""Re-read this installation's rows after an explicitly reconciled failure."""
|
|
await self._ensure_loaded(self._current_state(), storage_reconciled=True)
|
|
|
|
async def _state_for_update(self) -> _InstallationState:
|
|
st = self._current_state()
|
|
await self._ensure_loaded(st)
|
|
return st
|
|
|
|
@property
|
|
def sessions(self) -> dict[str, dict[str, Any]]:
|
|
"""会话元数据 of the invoking installation (empty until loaded)."""
|
|
return self._current_state().sessions
|
|
|
|
@property
|
|
def messages(self) -> dict[str, list[dict[str, Any]]]:
|
|
"""消息记录 of the invoking installation."""
|
|
return self._current_state().messages
|
|
|
|
@property
|
|
def _loaded(self) -> bool:
|
|
return self._current_state().loaded
|
|
|
|
def __del__(self) -> None:
|
|
# Will be called when plugin is terminating
|
|
pass
|
|
|
|
# ==================== 持久化 ====================
|
|
|
|
@staticmethod
|
|
async def _finish_operation(operation):
|
|
"""Keep the caller's lock until the shielded transaction has settled."""
|
|
task = asyncio.create_task(operation)
|
|
cancelled = False
|
|
while not task.done():
|
|
try:
|
|
await asyncio.shield(task)
|
|
except asyncio.CancelledError:
|
|
cancelled = True
|
|
# Retrieve errors before propagating cancellation: an RPC error is not proof
|
|
# of rejection, and _storage_mutation has fenced further access in that case.
|
|
result = task.result()
|
|
if cancelled:
|
|
raise asyncio.CancelledError
|
|
return result
|
|
|
|
async def _storage_mutation(self, st: _InstallationState, operation):
|
|
try:
|
|
return await operation
|
|
except BaseException:
|
|
# The SDK cannot prove whether a dispatched mutation committed.
|
|
st.loaded = False
|
|
st.storage_uncertain = True
|
|
logger.error(
|
|
"HumanTakeover remote storage outcome unknown for scope %s; writes "
|
|
"and clear are blocked until remote reconciliation",
|
|
st.scope,
|
|
)
|
|
raise
|
|
|
|
@staticmethod
|
|
def _session_storage_base(session_key: str) -> str:
|
|
# Fixed-length keys also accommodate long/non-ASCII platform identifiers.
|
|
return (
|
|
STORAGE_SESSION_PREFIX
|
|
+ hashlib.sha256(session_key.encode("utf-8")).hexdigest()
|
|
)
|
|
|
|
def _session_storage_key(self, st: _InstallationState, session_key: str) -> str:
|
|
return scoped_storage_key(st.scope, self._session_storage_base(session_key))
|
|
|
|
async def _load_all(self, st: _InstallationState) -> None:
|
|
"""Migrate legacy snapshots once, retaining them as an untouched backup."""
|
|
keys = await self.get_plugin_storage_keys()
|
|
schema_key = scoped_storage_key(st.scope, STORAGE_KEY_SCHEMA)
|
|
if schema_key not in keys:
|
|
sessions_key = scoped_storage_key(st.scope, STORAGE_KEY_SESSIONS)
|
|
messages_key = scoped_storage_key(st.scope, STORAGE_KEY_MESSAGES)
|
|
if sessions_key in keys:
|
|
st.sessions = json.loads(await self.get_plugin_storage(sessions_key))
|
|
if messages_key in keys:
|
|
st.messages = json.loads(await self.get_plugin_storage(messages_key))
|
|
# Older versions also treated a null top-level snapshot as empty.
|
|
if st.sessions is None:
|
|
st.sessions = {}
|
|
if st.messages is None:
|
|
st.messages = {}
|
|
self._validate_storage(st)
|
|
# Nothing may be written until both legacy snapshots have been read.
|
|
for session_key in st.sessions.keys() | st.messages.keys():
|
|
await self._persist_session(st, session_key)
|
|
# Commit migration last. A failed migration simply retries the legacy data.
|
|
await self._storage_mutation(
|
|
st, self.set_plugin_storage(schema_key, b"2")
|
|
)
|
|
return
|
|
|
|
if await self.get_plugin_storage(schema_key) != b"2":
|
|
raise ValueError("Unsupported HumanTakeover storage schema")
|
|
prefix = scoped_storage_key(st.scope, STORAGE_SESSION_PREFIX)
|
|
for key in keys:
|
|
if not key.startswith(prefix):
|
|
continue
|
|
record = json.loads(await self.get_plugin_storage(key))
|
|
session_key = record["session_key"]
|
|
if self._session_storage_key(st, session_key) != key:
|
|
raise ValueError("HumanTakeover session storage key mismatch")
|
|
if record["session"] is not None:
|
|
st.sessions[session_key] = record["session"]
|
|
if record["messages"] is not None:
|
|
st.messages[session_key] = record["messages"]
|
|
self._validate_storage(st)
|
|
|
|
def _validate_storage(self, st: _InstallationState) -> None:
|
|
if not isinstance(st.sessions, dict) or not isinstance(st.messages, dict):
|
|
raise ValueError("Invalid HumanTakeover storage: expected dictionaries") # noqa: TRY004
|
|
if any(not isinstance(session, dict) for session in st.sessions.values()):
|
|
raise ValueError("Invalid HumanTakeover session storage")
|
|
if any(
|
|
not isinstance(bucket, list)
|
|
or any(not isinstance(entry, dict) for entry in bucket)
|
|
for bucket in st.messages.values()
|
|
):
|
|
raise ValueError("Invalid HumanTakeover message storage")
|
|
|
|
async def _persist_session(
|
|
self, st: _InstallationState, session_key: str
|
|
) -> None:
|
|
"""Metadata + history commit together in one bounded KV value."""
|
|
raw = json.dumps(
|
|
{
|
|
"session_key": session_key,
|
|
"session": st.sessions.get(session_key),
|
|
"messages": st.messages.get(session_key),
|
|
},
|
|
ensure_ascii=False,
|
|
).encode("utf-8")
|
|
if len(raw) > MAX_SESSION_STORAGE_BYTES:
|
|
raise ValueError(
|
|
f"HumanTakeover session {session_key!r} exceeds the storage limit "
|
|
f"of {MAX_SESSION_STORAGE_BYTES} bytes; history has not been truncated"
|
|
)
|
|
await self._storage_mutation(
|
|
st,
|
|
self.set_plugin_storage(self._session_storage_key(st, session_key), raw),
|
|
)
|
|
|
|
@asynccontextmanager
|
|
async def _session_update(self, session_key: str):
|
|
# Load before taking the lock (the loader takes it too), then hold this
|
|
# installation's lock through I/O so an older snapshot cannot win a race.
|
|
st = await self._state_for_update()
|
|
async with st.lock:
|
|
# This caller may have passed the fence check before a concurrent
|
|
# operation failed while it waited for the lock; an ambiguous remote
|
|
# outcome must never be crossed by a queued writer.
|
|
self._check_failure_fence(st)
|
|
old_session = deepcopy(st.sessions.get(session_key))
|
|
old_messages = deepcopy(st.messages.get(session_key))
|
|
committed = False
|
|
|
|
async def commit():
|
|
nonlocal committed
|
|
await self._persist_session(st, session_key)
|
|
committed = True
|
|
|
|
try:
|
|
yield
|
|
if (st.sessions.get(session_key), st.messages.get(session_key)) != (
|
|
old_session,
|
|
old_messages,
|
|
):
|
|
await self._finish_operation(commit())
|
|
except BaseException:
|
|
if committed:
|
|
# Cancellation after acknowledgement must not roll back the cache.
|
|
raise
|
|
# Restore the last acknowledged cache, NOT proof of remote rejection.
|
|
# Ambiguous remote failures have already fenced further mutations.
|
|
if old_session is None:
|
|
st.sessions.pop(session_key, None)
|
|
else:
|
|
st.sessions[session_key] = old_session
|
|
if old_messages is None:
|
|
st.messages.pop(session_key, None)
|
|
else:
|
|
st.messages[session_key] = old_messages
|
|
raise
|
|
|
|
async def clear_all(self) -> None:
|
|
"""Delete only our data, serialized with updates; failures reach the caller."""
|
|
st = await self._state_for_update()
|
|
async with st.lock:
|
|
# Same lock-crossing rule as updates: a clear must not run while an
|
|
# earlier ambiguous mutation may still commit remotely.
|
|
self._check_failure_fence(st)
|
|
await self._finish_operation(self._clear_all(st))
|
|
|
|
async def _clear_all(self, st: _InstallationState) -> None:
|
|
keys = await self.get_plugin_storage_keys()
|
|
# Keep the schema marker: a restart must never resurrect the legacy backup.
|
|
legacy = {
|
|
scoped_storage_key(st.scope, STORAGE_KEY_SESSIONS),
|
|
scoped_storage_key(st.scope, STORAGE_KEY_MESSAGES),
|
|
}
|
|
prefix = scoped_storage_key(st.scope, STORAGE_SESSION_PREFIX)
|
|
for key in keys:
|
|
if key in legacy or key.startswith(prefix):
|
|
await self._storage_mutation(st, self.delete_plugin_storage(key))
|
|
st.sessions = {}
|
|
st.messages = {}
|
|
logger.info("HumanTakeover storage cleared for scope %s by user", st.scope)
|
|
|
|
# ==================== 会话与消息 ====================
|
|
|
|
# ==================== 配置读取 ====================
|
|
|
|
def get_takeover_timeout(self) -> int:
|
|
"""读取接管超时配置(秒),默认 600。"""
|
|
try:
|
|
cfg = self.get_config() or {}
|
|
val = int(cfg.get("takeover_timeout") or DEFAULT_TAKEOVER_TIMEOUT)
|
|
return val if val > 0 else DEFAULT_TAKEOVER_TIMEOUT
|
|
except Exception: # noqa: BLE001
|
|
return DEFAULT_TAKEOVER_TIMEOUT
|
|
|
|
def get_trigger_words(self) -> list[str]:
|
|
"""读取触发词列表。"""
|
|
try:
|
|
cfg = self.get_config() or {}
|
|
words = cfg.get("trigger_words") or []
|
|
return [str(w).strip() for w in words if str(w).strip()]
|
|
except Exception: # noqa: BLE001
|
|
return []
|
|
|
|
def get_auto_takeover_on_trigger(self) -> bool:
|
|
"""命中触发词时是否自动接管。"""
|
|
try:
|
|
cfg = self.get_config() or {}
|
|
val = cfg.get("auto_takeover_on_trigger")
|
|
return True if val is None else bool(val)
|
|
except Exception: # noqa: BLE001
|
|
return True
|
|
|
|
def match_trigger(self, text: str) -> str | None:
|
|
"""返回命中的第一个触发词,未命中返回 None。"""
|
|
if not text:
|
|
return None
|
|
for w in self.get_trigger_words():
|
|
if w and w in text:
|
|
return w
|
|
return None
|
|
|
|
# ==================== 会话与消息 ====================
|
|
|
|
@staticmethod
|
|
def make_session_key(launcher_type: str, launcher_id: Any, bot_uuid: str) -> str:
|
|
"""Return the session identity of one event.
|
|
|
|
The installation scope separates tenants, but a single installation can
|
|
serve several bots/adapters. The trusted bot uuid resolved from the event
|
|
query is therefore part of the key: without it two bots' same-type,
|
|
same-id conversations would share one history, takeover flag and reply
|
|
target. An event without a trusted bot identity must not be keyed at all.
|
|
"""
|
|
if not bot_uuid:
|
|
raise ValueError("HumanTakeover session key requires a trusted bot uuid")
|
|
return f"{bot_uuid}:{launcher_type}_{launcher_id}"
|
|
|
|
def _ensure_session(
|
|
self,
|
|
session_key: str,
|
|
session_type: str,
|
|
target_id: str,
|
|
bot_uuid: str,
|
|
name: str,
|
|
adapter: str = "",
|
|
) -> dict[str, Any]:
|
|
sessions = self.sessions
|
|
sess = sessions.get(session_key)
|
|
if sess is None:
|
|
sess = {
|
|
"session_key": session_key,
|
|
"type": session_type, # person / group
|
|
"target_id": str(target_id),
|
|
"bot_uuid": bot_uuid,
|
|
"name": name or str(target_id),
|
|
"adapter": "", # 消息平台适配器(收消息时缓存)
|
|
"members": {}, # group: {member_id: member_name}
|
|
"last_msg_at": 0,
|
|
"last_msg_preview": "",
|
|
"unread": False, # 未处理标记(触发词命中,类似微信红点)
|
|
"triggered_word": "", # 命中的触发词
|
|
"takeover": {
|
|
"active": False,
|
|
"started_at": 0,
|
|
"last_human_at": 0,
|
|
},
|
|
}
|
|
sessions[session_key] = sess
|
|
else:
|
|
# 更新可能变化的元数据,但身份与会话键绑定:另一个 Bot 的事件绝不
|
|
# 能覆盖本会话的回复目标。旧记录没有 Bot 身份时才允许补全。
|
|
stored_bot = str(sess.get("bot_uuid") or "")
|
|
if stored_bot and bot_uuid and stored_bot != str(bot_uuid):
|
|
raise RuntimeError(
|
|
"HumanTakeover session identity mismatch: refusing to record "
|
|
"another bot's event into an existing session"
|
|
)
|
|
if bot_uuid and not stored_bot:
|
|
sess["bot_uuid"] = bot_uuid
|
|
if name:
|
|
sess["name"] = name
|
|
if adapter:
|
|
stored_adapter = str(sess.get("adapter") or "")
|
|
if stored_adapter and stored_adapter != adapter:
|
|
raise RuntimeError(
|
|
"HumanTakeover session identity mismatch: refusing to record "
|
|
"another adapter's event into an existing session"
|
|
)
|
|
sess["adapter"] = adapter
|
|
return sess
|
|
|
|
async def record_message(
|
|
self,
|
|
*,
|
|
session_key: str,
|
|
session_type: str,
|
|
target_id: str,
|
|
bot_uuid: str,
|
|
session_name: str,
|
|
role: str,
|
|
sender_id: str,
|
|
sender_name: str,
|
|
content_type: str,
|
|
content: str,
|
|
adapter: str = "",
|
|
) -> None:
|
|
"""记录一条消息(用户/AI/人工)并更新会话元数据。"""
|
|
async with self._session_update(session_key):
|
|
sess = self._ensure_session(
|
|
session_key, session_type, target_id, bot_uuid, session_name, adapter
|
|
)
|
|
# 群聊记录发言用户
|
|
if session_type == "group" and sender_id:
|
|
sess.setdefault("members", {})[str(sender_id)] = sender_name or str(
|
|
sender_id
|
|
)
|
|
|
|
now = time.time()
|
|
entry = {
|
|
"role": role, # user / ai / human
|
|
"sender_id": str(sender_id) if sender_id else "",
|
|
"sender_name": sender_name or "",
|
|
"content_type": content_type, # text / image
|
|
"content": content,
|
|
"ts": now,
|
|
}
|
|
bucket = self.messages.setdefault(session_key, [])
|
|
bucket.append(entry)
|
|
# 截断,避免无限增长
|
|
if len(bucket) > MAX_MESSAGES_PER_SESSION:
|
|
del bucket[: len(bucket) - MAX_MESSAGES_PER_SESSION]
|
|
|
|
sess["last_msg_at"] = now
|
|
preview = content if content_type == "text" else "[图片]"
|
|
sess["last_msg_preview"] = (preview or "")[:50]
|
|
|
|
# ==================== 接管状态 ====================
|
|
|
|
def is_taken_over(self, session_key: str) -> bool:
|
|
"""判断会话当前是否处于接管状态(并惰性处理超时)。"""
|
|
sess = self.sessions.get(session_key)
|
|
if not sess:
|
|
return False
|
|
takeover = sess.get("takeover", {})
|
|
if not takeover.get("active"):
|
|
return False
|
|
# 惰性超时判定
|
|
last_human = takeover.get("last_human_at") or takeover.get("started_at") or 0
|
|
return time.time() - last_human < self.get_takeover_timeout()
|
|
|
|
def takeover_remaining(self, session_key: str) -> int:
|
|
"""返回接管剩余秒数;未接管返回 0。"""
|
|
sess = self.sessions.get(session_key)
|
|
if not sess:
|
|
return 0
|
|
takeover = sess.get("takeover", {})
|
|
if not takeover.get("active"):
|
|
return 0
|
|
last_human = takeover.get("last_human_at") or takeover.get("started_at") or 0
|
|
remaining = int(self.get_takeover_timeout() - (time.time() - last_human))
|
|
return max(0, remaining)
|
|
|
|
async def set_takeover(self, session_key: str, active: bool) -> bool:
|
|
"""开启或取消接管。"""
|
|
async with self._session_update(session_key):
|
|
sess = self.sessions.get(session_key)
|
|
if not sess:
|
|
return False
|
|
takeover = sess.setdefault(
|
|
"takeover", {"active": False, "started_at": 0, "last_human_at": 0}
|
|
)
|
|
now = time.time()
|
|
takeover["active"] = active
|
|
if active:
|
|
takeover["started_at"] = now
|
|
takeover["last_human_at"] = now
|
|
logger.info("HumanTakeover session %s taken over", session_key)
|
|
else:
|
|
logger.info("HumanTakeover session %s released", session_key)
|
|
return True
|
|
|
|
async def touch_human_response(self, session_key: str) -> None:
|
|
"""人工回复时刷新最后响应时间,重置 10 分钟计时。"""
|
|
async with self._session_update(session_key):
|
|
sess = self.sessions.get(session_key)
|
|
if not sess:
|
|
return
|
|
takeover = sess.setdefault(
|
|
"takeover", {"active": False, "started_at": 0, "last_human_at": 0}
|
|
)
|
|
takeover["last_human_at"] = time.time()
|
|
|
|
async def mark_unread(self, session_key: str, word: str) -> None:
|
|
"""标记会话为未处理(触发词命中),用于前端红点提醒。"""
|
|
async with self._session_update(session_key):
|
|
sess = self.sessions.get(session_key)
|
|
if not sess:
|
|
return
|
|
sess["unread"] = True
|
|
sess["triggered_word"] = word or ""
|
|
logger.info(
|
|
"HumanTakeover session %s marked unread by trigger word '%s'",
|
|
session_key,
|
|
word,
|
|
)
|
|
|
|
async def clear_unread(self, session_key: str) -> None:
|
|
"""清除会话未处理标记(人工打开/处理后调用)。"""
|
|
async with self._session_update(session_key):
|
|
sess = self.sessions.get(session_key)
|
|
if not sess:
|
|
return
|
|
sess["unread"] = False
|
|
sess["triggered_word"] = ""
|
|
|
|
async def expire_timeouts(self) -> None:
|
|
"""批量处理所有会话的接管超时(供 Page 轮询时调用)。"""
|
|
st = await self._state_for_update()
|
|
now = time.time()
|
|
for session_key in list(st.sessions):
|
|
async with self._session_update(session_key):
|
|
sess = self.sessions.get(session_key)
|
|
if not sess:
|
|
continue
|
|
takeover = sess.get("takeover", {})
|
|
if takeover.get("active"):
|
|
last_human = (
|
|
takeover.get("last_human_at") or takeover.get("started_at") or 0
|
|
)
|
|
if now - last_human >= self.get_takeover_timeout():
|
|
takeover["active"] = False
|
|
logger.info(
|
|
"HumanTakeover session %s auto-released (timeout sweep)",
|
|
session_key,
|
|
)
|
|
|
|
# ==================== 人工发送消息 ====================
|
|
|
|
async def human_send(
|
|
self,
|
|
session_key: str,
|
|
text: str | None = None,
|
|
image_base64: str | None = None,
|
|
file_base64: str | None = None,
|
|
file_name: str | None = None,
|
|
) -> tuple[bool, str]:
|
|
"""人工通过 Web 面板发送消息(文本 / base64 图片 / base64 文件)到目标会话。
|
|
|
|
返回 (是否成功, 错误信息)。
|
|
"""
|
|
await self._state_for_update()
|
|
sess = self.sessions.get(session_key)
|
|
if not sess:
|
|
return False, "session not found"
|
|
|
|
bot_uuid = sess.get("bot_uuid") or ""
|
|
target_type = sess.get("type") or "person"
|
|
target_id = sess.get("target_id") or ""
|
|
|
|
if not bot_uuid:
|
|
return False, "bot_uuid unknown for this session"
|
|
|
|
components: list[platform_message.MessageComponent] = []
|
|
|
|
if text:
|
|
components.append(platform_message.Plain(text=text))
|
|
|
|
if image_base64:
|
|
# 期望前端传入完整 data URL: data:image/png;base64,xxxx
|
|
components.append(platform_message.Image(base64=image_base64))
|
|
|
|
if file_base64:
|
|
components.append(
|
|
platform_message.File(base64=file_base64, name=file_name or "file")
|
|
)
|
|
|
|
if not components:
|
|
return False, "empty message"
|
|
|
|
# 组装可读的发送内容描述:文本输出内容,图片标注,文件仅输出文件名
|
|
parts = []
|
|
if text:
|
|
parts.append("文本: " + text)
|
|
if image_base64:
|
|
parts.append("[图片]")
|
|
if file_base64:
|
|
parts.append("[文件] " + (file_name or "file"))
|
|
content_log = " | ".join(parts)
|
|
|
|
# 目标用户名与适配器(适配器优先用收消息时缓存的值,避免发送时反复请求)
|
|
target_name = sess.get("name") or str(target_id)
|
|
adapter = sess.get("adapter") or "未知"
|
|
|
|
logger.info(
|
|
"HumanTakeover 人工发送 -> 用户[%s](%s) 适配器[%s]: %s",
|
|
target_name,
|
|
target_id,
|
|
adapter,
|
|
content_log,
|
|
)
|
|
|
|
try:
|
|
await self.send_message(
|
|
bot_uuid=bot_uuid,
|
|
target_type=target_type,
|
|
target_id=str(target_id),
|
|
message_chain=platform_message.MessageChain(components),
|
|
)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.error("HumanTakeover human_send failed for %s: %s", session_key, e)
|
|
return False, str(e)
|
|
|
|
# Delivery already succeeded. Never report a storage failure as a failed send.
|
|
try:
|
|
for kind, content in (
|
|
("text", text),
|
|
("image", image_base64),
|
|
("file", (file_name or "file") if file_base64 else None),
|
|
):
|
|
if content:
|
|
await self.record_message(
|
|
session_key=session_key,
|
|
session_type=target_type,
|
|
target_id=str(target_id),
|
|
bot_uuid=bot_uuid,
|
|
session_name=sess.get("name", ""),
|
|
role="human",
|
|
sender_id="",
|
|
sender_name="人工客服",
|
|
content_type=kind,
|
|
content=content,
|
|
)
|
|
await self.touch_human_response(session_key)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.error(
|
|
"HumanTakeover message sent but storage failed for %s: %s",
|
|
session_key,
|
|
e,
|
|
)
|
|
return (
|
|
False,
|
|
f"Message was sent, but history/status storage failed; do not resend. {e}",
|
|
)
|
|
return True, ""
|