Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ class ChatProviderTemplate(TypedDict):
"webhook_uuid": "",
"lark_encrypt_key": "",
"lark_verification_token": "",
"lark_auto_thread": False,
},
"钉钉(DingTalk)": {
"id": "dingtalk",
Expand Down Expand Up @@ -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",
Expand Down
51 changes: 41 additions & 10 deletions astrbot/core/platform/sources/lark/lark_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
50 changes: 46 additions & 4 deletions astrbot/core/platform/sources/lark/lark_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 消息的通用辅助函数

Expand All @@ -71,6 +74,7 @@ async def _send_im_message(
reply_message_id: 回复的消息ID(用于回复消息)
receive_id: 接收者ID(用于主动发送)
receive_id_type: 接收者ID类型(用于主动发送)
reply_in_thread: 是否在话题中回复

Returns:
是否发送成功
Expand All @@ -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()
Expand Down Expand Up @@ -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 模块未初始化,无法发送卡片")
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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:
"""通用的消息链发送方法

Expand All @@ -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 模块未初始化")
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 当前普通内容并发送卡片
Expand All @@ -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(
Expand All @@ -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:
Expand All @@ -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)

Expand All @@ -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:
"""发送文件消息

Expand All @@ -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
Expand All @@ -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:
"""发送音频消息

Expand Down Expand Up @@ -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
Expand All @@ -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:
"""发送视频消息

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion astrbot/core/provider/sources/anthropic_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

# 添加到最终结果
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,10 @@
"description": "Токен верификации",
"hint": "Для проверки запросов обратного вызова Lark."
},
"lark_auto_thread": {
"description": "Автосоздание темы",
"hint": "При включении бот автоматически создаёт тему (Thread) для каждого ответа, изолируя контекст разговора."
},
"misskey_allow_insecure_downloads": {
"description": "Разрешить небезопасные загрузки (без SSL)",
"hint": "Отключает проверку SSL если у удаленного сервера проблемы с сертификатами. Используйте с осторожностью."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,10 @@
"description": "Verification Token",
"hint": "用于验证飞书回调请求的令牌"
},
"lark_auto_thread": {
"description": "自动创建话题",
"hint": "开启后,机器人回复消息时会自动创建话题(Thread),每条对话的上下文独立隔离。"
},
"misskey_allow_insecure_downloads": {
"description": "允许不安全下载(禁用 SSL 验证)",
"hint": "当远端服务器存在证书问题导致无法正常下载时,自动禁用 SSL 验证作为回退方案。适用于某些图床的证书配置问题。启用有安全风险,仅在必要时使用。"
Expand Down