Files
LangBot/plugins/misc/HumanTakeover/main.py
T

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, ""