Repository: AstrBotDevs/AstrBot
Pull request #1256: perf: 为不支持流式输出的平台提供fallback。
Author: login: Raven95676
Created: 2025-04-12T18:27:23Z | Merged: 2025-04-20T07:24:31Z

### Motivation

不支持流式输出的平台需要Fallback

### Modifications

使用正则表达式匹配句子终止符，按句子终止符切分并逐段发送。

### Check
- [x] 我的 Commit Message 符合良好的[规范](https://www.conventionalcommits.org/en/v1.0.0/#summary)
- [x] 我新增/修复/优化的功能经过良好的测试

好的，这是翻译成中文的 pull request 总结：

## Sourcery 总结

实现不支持流式输出的平台的回退机制，方法是将消息拆分为句子并以增量方式发送

新特性：
- 引入了一种回退策略，用于跨多个平台事件处理程序流式传输消息输出

增强功能：
- 为流式传输能力有限的平台添加基于句子的消息拆分
- 实施一种基于正则表达式的方法来检测中文文本中的句子边界

<details>
<summary>Original summary in English</summary>

## Summary by Sourcery

Implement a fallback mechanism for platforms that do not support streaming output by splitting messages into sentences and sending them incrementally

New Features:
- Introduce a fallback strategy for streaming message output across multiple platform event handlers

Enhancements:
- Add sentence-based message splitting for platforms with limited streaming capabilities
- Implement a regex-based approach to detect sentence boundaries in Chinese text

</details>


Diff:
diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py
index f42da1871b..a15e040945 100644
--- a/astrbot/core/config/default.py
+++ b/astrbot/core/config/default.py
@@ -52,6 +52,7 @@
         "max_context_length": -1,
         "dequeue_context_length": 1,
         "streaming_response": False,
+        "streaming_segmented": False,
     },
     "provider_stt_settings": {
         "enable": False,
@@ -1015,6 +1016,11 @@
                         "type": "bool",
                         "hint": "启用后，将会流式输出 LLM 的响应。目前仅支持 OpenAI API提供商 以及 Telegram、QQ Official 私聊 两个平台",
                     },
+                    "streaming_segmented": {
+                        "description": "不支持流式回复的平台分段输出",
+                        "type": "bool",
+                        "hint": "启用后，若平台不支持流式回复，会分段输出。目前仅支持 aiocqhttp 和 gewechat 两个平台，不支持或无需使用流式分段输出的平台会静默忽略此选项",
+                    },
                 },
             },
             "persona": {
diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py
index be13370f4f..776f4a6253 100644
--- a/astrbot/core/pipeline/respond/stage.py
+++ b/astrbot/core/pipeline/respond/stage.py
@@ -146,9 +146,12 @@ async def process(
 
         if result.result_content_type == ResultContentType.STREAMING_RESULT:
             # 流式结果直接交付平台适配器处理
+            use_fallback = self.config.get("provider_settings", {}).get(
+                "streaming_segmented", False
+            )
             logger.info(f"应用流式输出({event.get_platform_name()})")
             await event._pre_send()
-            await event.send_streaming(result.async_stream)
+            await event.send_streaming(result.async_stream, use_fallback)
             await event._post_send()
             return
         elif len(result.chain) > 0:
@@ -159,7 +162,7 @@ async def process(
                         # 支持 File 消息段的路径映射。
                         component.file = path_Mapping(mappings, component.file)
                         event.get_result().chain[idx] = component
-            
+
             await event._pre_send()
 
             # 检查消息链是否为空
diff --git a/astrbot/core/platform/astr_message_event.py b/astrbot/core/platform/astr_message_event.py
index dba7f338a2..40aab64436 100644
--- a/astrbot/core/platform/astr_message_event.py
+++ b/astrbot/core/platform/astr_message_event.py
@@ -1,5 +1,6 @@
 import abc
 import asyncio
+import re
 import hashlib
 import uuid
 from dataclasses import dataclass
@@ -207,9 +208,26 @@ def is_admin(self) -> bool:
         """
         return self.role == "admin"
 
-    async def send_streaming(self, generator: AsyncGenerator[MessageChain, None]):
+    async def process_buffer(self, buffer: str, pattern: re.Pattern) -> str:
+        """
+        将消息缓冲区中的文本按指定正则表达式分割后发送至消息平台，作为不支持流式输出平台的Fallback。
+        """
+        while True:
+            match = re.search(pattern, buffer)
+            if not match:
+                break
+            matched_text = match.group()
+            await self.send(MessageChain([Plain(matched_text)]))
+            buffer = buffer[match.end() :]
+            await asyncio.sleep(1.5)  # 限速
+        return buffer
+
+    async def send_streaming(
+        self, generator: AsyncGenerator[MessageChain, None], use_fallback: bool = False
+    ):
         """发送流式消息到消息平台，使用异步生成器。
         目前仅支持: telegram，qq official 私聊。
+        Fallback仅支持 aiocqhttp, gewechat。
         """
         asyncio.create_task(
             Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py
index 898411dfef..4acb677dd0 100644
--- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py
+++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py
@@ -1,9 +1,10 @@
 import asyncio
-import typing
+import re
+from typing import AsyncGenerator, Dict, List
+from aiocqhttp import CQHttp
 from astrbot.api.event import AstrMessageEvent, MessageChain
+from astrbot.api.message_components import At, Image, Node, Nodes, Plain, Record
 from astrbot.api.platform import Group, MessageMember
-from astrbot.api.message_components import Plain, Image, Record, At, Node, Nodes
-from aiocqhttp import CQHttp
 
 
 class AiocqhttpMessageEvent(AstrMessageEvent):
@@ -82,18 +83,39 @@ async def send(self, message: MessageChain):
 
         await super().send(message)
 
-    async def send_streaming(self, generator):
-        buffer = None
-        async for chain in generator:
+    async def send_streaming(
+        self, generator: AsyncGenerator, use_fallback: bool = False
+    ):
+        if not use_fallback:
+            buffer = None
+            async for chain in generator:
+                if not buffer:
+                    buffer = chain
+                else:
+                    buffer.chain.extend(chain.chain)
             if not buffer:
-                buffer = chain
-            else:
-                buffer.chain.extend(chain.chain)
-        if not buffer:
-            return
-        buffer.squash_plain()
-        await self.send(buffer)
-        return await super().send_streaming(generator)
+                return
+            buffer.squash_plain()
+            await self.send(buffer)
+            return await super().send_streaming(generator, use_fallback)
+
+        buffer = ""
+        pattern = re.compile(r"[^。？！~…]+[。？！~…]+")
+
+        async for chain in generator:
+            if isinstance(chain, MessageChain):
+                for comp in chain.chain:
+                    if isinstance(comp, Plain):
+                        buffer += comp.text
+                        if any(p in buffer for p in "。？！~…"):
+                            buffer = await self.process_buffer(buffer, pattern)
+                    else:
+                        await self.send(MessageChain(chain=[comp]))
+                        await asyncio.sleep(1.5)  # 限速
+
+        if buffer.strip():
+            await self.send(MessageChain([Plain(buffer)]))
+        return await super().send_streaming(generator, use_fallback)
 
     async def get_group(self, group_id=None, **kwargs):
         if isinstance(group_id, str) and group_id.isdigit():
@@ -108,7 +130,7 @@ async def get_group(self, group_id=None, **kwargs):
             group_id=group_id,
         )
 
-        members: typing.List[typing.Dict] = await self.bot.call_action(
+        members: List[Dict] = await self.bot.call_action(
             "get_group_member_list",
             group_id=group_id,
         )
diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_event.py b/astrbot/core/platform/sources/dingtalk/dingtalk_event.py
index d850a759f3..4834032f54 100644
--- a/astrbot/core/platform/sources/dingtalk/dingtalk_event.py
+++ b/astrbot/core/platform/sources/dingtalk/dingtalk_event.py
@@ -61,7 +61,7 @@ async def send(self, message: MessageChain):
         await self.send_with_client(self.client, message)
         await super().send(message)
 
-    async def send_streaming(self, generator):
+    async def send_streaming(self, generator, use_fallback: bool = False):
         buffer = None
         async for chain in generator:
             if not buffer:
@@ -72,4 +72,4 @@ async def send_streaming(self, generator):
             return
         buffer.squash_plain()
         await self.send(buffer)
-        return await super().send_streaming(generator)
+        return await super().send_streaming(generator, use_fallback)
diff --git a/astrbot/core/platform/sources/gewechat/gewechat_event.py b/astrbot/core/platform/sources/gewechat/gewechat_event.py
index 829a348c68..5efff3f5a9 100644
--- a/astrbot/core/platform/sources/gewechat/gewechat_event.py
+++ b/astrbot/core/platform/sources/gewechat/gewechat_event.py
@@ -1,8 +1,11 @@
+import asyncio
+import re
 import wave
 import uuid
 import traceback
 import os
 
+from typing import AsyncGenerator
 from astrbot.core.utils.io import save_temp_img, download_file
 from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk
 from astrbot.api import logger
@@ -217,15 +220,36 @@ async def get_group(self, group_id=None, **kwargs):
             members=members,
         )
 
-    async def send_streaming(self, generator):
-        buffer = None
-        async for chain in generator:
+    async def send_streaming(
+        self, generator: AsyncGenerator, use_fallback: bool = False
+    ):
+        if not use_fallback:
+            buffer = None
+            async for chain in generator:
+                if not buffer:
+                    buffer = chain
+                else:
+                    buffer.chain.extend(chain.chain)
             if not buffer:
-                buffer = chain
-            else:
-                buffer.chain.extend(chain.chain)
-        if not buffer:
-            return
-        buffer.squash_plain()
-        await self.send(buffer)
-        return await super().send_streaming(generator)
+                return
+            buffer.squash_plain()
+            await self.send(buffer)
+            return await super().send_streaming(generator, use_fallback)
+
+        buffer = ""
+        pattern = re.compile(r"[^。？！~…]+[。？！~…]+")
+
+        async for chain in generator:
+            if isinstance(chain, MessageChain):
+                for comp in chain.chain:
+                    if isinstance(comp, Plain):
+                        buffer += comp.text
+                        if any(p in buffer for p in "。？！~…"):
+                            buffer = await self.process_buffer(buffer, pattern)
+                    else:
+                        await self.send(MessageChain(chain=[comp]))
+                        await asyncio.sleep(1.5)  # 限速
+
+        if buffer.strip():
+            await self.send(MessageChain([Plain(buffer)]))
+        return await super().send_streaming(generator, use_fallback)
diff --git a/astrbot/core/platform/sources/lark/lark_event.py b/astrbot/core/platform/sources/lark/lark_event.py
index b1aee548e6..600c13e560 100644
--- a/astrbot/core/platform/sources/lark/lark_event.py
+++ b/astrbot/core/platform/sources/lark/lark_event.py
@@ -104,7 +104,7 @@ async def send(self, message: MessageChain):
 
         await super().send(message)
 
-    async def send_streaming(self, generator):
+    async def send_streaming(self, generator, use_fallback: bool = False):
         buffer = None
         async for chain in generator:
             if not buffer:
@@ -115,4 +115,4 @@ async def send_streaming(self, generator):
             return
         buffer.squash_plain()
         await self.send(buffer)
-        return await super().send_streaming(generator)
+        return await super().send_streaming(generator, use_fallback)
diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py
index 55f1e8c446..d55c2671bc 100644
--- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py
+++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py
@@ -33,7 +33,7 @@ async def send(self, message: MessageChain):
         else:
             self.send_buffer.chain.extend(message.chain)
 
-    async def send_streaming(self, generator):
+    async def send_streaming(self, generator, use_fallback: bool = False):
         """流式输出仅支持消息列表私聊"""
         stream_payload = {"state": 1, "id": None, "index": 0, "reset": False}
         last_edit_time = 0  # 上次编辑消息的时间
@@ -66,7 +66,7 @@ async def send_streaming(self, generator):
             logger.error(f"发送流式消息时出错: {e}", exc_info=True)
             self.send_buffer = None
 
-        return await super().send_streaming(generator)
+        return await super().send_streaming(generator, use_fallback)
 
     async def _post_send(self, stream: dict = None):
         if not self.send_buffer:
@@ -97,7 +97,7 @@ async def _post_send(self, stream: dict = None):
             "msg_id": self.message_obj.message_id,
         }
 
-        if not isinstance(source, (botpy.message.Message,botpy.message.DirectMessage)):
+        if not isinstance(source, (botpy.message.Message, botpy.message.DirectMessage)):
             payload["msg_seq"] = random.randint(1, 10000)
 
         match type(source):
diff --git a/astrbot/core/platform/sources/telegram/tg_event.py b/astrbot/core/platform/sources/telegram/tg_event.py
index bcc9189c2f..8e26b89607 100644
--- a/astrbot/core/platform/sources/telegram/tg_event.py
+++ b/astrbot/core/platform/sources/telegram/tg_event.py
@@ -91,7 +91,7 @@ async def send(self, message: MessageChain):
             await self.send_with_client(self.client, message, self.get_sender_id())
         await super().send(message)
 
-    async def send_streaming(self, generator):
+    async def send_streaming(self, generator, use_fallback: bool = False):
         message_thread_id = None
 
         if self.get_message_type() == MessageType.GROUP_MESSAGE:
@@ -183,16 +183,14 @@ async def send_streaming(self, generator):
                         text=markdown_text,
                         chat_id=payload["chat_id"],
                         message_id=message_id,
-                        parse_mode="MarkdownV2"
+                        parse_mode="MarkdownV2",
                     )
                 except Exception as e:
                     logger.warning(f"Markdown转换失败，使用普通文本: {e!s}")
                     await self.client.edit_message_text(
-                        text=delta,
-                        chat_id=payload["chat_id"],
-                        message_id=message_id
+                        text=delta, chat_id=payload["chat_id"], message_id=message_id
                     )
         except Exception as e:
             logger.warning(f"编辑消息失败(streaming): {e!s}")
 
-        return await super().send_streaming(generator)
+        return await super().send_streaming(generator, use_fallback)
diff --git a/astrbot/core/platform/sources/webchat/webchat_event.py b/astrbot/core/platform/sources/webchat/webchat_event.py
index 30a6322a50..e60d6d144e 100644
--- a/astrbot/core/platform/sources/webchat/webchat_event.py
+++ b/astrbot/core/platform/sources/webchat/webchat_event.py
@@ -106,7 +106,7 @@ async def send(self, message: MessageChain):
         )
         await super().send(message)
 
-    async def send_streaming(self, generator):
+    async def send_streaming(self, generator, use_fallback: bool = False):
         final_data = ""
         async for chain in generator:
             final_data += await WebChatMessageEvent._send(
@@ -121,4 +121,4 @@ async def send_streaming(self, generator):
                 "cid": self.session_id.split("!")[-1],
             }
         )
-        await super().send_streaming(generator)
+        await super().send_streaming(generator, use_fallback)
diff --git a/astrbot/core/platform/sources/wecom/wecom_event.py b/astrbot/core/platform/sources/wecom/wecom_event.py
index d8ee8b9a3e..65e7a20a04 100644
--- a/astrbot/core/platform/sources/wecom/wecom_event.py
+++ b/astrbot/core/platform/sources/wecom/wecom_event.py
@@ -85,7 +85,7 @@ async def send(self, message: MessageChain):
 
         await super().send(message)
 
-    async def send_streaming(self, generator):
+    async def send_streaming(self, generator, use_fallback: bool = False):
         buffer = None
         async for chain in generator:
             if not buffer:
@@ -96,4 +96,4 @@ async def send_streaming(self, generator):
             return
         buffer.squash_plain()
         await self.send(buffer)
-        return await super().send_streaming(generator)
+        return await super().send_streaming(generator, use_fallback)


Reviews:
user: login: sourcery-ai[bot]
body: Hey @Raven95676 - I've reviewed your changes - here's some feedback:

**Overall Comments**:
- Consider creating a shared utility function for the duplicated `send_streaming` logic.
- The regular expression `[^。？！~…]+[。？！~…]+` could be simplified to `[^。？！~…]*[。？！~…]`.

<details>
<summary>Here's what I looked at during the review</summary>

- 🟡 **General issues**: 2 issues found
- 🟢 **Security**: all looks good
- 🟢 **Testing**: all looks good
- 🟡 **Complexity**: 4 issues found
- 🟢 **Documentation**: all looks good
</details>

***

<details>
<summary>Sourcery is free for open source - if you like our reviews please consider sharing them ✨</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https://sourcery.ai)
- [Facebook](https://www.facebook.com/sharer/sharer.php?u=https://sourcery.ai)

</details>

<sub>
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
</sub>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-12T18:28:38Z
user: login: Soulter
body: 快速进行了一下code review，此外有个问题是这些平台经过测试可用吗？会不会有最大回复数的限制
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-13T06:48:37Z
user: login: Soulter
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-16T02:27:55Z
user: login: anka-afk
body: 好好好, 不过use_fallback不作为参数传递, 而是在使用时直接从config对象读取会不会好看一些呢
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-17T14:20:25Z
user: login: Soulter
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-20T07:24:08Z

Review comments:
diff_hunk: @@ -61,15 +65,27 @@ async def send(self, message: MessageChain):
         await self.send_with_client(self.client, message)
         await super().send(message)
 
-    async def send_streaming(self, generator):
-        buffer = None
+    async def send_streaming(self, generator: AsyncGenerator):
path: astrbot/core/platform/sources/dingtalk/dingtalk_event.py
user: login: sourcery-ai[bot]
body: **suggestion:** Consider consolidating duplicate streaming logic.

The send_streaming implementations in dingtalk_event.py, aiocqhttp_message_event.py, gewechat_event.py, lark_event.py, and wecom_event.py are nearly identical. Extracting the common logic into a helper function or mixin would reduce duplication and ease future maintenance.
created_at: 2025-04-12T18:28:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -61,15 +65,27 @@ async def send(self, message: MessageChain):
         await self.send_with_client(self.client, message)
         await super().send(message)
 
-    async def send_streaming(self, generator):
-        buffer = None
+    async def send_streaming(self, generator: AsyncGenerator):
+        buffer = ""
+        pattern = r"[^。？！~…]+[。？！~…]+"
path: astrbot/core/platform/sources/dingtalk/dingtalk_event.py
user: login: sourcery-ai[bot]
body: **suggestion (performance):** Precompile the regular expression for efficiency.

Defining and compiling the regex pattern once (e.g., using re.compile) outside of the streaming loop could reduce repeated compilation overhead, especially if send_streaming processes many messages.

Suggested implementation:

```python
import re
# (Existing imports)

```

```python
    async def send_streaming(self, generator: AsyncGenerator):
        buffer = ""
        pattern = re.compile(r"[^。？！~…]+[。？！~…]+")

```

If the file already has an import for re, you can omit the added import. Also, if the regex pattern is used elsewhere in the module, consider moving the re.compile outside of the function to a module-level constant.
created_at: 2025-04-12T18:28:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -82,17 +84,30 @@ async def send(self, message: MessageChain):
 
         await super().send(message)
 
-    async def send_streaming(self, generator):
-        buffer = None
+    async def send_streaming(self, generator: AsyncGenerator):
path: astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py
user: login: sourcery-ai[bot]
body: **issue (complexity):** Consider extracting the plain text buffer processing logic into a helper function to improve readability and reduce nesting complexity in the send_streaming method.

Consider abstracting the unit that accumulates and processes the plain text buffer into its own helper to reduce the nesting and inline regex logic. For example:

```python
async def process_buffer(self, buffer: str, pattern: str) -> str:
    # Process complete sentences in the buffer.
    while (match := re.search(pattern, buffer)):
        matched_text = match.group()
        await self.send(MessageChain([Plain(matched_text)]))
        buffer = buffer[match.end():]
        await asyncio.sleep(0.5)  # rate limiting
    return buffer
```

Then, in your `send_streaming` method, you can simplify the inner loop:

```python
async def send_streaming(self, generator: AsyncGenerator):
    buffer = ""
    pattern = r"[^。？！~…]+[。？！~…]+"
    async for chain in generator:
        if isinstance(chain, MessageChain):
            for comp in chain.chain:
                if isinstance(comp, Plain):
                    buffer += comp.text
                    if any(p in buffer for p in "。？！~…"):
                        buffer = await self.process_buffer(buffer, pattern)
                else:
                    await self.send(MessageChain(chain=[comp]))
    if buffer.strip():
        await self.send(MessageChain([Plain(buffer)]))
    return await super().send_streaming(generator)
```

This refactoring extracts the regex search and buffer processing into a helper and reduces nesting while preserving functionality.
created_at: 2025-04-12T18:28:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -217,15 +220,27 @@ async def get_group(self, group_id=None, **kwargs):
             members=members,
         )
 
-    async def send_streaming(self, generator):
-        buffer = None
+    async def send_streaming(self, generator: AsyncGenerator):
path: astrbot/core/platform/sources/gewechat/gewechat_event.py
user: login: sourcery-ai[bot]
body: **issue (complexity):** Consider extracting the text buffering and regex logic into a dedicated helper function to improve code organization and readability by reducing nesting and isolating concerns related to text processing and sending messages in chunks.

Extract the text buffering and regex logic into a dedicated helper. This decreases nesting and isolates concerns. For example:

```python
async def _send_text_chunks(self, buffer: str) -> str:
    pattern = r"[^。？！~…]+[。？！~…]+"
    while True:
        match = re.search(pattern, buffer)
        if not match:
            break
        await self.send(MessageChain([Plain(match.group())]))
        buffer = buffer[match.end():]
        await asyncio.sleep(0.5)  # Rate limit
    return buffer
```

Then simplify the main loop:

```python
async def send_streaming(self, generator: AsyncGenerator):
    buffer = ""
    async for chain in generator:
        if isinstance(chain, MessageChain):
            for comp in chain.chain:
                if isinstance(comp, Plain):
                    buffer += comp.text
                    if any(p in buffer for p in "。？！~…"):
                        buffer = await self._send_text_chunks(buffer)
                else:
                    await self.send(MessageChain(chain=[comp]))
    if buffer.strip():
        await self.send(MessageChain([Plain(buffer)]))
    return await super().send_streaming(generator)
```

This keeps functionality intact while reducing complexity and duplication.
created_at: 2025-04-12T18:28:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -92,15 +94,27 @@ async def send(self, message: MessageChain):
 
         await super().send(message)
 
-    async def send_streaming(self, generator):
-        buffer = None
+    async def send_streaming(self, generator: AsyncGenerator):
path: astrbot/core/platform/sources/lark/lark_event.py
user: login: sourcery-ai[bot]
body: **issue (complexity):** Consider extracting the text-chunking logic into a dedicated helper function to improve clarity and reduce nesting in the `send_streaming` method, which will simplify the method's structure and enhance readability without altering its functionality .

```markdown
Consider extracting the text‐chunking logic from `send_streaming` into a small, dedicated helper to reduce nesting and improve clarity. For example, you could add a helper like:

```python
async def _split_and_send_plain(self, text: str) -> str:
    pattern = re.compile(r"[^。？！~…]+[。？！~…]+")
    while True:
        match = pattern.search(text)
        if not match:
            break
        await self.send(MessageChain([Plain(match.group())]))
        text = text[match.end():]
        await asyncio.sleep(0.5)  # Rate limiting
    return text
```

Then simplify your `send_streaming` method by delegating to this helper:

```python
async def send_streaming(self, generator: AsyncGenerator):
    buffer = ""
    async for chain in generator:
        if isinstance(chain, MessageChain):
            for comp in chain.chain:
                if isinstance(comp, Plain):
                    buffer += comp.text
                    if any(p in buffer for p in "。？！~…"):
                        buffer = await self._split_and_send_plain(buffer)
                else:
                    await self.send(MessageChain(chain=[comp]))
    if buffer.strip():
        await self.send(MessageChain([Plain(buffer)]))
    return await super().send_streaming(generator)
```

This refactoring preserves functionality while reducing in-method complexity.
```
created_at: 2025-04-12T18:28:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -85,15 +89,27 @@ async def send(self, message: MessageChain):
 
         await super().send(message)
 
-    async def send_streaming(self, generator):
-        buffer = None
+    async def send_streaming(self, generator: AsyncGenerator):
path: astrbot/core/platform/sources/wecom/wecom_event.py
user: login: sourcery-ai[bot]
body: **issue (complexity):** Consider extracting the inner loop logic into a helper function to reduce nesting and centralize sentence-flushing logic, improving code clarity and maintainability in the send_streaming method, and simplifying the main function's structure by delegating the sentence extraction and sending process to a dedicated helper function, thereby reducing its complexity and enhancing readability..

Consider extracting the inner loop logic into a helper function. This would reduce the nesting in **send_streaming** and centralize the sentence‐flushing logic. For example:

```python
def extract_sentences(buffer: str, pattern: str):
    sentences = []
    while True:
        match = re.search(pattern, buffer)
        if not match:
            break
        sentences.append(match.group())
        buffer = buffer[match.end():]
    return sentences, buffer

async def flush_buffer(self, buffer: str, pattern: str) -> str:
    sentences, remaining = extract_sentences(buffer, pattern)
    for sentence in sentences:
        await self.send(MessageChain([Plain(sentence)]))
        await asyncio.sleep(0.5)  # 限速
    return remaining
```

Then simplify **send_streaming** by using this helper:

```python
async def send_streaming(self, generator: AsyncGenerator):
    buffer = ""
    pattern = r"[^。？！~…]+[。？！~…]+"
    async for chain in generator:
        if isinstance(chain, MessageChain):
            for comp in chain.chain:
                if isinstance(comp, Plain):
                    buffer += comp.text
                    if any(p in buffer for p in "。？！~…"):
                        buffer = await self.flush_buffer(buffer, pattern)
                else:
                    await self.send(MessageChain(chain=[comp]))
    if buffer.strip():
        await self.send(MessageChain([Plain(buffer)]))
    return await super().send_streaming(generator)
```

This refactor preserves the existing functionality while reducing nested conditional code and improving clarity.
created_at: 2025-04-12T18:28:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -82,17 +84,33 @@ async def send(self, message: MessageChain):
 
         await super().send(message)
 
-    async def send_streaming(self, generator):
-        buffer = None
+    async def process_buffer(self, buffer: str, pattern: re.Pattern) -> str:
path: astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py
user: login: Soulter
body: process_buffer 是否可以统一调用，提高复用性，此外可以添加一下 docstring 注释
created_at: 2025-04-13T06:43:36Z
author_association: MEMBER
diff_hunk: @@ -1,5 +1,7 @@
 import asyncio
-import typing
+from typing import AsyncGenerator, List, Dict
+import re
path: astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py
user: login: Soulter
body: import 在 from import 之前
created_at: 2025-04-13T06:43:59Z
author_association: MEMBER

PR comments:
user: login: sourcery-ai[bot]
created_at: 2025-04-12T18:27:32Z
body: <!-- Generated by sourcery-ai[bot]: start review_guide -->

```html
## Sourcery 代码审查指南

此 Pull Request 实现了对不支持流式输出的平台的回退机制。它通过缓冲输出，根据句子结尾标点符号将其拆分为句子，并以延迟方式单独发送每个句子来实现这一点。

#### send_streaming 回退机制的序列图

```mermaid
sequenceDiagram
  participant User
  participant PlatformEvent
  participant MessageChainGenerator
  participant MessageChain

  User->>PlatformEvent: send_streaming(generator)
  activate PlatformEvent
  loop For each chain in generator
    PlatformEvent->>MessageChainGenerator: yield chain
    activate MessageChainGenerator
    MessageChainGenerator-->>PlatformEvent: chain: MessageChain
    deactivate MessageChainGenerator
    PlatformEvent->>MessageChain: Extract Plain text components
    activate MessageChain
    MessageChain-->>PlatformEvent: text
    deactivate MessageChain
    PlatformEvent->>PlatformEvent: buffer += text
    PlatformEvent->>PlatformEvent: Check for sentence-ending punctuation
    alt Punctuation found
      loop While sentences can be extracted
        PlatformEvent->>PlatformEvent: Extract sentence
        PlatformEvent->>MessageChain: Create MessageChain([Plain(sentence)])
        activate MessageChain
        MessageChain-->>PlatformEvent: message: MessageChain
        deactivate MessageChain
        PlatformEvent->>PlatformEvent: send(message)
        activate PlatformEvent
        PlatformEvent-->>User: message
        deactivate PlatformEvent
        PlatformEvent->>PlatformEvent: buffer = remaining text
        PlatformEvent->>PlatformEvent: asyncio.sleep(0.5)
      end
    end
  end
  alt buffer is not empty
    PlatformEvent->>MessageChain: Create MessageChain([Plain(buffer)])
    activate MessageChain
    MessageChain-->>PlatformEvent: message: MessageChain
    deactivate MessageChain
    PlatformEvent->>PlatformEvent: send(message)
    activate PlatformEvent
    PlatformEvent-->>User: message
    deactivate PlatformEvent
  end
  PlatformEvent-->>User: return
  deactivate PlatformEvent
```

### 文件级别变更

| 变更 | 详情 | 文件 |
| ------ | ------- | ----- |
| 实现了流式输出的回退机制，通过缓冲、拆分成句子并延迟发送。 | <ul><li>引入了一个缓冲区来累积来自消息链的文本。</li><li>添加了一个正则表达式来匹配句子结尾的标点符号。</li><li>实现了一个循环，根据正则表达式将缓冲区拆分为句子。</li><li>将每个句子作为单独的消息发送。</li><li>在发送句子之间添加了延迟以限制速率。</li><li>通过立即发送来处理非纯文本组件。</li><li>在处理完所有消息链后，发送缓冲区中剩余的任何文本。</li></ul> | `astrbot/core/platform/sources/dingtalk/dingtalk_event.py`<br/>`astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py`<br/>`astrbot/core/platform/sources/gewechat/gewechat_event.py`<br/>`astrbot/core/platform/sources/lark/lark_event.py`<br/>`astrbot/core/platform/sources/wecom/wecom_event.py` |

---

<details>
<summary>提示和命令</summary>

#### 与 Sourcery 互动

- **触发新的审查：** 在 Pull Request 上评论 `@sourcery-ai review`。
- **继续讨论：** 直接回复 Sourcery 的审查评论。
- **从审查评论生成 GitHub Issue：** 通过回复审查评论，要求 Sourcery 从审查评论创建一个 Issue。您也可以回复审查评论并使用 `@sourcery-ai issue` 从中创建一个 Issue。
- **生成 Pull Request 标题：** 在 Pull Request 标题中的任何位置写入 `@sourcery-ai` 以随时生成标题。您也可以在 Pull Request 上评论 `@sourcery-ai title` 以随时（重新）生成标题。
- **生成 Pull Request 摘要：** 在 Pull Request 正文中的任何位置写入 `@sourcery-ai summary` 以随时在您想要的位置生成 PR 摘要。您也可以在 Pull Request 上评论 `@sourcery-ai summary` 以随时（重新）生成摘要。
- **生成审查指南：** 在 Pull Request 上评论 `@sourcery-ai guide` 以随时（重新）生成审查指南。
- **解决所有 Sourcery 评论：** 在 Pull Request 上评论 `@sourcery-ai resolve` 以解决所有 Sourcery 评论。如果您已经处理了所有评论并且不想再看到它们，这将非常有用。
- **驳回所有 Sourcery 审查：** 在 Pull Request 上评论 `@sourcery-ai dismiss` 以驳回所有现有的 Sourcery 审查。如果您想重新开始一个新的审查，这将特别有用 - 不要忘记评论 `@sourcery-ai review` 以触发新的审查！
- **为 Issue 生成行动计划：** 在 Issue 上评论 `@sourcery-ai plan` 以为其生成行动计划。

#### 自定义您的体验

访问您的 [仪表板](https://app.sourcery.ai) 以：
- 启用或禁用审查功能，例如 Sourcery 生成的 Pull Request 摘要、审查指南等。
- 更改审查语言。
- 添加、删除或编辑自定义审查说明。
- 调整其他审查设置。

#### 获取帮助

- [联系我们的支持团队](mailto:support@sourcery.ai) 提出问题或反馈。
- 访问我们的 [文档](https://docs.sourcery.ai) 获取详细指南和信息。
- 通过在 [X/Twitter](https://x.com/SourceryAI)、[LinkedIn](https://www.linkedin.com/company/sourcery-ai/) 或 [GitHub](https://github.com/sourcery-ai) 上关注我们，与 Sourcery 团队保持联系。

</details>
```

<details>
<summary>Original review guide in English</summary>

## Reviewer's Guide by Sourcery

This pull request implements a fallback mechanism for platforms that do not support streaming output. It achieves this by buffering the output, splitting it into sentences based on sentence-ending punctuation, and sending each sentence separately with a delay.

#### Sequence diagram for send_streaming fallback mechanism

```mermaid
sequenceDiagram
  participant User
  participant PlatformEvent
  participant MessageChainGenerator
  participant MessageChain

  User->>PlatformEvent: send_streaming(generator)
  activate PlatformEvent
  loop For each chain in generator
    PlatformEvent->>MessageChainGenerator: yield chain
    activate MessageChainGenerator
    MessageChainGenerator-->>PlatformEvent: chain: MessageChain
    deactivate MessageChainGenerator
    PlatformEvent->>MessageChain: Extract Plain text components
    activate MessageChain
    MessageChain-->>PlatformEvent: text
    deactivate MessageChain
    PlatformEvent->>PlatformEvent: buffer += text
    PlatformEvent->>PlatformEvent: Check for sentence-ending punctuation
    alt Punctuation found
      loop While sentences can be extracted
        PlatformEvent->>PlatformEvent: Extract sentence
        PlatformEvent->>MessageChain: Create MessageChain([Plain(sentence)])
        activate MessageChain
        MessageChain-->>PlatformEvent: message: MessageChain
        deactivate MessageChain
        PlatformEvent->>PlatformEvent: send(message)
        activate PlatformEvent
        PlatformEvent-->>User: message
        deactivate PlatformEvent
        PlatformEvent->>PlatformEvent: buffer = remaining text
        PlatformEvent->>PlatformEvent: asyncio.sleep(0.5)
      end
    end
  end
  alt buffer is not empty
    PlatformEvent->>MessageChain: Create MessageChain([Plain(buffer)])
    activate MessageChain
    MessageChain-->>PlatformEvent: message: MessageChain
    deactivate MessageChain
    PlatformEvent->>PlatformEvent: send(message)
    activate PlatformEvent
    PlatformEvent-->>User: message
    deactivate PlatformEvent
  end
  PlatformEvent-->>User: return
  deactivate PlatformEvent
```

### File-Level Changes

| Change | Details | Files |
| ------ | ------- | ----- |
| Implemented fallback for streaming output by buffering, splitting into sentences, and sending with a delay. | <ul><li>Introduced a buffer to accumulate text from the message chain.</li><li>Added a regular expression to match sentence-ending punctuation.</li><li>Implemented a loop to split the buffer into sentences based on the regular expression.</li><li>Sent each sentence as a separate message.</li><li>Added a delay between sending sentences to limit the rate.</li><li>Handled non-plain text components by sending them immediately.</li><li>Sent any remaining text in the buffer after processing all message chains.</li></ul> | `astrbot/core/platform/sources/dingtalk/dingtalk_event.py`<br/>`astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py`<br/>`astrbot/core/platform/sources/gewechat/gewechat_event.py`<br/>`astrbot/core/platform/sources/lark/lark_event.py`<br/>`astrbot/core/platform/sources/wecom/wecom_event.py` |

---

<details>
<summary>Tips and commands</summary>

#### Interacting with Sourcery

- **Trigger a new review:** Comment `@sourcery-ai review` on the pull request.
- **Continue discussions:** Reply directly to Sourcery's review comments.
- **Generate a GitHub issue from a review comment:** Ask Sourcery to create an
  issue from a review comment by replying to it. You can also reply to a
  review comment with `@sourcery-ai issue` to create an issue from it.
- **Generate a pull request title:** Write `@sourcery-ai` anywhere in the pull
  request title to generate a title at any time. You can also comment
  `@sourcery-ai title` on the pull request to (re-)generate the title at any time.
- **Generate a pull request summary:** Write `@sourcery-ai summary` anywhere in
  the pull request body to generate a PR summary at any time exactly where you
  want it. You can also comment `@sourcery-ai summary` on the pull request to
  (re-)generate the summary at any time.
- **Generate reviewer's guide:** Comment `@sourcery-ai guide` on the pull
  request to (re-)generate the reviewer's guide at any time.
- **Resolve all Sourcery comments:** Comment `@sourcery-ai resolve` on the
  pull request to resolve all Sourcery comments. Useful if you've already
  addressed all the comments and don't want to see them anymore.
- **Dismiss all Sourcery reviews:** Comment `@sourcery-ai dismiss` on the pull
  request to dismiss all existing Sourcery reviews. Especially useful if you
  want to start fresh with a new review - don't forget to comment
  `@sourcery-ai review` to trigger a new review!
- **Generate a plan of action for an issue:** Comment `@sourcery-ai plan` on
  an issue to generate a plan of action for it.

#### Customizing Your Experience

Access your [dashboard](https://app.sourcery.ai) to:
- Enable or disable review features such as the Sourcery-generated pull request
  summary, the reviewer's guide, and others.
- Change the review language.
- Add, remove or edit custom review instructions.
- Adjust other review settings.

#### Getting Help

- [Contact our support team](mailto:support@sourcery.ai) for questions or feedback.
- Visit our [documentation](https://docs.sourcery.ai) for detailed guides and information.
- Keep in touch with the Sourcery team by following us on [X/Twitter](https://x.com/SourceryAI), [LinkedIn](https://www.linkedin.com/company/sourcery-ai/) or [GitHub](https://github.com/sourcery-ai).

</details>

</details>

<!-- Generated by sourcery-ai[bot]: end review_guide -->
author_association: CONTRIBUTOR
user: login: Raven95676
created_at: 2025-04-13T09:15:33Z
body: Napcat平台已完成全面测试，功能正常，理论上支持所有平台。

未找到官方文档中关于最大回复数限制的内容，仅发现消息发送速率限制（钉钉/企业微信：20条/分钟，飞书：5条/秒）。

鉴于钉钉、企业微信、飞书使用官方 API，且目前缺乏测试环境，为稳妥起见，已撤销对这些平台的更改。gewechat 等第三方框架不太可能有最大回复数限制，因此保留相关更改。

消息发送间隔已调整为 1.5 秒。
author_association: MEMBER
user: login: Soulter
created_at: 2025-04-15T12:09:41Z
body: 此外，其实我觉得是不是留一个是否应用这个Fallback的可配置项比较好（？）
author_association: MEMBER
user: login: Raven95676
created_at: 2025-04-15T13:25:15Z
body: > 此外，其实我觉得是不是留一个是否应用这个Fallback的可配置项比较好（？）

加了。确实，更新了一个版本突然就分段了不太好（毕竟不是真的支持流式了）。
author_association: MEMBER
user: login: Raven95676
created_at: 2025-04-17T11:08:44Z
body: @anka-afk 
author_association: MEMBER
user: login: Raven95676
created_at: 2025-04-17T15:08:10Z
body: > 好好好, 不过use_fallback不作为参数传递, 而是在使用时直接从config对象读取会不会好看一些呢

Event这里好像没有传配置过来（
author_association: MEMBER
user: login: anka-afk
created_at: 2025-04-18T15:27:16Z
body: > > 好好好, 不过use_fallback不作为参数传递, 而是在使用时直接从config对象读取会不会好看一些呢
> 
> Event这里好像没有传配置过来（

直接from astrbot.core import astrbot_config, 不过可能有点不好看?
author_association: MEMBER
user: login: Raven95676
created_at: 2025-04-18T15:54:28Z
body: > > > 好好好, 不过use_fallback不作为参数传递, 而是在使用时直接从config对象读取会不会好看一些呢
> > 
> > 
> > Event这里好像没有传配置过来（
> 
> 直接from astrbot.core import astrbot_config, 不过可能有点不好看?

还是尽量减少这种更改比较好（
author_association: MEMBER