diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 0f43dbd06d..bd1eab1855 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -423,6 +423,7 @@ class ChatProviderTemplate(TypedDict): "webhook_uuid": "", "lark_encrypt_key": "", "lark_verification_token": "", + "lark_auto_thread": False, }, "钉钉(DingTalk)": { "id": "dingtalk", @@ -545,6 +546,11 @@ class ChatProviderTemplate(TypedDict): "options": ["socket", "webhook"], "labels": ["长连接模式", "推送至服务器模式"], }, + "lark_auto_thread": { + "description": "自动创建话题", + "type": "bool", + "hint": "开启后,机器人回复消息时会自动创建话题(Thread),每条对话的上下文独立隔离。仅对飞书平台生效。", + }, "lark_encrypt_key": { "description": "Encrypt Key", "type": "string", diff --git a/astrbot/core/platform/sources/lark/lark_adapter.py b/astrbot/core/platform/sources/lark/lark_adapter.py index 60e8e0d931..7c426a9a26 100644 --- a/astrbot/core/platform/sources/lark/lark_adapter.py +++ b/astrbot/core/platform/sources/lark/lark_adapter.py @@ -33,6 +33,14 @@ from .server import LarkWebhookServer +def _strip_session_suffix(session_id: str) -> str: + """从 session_id 中提取真实的会话 ID,去除 %thread% / %root% 后缀。""" + for _suffix in ("%thread%", "%root%"): + if _suffix in session_id: + return session_id.split(_suffix)[0] + return session_id + + @register_platform_adapter( "lark", "飞书机器人官方 API 适配器", support_streaming_message=True ) @@ -469,12 +477,10 @@ async def send_by_session( ) -> None: if session.message_type == MessageType.GROUP_MESSAGE: id_type = "chat_id" - receive_id = session.session_id - if "%" in receive_id: - receive_id = receive_id.split("%")[1] + receive_id = _strip_session_suffix(session.session_id) else: id_type = "open_id" - receive_id = session.session_id + receive_id = _strip_session_suffix(session.session_id) # 复用 LarkMessageEvent 中的通用发送逻辑 await LarkMessageEvent.send_message_chain( @@ -580,20 +586,45 @@ async def convert_msg(self, event: lark.im.v1.P2ImMessageReceiveV1) -> None: user_id=event.event.sender.sender_id.open_id, nickname=event.event.sender.sender_id.open_id[:8], ) + # 构建 session_id:按话题/回复链隔离上下文 if abm.type == MessageType.GROUP_MESSAGE: - abm.session_id = abm.group_id + base_id = abm.group_id or "" + if message.thread_id: + # 话题群中的消息,按 thread_id 隔离 + abm.session_id = f"{base_id}%thread%{message.thread_id}" + elif message.root_id: + # 群聊中的回复链,按 root_id 隔离 + abm.session_id = f"{base_id}%root%{message.root_id}" + else: + abm.session_id = base_id else: - abm.session_id = abm.sender.user_id - - await self.handle_msg(abm) - - async def handle_msg(self, abm: AstrBotMessage) -> None: + base_id = abm.sender.user_id + if message.thread_id: + abm.session_id = f"{base_id}%thread%{message.thread_id}" + elif message.root_id: + # 单聊中的回复链,按 root_id 隔离 + abm.session_id = f"{base_id}%root%{message.root_id}" + else: + abm.session_id = base_id + + # 判断是否需要通过 reply_in_thread 创建新话题 + # 读取配置开关,默认关闭 + auto_thread = self.config.get("lark_auto_thread", False) + # 没有已存在的 thread_id 且开关开启时,需要 reply_in_thread=True 创建话题 + # 已在话题中的消息回复自然在话题内,无需 reply_in_thread + _should_reply_in_thread = auto_thread and not bool(message.thread_id) + await self.handle_msg(abm, should_reply_in_thread=_should_reply_in_thread) + + async def handle_msg( + self, abm: AstrBotMessage, should_reply_in_thread: bool = False + ) -> None: event = LarkMessageEvent( message_str=abm.message_str, message_obj=abm, platform_meta=self.meta(), session_id=abm.session_id, bot=self.lark_api, + should_reply_in_thread=should_reply_in_thread, ) self._event_queue.put_nowait(event) diff --git a/astrbot/core/platform/sources/lark/lark_event.py b/astrbot/core/platform/sources/lark/lark_event.py index 1c7dd0b432..a50f35428f 100644 --- a/astrbot/core/platform/sources/lark/lark_event.py +++ b/astrbot/core/platform/sources/lark/lark_event.py @@ -48,9 +48,11 @@ def __init__( platform_meta, session_id, bot: lark.Client, + should_reply_in_thread: bool = False, ) -> None: super().__init__(message_str, message_obj, platform_meta, session_id) self.bot = bot + self.should_reply_in_thread = should_reply_in_thread @staticmethod async def _send_im_message( @@ -61,6 +63,7 @@ async def _send_im_message( reply_message_id: str | None = None, receive_id: str | None = None, receive_id_type: str | None = None, + reply_in_thread: bool = False, ) -> bool: """发送飞书 IM 消息的通用辅助函数 @@ -71,6 +74,7 @@ async def _send_im_message( reply_message_id: 回复的消息ID(用于回复消息) receive_id: 接收者ID(用于主动发送) receive_id_type: 接收者ID类型(用于主动发送) + reply_in_thread: 是否在话题中回复 Returns: 是否发送成功 @@ -88,7 +92,7 @@ async def _send_im_message( .content(content) .msg_type(msg_type) .uuid(str(uuid.uuid4())) - .reply_in_thread(False) + .reply_in_thread(reply_in_thread) .build() ) .build() @@ -367,6 +371,7 @@ async def _send_interactive_card( reply_message_id: str | None = None, receive_id: str | None = None, receive_id_type: str | None = None, + reply_in_thread: bool = False, ) -> bool: if lark_client.cardkit is None: logger.error("[Lark] API Client cardkit 模块未初始化,无法发送卡片") @@ -405,6 +410,7 @@ async def _send_interactive_card( reply_message_id=reply_message_id, receive_id=receive_id, receive_id_type=receive_id_type, + reply_in_thread=reply_in_thread, ) @staticmethod @@ -415,6 +421,7 @@ async def _send_collapsible_reasoning_panel( reply_message_id: str | None = None, receive_id: str | None = None, receive_id_type: str | None = None, + reply_in_thread: bool = False, ) -> bool: if not reasoning_content: return True @@ -428,6 +435,7 @@ async def _send_collapsible_reasoning_panel( reply_message_id=reply_message_id, receive_id=receive_id, receive_id_type=receive_id_type, + reply_in_thread=reply_in_thread, ) @staticmethod @@ -437,6 +445,7 @@ async def send_message_chain( reply_message_id: str | None = None, receive_id: str | None = None, receive_id_type: str | None = None, + reply_in_thread: bool = False, ) -> None: """通用的消息链发送方法 @@ -446,6 +455,7 @@ async def send_message_chain( reply_message_id: 回复的消息ID(用于回复消息) receive_id: 接收者ID(用于主动发送) receive_id_type: 接收者ID类型,如 'open_id', 'chat_id'(用于主动发送) + reply_in_thread: 是否在话题中回复 """ if lark_client.im is None: logger.error("[Lark] API Client im 模块未初始化") @@ -486,6 +496,7 @@ async def send_message_chain( reply_message_id=reply_message_id, receive_id=receive_id, receive_id_type=receive_id_type, + reply_in_thread=reply_in_thread, ): return @@ -520,6 +531,7 @@ async def _flush_buffer() -> None: reply_message_id=reply_message_id, receive_id=receive_id, receive_id_type=receive_id_type, + reply_in_thread=reply_in_thread, ) # 维持组件顺序:遇到折叠面板标记先 flush 当前普通内容并发送卡片 @@ -539,6 +551,7 @@ async def _flush_buffer() -> None: reply_message_id=reply_message_id, receive_id=receive_id, receive_id_type=receive_id_type, + reply_in_thread=reply_in_thread, ) if not success: buffered_components.append( @@ -554,17 +567,32 @@ async def _flush_buffer() -> None: # 发送附件 for file_comp in file_components: await LarkMessageEvent._send_file_message( - file_comp, lark_client, reply_message_id, receive_id, receive_id_type + file_comp, + lark_client, + reply_message_id, + receive_id, + receive_id_type, + reply_in_thread=reply_in_thread, ) for audio_comp in audio_components: await LarkMessageEvent._send_audio_message( - audio_comp, lark_client, reply_message_id, receive_id, receive_id_type + audio_comp, + lark_client, + reply_message_id, + receive_id, + receive_id_type, + reply_in_thread=reply_in_thread, ) for media_comp in media_components: await LarkMessageEvent._send_media_message( - media_comp, lark_client, reply_message_id, receive_id, receive_id_type + media_comp, + lark_client, + reply_message_id, + receive_id, + receive_id_type, + reply_in_thread=reply_in_thread, ) async def send(self, message: MessageChain) -> None: @@ -573,6 +601,7 @@ async def send(self, message: MessageChain) -> None: message, self.bot, reply_message_id=self.message_obj.message_id, + reply_in_thread=self.should_reply_in_thread, ) await super().send(message) @@ -583,6 +612,7 @@ async def _send_file_message( reply_message_id: str | None = None, receive_id: str | None = None, receive_id_type: str | None = None, + reply_in_thread: bool = False, ) -> None: """发送文件消息 @@ -608,6 +638,7 @@ async def _send_file_message( reply_message_id=reply_message_id, receive_id=receive_id, receive_id_type=receive_id_type, + reply_in_thread=reply_in_thread, ) @staticmethod @@ -617,6 +648,7 @@ async def _send_audio_message( reply_message_id: str | None = None, receive_id: str | None = None, receive_id_type: str | None = None, + reply_in_thread: bool = False, ) -> None: """发送音频消息 @@ -681,6 +713,7 @@ async def _send_audio_message( reply_message_id=reply_message_id, receive_id=receive_id, receive_id_type=receive_id_type, + reply_in_thread=reply_in_thread, ) @staticmethod @@ -690,6 +723,7 @@ async def _send_media_message( reply_message_id: str | None = None, receive_id: str | None = None, receive_id_type: str | None = None, + reply_in_thread: bool = False, ) -> None: """发送视频消息 @@ -754,6 +788,7 @@ async def _send_media_message( reply_message_id=reply_message_id, receive_id=receive_id, receive_id_type=receive_id_type, + reply_in_thread=reply_in_thread, ) async def react(self, emoji: str) -> None: @@ -845,6 +880,7 @@ async def _send_card_message( reply_message_id: str | None = None, receive_id: str | None = None, receive_id_type: str | None = None, + reply_in_thread: bool = False, ) -> bool: """将卡片实体作为 interactive 消息发送。""" content = json.dumps( @@ -858,6 +894,7 @@ async def _send_card_message( reply_message_id=reply_message_id, receive_id=receive_id, receive_id_type=receive_id_type, + reply_in_thread=reply_in_thread, ) async def _update_streaming_text( @@ -960,6 +997,10 @@ async def send_streaming(self, generator, use_fallback: bool = False): 使用解耦发送循环,LLM token 到达时只更新 buffer 并唤醒发送协程, 发送频率由网络 RTT 自然限流。 """ + # 非话题消息:通过 reply_in_thread=True 创建新话题,同时使用流式卡片 + if self.should_reply_in_thread: + logger.info("[Lark] 非话题消息,将通过 reply_in_thread=True 创建新话题") + # Lazy-init: card & sender loop created on first text token card_id = None sequence = 0 @@ -1054,6 +1095,7 @@ async def _flush_and_close_card() -> None: sent = await self._send_card_message( card_id, reply_message_id=self.message_obj.message_id, + reply_in_thread=self.should_reply_in_thread, ) if not sent: logger.error( diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index 203d0610ff..2e63c3c3c5 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -419,7 +419,7 @@ async def _query_stream( # 解析完整的工具调用 tool_info = tool_use_buffer[event.index] try: - if "input_json" in tool_info: + if isinstance(tool_info.get("input_json"), str) and tool_info["input_json"]: tool_info["input"] = json.loads(tool_info["input_json"]) # 添加到最终结果 diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index 43aae5984b..96784192e0 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -440,6 +440,10 @@ "description": "Verification Token", "hint": "Token for verifying Lark callback requests." }, + "lark_auto_thread": { + "description": "Auto-create Thread", + "hint": "When enabled, the bot will automatically create a thread for each reply, isolating conversation context per thread." + }, "misskey_allow_insecure_downloads": { "description": "Allow Insecure Downloads (Disable SSL Verification)", "hint": "If remote servers have certificate issues, SSL verification will be disabled as a fallback. Use only when necessary due to security risks." diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index 06f60dd40a..65cdcfedb5 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -440,6 +440,10 @@ "description": "Токен верификации", "hint": "Для проверки запросов обратного вызова Lark." }, + "lark_auto_thread": { + "description": "Автосоздание темы", + "hint": "При включении бот автоматически создаёт тему (Thread) для каждого ответа, изолируя контекст разговора." + }, "misskey_allow_insecure_downloads": { "description": "Разрешить небезопасные загрузки (без SSL)", "hint": "Отключает проверку SSL если у удаленного сервера проблемы с сертификатами. Используйте с осторожностью." diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 7b59a981d5..cd8d73cdfe 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -442,6 +442,10 @@ "description": "Verification Token", "hint": "用于验证飞书回调请求的令牌" }, + "lark_auto_thread": { + "description": "自动创建话题", + "hint": "开启后,机器人回复消息时会自动创建话题(Thread),每条对话的上下文独立隔离。" + }, "misskey_allow_insecure_downloads": { "description": "允许不安全下载(禁用 SSL 验证)", "hint": "当远端服务器存在证书问题导致无法正常下载时,自动禁用 SSL 验证作为回退方案。适用于某些图床的证书配置问题。启用有安全风险,仅在必要时使用。"