Skip to content

Conversation

@XYZliang
Copy link

@XYZliang XYZliang commented Nov 30, 2025

为 AstrBot 增加企业微信消息推送机器人(原群机器人)适配器,抢先适配,使其能够接收企业微信推送内容并通过 AstrBot 的会话处理与其他平台统一管理。
适配 #3857

Modifications / 改动点

  • astrbot/core/config/default.py 中新增 wecom_group_bot 平台配置模板和相关配置项说明,供仪表盘与配置文件启用。

  • astrbot/core/platform/manager.py 以及 astrbot/core/platform/sources/wecom_ai_bot 相关文件中扩展平台路由与消息加解密逻辑,以共享企业微信的安全校验处理。

  • 新增 astrbot/core/platform/sources/wecom_group_bot/ 目录,包含适配器的客户端、事件模型、解析器和服务端实现,实现企业微信回调解析、消息入队与 AstrBot 内部消息格式的转换。

  • 为前端配置面板 dashboard/src/utils/platformUtils.js 加入新的平台条目和配置映射,让用户可以在仪表盘里开关和设置该适配器。

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果

(暂无运行截图,已在本地完成功能联调。)


Checklist / 检查清单

  • 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
  • 👀 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”。/ My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
  • 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 requirements.txtpyproject.toml 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
  • 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.

Summary by Sourcery

为 AstrBot 新增一个平台适配器,将企业微信消息推送(群机器人)集成进来,包括服务端回调处理、消息解析和统一事件分发。

New Features:

  • 引入 WecomGroupBot 平台适配器,通过 AstrBot 接收和发送企业微信消息推送(群机器人)消息。
  • 在核心配置中为 WecomGroupBot 平台新增配置模板和默认值,以便启用和调优该适配器。
  • 通过平台管理器和控制面板暴露 WecomGroupBot 适配器,使其可以在 UI 中被选择和配置。

Enhancements:

  • 实现独立的 HTTP 服务器、解析器、客户端和事件封装器,用于处理企业微信群机器人回调、加解密,以及将消息转换为 AstrBot 的内部格式。
Original summary in English

Summary by Sourcery

Add a new platform adapter integrating WeCom message push (group) bots into AstrBot, including server-side callback handling, message parsing, and unified event dispatch.

New Features:

  • Introduce the WecomGroupBot platform adapter to receive and send Enterprise WeChat message push (group bot) messages through AstrBot.
  • Add configuration template and defaults for the WecomGroupBot platform in the core config to allow enabling and tuning the adapter.
  • Expose the WecomGroupBot adapter through the platform manager and dashboard so it can be selected and configured via the UI.

Enhancements:

  • Implement a dedicated HTTP server, parser, client, and event wrapper to handle Enterprise WeChat group bot callbacks, encryption, and message conversion into AstrBot's internal format.

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - 我已经审查了你的修改,这里是一些反馈:

  • WecomGroupBotParser._should_parse_as_json 中,在不去除空白字符的情况下使用 payload.startswith('<'),可能会把前面带有空格/换行的 XML 错误识别为 JSON;建议改用 payload.lstrip().startswith('<')(或类似方式),这样在 prefer_formatxml 时可以更可靠地识别 XML。
  • WecomGroupBotEvent.send_streaming 中,目前的实现会先缓存整个流并在结束时一次性发送,这违背了流式(streaming)的语义;如果平台可以处理部分更新,建议在生成块时就增量发送,而不是在发送前把所有内容聚合起来。
面向 AI Agent 的提示词
请根据以下代码审查中的评论进行修改:

## 总体评论
-`WecomGroupBotParser._should_parse_as_json` 中,在不去除空白字符的情况下使用 `payload.startswith('<')`,可能会把前面带有空格/换行的 XML 错误识别为 JSON;建议改用 `payload.lstrip().startswith('<')`(或类似方式),这样在 `prefer_format``xml` 时可以更可靠地识别 XML。
-`WecomGroupBotEvent.send_streaming` 中,目前的实现会先缓存整个流并在结束时一次性发送,这违背了流式(streaming)的语义;如果平台可以处理部分更新,建议在生成块时就增量发送,而不是在发送前把所有内容聚合起来。

## 单独评论

### 评论 1
<location> `astrbot/core/platform/sources/wecom_group_bot/wecom_group_bot_event.py:64-73` </location>
<code_context>
+
+        await super().send(message)
+
+    async def send_streaming(self, generator, use_fallback: bool = False):
+        buffer = None
+        async for chain in generator:
+            if not buffer:
+                buffer = chain
+            else:
+                buffer.chain.extend(chain.chain)
+        if buffer:
+            await self.send(buffer)
+        return await super().send_streaming(generator, use_fallback)
+
+    def _extract_chat_id(self) -> str:
</code_context>

<issue_to_address>
**issue (bug_risk):** `send_streaming` 的实现会先耗尽 generator,然后仍然调用 `super().send_streaming(generator)`。

这意味着父类的 `send_streaming` 接收到的是一个已经被耗尽的 generator,实际上什么都不会做。如果这个适配器是为了非流式聚合发送,那么在 `await self.send(buffer)` 之后很可能应该直接 `return`,而不是调用 `super()`。如果需要真正的流式行为,这个方法应该在块到达时就向下游转发,而不是先聚合再发送。
</issue_to_address>

### 评论 2
<location> `astrbot/core/platform/sources/wecom_group_bot/wecom_group_bot_adapter.py:151-158` </location>
<code_context>
    async def _build_message_components(
        self,
        msgtype: str,
        payload: dict[str, Any],
    ) -> tuple[list, str]:
        components: list = []
        message_str = ""

        if msgtype == "text":
            content = str(payload.get("text", {}).get("content", "")).strip()
            message_str = content
            components.append(Plain(content))
        elif msgtype == "image":
            image_url = payload.get("image", {}).get("image_url") or payload.get("image", {}).get("url")
            message_str = "[图片]"
            if image_url:
                components.append(Image(file=image_url, url=image_url))
        elif msgtype == "mixed":
            items = payload.get("mixed_message", {}).get("msg_item", [])
            texts: list[str] = []
            for item in items:
                item_type = str(item.get("msg_type", "")).lower()
                if item_type == "text":
                    text_content = item.get("text", {}).get("content", "")
                    texts.append(text_content)
                    components.append(Plain(text_content))
                elif item_type == "image":
                    image_url = item.get("image", {}).get("image_url")
                    if image_url:
                        components.append(Image(file=image_url, url=image_url))
            message_str = " ".join(texts)
        elif msgtype == "event":
            event_type = payload.get("event", {}).get("event_type")
            message_str = f"[事件] {event_type}" if event_type else "[事件]"
            components.append(Plain(message_str))
        elif msgtype == "attachment":
            callback_id = payload.get("attachment", {}).get("callback_id")
            message_str = f"[按钮回调] {callback_id or ''}".strip()
            components.append(Plain(message_str))
        else:
            message_str = f"[{msgtype}]"
            components.append(Plain(message_str))

        return components, message_str

</code_context>

<issue_to_address>
**issue (code-quality):** 我们发现了如下问题:

- 将条件逻辑简化为类似 switch 的形式([`switch`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/switch/)- 使用命名表达式来简化赋值和条件判断([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))
</issue_to_address>

### 评论 3
<location> `astrbot/core/platform/sources/wecom_group_bot/wecom_group_bot_event.py:79-80` </location>
<code_context>
    def _extract_chat_id(self) -> str:
        raw = self.message_obj.raw_message or {}
        if isinstance(raw, dict):
            payload: dict[str, Any] = raw.get("payload") or raw
            chat_id = payload.get("chatid") or payload.get("chat_id")
            if chat_id:
                return str(chat_id)
        return self.message_obj.session_id or self.session_id or ""

</code_context>

<issue_to_address>
**suggestion (code-quality):** 使用命名表达式来简化赋值和条件判断([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/)```suggestion
            if chat_id := payload.get("chatid") or payload.get("chat_id"):
```
</issue_to_address>

### 评论 4
<location> `astrbot/core/platform/sources/wecom_group_bot/wecom_group_bot_parser.py:17-19` </location>
<code_context>
def _camel_to_snake(value: str) -> str:
    if not value:
        return value
    return _CAMEL_CASE_PATTERN.sub("_", value).lower()

</code_context>

<issue_to_address>
**suggestion (code-quality):** 我们发现了如下问题:

- 在控制流跳转后,将代码提升到对应的 else 分支中([`reintroduce-else`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/reintroduce-else/)- 使用 if 表达式替换 if 语句([`assign-if-exp`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/assign-if-exp/)```suggestion
    return value if not value else _CAMEL_CASE_PATTERN.sub("_", value).lower()
```
</issue_to_address>

### 评论 5
<location> `astrbot/core/platform/sources/wecom_group_bot/wecom_group_bot_parser.py:74-76` </location>
<code_context>
    def _parse_xml(self, payload: str) -> dict[str, Any]:
        root = ET.fromstring(payload)
        parsed: dict[str, Any] = {}
        for child in root:
            parsed[_camel_to_snake(child.tag)] = _element_to_data(child)
        return parsed

</code_context>

<issue_to_address>
**suggestion (code-quality):** 将 for 循环转换为字典推导式([`dict-comprehension`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/dict-comprehension/)```suggestion
        parsed: dict[str, Any] = {
            _camel_to_snake(child.tag): _element_to_data(child) for child in root
        }
```
</issue_to_address>

Sourcery 对开源项目免费——如果你觉得我们的审查有帮助,欢迎分享 ✨
帮我变得更有用!请在每条评论上点 👍 或 👎,我会根据反馈改进后续的审查。
Original comment in English

Hey there - I've reviewed your changes - here's some feedback:

  • In WecomGroupBotParser._should_parse_as_json, using payload.startswith('<') without stripping whitespace can misclassify XML with leading spaces/newlines as JSON; consider using payload.lstrip().startswith('<') (or similar) so XML is reliably detected when prefer_format is xml.
  • In WecomGroupBotEvent.send_streaming, the implementation buffers the entire stream and sends once at the end, which defeats streaming semantics; if the platform can handle partial updates, consider emitting chunks incrementally instead of aggregating them all before sending.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `WecomGroupBotParser._should_parse_as_json`, using `payload.startswith('<')` without stripping whitespace can misclassify XML with leading spaces/newlines as JSON; consider using `payload.lstrip().startswith('<')` (or similar) so XML is reliably detected when `prefer_format` is `xml`.
- In `WecomGroupBotEvent.send_streaming`, the implementation buffers the entire stream and sends once at the end, which defeats streaming semantics; if the platform can handle partial updates, consider emitting chunks incrementally instead of aggregating them all before sending.

## Individual Comments

### Comment 1
<location> `astrbot/core/platform/sources/wecom_group_bot/wecom_group_bot_event.py:64-73` </location>
<code_context>
+
+        await super().send(message)
+
+    async def send_streaming(self, generator, use_fallback: bool = False):
+        buffer = None
+        async for chain in generator:
+            if not buffer:
+                buffer = chain
+            else:
+                buffer.chain.extend(chain.chain)
+        if buffer:
+            await self.send(buffer)
+        return await super().send_streaming(generator, use_fallback)
+
+    def _extract_chat_id(self) -> str:
</code_context>

<issue_to_address>
**issue (bug_risk):** The `send_streaming` implementation exhausts the generator and then still calls `super().send_streaming(generator)`.

This means the superclass `send_streaming` receives an already-exhausted generator and effectively does nothing. If this adapter is meant to aggregate non-streamingly, it should likely just `return` after `await self.send(buffer)` and not call `super()`. If true streaming is required, this method should instead forward chunks as they arrive rather than aggregating them first.
</issue_to_address>

### Comment 2
<location> `astrbot/core/platform/sources/wecom_group_bot/wecom_group_bot_adapter.py:151-158` </location>
<code_context>
    async def _build_message_components(
        self,
        msgtype: str,
        payload: dict[str, Any],
    ) -> tuple[list, str]:
        components: list = []
        message_str = ""

        if msgtype == "text":
            content = str(payload.get("text", {}).get("content", "")).strip()
            message_str = content
            components.append(Plain(content))
        elif msgtype == "image":
            image_url = payload.get("image", {}).get("image_url") or payload.get("image", {}).get("url")
            message_str = "[图片]"
            if image_url:
                components.append(Image(file=image_url, url=image_url))
        elif msgtype == "mixed":
            items = payload.get("mixed_message", {}).get("msg_item", [])
            texts: list[str] = []
            for item in items:
                item_type = str(item.get("msg_type", "")).lower()
                if item_type == "text":
                    text_content = item.get("text", {}).get("content", "")
                    texts.append(text_content)
                    components.append(Plain(text_content))
                elif item_type == "image":
                    image_url = item.get("image", {}).get("image_url")
                    if image_url:
                        components.append(Image(file=image_url, url=image_url))
            message_str = " ".join(texts)
        elif msgtype == "event":
            event_type = payload.get("event", {}).get("event_type")
            message_str = f"[事件] {event_type}" if event_type else "[事件]"
            components.append(Plain(message_str))
        elif msgtype == "attachment":
            callback_id = payload.get("attachment", {}).get("callback_id")
            message_str = f"[按钮回调] {callback_id or ''}".strip()
            components.append(Plain(message_str))
        else:
            message_str = f"[{msgtype}]"
            components.append(Plain(message_str))

        return components, message_str

</code_context>

<issue_to_address>
**issue (code-quality):** We've found these issues:

- Simplify conditional into switch-like form ([`switch`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/switch/))
- Use named expression to simplify assignment and conditional ([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))
</issue_to_address>

### Comment 3
<location> `astrbot/core/platform/sources/wecom_group_bot/wecom_group_bot_event.py:79-80` </location>
<code_context>
    def _extract_chat_id(self) -> str:
        raw = self.message_obj.raw_message or {}
        if isinstance(raw, dict):
            payload: dict[str, Any] = raw.get("payload") or raw
            chat_id = payload.get("chatid") or payload.get("chat_id")
            if chat_id:
                return str(chat_id)
        return self.message_obj.session_id or self.session_id or ""

</code_context>

<issue_to_address>
**suggestion (code-quality):** Use named expression to simplify assignment and conditional ([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))

```suggestion
            if chat_id := payload.get("chatid") or payload.get("chat_id"):
```
</issue_to_address>

### Comment 4
<location> `astrbot/core/platform/sources/wecom_group_bot/wecom_group_bot_parser.py:17-19` </location>
<code_context>
def _camel_to_snake(value: str) -> str:
    if not value:
        return value
    return _CAMEL_CASE_PATTERN.sub("_", value).lower()

</code_context>

<issue_to_address>
**suggestion (code-quality):** We've found these issues:

- Lift code into else after jump in control flow ([`reintroduce-else`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/reintroduce-else/))
- Replace if statement with if expression ([`assign-if-exp`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/assign-if-exp/))

```suggestion
    return value if not value else _CAMEL_CASE_PATTERN.sub("_", value).lower()
```
</issue_to_address>

### Comment 5
<location> `astrbot/core/platform/sources/wecom_group_bot/wecom_group_bot_parser.py:74-76` </location>
<code_context>
    def _parse_xml(self, payload: str) -> dict[str, Any]:
        root = ET.fromstring(payload)
        parsed: dict[str, Any] = {}
        for child in root:
            parsed[_camel_to_snake(child.tag)] = _element_to_data(child)
        return parsed

</code_context>

<issue_to_address>
**suggestion (code-quality):** Convert for loop into dictionary comprehension ([`dict-comprehension`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/dict-comprehension/))

```suggestion
        parsed: dict[str, Any] = {
            _camel_to_snake(child.tag): _element_to_data(child) for child in root
        }
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@XYZliang
Copy link
Author

附上成功截图和日志。目前该功能还没全部推送
image
image
image

@Naich
Copy link

Naich commented Dec 5, 2025

看起来这个正在测试的消息推送(原群机器人)和新出智能机器人是一样的通讯方式

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants