Compare commits

...

79 Commits

Author SHA1 Message Date
Junyan Qin (Chin)
7538973b33 chore: release v3.4.14.3 (#1358) 2025-04-29 19:45:19 +08:00
shinelin
3554702054 feat(gewechat): 重构target2yiri代码+引用消息展开 (#1352)
* feat(gewechat): 重构target2yiri代码+引用消息展开

* feat(gewe): 引用消息,图片视频音频是单独的类型
2025-04-29 13:18:19 +08:00
Guanchao Wang
96183eb3e0 fix: access_token problems in wecomcs (#1355) 2025-04-29 13:04:52 +08:00
Guanchao Wang
778065f7fb fix: image couldn't be sent in lark (#1348) 2025-04-28 15:30:30 +08:00
shinelin
ac500266f3 feat(gewechat): 优化了代码结构+fix群聊艾特逻辑,新增消息类型 (#1336)
* feat(gewechat): 优化了代码结构+fix群聊艾特逻辑,新增消息类型

* feat(gewechat): 移除不合理的message定义,优化GewechatMessageConverter

* bugfix(gewechat): fix typo

* feat(gewechat): 去掉多余日志+公众号消息和文件消息转发+msg_source取空异常fix

* bugfix(message):删除image中的xml定义

* bugfix(message): fix typo
2025-04-27 20:48:55 +08:00
Junyan Qin (Chin)
efed9f3348 Merge pull request #1338 from RockChinQ/RockChinQ-patch-1
Update README_EN.md
2025-04-26 21:08:55 +08:00
Junyan Qin (Chin)
f1ed79fa4e 优化了处理语音消息和群聊图片消息,增加了发送语音消息(只能发送silk格式语音文件链接)和转发链接消息 (#1323)
* 优化了处理语音消息和处理群聊图片消息,增加了发送语音消息

* 增加了微信转发链接消息组件

* 增加了转发链接

* 修改字段内容手误问题

* 优化收到小程序,公众号转账等消息时将其通过unknown传递出来,并修复voice字段写错问题

* 移除有一处将数据当作base64处理并通过unknown中content(但是没有啊)传递。
2025-04-24 22:13:02 +08:00
Dong_master
cb7f7b80df 移除有一处将数据当作base64处理并通过unknown中content(但是没有啊)传递。 2025-04-24 22:05:54 +08:00
Dong_master
112f99d6d9 优化收到小程序,公众号转账等消息时将其通过unknown传递出来,并修复voice字段写错问题 2025-04-24 21:12:30 +08:00
Dong_master
00cafb1188 修改字段内容手误问题 2025-04-24 00:00:49 +08:00
Junyan Qin (Chin)
8af401eea4 chore: release v3.4.14.2 (#1326) 2025-04-23 17:34:00 +08:00
Junyan Qin (Chin)
446546b69f fix(dify runner): response message event incorrect when using agent app (#1325) 2025-04-23 16:55:52 +08:00
Dong_master
5c26ce215b 增加了转发链接 2025-04-23 02:36:36 +08:00
Dong_master
8ca714853a 增加了微信转发链接消息组件 2025-04-23 02:28:39 +08:00
Dong_master
577dc0d175 优化了处理语音消息和处理群聊图片消息,增加了发送语音消息 2025-04-23 02:25:58 +08:00
Junyan Qin (Chin)
8a6d9d76da perf: reduce newline in think tag converting (#1319) 2025-04-20 13:41:02 +08:00
Junyan Qin (Chin)
92acaf6c27 chore: release 3.4.14.1 (#1315) 2025-04-19 22:30:22 +08:00
Junyan Qin (Chin)
4d53b3cb06 doc: update README
doc: update README
2025-04-18 20:25:50 +08:00
Junyan Qin (Chin)
7cad4ffa37 Merge pull request #1311 from RockChinQ/feat/ppio
feat: add support for ppio
2025-04-17 16:36:01 +08:00
Junyan Qin
b6f312325f chore: fix 2025-04-17 16:33:35 +08:00
Junyan Qin
43a6492cab chore: migration for ppio config 2025-04-17 16:32:19 +08:00
WangCham
92e3546e8a feat: add support for ppio 2025-04-17 16:18:05 +08:00
Junyan Qin (Chin)
8a9000cc67 chore: release v3.4.14 (#1307)
* chore: release v3.4.14

* doc(README): wecom cs
2025-04-16 15:06:47 +08:00
Guanchao Wang
6e3514c0b2 feat: add support for wecom customer service (#1304) 2025-04-16 15:02:01 +08:00
SkyFutu
2782c8cebe Fix/windows compatibility (#1303)
* Update anthropicmsgs.py

* Update anthropicmsgs.py

* Update anthropicmsgs.py

* Update anthropicmsgs.py

* Update anthropicmsgs.py
2025-04-15 22:00:02 +08:00
Junyan Qin (Chin)
13e29a9966 chore: release v3.4.13.1 (#1299) 2025-04-14 20:19:18 +08:00
Guanchao Wang
601b0a8964 fix(moonshot): tool_call_id not found error (#1040) (#1298) 2025-04-14 20:17:11 +08:00
Guanchao Wang
7c2ceb0aca fix: add reasoning content for deepseek-reasoner (#1296) 2025-04-14 15:05:53 +08:00
Guanchao Wang
42fabd5133 fix: delete print function in lark (#1295) 2025-04-14 14:37:34 +08:00
Guanchao Wang
210a8856e2 fix: telegram markdown & supergroup bugs (#1293) 2025-04-13 18:48:38 +08:00
Guanchao Wang
c531cb11af fix: bailian api streaming mode can't be established 2025-04-13 17:47:05 +08:00
Junyan Qin (Chin)
07e073f526 chore: perf issue template (#1289) 2025-04-11 17:52:04 +08:00
Junyan Qin (Chin)
c5457374a8 chore: release v3.4.13 (#1284) 2025-04-09 21:58:23 +08:00
Junyan Qin (Chin)
5198349591 Merge pull request #1275 from yrk111222/master
Add ModelScope Support
2025-04-03 21:00:03 +08:00
Junyan Qin
8a4967525a fix(modelscope): bad base-url in migration 2025-04-03 20:52:01 +08:00
Junyan Qin
30b068c6e2 doc: reorder modelscope in README 2025-04-03 20:44:41 +08:00
Junyan Qin
ea3fff59ac chore: remove verbose models from llm-models.json 2025-04-03 20:40:36 +08:00
yrk
b09ce8296f Add ModelScope Support 2025-04-03 16:55:14 +08:00
Junyan Qin (Chin)
f9d07779a9 fix: slack is incorrectly enabled as default (#1274) 2025-04-03 14:17:21 +08:00
Junyan Qin (Chin)
51634c1caf chore: release v3.4.12.1 (#1271) 2025-04-02 15:23:38 +08:00
Guanchao Wang
0e00da6617 Merge pull request #1270 from RockChinQ/fix/telegram-markdown
fix: markdown and image problems in tg
2025-04-02 12:33:15 +08:00
Junyan Qin (Chin)
5ee6baeaaa Merge pull request #1268 from RockChinQ/version/3.4.12
chore: release v3.4.12
2025-04-01 21:15:46 +08:00
Junyan Qin
f11a036c60 chore: release v3.4.12 2025-04-01 21:13:41 +08:00
Junyan Qin (Chin)
0ac02ff4ce Merge pull request #1267 from RockChinQ/chore/default-prompt
chore: provide default prompt
2025-04-01 20:43:33 +08:00
Junyan Qin
99cc50b5cb chore: provide default prompt 2025-04-01 20:42:23 +08:00
Junyan Qin (Chin)
1d8fb02989 Merge pull request #1218 from fdc310/master
新增了微信发送小程序、转发小程序,发送emoji表情以及发送链接
2025-04-01 20:38:32 +08:00
Junyan Qin
122cb1188c style: standardized component names 2025-04-01 20:37:39 +08:00
Junyan Qin (Chin)
ca36ade288 Merge pull request #1266 from RockChinQ/chore/slack-schema
chore: add slack config schema
2025-04-01 20:04:08 +08:00
Junyan Qin
0877046db7 chore: add slack config schema 2025-04-01 20:03:42 +08:00
Junyan Qin (Chin)
ce9615a00e Merge pull request #1265 from RockChinQ/feat/markdowncard
add support for markdown card in dingtalk & tg
2025-04-01 20:01:44 +08:00
Junyan Qin
dbe5a41395 chore: schema for markdown config 2025-04-01 20:01:20 +08:00
Junyan Qin
4a4ca54c6e feat: migration for markdown config 2025-04-01 19:59:45 +08:00
wangcham
47acb63feb add support for markdown card in dingtalk & tg 2025-04-01 07:11:48 -04:00
Junyan Qin (Chin)
038c5d41e2 Merge pull request #1258 from RockChinQ/feat/slack
feat: add slack adapter
2025-04-01 15:33:22 +08:00
Junyan Qin
011a795895 doc(README): add slack 2025-04-01 15:32:48 +08:00
wangcham
873a0339d8 feat: add support for sending active message in slack 2025-04-01 03:03:48 -04:00
wangcham
715da548c8 fix: put the link and content together 2025-04-01 02:37:25 -04:00
Junyan Qin (Chin)
5378c6ba35 chore: provides TZ=Asia/Shanghai in docker-compose.yaml as default (#1259) 2025-03-31 14:00:08 +08:00
Guanchao Wang
8799f86ea4 Update pkg/platform/sources/slack.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-03-31 13:48:37 +08:00
wangcham
686be4acbc fix: eliminate host config 2025-03-31 01:10:45 -04:00
wangcham
5744eca37a fix: bot user id in slack 2025-03-30 23:06:03 -04:00
wangcham
70f8ddb1ba fix: delete useless image function in slack 2025-03-30 22:56:51 -04:00
wangcham
be1328cee9 feat: add support for slack 2025-03-30 22:24:53 -04:00
wangcham
c0dbf6fd13 feat:add support for slack 2025-03-30 12:53:48 -04:00
Junyan Qin (Chin)
ffe9c3e0f8 chore: release v3.4.11.2 (#1257) 2025-03-31 00:02:54 +08:00
Junyan Qin (Chin)
e20b79b0ed perf(chatcmpl): remove space from base-url (#1256) 2025-03-30 23:59:55 +08:00
Junyan Qin (Chin)
e04d46db2c perf(claude): ensure system message removed (#867) (#1255) 2025-03-30 23:51:53 +08:00
Junyan Qin (Chin)
7341435127 perf(chatcmpl): use extra_body to pass args (#1254) 2025-03-30 23:43:45 +08:00
Junyan Qin (Chin)
8b56f94667 perf: add debugging msg for webhook style adapters (#1253) 2025-03-30 23:23:31 +08:00
Junyan Qin (Chin)
f5e98d4ebb fix(gewe): should not block main launching process (#1163) (#1252) 2025-03-30 23:14:56 +08:00
Junyan Qin (Chin)
23a0dba470 feat(dify): throw error event (#1251) 2025-03-30 23:04:46 +08:00
fdc310
512371cc25 Merge branch 'RockChinQ:master' into master 2025-03-30 22:55:55 +08:00
Dong_master
cd4a06b692 修改因为手误的参数名错误以及类名规范化 2025-03-29 01:18:30 +08:00
Dong_master
432440d6bf 新增reply发送消息及文件 2025-03-27 00:01:05 +08:00
fdc310
71ffbb9eb5 Merge branch 'RockChinQ:master' into master 2025-03-19 23:13:58 +08:00
Dong_master
e22c804deb 新增发送emoji表情?(好像没啥用)和发送链接功能 2025-03-19 22:47:10 +08:00
Dong_master
c136e790ef 新增小程序发送,小程序转发更名为ForwardMiniPrograms 2025-03-19 21:56:13 +08:00
Dong_master
3697afd9d6 新增小程序发送,小程序转发更名为ForwardMiniPrograms 2025-03-19 21:55:36 +08:00
Dong_master
c597c6482a 新增小程序转发 2025-03-19 20:46:56 +08:00
47 changed files with 2483 additions and 367 deletions

View File

@@ -3,22 +3,6 @@ description: 报错或漏洞请使用这个模板创建,不使用此模板创
title: "[Bug]: "
labels: ["bug?"]
body:
- type: dropdown
attributes:
label: 消息平台适配器
description: "接入的消息平台类型"
options:
- 其他(或暂未使用)
- Nakurugo-cqhttp
- aiocqhttp使用 OneBot 协议接入的)
- qq-botpyQQ官方API WebSocket
- qqofficialQQ官方API Webhook
- lark飞书
- wecom企业微信
- gewechat个人微信
- discord
validations:
required: true
- type: input
attributes:
label: 运行环境

3
.gitignore vendored
View File

@@ -38,4 +38,5 @@ botpy.log*
/poc
/libs/wecom_api/test.py
/venv
/jp-tyo-churros-05.rockchin.top
test.py
/web_ui

View File

@@ -8,7 +8,7 @@
<a href="https://trendshift.io/repositories/12901" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12901" alt="RockChinQ%2FLangBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://docs.langbot.app">项目主页</a>
<a href="https://langbot.app">项目主页</a>
<a href="https://docs.langbot.app/insight/intro.html">功能介绍</a>
<a href="https://docs.langbot.app/insight/guide.html">部署文档</a>
<a href="https://docs.langbot.app/usage/faq.html">常见问题</a>
@@ -87,13 +87,14 @@
| QQ 个人号 | ✅ | QQ 个人号私聊、群聊 |
| QQ 官方机器人 | ✅ | QQ 官方机器人,支持频道、私聊、群聊 |
| 企业微信 | ✅ | |
| 企微对外客服 | ✅ | |
| 个人微信 | ✅ | 使用 [Gewechat](https://github.com/Devo919/Gewechat) 接入 |
| 微信公众号 | ✅ | |
| 飞书 | ✅ | |
| 钉钉 | ✅ | |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | 🚧 | |
| Slack | | |
| LINE | 🚧 | |
| WhatsApp | 🚧 | |
@@ -109,6 +110,7 @@
| [Anthropic](https://www.anthropic.com/) | ✅ | |
| [xAI](https://x.ai/) | ✅ | |
| [智谱AI](https://open.bigmodel.cn/) | ✅ | |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | 大模型和 GPU 资源平台 |
| [Dify](https://dify.ai) | ✅ | LLMOps 平台 |
| [Ollama](https://ollama.com/) | ✅ | 本地大模型运行平台 |
| [LMStudio](https://lmstudio.ai/) | ✅ | 本地大模型运行平台 |
@@ -116,6 +118,7 @@
| [SiliconFlow](https://siliconflow.cn/) | ✅ | 大模型聚合平台 |
| [阿里云百炼](https://bailian.console.aliyun.com/) | ✅ | 大模型聚合平台, LLMOps 平台 |
| [火山方舟](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ✅ | 大模型聚合平台, LLMOps 平台 |
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ✅ | 大模型聚合平台 |
| [MCP](https://modelcontextprotocol.io/) | ✅ | 支持通过 MCP 协议获取工具 |
### TTS

View File

@@ -7,7 +7,7 @@
<a href="https://trendshift.io/repositories/12901" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12901" alt="RockChinQ%2FLangBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://docs.langbot.app">Home</a>
<a href="https://langbot.app">Home</a>
<a href="https://docs.langbot.app/insight/intro.html">Features</a>
<a href="https://docs.langbot.app/insight/guide.html">Deployment</a>
<a href="https://docs.langbot.app/usage/faq.html">FAQ</a>
@@ -45,6 +45,7 @@
>
> - Before you start deploying in any way, please read the [New User Guide](https://docs.langbot.app/insight/guide.html).
> - All documentation is in Chinese, we will provide i18n version in the near future.
> - Read [the auto-generated wiki on DeepWiki](https://deepwiki.com/RockChinQ/LangBot).
#### Docker Compose Deployment
@@ -85,12 +86,13 @@ Directly use the released version to run, see the [Manual Deployment](https://do
| Personal QQ | ✅ | |
| QQ Official API | ✅ | |
| WeCom | ✅ | |
| WeComCS | ✅ | |
| Personal WeChat | ✅ | Use [Gewechat](https://github.com/Devo919/Gewechat) to access |
| Lark | ✅ | |
| DingTalk | ✅ | |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | 🚧 | |
| Slack | | |
| LINE | 🚧 | |
| WhatsApp | 🚧 | |
@@ -107,12 +109,14 @@ Directly use the released version to run, see the [Manual Deployment](https://do
| [xAI](https://x.ai/) | ✅ | |
| [Zhipu AI](https://open.bigmodel.cn/) | ✅ | |
| [Dify](https://dify.ai) | ✅ | LLMOps platform |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | LLM and GPU resource platform |
| [Ollama](https://ollama.com/) | ✅ | Local LLM running platform |
| [LMStudio](https://lmstudio.ai/) | ✅ | Local LLM running platform |
| [GiteeAI](https://ai.gitee.com/) | ✅ | LLM interface gateway(MaaS) |
| [SiliconFlow](https://siliconflow.cn/) | ✅ | LLM gateway(MaaS) |
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | ✅ | LLM gateway(MaaS), LLMOps platform |
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ✅ | LLM gateway(MaaS), LLMOps platform |
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ✅ | LLM gateway(MaaS) |
| [MCP](https://modelcontextprotocol.io/) | ✅ | Support tool access through MCP protocol |
## 🤝 Community Contribution

View File

@@ -7,7 +7,7 @@
<a href="https://trendshift.io/repositories/12901" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12901" alt="RockChinQ%2FLangBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://docs.langbot.app">ホーム</a>
<a href="https://langbot.app">ホーム</a>
<a href="https://docs.langbot.app/insight/intro.html">機能</a>
<a href="https://docs.langbot.app/insight/guide.html">デプロイ</a>
<a href="https://docs.langbot.app/usage/faq.html">FAQ</a>
@@ -44,6 +44,7 @@
>
> - どのデプロイ方法を始める前に、必ず[新規ユーザーガイド](https://docs.langbot.app/insight/guide.html)をお読みください。
> - すべてのドキュメントは中国語で提供されています。近い将来、i18nバージョンを提供する予定です。
> - Read [the auto-generated wiki on DeepWiki](https://deepwiki.com/RockChinQ/LangBot)。
#### Docker Compose デプロイ
@@ -84,12 +85,13 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
| 個人QQ | ✅ | |
| QQ公式API | ✅ | |
| WeCom | ✅ | |
| WeComCS | ✅ | |
| 個人WeChat | ✅ | [Gewechat](https://github.com/Devo919/Gewechat)を使用して接続 |
| Lark | ✅ | |
| DingTalk | ✅ | |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | 🚧 | |
| Slack | | |
| LINE | 🚧 | |
| WhatsApp | 🚧 | |
@@ -105,6 +107,7 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
| [Anthropic](https://www.anthropic.com/) | ✅ | |
| [xAI](https://x.ai/) | ✅ | |
| [Zhipu AI](https://open.bigmodel.cn/) | ✅ | |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | 大模型とGPUリソースプラットフォーム |
| [Dify](https://dify.ai) | ✅ | LLMOpsプラットフォーム |
| [Ollama](https://ollama.com/) | ✅ | ローカルLLM実行プラットフォーム |
| [LMStudio](https://lmstudio.ai/) | ✅ | ローカルLLM実行プラットフォーム |
@@ -112,6 +115,7 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
| [SiliconFlow](https://siliconflow.cn/) | ✅ | LLMゲートウェイ(MaaS) |
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | ✅ | LLMゲートウェイ(MaaS), LLMOpsプラットフォーム |
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ✅ | LLMゲートウェイ(MaaS), LLMOpsプラットフォーム |
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ✅ | LLMゲートウェイ(MaaS) |
| [MCP](https://modelcontextprotocol.io/) | ✅ | MCPプロトコルをサポート |
## 🤝 コミュニティ貢献

View File

@@ -8,6 +8,8 @@ services:
- ./data:/app/data
- ./plugins:/app/plugins
restart: on-failure
environment:
- TZ=Asia/Shanghai
ports:
- 5300:5300 # 供 WebUI 使用
- 2280-2290:2280-2290 # 供消息平台适配器方向连接

View File

@@ -10,7 +10,7 @@ import traceback
class DingTalkClient:
def __init__(self, client_id: str, client_secret: str,robot_name:str,robot_code:str):
def __init__(self, client_id: str, client_secret: str,robot_name:str,robot_code:str,markdown_card:bool):
"""初始化 WebSocket 连接并自动启动"""
self.credential = dingtalk_stream.Credential(client_id, client_secret)
self.client = dingtalk_stream.DingTalkStreamClient(self.credential)
@@ -26,6 +26,7 @@ class DingTalkClient:
self.robot_name = robot_name
self.robot_code = robot_code
self.access_token_expiry_time = ''
self.markdown_card = markdown_card
@@ -128,7 +129,10 @@ class DingTalkClient:
async def send_message(self,content:str,incoming_message):
self.EchoTextHandler.reply_text(content,incoming_message)
if self.markdown_card:
self.EchoTextHandler.reply_markdown(title=self.robot_name+'的回答',text=content,incoming_message=incoming_message)
else:
self.EchoTextHandler.reply_text(content,incoming_message)
async def get_incoming_message(self):

View File

111
libs/slack_api/api.py Normal file
View File

@@ -0,0 +1,111 @@
import json
from quart import Quart, jsonify,request
from slack_sdk.web.async_client import AsyncWebClient
from .slackevent import SlackEvent
from typing import Callable, Dict, Any
from pkg.platform.types import events as platform_events, message as platform_message
class SlackClient():
def __init__(self,bot_token:str,signing_secret:str):
self.bot_token = bot_token
self.signing_secret = signing_secret
self.app = Quart(__name__)
self.client = AsyncWebClient(self.bot_token)
self.app.add_url_rule('/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST'])
self._message_handlers = {
"example":[],
}
self.bot_user_id = None # 避免机器人回复自己的消息
async def handle_callback_request(self):
try:
body = await request.get_data()
data = json.loads(body)
if 'type' in data:
if data['type'] == 'url_verification':
return data['challenge']
bot_user_id = data.get("event",{}).get("bot_id","")
if self.bot_user_id and bot_user_id == self.bot_user_id:
return jsonify({'status': 'ok'})
# 处理私信
if data and data.get("event", {}).get("channel_type") in ["im"]:
event = SlackEvent.from_payload(data)
await self._handle_message(event)
return jsonify({'status': 'ok'})
#处理群聊
if data.get("event",{}).get("type") == 'app_mention':
data.setdefault("event", {})["channel_type"] = "channel"
event = SlackEvent.from_payload(data)
await self._handle_message(event)
return jsonify({'status':'ok'})
return jsonify({'status': 'ok'})
except Exception as e:
raise(e)
async def _handle_message(self, event: SlackEvent):
"""
处理消息事件。
"""
msg_type = event.type
if msg_type in self._message_handlers:
for handler in self._message_handlers[msg_type]:
await handler(event)
def on_message(self, msg_type: str):
"""注册消息类型处理器"""
def decorator(func: Callable[[platform_events.Event], None]):
if msg_type not in self._message_handlers:
self._message_handlers[msg_type] = []
self._message_handlers[msg_type].append(func)
return func
return decorator
async def send_message_to_channel(self,text:str,channel_id:str):
try:
response = await self.client.chat_postMessage(
channel=channel_id,
text=text
)
if self.bot_user_id is None and response.get("ok"):
self.bot_user_id = response["message"]["bot_id"]
return
except Exception as e:
raise e
async def send_message_to_one(self,text:str,user_id:str):
try:
response = await self.client.chat_postMessage(
channel = '@'+user_id,
text= text
)
if self.bot_user_id is None and response.get("ok"):
self.bot_user_id = response["message"]["bot_id"]
return
except Exception as e:
raise e
async def run_task(self, host: str, port: int, *args, **kwargs):
"""
启动 Quart 应用。
"""
await self.app.run_task(host=host, port=port, *args, **kwargs)

View File

@@ -0,0 +1,91 @@
from typing import Dict, Any, Optional
class SlackEvent(dict):
@staticmethod
def from_payload(payload: Dict[str, Any]) -> Optional["SlackEvent"]:
try:
event = SlackEvent(payload)
return event
except KeyError:
return None
@property
def text(self) -> str:
if self.get("event", {}).get("channel_type") == "im":
blocks = self.get("event", {}).get("blocks", [])
if not blocks:
return ""
elements = blocks[0].get("elements", [])
if not elements:
return ""
elements = elements[0].get("elements", [])
text = ""
for el in elements:
if el.get("type") == "text":
text += el.get("text", "")
elif el.get("type") == "link":
text += el.get("url", "")
return text
if self.get("event",{}).get("channel_type") == 'channel':
message_text = ""
for block in self.get("event", {}).get("blocks", []):
if block.get("type") == "rich_text":
for element in block.get("elements", []):
if element.get("type") == "rich_text_section":
parts = []
for el in element.get("elements", []):
if el.get("type") == "text":
parts.append(el["text"])
elif el.get("type") == "link":
parts.append(el["url"])
message_text = "".join(parts)
return message_text
@property
def user_id(self) -> Optional[str]:
return self.get("event", {}).get("user","")
@property
def channel_id(self) -> Optional[str]:
return self.get("event", {}).get("channel","")
@property
def type(self) -> str:
""" message对应私聊app_mention对应频道at """
return self.get("event", {}).get("channel_type", "")
@property
def message_id(self) -> str:
return self.get("event_id","")
@property
def pic_url(self) -> str:
"""提取 Slack 事件中的图片 URL"""
files = self.get("event", {}).get("files", [])
if files:
return files[0].get("url_private", "")
return None
@property
def sender_name(self) -> str:
return self.get("event", {}).get("user","")
def __getattr__(self, key: str) -> Optional[Any]:
return self.get(key)
def __setattr__(self, key: str, value: Any) -> None:
self[key] = value
def __repr__(self) -> str:
return f"<SlackEvent {super().__repr__()}>"

View File

@@ -0,0 +1,343 @@
from quart import request
from ..wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt
import base64
import binascii
import httpx
import traceback
from quart import Quart
import xml.etree.ElementTree as ET
from typing import Callable, Dict, Any
from .wecomcsevent import WecomCSEvent
from pkg.platform.types import events as platform_events, message as platform_message
import aiofiles
class WecomCSClient():
def __init__(self,corpid:str,secret:str,token:str,EncodingAESKey:str):
self.corpid = corpid
self.secret = secret
self.access_token_for_contacts =''
self.token = token
self.aes = EncodingAESKey
self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin'
self.access_token = ''
self.app = Quart(__name__)
self.wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid)
self.app.add_url_rule('/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST'])
self._message_handlers = {
"example":[],
}
async def get_pic_url(self, media_id: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = f"{self.base_url}/media/get?access_token={self.access_token}&media_id={media_id}"
async with httpx.AsyncClient() as client:
response = await client.get(url)
if response.headers.get("Content-Type", "").startswith("application/json"):
data = response.json()
if data.get('errcode') in [40014, 42001]:
self.access_token = await self.get_access_token(self.secret)
return await self.get_pic_url(media_id)
else:
raise Exception("Failed to get image: " + str(data))
# 否则是图片,转成 base64
image_bytes = response.content
content_type = response.headers.get("Content-Type", "")
base64_str = base64.b64encode(image_bytes).decode("utf-8")
base64_str = f"data:{content_type};base64,{base64_str}"
return base64_str
#access——token操作
async def check_access_token(self):
return bool(self.access_token and self.access_token.strip())
async def check_access_token_for_contacts(self):
return bool(self.access_token_for_contacts and self.access_token_for_contacts.strip())
async def get_access_token(self,secret):
url = f'https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={secret}'
async with httpx.AsyncClient() as client:
response = await client.get(url)
data = response.json()
if 'access_token' in data:
return data['access_token']
else:
raise Exception(f"未获取access token: {data}")
async def get_detailed_message_list(self,xml_msg:str):
# 在本方法中解析消息,并且获得消息的具体内容
if isinstance(xml_msg, bytes):
xml_msg = xml_msg.decode('utf-8')
root = ET.fromstring(xml_msg)
token = root.find("Token").text
open_kfid = root.find("OpenKfId").text
# if open_kfid in self.openkfid_list:
# return None
# else:
# self.openkfid_list.append(open_kfid)
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url+'/kf/sync_msg?access_token='+ self.access_token
async with httpx.AsyncClient() as client:
params = {
"token": token,
"voice_format": 0,
"open_kfid": open_kfid,
}
response = await client.post(url,json=params)
data = response.json()
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.get_detailed_message_list(xml_msg)
if data['errcode'] != 0:
raise Exception("Failed to get message")
last_msg_data = data['msg_list'][-1]
open_kfid = last_msg_data.get("open_kfid")
# 进行获取图片操作
if last_msg_data.get("msgtype") == "image":
media_id = last_msg_data.get("image").get("media_id")
picurl = await self.get_pic_url(media_id)
last_msg_data["picurl"] = picurl
# await self.change_service_status(userid=external_userid,openkfid=open_kfid,servicer=servicer)
return last_msg_data
async def change_service_status(self,userid:str,openkfid:str,servicer:str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url+"/kf/service_state/get?access_token="+self.access_token
async with httpx.AsyncClient() as client:
params = {
"open_kfid" : openkfid,
"external_userid" : userid,
"service_state" : 1,
"servicer_userid" : servicer,
}
response = await client.post(url,json=params)
data = response.json()
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.change_service_status(userid,openkfid)
if data['errcode'] != 0:
raise Exception("Failed to change service status: "+str(data))
async def send_image(self,user_id:str,agent_id:int,media_id:str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url+'/media/upload?access_token='+self.access_token
async with httpx.AsyncClient() as client:
params = {
"touser" : user_id,
"toparty" : "",
"totag":"",
"agentid" : agent_id,
"msgtype" : "image",
"image" : {
"media_id" : media_id,
},
"safe":0,
"enable_id_trans": 0,
"enable_duplicate_check": 0,
"duplicate_check_interval": 1800
}
try:
response = await client.post(url,json=params)
data = response.json()
except Exception as e:
raise Exception("Failed to send image: "+str(e))
# 企业微信错误码40014和42001代表accesstoken问题
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.send_image(user_id,agent_id,media_id)
if data['errcode'] != 0:
raise Exception("Failed to send image: "+str(data))
async def send_text_msg(self, open_kfid: str, external_userid: str, msgid: str,content:str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = f"https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg?access_token={self.access_token}"
payload = {
"touser": external_userid,
"open_kfid": open_kfid,
"msgid": msgid,
"msgtype": "text",
"text": {
"content": content,
}
}
async with httpx.AsyncClient() as client:
response = await client.post(url, json=payload)
data = response.json()
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.send_text_msg(open_kfid,external_userid,msgid,content)
if data['errcode'] != 0:
raise Exception("Failed to send message")
return data
async def handle_callback_request(self):
"""
处理回调请求,包括 GET 验证和 POST 消息接收。
"""
try:
msg_signature = request.args.get("msg_signature")
timestamp = request.args.get("timestamp")
nonce = request.args.get("nonce")
if request.method == "GET":
echostr = request.args.get("echostr")
ret, reply_echo_str = self.wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
if ret != 0:
raise Exception(f"验证失败,错误码: {ret}")
return reply_echo_str
elif request.method == "POST":
encrypt_msg = await request.data
ret, xml_msg = self.wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
if ret != 0:
raise Exception(f"消息解密失败,错误码: {ret}")
# 解析消息并处理
message_data = await self.get_detailed_message_list(xml_msg)
if message_data is not None:
event = WecomCSEvent.from_payload(message_data)
if event:
await self._handle_message(event)
return "success"
except Exception as e:
traceback.print_exc()
return f"Error processing request: {str(e)}", 400
async def run_task(self, host: str, port: int, *args, **kwargs):
"""
启动 Quart 应用。
"""
await self.app.run_task(host=host, port=port, *args, **kwargs)
def on_message(self, msg_type: str):
"""
注册消息类型处理器。
"""
def decorator(func: Callable[[WecomCSEvent], None]):
if msg_type not in self._message_handlers:
self._message_handlers[msg_type] = []
self._message_handlers[msg_type].append(func)
return func
return decorator
async def _handle_message(self, event: WecomCSEvent):
"""
处理消息事件。
"""
msg_type = event.type
if msg_type in self._message_handlers:
for handler in self._message_handlers[msg_type]:
await handler(event)
@staticmethod
async def get_image_type(image_bytes: bytes) -> str:
"""
通过图片的magic numbers判断图片类型
"""
magic_numbers = {
b'\xFF\xD8\xFF': 'jpg',
b'\x89\x50\x4E\x47': 'png',
b'\x47\x49\x46': 'gif',
b'\x42\x4D': 'bmp',
b'\x00\x00\x01\x00': 'ico'
}
for magic, ext in magic_numbers.items():
if image_bytes.startswith(magic):
return ext
return 'jpg' # 默认返回jpg
async def upload_to_work(self, image: platform_message.Image):
"""
获取 media_id
"""
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file'
file_bytes = None
file_name = "uploaded_file.txt"
# 获取文件的二进制数据
if image.path:
async with aiofiles.open(image.path, 'rb') as f:
file_bytes = await f.read()
file_name = image.path.split('/')[-1]
elif image.url:
file_bytes = await self.download_image_to_bytes(image.url)
file_name = image.url.split('/')[-1]
elif image.base64:
try:
base64_data = image.base64
if ',' in base64_data:
base64_data = base64_data.split(',', 1)[1]
padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0
padded_base64 = base64_data + '=' * padding
file_bytes = base64.b64decode(padded_base64)
except binascii.Error as e:
raise ValueError(f"Invalid base64 string: {str(e)}")
else:
raise ValueError("image对象出错")
# 设置 multipart/form-data 格式的文件
boundary = "-------------------------acebdf13572468"
headers = {
'Content-Type': f'multipart/form-data; boundary={boundary}'
}
body = (
f"--{boundary}\r\n"
f"Content-Disposition: form-data; name=\"media\"; filename=\"{file_name}\"; filelength={len(file_bytes)}\r\n"
f"Content-Type: application/octet-stream\r\n\r\n"
).encode('utf-8') + file_bytes + f"\r\n--{boundary}--\r\n".encode('utf-8')
# 上传文件
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, content=body)
data = response.json()
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
media_id = await self.upload_to_work(image)
if data.get('errcode', 0) != 0:
raise Exception("failed to upload file")
media_id = data.get('media_id')
return media_id
async def download_image_to_bytes(self,url:str) -> bytes:
async with httpx.AsyncClient() as client:
response = await client.get(url)
response.raise_for_status()
return response.content
#进行media_id的获取
async def get_media_id(self, image: platform_message.Image):
media_id = await self.upload_to_work(image=image)
return media_id

View File

@@ -0,0 +1,134 @@
from typing import Dict, Any, Optional
class WecomCSEvent(dict):
"""
封装从企业微信收到的事件数据对象(字典),提供属性以获取其中的字段。
除 `type` 和 `detail_type` 属性对于任何事件都有效外,其它属性是否存在(若不存在则返回 `None`)依事件类型不同而不同。
"""
@staticmethod
def from_payload(payload: Dict[str, Any]) -> Optional["WecomCSEvent"]:
"""
从企业微信(客服会话)事件数据构造 `WecomEvent` 对象。
Args:
payload (Dict[str, Any]): 解密后的企业微信事件数据。
Returns:
Optional[WecomEvent]: 如果事件数据合法,则返回 WecomEvent 对象;否则返回 None。
"""
try:
event = WecomCSEvent(payload)
_ = event.type,
return event
except KeyError:
return None
@property
def type(self) -> str:
"""
事件类型,例如 "message""event""text" 等。
Returns:
str: 事件类型。
"""
return self.get("msgtype", "")
@property
def user_id(self) -> Optional[str]:
"""
用户 ID例如消息的发送者或事件的触发者。
Returns:
Optional[str]: 用户 ID。
"""
return self.get("external_userid")
@property
def receiver_id(self) -> Optional[str]:
"""
接收者 ID例如机器人自身的企业微信 ID。
Returns:
Optional[str]: 接收者 ID。
"""
return self.get("open_kfid","")
@property
def picurl(self) -> Optional[str]:
"""
图片 URL仅在图片消息中存在。
base64格式
Returns:
Optional[str]: 图片 URL。
"""
return self.get("picurl","")
@property
def message_id(self) -> Optional[str]:
"""
消息 ID仅在消息类型事件中存在。
Returns:
Optional[str]: 消息 ID。
"""
return self.get("msgid")
@property
def message(self) -> Optional[str]:
"""
消息内容,仅在消息类型事件中存在。
Returns:
Optional[str]: 消息内容。
"""
if self.get("msgtype") == 'text':
return self.get("text").get("content","")
else:
return None
@property
def timestamp(self) -> Optional[int]:
"""
事件发生的时间戳。
Returns:
Optional[int]: 时间戳。
"""
return self.get("send_time")
def __getattr__(self, key: str) -> Optional[Any]:
"""
允许通过属性访问数据中的任意字段。
Args:
key (str): 字段名。
Returns:
Optional[Any]: 字段值。
"""
return self.get(key)
def __setattr__(self, key: str, value: Any) -> None:
"""
允许通过属性设置数据中的任意字段。
Args:
key (str): 字段名。
value (Any): 字段值。
"""
self[key] = value
def __repr__(self) -> str:
"""
生成事件对象的字符串表示。
Returns:
str: 字符串表示。
"""
return f"<WecomEvent {super().__repr__()}>"

View File

@@ -35,6 +35,7 @@ required_deps = {
"telegram": "python-telegram-bot",
"certifi": "certifi",
"mcp": "mcp",
"telegramify_markdown":"telegramify-markdown",
}

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from .. import migration
@migration.migration_class("tg-dingtalk-markdown", 38)
class TgDingtalkMarkdownMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
for adapter in self.ap.platform_cfg.data['platform-adapters']:
if adapter['adapter'] in ['dingtalk','telegram']:
if 'markdown_card' not in adapter:
return True
return False
async def run(self):
"""执行迁移"""
for adapter in self.ap.platform_cfg.data['platform-adapters']:
if adapter['adapter'] in ['dingtalk','telegram']:
if 'markdown_card' not in adapter:
adapter['markdown_card'] = False
await self.ap.platform_cfg.dump_config()

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from .. import migration
@migration.migration_class("modelscope-config-completion", 39)
class ModelScopeConfigCompletionMigration(migration.Migration):
"""ModelScope配置迁移
"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移
"""
return 'modelscope-chat-completions' not in self.ap.provider_cfg.data['requester'] \
or 'modelscope' not in self.ap.provider_cfg.data['keys']
async def run(self):
"""执行迁移
"""
if 'modelscope-chat-completions' not in self.ap.provider_cfg.data['requester']:
self.ap.provider_cfg.data['requester']['modelscope-chat-completions'] = {
'base-url': 'https://api-inference.modelscope.cn/v1',
'args': {},
'timeout': 120,
}
if 'modelscope' not in self.ap.provider_cfg.data['keys']:
self.ap.provider_cfg.data['keys']['modelscope'] = []
await self.ap.provider_cfg.dump_config()

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from .. import migration
@migration.migration_class("ppio-config", 40)
class PPIOConfigMigration(migration.Migration):
"""PPIO配置迁移
"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移
"""
return 'ppio-chat-completions' not in self.ap.provider_cfg.data['requester'] \
or 'ppio' not in self.ap.provider_cfg.data['keys']
async def run(self):
"""执行迁移
"""
if 'ppio-chat-completions' not in self.ap.provider_cfg.data['requester']:
self.ap.provider_cfg.data['requester']['ppio-chat-completions'] = {
'base-url': 'https://api.ppinfra.com/v3/openai',
'args': {},
'timeout': 120,
}
if 'ppio' not in self.ap.provider_cfg.data['keys']:
self.ap.provider_cfg.data['keys']['ppio'] = []
await self.ap.provider_cfg.dump_config()

View File

@@ -12,7 +12,7 @@ from ..migrations import m020_wecom_config, m021_lark_config, m022_lmstudio_conf
from ..migrations import m026_qqofficial_config, m027_wx_official_account_config, m028_aliyun_requester_config
from ..migrations import m029_dashscope_app_api_config, m030_lark_config_cmpl, m031_dingtalk_config, m032_volcark_config
from ..migrations import m033_dify_thinking_config, m034_gewechat_file_url_config, m035_wxoa_mode, m036_wxoa_loading_message
from ..migrations import m037_mcp_config
from ..migrations import m037_mcp_config, m038_tg_dingtalk_markdown, m039_modelscope_cfg_completion, m040_ppio_config
@stage.stage_class("MigrationStage")

View File

@@ -110,7 +110,7 @@ class PlatformManager:
if len(self.adapters) == 0:
self.ap.logger.warning('未运行平台适配器,请根据文档配置并启用平台适配器。')
async def write_back_config(self, adapter_name: str, adapter_inst: msadapter.MessagePlatformAdapter, config: dict):
def write_back_config(self, adapter_name: str, adapter_inst: msadapter.MessagePlatformAdapter, config: dict):
index = -2
for i, adapter in enumerate(self.adapters):
@@ -137,7 +137,7 @@ class PlatformManager:
**config
}
self.ap.platform_cfg.data['platform-adapters'][real_index] = new_cfg
await self.ap.platform_cfg.dump_config()
self.ap.platform_cfg.dump_config_sync()
async def send(self, event: platform_events.MessageEvent, msg: platform_message.MessageChain, adapter: msadapter.MessagePlatformAdapter):

View File

@@ -131,7 +131,8 @@ class DingTalkAdapter(adapter.MessagePlatformAdapter):
client_id=config["client_id"],
client_secret=config["client_secret"],
robot_name = config["robot_name"],
robot_code=config["robot_code"]
robot_code=config["robot_code"],
markdown_card=config["markdown_card"]
)
async def reply_message(

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
import gewechat_client
import typing
@@ -26,7 +24,8 @@ from ..types import events as platform_events
from ..types import entities as platform_entities
from ...utils import image
import xml.etree.ElementTree as ET
from typing import Optional, List, Tuple
from functools import partial
class GewechatMessageConverter(adapter.MessageConverter):
@@ -47,168 +46,378 @@ class GewechatMessageConverter(adapter.MessageConverter):
if not component.url:
pass
content_list.append({"type": "image", "image": component.url})
elif isinstance(component, platform_message.WeChatMiniPrograms):
content_list.append({"type": 'WeChatMiniPrograms', 'mini_app_id': component.mini_app_id, 'display_name': component.display_name,
'page_path': component.page_path, 'cover_img_url': component.image_url, 'title': component.title,
'user_name': component.user_name})
elif isinstance(component, platform_message.WeChatForwardMiniPrograms):
content_list.append({"type": 'WeChatForwardMiniPrograms', 'xml_data': component.xml_data, 'image_url': component.image_url})
elif isinstance(component, platform_message.WeChatEmoji):
content_list.append({'type': 'WeChatEmoji', 'emoji_md5': component.emoji_md5, 'emoji_size': component.emoji_size})
elif isinstance(component, platform_message.WeChatLink):
content_list.append({'type': 'WeChatLink', 'link_title': component.link_title, 'link_desc': component.link_desc,
'link_thumb_url': component.link_thumb_url, 'link_url': component.link_url})
elif isinstance(component, platform_message.WeChatForwardLink):
content_list.append({'type': 'WeChatForwardLink', 'xml_data': component.xml_data})
elif isinstance(component, platform_message.Voice):
content_list.append({"type": "voice", "url": component.url, "length": component.length})
content_list.append({"type": "voice", "url": component.url, "length": component.length})
elif isinstance(component, platform_message.WeChatForwardImage):
content_list.append({'type': 'WeChatForwardImage', 'xml_data': component.xml_data})
elif isinstance(component, platform_message.WeChatForwardFile):
content_list.append({'type': 'WeChatForwardFile', 'xml_data': component.xml_data})
elif isinstance(component, platform_message.WeChatAppMsg):
content_list.append({'type': 'WeChatAppMsg', 'app_msg': component.app_msg})
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
content_list.extend(await GewechatMessageConverter.yiri2target(node.message_chain))
if node.message_chain:
content_list.extend(await GewechatMessageConverter.yiri2target(node.message_chain))
return content_list
async def target2yiri(
self,
message: dict,
bot_account_id: str
) -> platform_message.MessageChain:
"""外部消息转平台消息"""
# 数据预处理
message_list = []
ats_bot = False # 是否被@
content = message["Data"]["Content"]["string"]
content_no_preifx = content # 群消息则去掉前缀
is_group_message = self._is_group_message(message)
if is_group_message:
ats_bot = self._ats_bot(message, bot_account_id)
if "@所有人" in content:
message_list.append(platform_message.AtAll())
elif ats_bot:
message_list.append(platform_message.At(target=bot_account_id))
content_no_preifx, _ = self._extract_content_and_sender(content)
msg_type = message["Data"]["MsgType"]
# 映射消息类型到处理器方法
handler_map = {
1: self._handler_text,
3: self._handler_image,
34: self._handler_voice,
49: self._handler_compound, # 复合类型
}
if message["Data"]["MsgType"] == 1:
# 检查消息开头,如果有 wxid_sbitaz0mt65n22:\n 则删掉
regex = re.compile(r"^wxid_.*:")
# print(message)
# 分派处理
handler = handler_map.get(msg_type, self._handler_default)
handler_result = await handler(
message = message, # 原始的message
content_no_preifx = content_no_preifx, # 处理后的content
)
if handler_result and len(handler_result) > 0:
message_list.extend(handler_result)
return platform_message.MessageChain(message_list)
line_split = message["Data"]["Content"]["string"].split("\n")
if len(line_split) > 0 and regex.match(line_split[0]):
message["Data"]["Content"]["string"] = "\n".join(line_split[1:])
# 正则表达式模式,匹配'@'后跟任意数量的非空白字符
async def _handler_text(
self,
message: Optional[dict],
content_no_preifx: str
) -> platform_message.MessageChain:
"""处理文本消息 (msg_type=1)"""
if message and self._is_group_message(message):
pattern = r'@\S+'
at_string = f"@{bot_account_id}"
content_list = []
if at_string in message["Data"]["Content"]["string"]:
content_list.append(platform_message.At(target=bot_account_id))
content_list.append(platform_message.Plain(message["Data"]["Content"]["string"].replace(at_string, '', 1)))
# 更优雅的替换改名后@机器人仅仅限于单独AT的情况
elif "PushContent" in message['Data'] and '在群聊中@了你' in message["Data"]["PushContent"]:
if '@所有人' in message["Data"]["Content"]["string"]: # at全员时候传入atll不当作at自己
content_list.append(platform_message.AtAll())
else:
content_list.append(platform_message.At(target=bot_account_id))
content_list.append(platform_message.Plain(re.sub(pattern, '', message["Data"]["Content"]["string"])))
else:
content_list = [platform_message.Plain(message["Data"]["Content"]["string"])]
return platform_message.MessageChain(content_list)
elif message["Data"]["MsgType"] == 3:
image_xml = message["Data"]["Content"]["string"]
content_no_preifx = re.sub(pattern, '', content_no_preifx)
return platform_message.MessageChain([platform_message.Plain(content_no_preifx)])
async def _handler_image(
self,
message: Optional[dict],
content_no_preifx: str
) -> platform_message.MessageChain:
"""处理图像消息 (msg_type=3)"""
try:
image_xml = content_no_preifx
if not image_xml:
return platform_message.MessageChain([
platform_message.Plain(text="[图片内容为空]")
])
try:
base64_str, image_format = await image.get_gewechat_image_base64(
gewechat_url=self.config["gewechat_url"],
gewechat_file_url=self.config["gewechat_file_url"],
app_id=self.config["app_id"],
xml_content=image_xml,
token=self.config["token"],
image_type=2,
)
return platform_message.MessageChain([
platform_message.Image(
base64=f"data:image/{image_format};base64,{base64_str}"
)
])
except Exception as e:
print(f"处理图片消息失败: {str(e)}")
return platform_message.MessageChain([
platform_message.Plain(text=f"[图片处理失败]")
])
elif message["Data"]["MsgType"] == 34:
audio_base64 = message["Data"]["ImgBuf"]["buffer"]
return platform_message.MessageChain(
[platform_message.Voice(base64=f"data:audio/silk;base64,{audio_base64}")]
return platform_message.MessageChain([platform_message.Unknown("[图片内容为空]")])
base64_str, image_format = await image.get_gewechat_image_base64(
gewechat_url=self.config["gewechat_url"],
gewechat_file_url=self.config["gewechat_file_url"],
app_id=self.config["app_id"],
xml_content=image_xml,
token=self.config["token"],
image_type=2,
)
elif message["Data"]["MsgType"] == 49:
# 支持微信聊天记录的消息类型,将 XML 内容转换为 MessageChain 传递
elements = [
platform_message.Image(base64=f"data:image/{image_format};base64,{base64_str}"),
platform_message.WeChatForwardImage(xml_data=image_xml) # 微信消息转发
]
return platform_message.MessageChain(elements)
except Exception as e:
print(f"处理图片失败: {str(e)}")
return platform_message.MessageChain([platform_message.Unknown("[图片处理失败]")])
async def _handler_voice(
self,
message: Optional[dict],
content_no_preifx: str
) -> platform_message.MessageChain:
"""处理语音消息 (msg_type=34)"""
message_List = []
try:
# 从消息中提取语音数据(需根据实际数据结构调整字段名)
audio_base64 = message["Data"]["ImgBuf"]["buffer"]
# 验证语音数据有效性
if not audio_base64:
message_List.append(platform_message.Unknown(text="[语音内容为空]"))
return platform_message.MessageChain(message_List)
# 转换为平台支持的语音格式(如 Silk 格式)
voice_element = platform_message.Voice(
base64=f"data:audio/silk;base64,{audio_base64}"
)
message_List.append(voice_element)
except KeyError as e:
print(f"语音数据字段缺失: {str(e)}")
message_List.append(platform_message.Unknown(text="[语音数据解析失败]"))
except Exception as e:
print(f"处理语音消息异常: {str(e)}")
message_List.append(platform_message.Unknown(text="[语音处理失败]"))
return platform_message.MessageChain(message_List)
async def _handler_compound(
self,
message: Optional[dict],
content_no_preifx: str
) -> platform_message.MessageChain:
"""处理复合消息 (msg_type=49),根据子类型分派"""
try:
xml_data = ET.fromstring(content_no_preifx)
appmsg_data = xml_data.find('.//appmsg')
data_type = appmsg_data.findtext('.//type', "")
# 二次分派处理器
sub_handler_map = {
'57': self._handler_compound_quote,
'5': self._handler_compound_link,
'6': self._handler_compound_file,
'33': self._handler_compound_mini_program,
'36': self._handler_compound_mini_program,
'2000': partial(self._handler_compound_unsupported, text="[转账消息]"),
'2001': partial(self._handler_compound_unsupported, text="[红包消息]"),
'51': partial(self._handler_compound_unsupported, text="[视频号消息]"),
}
handler = sub_handler_map.get(data_type, self._handler_compound_unsupported)
return await handler(
message=message, #原始msg
xml_data=xml_data, # xml数据
)
except Exception as e:
print(f"解析复合消息失败: {str(e)}")
return platform_message.MessageChain([platform_message.Unknown(text=content_no_preifx)])
async def _handler_compound_quote(
self,
message: Optional[dict],
xml_data: ET.Element
) -> platform_message.MessageChain:
"""处理引用消息 (data_type=57)"""
# print("_handler_compound_quote", ET.tostring(xml_data, encoding='unicode'))
appmsg_data = xml_data.find('.//appmsg')
quote_data = "" # 引用原文
quote_id = None # 引用消息的原发送者
tousername = None # 接收方: 所属微信的wxid
user_data = "" # 用户消息
sender_id = xml_data.findtext('.//fromusername') # 发送方:单聊用户/群member
if appmsg_data:
user_data = appmsg_data.findtext('.//title') or ""
quote_data = appmsg_data.find('.//refermsg').findtext('.//content')
quote_id = appmsg_data.find('.//refermsg').findtext('.//chatusr')
if message:
tousername = message['Wxid']
message_list = []
# quote_data原始的消息
if quote_data:
quote_data_message_list = platform_message.MessageChain()
# 文本消息
try:
content = message["Data"]["Content"]["string"]
# 有三种可能的消息结构weid开头私聊直接<?xml>和直接<msg>
if content.startswith('wxid'):
xml_list = content.split('\n')[2:]
xml_data = '\n'.join(xml_list)
elif content.startswith('<?xml'):
xml_list = content.split('\n')[1:]
xml_data = '\n'.join(xml_list)
if "<msg>" not in quote_data:
quote_data_message_list.append(platform_message.Plain(quote_data))
else:
xml_data = content
content_data = ET.fromstring(xml_data)
# print(xml_data)
# 拿到细分消息类型按照gewe接口中描述
'''
小程序33/36
引用消息57
转账消息2000
红包消息2001
视频号消息51
'''
appmsg_data = content_data.find('.//appmsg')
data_type = appmsg_data.find('.//type').text
if data_type == '57':
user_data = appmsg_data.find('.//title').text # 拿到用户消息
quote_data = appmsg_data.find('.//refermsg').find('.//content').text # 引用原文
sender_id = appmsg_data.find('.//refermsg').find('.//chatusr').text # 引用用户id
from_name = message['Data']['FromUserName']['string']
message_list =[]
if message['Wxid'] == sender_id and from_name.endswith('@chatroom'): # 因为引用机制暂时无法响应用户所以当引用用户是机器人是构建一个at激活机器人
message_list.append(platform_message.At(target=bot_account_id))
message_list.append(platform_message.Quote(
sender_id=sender_id,
origin=platform_message.MessageChain(
[platform_message.Plain(quote_data)]
)))
message_list.append(platform_message.Plain(user_data))
return platform_message.MessageChain(message_list)
elif data_type == '51':
return platform_message.MessageChain(
[platform_message.Plain(text=f'[视频号消息]')]
)
# print(content_data)
elif data_type == '2000':
return platform_message.MessageChain(
[platform_message.Plain(text=f'[转账消息]')]
)
elif data_type == '2001':
return platform_message.MessageChain(
[platform_message.Plain(text=f'[红包消息]')]
)
elif data_type == '5':
return platform_message.MessageChain(
[platform_message.Plain(text=f'[公众号消息]')]
)
elif data_type == '33' or data_type == '36':
return platform_message.MessageChain(
[platform_message.Plain(text=f'[小程序消息]')]
)
# print(data_type.text)
else:
try:
content_bytes = content.encode('utf-8')
decoded_content = base64.b64decode(content_bytes)
return platform_message.MessageChain(
[platform_message.Unknown(content=decoded_content)]
)
except Exception as e:
return platform_message.MessageChain(
[platform_message.Plain(text=content)]
)
# 引用消息展开
quote_data_xml = ET.fromstring(quote_data)
if quote_data_xml.find("img"):
quote_data_message_list.extend(await self._handler_image(None, quote_data))
elif quote_data_xml.find("voicemsg"):
quote_data_message_list.extend(await self._handler_voice(None, quote_data))
elif quote_data_xml.find("videomsg"):
quote_data_message_list.extend(await self._handler_default(None, quote_data)) # 先不处理
else:
# appmsg
quote_data_message_list.extend(await self._handler_compound(None, quote_data))
except Exception as e:
print(f"Error processing type 49 message: {str(e)}")
return platform_message.MessageChain(
[platform_message.Plain(text="[无法解析的消息]")]
print(f"处理引用消息异常 expcetion:{e}")
quote_data_message_list.append(platform_message.Plain(quote_data))
message_list.append(
platform_message.Quote(
sender_id=sender_id,
origin=quote_data_message_list,
)
)
if len(user_data) > 0:
pattern = r'^@\S+'
user_data = re.sub(pattern, '', user_data)
message_list.append(platform_message.Plain(user_data))
# print(f"**_handler_compound_quote message_list len={len(message_list)}")
# for comp in message_list:
# if isinstance(comp, platform_message.Quote):
# print(f"**_handler_compound_quote send_id {comp.sender_id}" )
# for quote_item in comp.origin:
# print(f"******* _handler_compound_quote item {quote_item.type} + message: {quote_item}" )
# else:
# print(f"_handler_compound_quote type: {comp.type} + message: {comp}")
return platform_message.MessageChain(message_list)
async def _handler_compound_file(
self,
message: dict,
xml_data: ET.Element
) -> platform_message.MessageChain:
"""处理文件消息 (data_type=6)"""
xml_data_str = ET.tostring(xml_data, encoding='unicode')
return platform_message.MessageChain([
platform_message.WeChatForwardFile(xml_data=xml_data_str)
])
async def _handler_compound_link(
self,
message: dict,
xml_data: ET.Element
) -> platform_message.MessageChain:
"""处理链接消息(如公众号文章、外部网页)"""
message_list = []
try:
# 解析 XML 中的链接参数
appmsg = xml_data.find('.//appmsg')
if appmsg is None:
return platform_message.MessageChain()
message_list.append(
platform_message.WeChatLink(
link_title = appmsg.findtext('title', ''),
link_desc = appmsg.findtext('des', ''),
link_url = appmsg.findtext('url', ''),
link_thumb_url = appmsg.findtext("thumburl", '') # 这个字段拿不到
)
)
# 转发消息
xml_data_str = ET.tostring(xml_data, encoding='unicode')
# print(xml_data_str)
message_list.append(
platform_message.WeChatForwardLink(
xml_data=xml_data_str
)
)
except Exception as e:
print(f"解析链接消息失败: {str(e)}")
return platform_message.MessageChain(message_list)
async def _handler_compound_mini_program(
self,
message: dict,
xml_data: ET.Element
) -> platform_message.MessageChain:
"""处理小程序消息(如小程序卡片、服务通知)"""
xml_data_str = ET.tostring(xml_data, encoding='unicode')
return platform_message.MessageChain([
platform_message.WeChatForwardMiniPrograms(xml_data=xml_data_str)
])
async def _handler_default(
self,
message: Optional[dict],
content_no_preifx: str
) -> platform_message.MessageChain:
"""处理未知消息类型"""
if message:
msg_type = message["Data"]["MsgType"]
else:
msg_type = ""
return platform_message.MessageChain([
platform_message.Unknown(text=f"[未知消息类型 msg_type:{msg_type}]")
])
def _handler_compound_unsupported(
self,
message: dict,
xml_data: str,
text: Optional[str] = None
) -> platform_message.MessageChain:
"""处理未支持复合消息类型(msg_type=49)子类型"""
if not text:
text = f"[xml_data={xml_data}]"
content_list = []
content_list.append(
platform_message.Unknown(text=f"[处理未支持复合消息类型[msg_type=49]|{text}"))
return platform_message.MessageChain(content_list)
# 返回是否被艾特
def _ats_bot(self, message: dict, bot_account_id:str) -> bool:
ats_bot = False
try:
to_user_name = message['Wxid'] # 接收方: 所属微信的wxid
raw_content = message["Data"]["Content"]["string"] # 原始消息内容
content_no_prefix, _ = self._extract_content_and_sender(raw_content)
# 直接艾特机器人
ats_bot = ats_bot or (f"@{bot_account_id}" in content_no_prefix)
# 文本类@bot
push_content = message.get('Data', {}).get('PushContent', '')
ats_bot = ats_bot or ('在群聊中@了你' in push_content)
# 引用别人时@bot
msg_source = message.get('Data', {}).get('MsgSource', '') or ''
if len(msg_source) > 0:
msg_source_data = ET.fromstring(msg_source)
at_user_list = msg_source_data.findtext("atuserlist") or ""
ats_bot = ats_bot or (to_user_name in at_user_list)
# 引用bot
if message.get('Data', {}).get('MsgType', 0) == 49:
xml_data = ET.fromstring(content_no_prefix)
appmsg_data = xml_data.find('.//appmsg')
tousername = message['Wxid'] # 接收方: 所属微信的wxid
quote_id = appmsg_data.find('.//refermsg').findtext('.//chatusr') # 引用消息的原发送者
ats_bot = ats_bot or (quote_id == tousername)
except Exception as e:
print(f"_ats_bot got except: {e}")
finally:
return ats_bot
# 提取一下content前面的sender_id, 和去掉前缀的内容
def _extract_content_and_sender(self, raw_content: str) -> Tuple[str, Optional[str]]:
try:
# 检查消息开头,如果有 wxid_sbitaz0mt65n22:\n 则删掉
# add: 有些用户的wxid不是上述格式。换成user_name:
regex = re.compile(r"^[a-zA-Z0-9_\-]{5,20}:")
line_split = raw_content.split("\n")
if len(line_split) > 0 and regex.match(line_split[0]):
raw_content = "\n".join(line_split[1:])
sender_id = line_split[0].strip(":")
return raw_content, sender_id
except Exception as e:
print(f"_extract_content_and_sender got except: {e}")
finally:
return raw_content, None
# 是否是群消息
def _is_group_message(self, message: dict)->bool:
from_user_name = message['Data']['FromUserName']['string']
return from_user_name.endswith("@chatroom")
class GewechatEventConverter(adapter.EventConverter):
@@ -300,7 +509,6 @@ class GeWeChatAdapter(adapter.MessagePlatformAdapter):
def __init__(self, config: dict, ap: app.Application):
self.config = config
self.ap = ap
self.quart_app = quart.Quart(__name__)
self.message_converter = GewechatMessageConverter(config)
@@ -310,6 +518,9 @@ class GeWeChatAdapter(adapter.MessagePlatformAdapter):
async def gewechat_callback():
data = await quart.request.json
# print(json.dumps(data, indent=4, ensure_ascii=False))
self.ap.logger.debug(
f"Gewechat callback event: {data}"
)
if 'data' in data:
data['Data'] = data['data']
@@ -332,37 +543,118 @@ class GeWeChatAdapter(adapter.MessagePlatformAdapter):
return 'ok'
async def _handle_message(
self,
message: platform_message.MessageChain,
target_id: str
):
"""统一消息处理核心逻辑"""
content_list = await self.message_converter.yiri2target(message)
at_targets = [item["target"] for item in content_list if item["type"] == "at"]
# 处理@逻辑
at_targets = at_targets or []
member_info = []
if at_targets:
member_info = self.bot.get_chatroom_member_detail(
self.config["app_id"],
target_id,
at_targets[::-1]
)["data"]
# 处理消息组件
for msg in content_list:
# 文本消息处理@
if msg['type'] == 'text' and at_targets:
for member in member_info:
msg['content'] = f'@{member["nickName"]} {msg["content"]}'
# 统一消息派发
handler_map = {
'text': lambda msg: self.bot.post_text(
app_id=self.config['app_id'],
to_wxid=target_id,
content=msg['content'],
ats=",".join(at_targets)
),
'image': lambda msg: self.bot.post_image(
app_id=self.config['app_id'],
to_wxid=target_id,
img_url=msg["image"]
),
'WeChatForwardMiniPrograms': lambda msg: self.bot.forward_mini_app(
app_id=self.config['app_id'],
to_wxid=target_id,
xml=msg['xml_data'],
cover_img_url=msg.get('image_url')
),
'WeChatEmoji': lambda msg: self.bot.post_emoji(
app_id=self.config['app_id'],
to_wxid=target_id,
emoji_md5=msg['emoji_md5'],
emoji_size=msg['emoji_size']
),
'WeChatLink': lambda msg: self.bot.post_link(
app_id=self.config['app_id'],
to_wxid=target_id,
title=msg['link_title'],
desc=msg['link_desc'],
link_url=msg['link_url'],
thumb_url=msg['link_thumb_url'],
),
'WeChatMiniPrograms': lambda msg: self.bot.post_mini_app(
app_id=self.config['app_id'],
to_wxid=target_id,
mini_app_id=msg['mini_app_id'],
display_name=msg['display_name'],
page_path=msg['page_path'],
cover_img_url=msg['cover_img_url'],
title=msg['title'],
user_name=msg['user_name']
),
'WeChatForwardLink': lambda msg: self.bot.forward_url(
app_id=self.config['app_id'],
to_wxid=target_id,
xml=msg['xml_data']
),
'WeChatForwardImage': lambda msg: self.bot.forward_image(
app_id=self.config['app_id'],
to_wxid=target_id,
xml=msg['xml_data']
),
'WeChatForwardFile': lambda msg: self.bot.forward_file(
app_id=self.config['app_id'],
to_wxid=target_id,
xml=msg['xml_data']
),
'voice': lambda msg: self.bot.post_voice(
app_id=self.config['app_id'],
to_wxid=target_id,
voice_url=msg['url'],
voice_duration=msg['length']
),
'WeChatAppMsg': lambda msg: self.bot.post_app_msg(
app_id=self.config['app_id'],
to_wxid=target_id,
appmsg=msg['app_msg']
),
'at': lambda msg: None
}
if handler := handler_map.get(msg['type']):
handler(msg)
else:
self.ap.logger.warning(f"未处理的消息类型: {msg['type']}")
continue
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain
):
geweap_msg = await self.message_converter.yiri2target(message)
# 此处加上群消息at处理
ats = [item["target"] for item in geweap_msg if item["type"] == "at"]
for msg in geweap_msg:
# at主动发送消息
if msg['type'] == 'text':
if ats:
member_info = self.bot.get_chatroom_member_detail(
self.config["app_id"],
target_id,
ats[::-1]
)["data"]
for member in member_info:
msg['content'] = f'@{member["nickName"]} {msg["content"]}'
self.bot.post_text(app_id=self.config['app_id'], to_wxid=target_id, content=msg['content'],
ats=",".join(ats))
elif msg['type'] == 'image':
self.bot.post_image(app_id=self.config['app_id'], to_wxid=target_id, img_url=msg["image"])
"""主动发送消息"""
return await self._handle_message(message, target_id)
async def reply_message(
self,
@@ -370,29 +662,10 @@ class GeWeChatAdapter(adapter.MessagePlatformAdapter):
message: platform_message.MessageChain,
quote_origin: bool = False
):
content_list = await self.message_converter.yiri2target(message)
ats = [item["target"] for item in content_list if item["type"] == "at"]
for msg in content_list:
if msg["type"] == "text":
if ats:
member_info = self.bot.get_chatroom_member_detail(
self.config["app_id"],
message_source.source_platform_object["Data"]["FromUserName"]["string"],
ats[::-1]
)["data"]
for member in member_info:
msg['content'] = f'@{member["nickName"]} {msg["content"]}'
self.bot.post_text(
app_id=self.config["app_id"],
to_wxid=message_source.source_platform_object["Data"]["FromUserName"]["string"],
content=msg["content"],
ats=",".join(ats)
)
"""回复消息"""
if message_source.source_platform_object:
target_id = message_source.source_platform_object["Data"]["FromUserName"]["string"]
return await self._handle_message(message, target_id)
async def is_muted(self, group_id: int) -> bool:
pass
@@ -428,26 +701,33 @@ class GeWeChatAdapter(adapter.MessagePlatformAdapter):
self.config["token"]
)
app_id, error_msg = self.bot.login(self.config["app_id"])
if error_msg:
raise Exception(f"Gewechat 登录失败: {error_msg}")
def gewechat_login_process():
self.config["app_id"] = app_id
app_id, error_msg = self.bot.login(self.config["app_id"])
if error_msg:
raise Exception(f"Gewechat 登录失败: {error_msg}")
self.ap.logger.info(f"Gewechat 登录成功app_id: {app_id}")
self.config["app_id"] = app_id
await self.ap.platform_mgr.write_back_config('gewechat', self, self.config)
self.ap.logger.info(f"Gewechat 登录成功app_id: {app_id}")
# 获取 nickname
profile = self.bot.get_profile(self.config["app_id"])
self.bot_account_id = profile["data"]["nickName"]
self.ap.platform_mgr.write_back_config('gewechat', self, self.config)
def thread_set_callback():
time.sleep(3)
ret = self.bot.set_callback(self.config["token"], self.config["callback_url"])
print('设置 Gewechat 回调:', ret)
# 获取 nickname
profile = self.bot.get_profile(self.config["app_id"])
self.bot_account_id = profile["data"]["nickName"]
threading.Thread(target=thread_set_callback).start()
time.sleep(2)
try:
# gewechat-server容器重启, token会变但是还会登录成功
# 换新token也会收不到回调要重新登陆下。
ret = self.bot.set_callback(self.config["token"], self.config["callback_url"])
except Exception as e:
raise Exception(f"设置 Gewechat 回调失败, token失效 {e}")
threading.Thread(target=gewechat_login_process).start()
async def shutdown_trigger_placeholder():
while True:
@@ -460,4 +740,4 @@ class GeWeChatAdapter(adapter.MessagePlatformAdapter):
)
async def kill(self) -> bool:
pass
pass

View File

@@ -1,7 +1,8 @@
from __future__ import annotations
import lark_oapi
from lark_oapi.api.im.v1 import CreateImageRequest, CreateImageRequestBody, CreateImageResponse
import traceback
import typing
import asyncio
import traceback
@@ -60,12 +61,22 @@ class LarkMessageConverter(adapter.MessageConverter):
message_chain: platform_message.MessageChain, api_client: lark_oapi.Client
) -> typing.Tuple[list]:
message_elements = []
pending_paragraph = []
for msg in message_chain:
if isinstance(msg, platform_message.Plain):
pending_paragraph.append({"tag": "md", "text": msg.text})
# Ensure text is valid UTF-8
try:
text = msg.text.encode('utf-8').decode('utf-8')
pending_paragraph.append({"tag": "md", "text": text})
except UnicodeError:
# If text is not valid UTF-8, try to decode with other encodings
try:
text = msg.text.encode('latin1').decode('utf-8')
pending_paragraph.append({"tag": "md", "text": text})
except UnicodeError:
# If still fails, replace invalid characters
text = msg.text.encode('utf-8', errors='replace').decode('utf-8')
pending_paragraph.append({"tag": "md", "text": text})
elif isinstance(msg, platform_message.At):
pending_paragraph.append(
{"tag": "at", "user_id": msg.target, "style": []}
@@ -73,51 +84,84 @@ class LarkMessageConverter(adapter.MessageConverter):
elif isinstance(msg, platform_message.AtAll):
pending_paragraph.append({"tag": "at", "user_id": "all", "style": []})
elif isinstance(msg, platform_message.Image):
image_bytes = None
if msg.base64:
image_bytes = base64.b64decode(msg.base64)
try:
# Remove data URL prefix if present
if msg.base64.startswith('data:'):
msg.base64 = msg.base64.split(',', 1)[1]
image_bytes = base64.b64decode(msg.base64)
except Exception as e:
traceback.print_exc()
continue
elif msg.url:
async with aiohttp.ClientSession() as session:
async with session.get(msg.url) as response:
image_bytes = await response.read()
try:
async with aiohttp.ClientSession() as session:
async with session.get(msg.url) as response:
if response.status == 200:
image_bytes = await response.read()
else:
traceback.print_exc()
continue
except Exception as e:
traceback.print_exc()
continue
elif msg.path:
with open(msg.path, "rb") as f:
image_bytes = f.read()
try:
with open(msg.path, "rb") as f:
image_bytes = f.read()
except Exception as e:
traceback.print_exc()
continue
request: CreateImageRequest = (
CreateImageRequest.builder()
.request_body(
CreateImageRequestBody.builder()
.image_type("message")
.image(image_bytes)
.build()
)
.build()
)
if image_bytes is None:
continue
response: CreateImageResponse = await api_client.im.v1.image.acreate(
request
)
try:
# Create a temporary file to store the image bytes
import tempfile
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
temp_file.write(image_bytes)
temp_file.flush()
# Create image request using the temporary file
request = CreateImageRequest.builder()\
.request_body(
CreateImageRequestBody.builder()
.image_type("message")
.image(open(temp_file.name, "rb"))
.build()
)\
.build()
response = await api_client.im.v1.image.acreate(request)
if not response.success():
raise Exception(
f"client.im.v1.image.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}"
)
image_key = response.data.image_key
if not response.success():
raise Exception(
f"client.im.v1.image.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}"
)
image_key = response.data.image_key
message_elements.append(pending_paragraph)
message_elements.append(
[
{
"tag": "img",
"image_key": image_key,
}
]
)
pending_paragraph = []
message_elements.append(pending_paragraph)
message_elements.append(
[
{
"tag": "img",
"image_key": image_key,
}
]
)
pending_paragraph = []
except Exception as e:
traceback.print_exc()
continue
finally:
# Clean up the temporary file
import os
if 'temp_file' in locals():
os.unlink(temp_file.name)
elif isinstance(msg, platform_message.Forward):
for node in msg.node_list:
message_elements.extend(await LarkMessageConverter.yiri2target(node.message_chain, api_client))
@@ -328,6 +372,10 @@ class LarkAdapter(adapter.MessagePlatformAdapter):
try:
data = await quart.request.json
self.ap.logger.debug(
f"Lark callback event: {data}"
)
if 'encrypt' in data:
cipher = AESCipher(self.config['encrypt-key'])
data = cipher.decrypt_string(data['encrypt'])
@@ -339,7 +387,6 @@ class LarkAdapter(adapter.MessagePlatformAdapter):
type = context.header.event_type
if 'url_verification' == type:
print(data.get("challenge"))
# todo 验证verification token
return {
"challenge": data.get("challenge")

View File

@@ -0,0 +1,204 @@
from __future__ import annotations
import typing
import asyncio
import traceback
import datetime
from libs.slack_api.api import SlackClient
from pkg.platform.adapter import MessagePlatformAdapter
from pkg.platform.types import events as platform_events, message as platform_message
from libs.slack_api.slackevent import SlackEvent
from pkg.core import app
from .. import adapter
from ...core import app
from ..types import message as platform_message
from ..types import events as platform_events
from ..types import entities as platform_entities
from ...command.errors import ParamNotEnoughError
from ...utils import image
class SlackMessageConverter(adapter.MessageConverter):
@staticmethod
async def yiri2target(message_chain:platform_message.MessageChain):
content_list = []
for msg in message_chain:
if type(msg) is platform_message.Plain:
content_list.append({
"content":msg.text,
})
return content_list
@staticmethod
async def target2yiri(message:str,message_id:str,pic_url:str,bot:SlackClient):
yiri_msg_list = []
yiri_msg_list.append(
platform_message.Source(id=message_id,time=datetime.datetime.now())
)
if pic_url is not None:
base64_url = await image.get_slack_image_to_base64(pic_url=pic_url,bot_token=bot.bot_token)
yiri_msg_list.append(
platform_message.Image(base64=base64_url)
)
yiri_msg_list.append(platform_message.Plain(text=message))
chain = platform_message.MessageChain(yiri_msg_list)
return chain
class SlackEventConverter(adapter.EventConverter):
@staticmethod
async def yiri2target(event:platform_events.MessageEvent) -> SlackEvent:
return event.source_platform_object
@staticmethod
async def target2yiri(event:SlackEvent,bot:SlackClient):
yiri_chain = await SlackMessageConverter.target2yiri(
message=event.text,message_id=event.message_id,pic_url=event.pic_url,bot=bot
)
if event.type == 'channel':
yiri_chain.insert(0, platform_message.At(target="SlackBot"))
sender = platform_entities.GroupMember(
id = event.user_id,
member_name= str(event.sender_name),
permission= 'MEMBER',
group = platform_entities.Group(
id = event.channel_id,
name = 'MEMBER',
permission= platform_entities.Permission.Member
),
special_title='',
join_timestamp=0,
last_speak_timestamp=0,
mute_time_remaining=0
)
time = int(datetime.datetime.utcnow().timestamp())
return platform_events.GroupMessage(
sender = sender,
message_chain=yiri_chain,
time = time,
source_platform_object=event
)
if event.type == 'im':
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=event.user_id,
nickname = event.sender_name,
remark=""
),
message_chain = yiri_chain,
time = float(datetime.datetime.now().timestamp()),
source_platform_object=event,
)
class SlackAdapter(adapter.MessagePlatformAdapter):
bot: SlackClient
ap: app.Application
bot_account_id: str
message_converter: SlackMessageConverter = SlackMessageConverter()
event_converter: SlackEventConverter = SlackEventConverter()
config: dict
def __init__(self,config:dict,ap:app.Application):
self.config = config
self.ap = ap
required_keys = [
"bot_token",
"signing_secret",
]
missing_keys = [key for key in required_keys if key not in config]
if missing_keys:
raise ParamNotEnoughError("Slack机器人缺少相关配置项请查看文档或联系管理员")
self.bot = SlackClient(
bot_token=self.config["bot_token"],
signing_secret=self.config["signing_secret"]
)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
slack_event = await SlackEventConverter.yiri2target(
message_source
)
content_list = await SlackMessageConverter.yiri2target(message)
for content in content_list:
if slack_event.type == 'channel':
await self.bot.send_message_to_channel(
content['content'],slack_event.channel_id
)
if slack_event.type == 'im':
await self.bot.send_message_to_one(
content['content'],slack_event.user_id
)
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
content_list = await SlackMessageConverter.yiri2target(message)
for content in content_list:
if target_type == 'person':
await self.bot.send_message_to_one(content['content'],target_id)
if target_type == 'group':
await self.bot.send_message_to_channel(content['content'],target_id)
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, adapter.MessagePlatformAdapter], None
],
):
async def on_message(event:SlackEvent):
self.bot_account_id = 'SlackBot'
try:
return await callback(
await self.event_converter.target2yiri(event,self.bot),self
)
except:
traceback.print_exc()
if event_type == platform_events.FriendMessage:
self.bot.on_message("im")(on_message)
elif event_type == platform_events.GroupMessage:
self.bot.on_message("channel")(on_message)
async def run_async(self):
async def shutdown_trigger_placeholder():
while True:
await asyncio.sleep(1)
await self.bot.run_task(
host="0.0.0.0",
port=self.config["port"],
shutdown_trigger=shutdown_trigger_placeholder,
)
async def kill(self) -> bool:
return False
async def unregister_listener(
self,
event_type: type,
callback: typing.Callable[[platform_events.Event, MessagePlatformAdapter], None],
):
return super().unregister_listener(event_type, callback)

View File

@@ -0,0 +1,37 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: slack
label:
en_US: Slack API
zh_CN: Slack API
description:
en_US: Slack API
zh_CN: Slack API
spec:
config:
- name: bot_token
label:
en_US: Bot Token
zh_CN: 机器人令牌
type: string
required: true
default: ""
- name: signing_secret
label:
en_US: signing_secret
zh_CN: 密钥
type: string
required: true
default: ""
- name: port
label:
en_US: Port
zh_CN: 监听端口
type: int
required: true
default: 2288
execution:
python:
path: ./slack.py
attr: SlackAdapter

View File

@@ -4,7 +4,7 @@ import telegram
import telegram.ext
from telegram import Update
from telegram.ext import ApplicationBuilder, ContextTypes, CommandHandler, MessageHandler, filters
import telegramify_markdown
import typing
import asyncio
import traceback
@@ -86,9 +86,10 @@ class TelegramMessageConverter(adapter.MessageConverter):
if message.text:
message_text = message.text
message_components.extend(parse_message_text(message_text))
if message.photo:
message_components.extend(parse_message_text(message.caption))
if message.caption:
message_components.extend(parse_message_text(message.caption))
file = await message.photo[-1].get_file()
@@ -126,7 +127,7 @@ class TelegramEventConverter(adapter.EventConverter):
time=event.message.date.timestamp(),
source_platform_object=event
)
elif event.effective_chat.type == 'group':
elif event.effective_chat.type == 'group' or 'supergroup' :
return platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=event.effective_chat.id,
@@ -201,16 +202,23 @@ class TelegramAdapter(adapter.MessagePlatformAdapter):
for component in components:
if component['type'] == 'text':
if self.config['markdown_card'] is True:
content = telegramify_markdown.markdownify(
content= component['text'],
)
else:
content = component['text']
args = {
"chat_id": message_source.source_platform_object.effective_chat.id,
"text": component['text'],
"text": content,
}
if self.config['markdown_card'] is True:
args["parse_mode"] = "MarkdownV2"
if quote_origin:
args['reply_to_message_id'] = message_source.source_platform_object.message.id
if quote_origin:
args['reply_to_message_id'] = message_source.source_platform_object.message.id
await self.bot.send_message(**args)
await self.bot.send_message(**args)
async def is_muted(self, group_id: int) -> bool:
return False

View File

@@ -0,0 +1,223 @@
from __future__ import annotations
import typing
import asyncio
import traceback
import datetime
from libs.wecom_customer_service_api.api import WecomCSClient
from pkg.platform.adapter import MessagePlatformAdapter
from pkg.platform.types import events as platform_events, message as platform_message
from libs.wecom_customer_service_api.wecomcsevent import WecomCSEvent
from pkg.core import app
from .. import adapter
from ...core import app
from ..types import message as platform_message
from ..types import events as platform_events
from ..types import entities as platform_entities
from ...command.errors import ParamNotEnoughError
from ...utils import image
class WecomMessageConverter(adapter.MessageConverter):
@staticmethod
async def yiri2target(
message_chain: platform_message.MessageChain, bot: WecomCSClient
):
content_list = []
for msg in message_chain:
if type(msg) is platform_message.Plain:
content_list.append({
"type": "text",
"content": msg.text,
})
elif type(msg) is platform_message.Image:
content_list.append({
"type": "image",
"media_id": await bot.get_media_id(msg),
})
elif type(msg) is platform_message.Forward:
for node in msg.node_list:
content_list.extend((await WecomMessageConverter.yiri2target(node.message_chain, bot)))
else:
content_list.append({
"type": "text",
"content": str(msg),
})
return content_list
@staticmethod
async def target2yiri(message: str, message_id: int = -1):
yiri_msg_list = []
yiri_msg_list.append(
platform_message.Source(id=message_id, time=datetime.datetime.now())
)
yiri_msg_list.append(platform_message.Plain(text=message))
chain = platform_message.MessageChain(yiri_msg_list)
return chain
@staticmethod
async def target2yiri_image(picurl: str, message_id: int = -1):
yiri_msg_list = []
yiri_msg_list.append(
platform_message.Source(id=message_id, time=datetime.datetime.now())
)
yiri_msg_list.append(platform_message.Image(base64=picurl))
chain = platform_message.MessageChain(yiri_msg_list)
return chain
class WecomEventConverter:
@staticmethod
async def yiri2target(
event: platform_events.Event, bot_account_id: int, bot: WecomCSClient
) -> WecomCSEvent:
# only for extracting user information
if type(event) is platform_events.GroupMessage:
pass
if type(event) is platform_events.FriendMessage:
return event.source_platform_object
@staticmethod
async def target2yiri(event: WecomCSEvent):
"""
将 WecomEvent 转换为平台的 FriendMessage 对象。
Args:
event (WecomEvent): 企业微信客服事件。
Returns:
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
"""
# 转换消息链
if event.type == "text":
yiri_chain = await WecomMessageConverter.target2yiri(
event.message, event.message_id
)
friend = platform_entities.Friend(
id=f"u{event.user_id}",
nickname=str(event.user_id),
remark="",
)
return platform_events.FriendMessage(
sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event
)
elif event.type == "image":
friend = platform_entities.Friend(
id=f"u{event.user_id}",
nickname=str(event.user_id),
remark="",
)
yiri_chain = await WecomMessageConverter.target2yiri_image(
picurl=event.picurl, message_id=event.message_id
)
return platform_events.FriendMessage(
sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event
)
class WecomCSAdapter(adapter.MessagePlatformAdapter):
bot: WecomCSClient
ap: app.Application
bot_account_id: str
message_converter: WecomMessageConverter = WecomMessageConverter()
event_converter: WecomEventConverter = WecomEventConverter()
config: dict
def __init__(self, config: dict, ap: app.Application):
self.config = config
self.ap = ap
required_keys = [
"corpid",
"secret",
"token",
"EncodingAESKey",
]
missing_keys = [key for key in required_keys if key not in config]
if missing_keys:
raise ParamNotEnoughError("企业微信客服缺少相关配置项,请查看文档或联系管理员")
self.bot = WecomCSClient(
corpid=config["corpid"],
secret=config["secret"],
token=config["token"],
EncodingAESKey=config["EncodingAESKey"],
)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
Wecom_event = await WecomEventConverter.yiri2target(
message_source, self.bot_account_id, self.bot
)
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
for content in content_list:
if content["type"] == "text":
await self.bot.send_text_msg(open_kfid=Wecom_event.receiver_id,external_userid=Wecom_event.user_id,msgid=Wecom_event.message_id,content=content["content"])
async def send_message(
self, target_type: str, target_id: str, message: platform_message.MessageChain
):
pass
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, adapter.MessagePlatformAdapter], None
],
):
async def on_message(event: WecomCSEvent):
self.bot_account_id = event.receiver_id
try:
return await callback(
await self.event_converter.target2yiri(event), self
)
except:
traceback.print_exc()
if event_type == platform_events.FriendMessage:
self.bot.on_message("text")(on_message)
self.bot.on_message("image")(on_message)
elif event_type == platform_events.GroupMessage:
pass
async def run_async(self):
async def shutdown_trigger_placeholder():
while True:
await asyncio.sleep(1)
await self.bot.run_task(
host="0.0.0.0",
port=self.config["port"],
shutdown_trigger=shutdown_trigger_placeholder,
)
async def kill(self) -> bool:
return False
async def unregister_listener(
self,
event_type: type,
callback: typing.Callable[[platform_events.Event, MessagePlatformAdapter], None],
):
return super().unregister_listener(event_type, callback)

View File

@@ -0,0 +1,51 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: wecomcs
label:
en_US: WeComCustomerService
zh_CN: 企业微信客服
description:
en_US: WeComCSAdapter
zh_CN: 企业微信客服适配器
spec:
config:
- name: port
label:
en_US: Port
zh_CN: 监听端口
type: int
required: true
default: 2289
- name: corpid
label:
en_US: Corpid
zh_CN: 企业ID
type: string
required: true
default: ""
- name: secret
label:
en_US: Secret
zh_CN: 密钥
type: string
required: true
default: ""
- name: token
label:
en_US: Token
zh_CN: 令牌
type: string
required: true
default: ""
- name: EncodingAESKey
label:
en_US: EncodingAESKey
zh_CN: 消息加解密密钥
type: string
required: true
default: ""
execution:
python:
path: ./wecomcs.py
attr: WecomCSAdapter

View File

@@ -1,16 +1,15 @@
import itertools
import logging
import typing
from datetime import datetime
from enum import Enum
from pathlib import Path
import typing
import pydantic.v1 as pydantic
from . import entities as platform_entities
from .base import PlatformBaseModel, PlatformIndexedMetaclass, PlatformIndexedModel
logger = logging.getLogger(__name__)
@@ -642,7 +641,8 @@ class Unknown(MessageComponent):
"""消息组件类型。"""
text: str
"""文本。"""
def __str__(self):
return f'Unknown Message: {self.text}'
class Voice(MessageComponent):
"""语音。"""
@@ -807,6 +807,90 @@ class File(MessageComponent):
"""文件名称。"""
size: int
"""文件大小。"""
def __str__(self):
return f'[文件]{self.name}'
# ================ 个人微信专用组件 ================
class WeChatMiniPrograms(MessageComponent):
"""小程序。个人微信专用组件。"""
type: str = 'WeChatMiniPrograms'
"""小程序id"""
mini_app_id: str
"""小程序归属用户id"""
user_name: str
"""小程序名称"""
display_name: typing.Optional[str] = ''
"""打开地址"""
page_path: typing.Optional[str] = ''
"""小程序标题"""
title: typing.Optional[str] = ''
"""首页图片"""
image_url: typing.Optional[str] = ''
class WeChatForwardMiniPrograms(MessageComponent):
"""转发小程序。个人微信专用组件。"""
type: str = 'WeChatForwardMiniPrograms'
"""xml数据"""
xml_data: str
"""首页图片"""
image_url: typing.Optional[str] = None
def __str__(self):
return self.xml_data
class WeChatEmoji(MessageComponent):
"""emoji表情。个人微信专用组件。"""
type: str = 'WeChatEmoji'
"""emojimd5"""
emoji_md5: str
"""emoji大小"""
emoji_size: int
class WeChatLink(MessageComponent):
"""发送链接。个人微信专用组件。"""
type: str = 'WeChatLink'
"""标题"""
link_title: str = ''
"""链接描述"""
link_desc: str = ''
"""链接地址"""
link_url: str = ''
"""链接略缩图"""
link_thumb_url: str = ''
class WeChatForwardLink(MessageComponent):
"""转发链接。个人微信专用组件。"""
type: str = 'WeChatForwardLink'
"""xml数据"""
xml_data: str
def __str__(self):
return self.xml_data
class WeChatForwardImage(MessageComponent):
"""转发图片。个人微信专用组件。"""
type: str = 'WeChatForwardImage'
"""xml数据"""
xml_data: str
def __str__(self):
return self.xml_data
class WeChatForwardFile(MessageComponent):
"""转发文件。个人微信专用组件。"""
type: str = 'WeChatForwardFile'
"""xml数据"""
xml_data: str
def __str__(self):
return self.xml_data
class WeChatAppMsg(MessageComponent):
"""通用appmsg发送。个人微信专用组件。"""
type: str = 'WeChatAppMsg'
"""xml数据"""
app_msg: str
def __str__(self):
return self.app_msg

View File

@@ -6,7 +6,7 @@ from . import entities, requester
from ...core import app
from ...discover import engine
from . import token
from .requesters import bailianchatcmpl, chatcmpl, anthropicmsgs, moonshotchatcmpl, deepseekchatcmpl, ollamachat, giteeaichatcmpl, volcarkchatcmpl, xaichatcmpl, zhipuaichatcmpl, lmstudiochatcmpl, siliconflowchatcmpl, volcarkchatcmpl
from .requesters import bailianchatcmpl, chatcmpl, anthropicmsgs, moonshotchatcmpl, deepseekchatcmpl, ollamachat, giteeaichatcmpl, volcarkchatcmpl, xaichatcmpl, zhipuaichatcmpl, lmstudiochatcmpl, siliconflowchatcmpl, volcarkchatcmpl, modelscopechatcmpl
FETCH_MODEL_LIST_URL = "https://api.qchatgpt.rockchin.top/api/v2/fetch/model_list"

View File

@@ -4,6 +4,8 @@ import typing
import json
import traceback
import base64
import platform
import socket
import anthropic
import httpx
@@ -23,9 +25,15 @@ class AnthropicMessages(requester.LLMAPIRequester):
client: anthropic.AsyncAnthropic
async def initialize(self):
# 兼容 Windows 缺失 TCP_KEEPINTVL 和 TCP_KEEPCNT 的问题
if platform.system() == "Windows":
if not hasattr(socket, "TCP_KEEPINTVL"):
socket.TCP_KEEPINTVL = 0
if not hasattr(socket, "TCP_KEEPCNT"):
socket.TCP_KEEPCNT = 0
httpx_client = anthropic._base_client.AsyncHttpxClientWrapper(
base_url=self.ap.provider_cfg.data['requester']['anthropic-messages']['base-url'],
base_url=self.ap.provider_cfg.data['requester']['anthropic-messages']['base-url'].replace(' ', ''),
# cast to a valid type because mypy doesn't understand our type narrowing
timeout=typing.cast(httpx.Timeout, self.ap.provider_cfg.data['requester']['anthropic-messages']['timeout']),
limits=anthropic._constants.DEFAULT_CONNECTION_LIMITS,
@@ -59,9 +67,11 @@ class AnthropicMessages(requester.LLMAPIRequester):
if m.role == "system":
system_role_message = m
messages.pop(i)
break
if system_role_message:
messages.pop(i)
if isinstance(system_role_message, llm_entities.Message) \
and isinstance(system_role_message.content, str):
args['system'] = system_role_message.content

View File

@@ -2,12 +2,12 @@ from __future__ import annotations
import openai
from . import chatcmpl
from . import chatcmpl, modelscopechatcmpl
from .. import requester
from ....core import app
class BailianChatCompletions(chatcmpl.OpenAIChatCompletions):
class BailianChatCompletions(modelscopechatcmpl.ModelScopeChatCompletions):
"""阿里云百炼大模型平台 ChatCompletion API 请求器"""
client: openai.AsyncClient
@@ -18,3 +18,4 @@ class BailianChatCompletions(chatcmpl.OpenAIChatCompletions):
self.ap = ap
self.requester_cfg = self.ap.provider_cfg.data['requester']['bailian-chat-completions']

View File

@@ -36,7 +36,7 @@ class OpenAIChatCompletions(requester.LLMAPIRequester):
self.client = openai.AsyncClient(
api_key="",
base_url=self.requester_cfg['base-url'],
base_url=self.requester_cfg['base-url'].replace(' ', ''),
timeout=self.requester_cfg['timeout'],
http_client=httpx.AsyncClient(
trust_env=True,
@@ -47,8 +47,9 @@ class OpenAIChatCompletions(requester.LLMAPIRequester):
async def _req(
self,
args: dict,
extra_body: dict = {},
) -> chat_completion.ChatCompletion:
return await self.client.chat.completions.create(**args)
return await self.client.chat.completions.create(**args, extra_body=extra_body)
async def _make_msg(
self,
@@ -60,6 +61,12 @@ class OpenAIChatCompletions(requester.LLMAPIRequester):
if 'role' not in chatcmpl_message or chatcmpl_message['role'] is None:
chatcmpl_message['role'] = 'assistant'
reasoning_content = chatcmpl_message['reasoning_content'] if 'reasoning_content' in chatcmpl_message else None
# deepseek的reasoner模型
if reasoning_content is not None:
chatcmpl_message['content'] = "<think>\n" + reasoning_content + "\n</think>\n"+ chatcmpl_message['content']
message = llm_entities.Message(**chatcmpl_message)
return message
@@ -73,7 +80,7 @@ class OpenAIChatCompletions(requester.LLMAPIRequester):
) -> llm_entities.Message:
self.client.api_key = use_model.token_mgr.get_token()
args = self.requester_cfg['args'].copy()
args = {}
args["model"] = use_model.name if use_model.model_name is None else use_model.model_name
if use_funcs:
@@ -99,7 +106,7 @@ class OpenAIChatCompletions(requester.LLMAPIRequester):
args["messages"] = messages
# 发送请求
resp = await self._req(args)
resp = await self._req(args, extra_body=self.requester_cfg['args'])
# 处理请求结果
message = await self._make_msg(resp)

View File

@@ -23,7 +23,7 @@ class DeepseekChatCompletions(chatcmpl.OpenAIChatCompletions):
) -> llm_entities.Message:
self.client.api_key = use_model.token_mgr.get_token()
args = self.requester_cfg['args'].copy()
args = {}
args["model"] = use_model.name if use_model.model_name is None else use_model.model_name
if use_funcs:
@@ -43,7 +43,7 @@ class DeepseekChatCompletions(chatcmpl.OpenAIChatCompletions):
args["messages"] = messages
# 发送请求
resp = await self._req(args)
resp = await self._req(args, extra_body=self.requester_cfg['args'])
if resp is None:
raise errors.RequesterError('接口返回为空,请确定模型提供商服务是否正常')

View File

@@ -30,7 +30,7 @@ class GiteeAIChatCompletions(chatcmpl.OpenAIChatCompletions):
) -> llm_entities.Message:
self.client.api_key = use_model.token_mgr.get_token()
args = self.requester_cfg['args'].copy()
args = {}
args["model"] = use_model.name if use_model.model_name is None else use_model.model_name
if use_funcs:
@@ -46,7 +46,7 @@ class GiteeAIChatCompletions(chatcmpl.OpenAIChatCompletions):
args["messages"] = req_messages
resp = await self._req(args)
resp = await self._req(args, extra_body=self.requester_cfg['args'])
message = await self._make_msg(resp)

View File

@@ -0,0 +1,207 @@
from __future__ import annotations
import asyncio
import typing
import json
import base64
from typing import AsyncGenerator
import openai
import openai.types.chat.chat_completion as chat_completion
import openai.types.chat.chat_completion_message_tool_call as chat_completion_message_tool_call
import httpx
import aiohttp
import async_lru
from .. import entities, errors, requester
from ....core import entities as core_entities, app
from ... import entities as llm_entities
from ...tools import entities as tools_entities
from ....utils import image
class ModelScopeChatCompletions(requester.LLMAPIRequester):
"""ModelScope ChatCompletion API 请求器"""
client: openai.AsyncClient
requester_cfg: dict
def __init__(self, ap: app.Application):
self.ap = ap
self.requester_cfg = self.ap.provider_cfg.data['requester']['modelscope-chat-completions']
async def initialize(self):
self.client = openai.AsyncClient(
api_key="",
base_url=self.requester_cfg['base-url'],
timeout=self.requester_cfg['timeout'],
http_client=httpx.AsyncClient(
trust_env=True,
timeout=self.requester_cfg['timeout']
)
)
async def _req(
self,
args: dict,
) -> chat_completion.ChatCompletion:
args["stream"] = True
chunk = None
pending_content = ""
tool_calls = []
resp_gen: openai.AsyncStream = await self.client.chat.completions.create(**args)
async for chunk in resp_gen:
# print(chunk)
if not chunk or not chunk.id or not chunk.choices or not chunk.choices[0] or not chunk.choices[0].delta:
continue
if chunk.choices[0].delta.content is not None:
pending_content += chunk.choices[0].delta.content
if chunk.choices[0].delta.tool_calls is not None:
for tool_call in chunk.choices[0].delta.tool_calls:
for tc in tool_calls:
if tc.index == tool_call.index:
tc.function.arguments += tool_call.function.arguments
break
else:
tool_calls.append(tool_call)
if chunk.choices[0].finish_reason is not None:
break
real_tool_calls = []
for tc in tool_calls:
function = chat_completion_message_tool_call.Function(
name=tc.function.name,
arguments=tc.function.arguments
)
real_tool_calls.append(chat_completion_message_tool_call.ChatCompletionMessageToolCall(
id=tc.id,
function=function,
type="function"
))
return chat_completion.ChatCompletion(
id=chunk.id,
object="chat.completion",
created=chunk.created,
choices=[
chat_completion.Choice(
index=0,
message=chat_completion.ChatCompletionMessage(
role="assistant",
content=pending_content,
tool_calls=real_tool_calls if len(real_tool_calls) > 0 else None
),
finish_reason=chunk.choices[0].finish_reason if hasattr(chunk.choices[0], 'finish_reason') and chunk.choices[0].finish_reason is not None else 'stop',
logprobs=chunk.choices[0].logprobs,
)
],
model=chunk.model,
service_tier=chunk.service_tier if hasattr(chunk, 'service_tier') else None,
system_fingerprint=chunk.system_fingerprint if hasattr(chunk, 'system_fingerprint') else None,
usage=chunk.usage if hasattr(chunk, 'usage') else None
) if chunk else None
return await self.client.chat.completions.create(**args)
async def _make_msg(
self,
chat_completion: chat_completion.ChatCompletion,
) -> llm_entities.Message:
chatcmpl_message = chat_completion.choices[0].message.dict()
# 确保 role 字段存在且不为 None
if 'role' not in chatcmpl_message or chatcmpl_message['role'] is None:
chatcmpl_message['role'] = 'assistant'
message = llm_entities.Message(**chatcmpl_message)
return message
async def _closure(
self,
query: core_entities.Query,
req_messages: list[dict],
use_model: entities.LLMModelInfo,
use_funcs: list[tools_entities.LLMFunction] = None,
) -> llm_entities.Message:
self.client.api_key = use_model.token_mgr.get_token()
args = self.requester_cfg['args'].copy()
args["model"] = use_model.name if use_model.model_name is None else use_model.model_name
if use_funcs:
tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs)
if tools:
args["tools"] = tools
# 设置此次请求中的messages
messages = req_messages.copy()
# 检查vision
for msg in messages:
if 'content' in msg and isinstance(msg["content"], list):
for me in msg["content"]:
if me["type"] == "image_base64":
me["image_url"] = {
"url": me["image_base64"]
}
me["type"] = "image_url"
del me["image_base64"]
args["messages"] = messages
# 发送请求
resp = await self._req(args)
# 处理请求结果
message = await self._make_msg(resp)
return message
async def call(
self,
query: core_entities.Query,
model: entities.LLMModelInfo,
messages: typing.List[llm_entities.Message],
funcs: typing.List[tools_entities.LLMFunction] = None,
) -> llm_entities.Message:
req_messages = [] # req_messages 仅用于类内,外部同步由 query.messages 进行
for m in messages:
msg_dict = m.dict(exclude_none=True)
content = msg_dict.get("content")
if isinstance(content, list):
# 检查 content 列表中是否每个部分都是文本
if all(isinstance(part, dict) and part.get("type") == "text" for part in content):
# 将所有文本部分合并为一个字符串
msg_dict["content"] = "\n".join(part["text"] for part in content)
req_messages.append(msg_dict)
try:
return await self._closure(query=query, req_messages=req_messages, use_model=model, use_funcs=funcs)
except asyncio.TimeoutError:
raise errors.RequesterError('请求超时')
except openai.BadRequestError as e:
if 'context_length_exceeded' in e.message:
raise errors.RequesterError(f'上文过长,请重置会话: {e.message}')
else:
raise errors.RequesterError(f'请求参数错误: {e.message}')
except openai.AuthenticationError as e:
raise errors.RequesterError(f'无效的 api-key: {e.message}')
except openai.NotFoundError as e:
raise errors.RequesterError(f'请求路径错误: {e.message}')
except openai.RateLimitError as e:
raise errors.RequesterError(f'请求过于频繁或余额不足: {e.message}')
except openai.APIError as e:
raise errors.RequesterError(f'请求错误: {e.message}')

View File

@@ -0,0 +1,34 @@
apiVersion: v1
kind: LLMAPIRequester
metadata:
name: modelscope-chat-completions
label:
en_US: ModelScope
zh_CN: 魔搭社区
spec:
config:
- name: base-url
label:
en_US: Base URL
zh_CN: 基础 URL
type: string
required: true
default: "https://api-inference.modelscope.cn/v1"
- name: args
label:
en_US: Args
zh_CN: 附加参数
type: object
required: true
default: {}
- name: timeout
label:
en_US: Timeout
zh_CN: 超时时间
type: int
required: true
default: 120
execution:
python:
path: ./modelscopechatcmpl.py
attr: ModelScopeChatCompletions

View File

@@ -25,7 +25,7 @@ class MoonshotChatCompletions(chatcmpl.OpenAIChatCompletions):
) -> llm_entities.Message:
self.client.api_key = use_model.token_mgr.get_token()
args = self.requester_cfg['args'].copy()
args = {}
args["model"] = use_model.name if use_model.model_name is None else use_model.model_name
if use_funcs:
@@ -42,13 +42,13 @@ class MoonshotChatCompletions(chatcmpl.OpenAIChatCompletions):
if 'content' in m and isinstance(m["content"], list):
m["content"] = " ".join([c["text"] for c in m["content"]])
# 删除空的
messages = [m for m in messages if m["content"].strip() != ""]
# 删除空的,不知道干嘛的,直接删了。
# messages = [m for m in messages if m["content"].strip() != "" and ('tool_calls' not in m or not m['tool_calls'])]
args["messages"] = messages
# 发送请求
resp = await self._req(args)
resp = await self._req(args, extra_body=self.requester_cfg['args'])
# 处理请求结果
message = await self._make_msg(resp)

View File

@@ -0,0 +1,20 @@
from __future__ import annotations
import openai
from . import chatcmpl, modelscopechatcmpl
from .. import requester
from ....core import app
class PPIOChatCompletions(chatcmpl.OpenAIChatCompletions):
"""欧派云 ChatCompletion API 请求器"""
client: openai.AsyncClient
requester_cfg: dict
def __init__(self, ap: app.Application):
self.ap = ap
self.requester_cfg = self.ap.provider_cfg.data['requester']['ppio-chat-completions']

View File

@@ -0,0 +1,34 @@
apiVersion: v1
kind: LLMAPIRequester
metadata:
name: ppio-chat-completions
label:
en_US: ppio
zh_CN: 派欧云
spec:
config:
- name: base-url
label:
en_US: Base URL
zh_CN: 基础 URL
type: string
required: true
default: "https://api.ppinfra.com/v3/openai"
- name: args
label:
en_US: Args
zh_CN: 附加参数
type: object
required: true
default: {}
- name: timeout
label:
en_US: Timeout
zh_CN: 超时时间
type: int
required: true
default: 120
execution:
python:
path: ./ppiochatcmpl.py
attr: PPIOChatCompletions

View File

@@ -164,12 +164,14 @@ class DifyServiceAPIRunner(runner.RequestRunner):
for image_id in image_ids
]
ignored_events = ["agent_message"]
ignored_events = []
inputs = {}
inputs.update(query.variables)
pending_agent_message = ''
async for chunk in self.dify_client.chat_messages(
inputs=inputs,
query=plain_text,
@@ -183,48 +185,55 @@ class DifyServiceAPIRunner(runner.RequestRunner):
if chunk["event"] in ignored_events:
continue
if chunk["event"] == "agent_thought":
if chunk['tool'] != '' and chunk['observation'] != '': # 工具调用结果,跳过
continue
if chunk['thought'].strip() != '': # 文字回复内容
msg = llm_entities.Message(
role="assistant",
content=chunk["thought"],
)
yield msg
if chunk['tool']:
msg = llm_entities.Message(
role="assistant",
tool_calls=[
llm_entities.ToolCall(
id=chunk['id'],
type="function",
function=llm_entities.FunctionCall(
name=chunk["tool"],
arguments=json.dumps({}),
),
)
],
)
yield msg
if chunk['event'] == 'message_file':
if chunk['type'] == 'image' and chunk['belongs_to'] == 'assistant':
base_url = self.dify_client.base_url
if base_url.endswith('/v1'):
base_url = base_url[:-3]
image_url = base_url + chunk['url']
if chunk['event'] == 'agent_message':
pending_agent_message += chunk['answer']
else:
if pending_agent_message.strip() != '':
pending_agent_message = pending_agent_message.replace('</details>Action:', '</details>')
yield llm_entities.Message(
role="assistant",
content=[llm_entities.ContentElement.from_image_url(image_url)],
content=self._try_convert_thinking(pending_agent_message),
)
pending_agent_message = ''
if chunk["event"] == "agent_thought":
if chunk['tool'] != '' and chunk['observation'] != '': # 工具调用结果,跳过
continue
if chunk['tool']:
msg = llm_entities.Message(
role="assistant",
tool_calls=[
llm_entities.ToolCall(
id=chunk['id'],
type="function",
function=llm_entities.FunctionCall(
name=chunk["tool"],
arguments=json.dumps({}),
),
)
],
)
yield msg
if chunk['event'] == 'message_file':
if chunk['type'] == 'image' and chunk['belongs_to'] == 'assistant':
base_url = self.dify_client.base_url
if base_url.endswith('/v1'):
base_url = base_url[:-3]
image_url = base_url + chunk['url']
yield llm_entities.Message(
role="assistant",
content=[llm_entities.ContentElement.from_image_url(image_url)],
)
if chunk['event'] == 'error':
raise errors.DifyAPIError("dify 服务错误: " + chunk['message'])
query.session.using_conversation.uuid = chunk["conversation_id"]
@@ -301,11 +310,11 @@ class DifyServiceAPIRunner(runner.RequestRunner):
msg = llm_entities.Message(
role="assistant",
content=chunk["data"]["outputs"][
content=self._try_convert_thinking(chunk["data"]["outputs"][
self.ap.provider_cfg.data["dify-service-api"]["workflow"][
"output-key"
]
],
]),
)
yield msg

View File

@@ -1,4 +1,4 @@
semantic_version = "v3.4.11.1"
semantic_version = "v3.4.14.3"
debug_mode = False

View File

@@ -212,4 +212,19 @@ async def extract_b64_and_format(image_base64_data: str) -> typing.Tuple[str, st
"""
base64_str = image_base64_data.split(',')[-1]
image_format = image_base64_data.split(':')[-1].split(';')[0].split('/')[-1]
return base64_str, image_format
return base64_str, image_format
async def get_slack_image_to_base64(pic_url:str, bot_token:str):
headers = {"Authorization": f"Bearer {bot_token}"}
try:
async with aiohttp.ClientSession() as session:
async with session.get(pic_url, headers=headers) as resp:
image_data = await resp.read()
return base64.b64encode(image_data).decode('utf-8')
except Exception as e:
raise(e)

View File

@@ -34,6 +34,8 @@ dashscope
python-telegram-bot
certifi
mcp
slack_sdk
telegramify-markdown
# indirect
taskgroup==0.0.0a4
taskgroup==0.0.0a4
python-socks

View File

@@ -71,29 +71,47 @@
"token": ""
},
{
"adapter":"officialaccount",
"adapter": "officialaccount",
"enable": false,
"token": "",
"EncodingAESKey":"",
"AppID":"",
"AppSecret":"",
"Mode":"drop",
"LoadingMessage":"AI正在思考中请发送任意内容获取回复。",
"EncodingAESKey": "",
"AppID": "",
"AppSecret": "",
"Mode": "drop",
"LoadingMessage": "AI正在思考中请发送任意内容获取回复。",
"host": "0.0.0.0",
"port": 2287
},
{
"adapter":"dingtalk",
"adapter": "dingtalk",
"enable": false,
"client_id":"",
"client_secret":"",
"robot_code":"",
"robot_name":""
"client_id": "",
"client_secret": "",
"robot_code": "",
"robot_name": "",
"markdown_card": false
},
{
"adapter":"telegram",
"adapter": "telegram",
"enable": false,
"token":""
"token": "",
"markdown_card": false
},
{
"adapter": "slack",
"enable": false,
"bot_token": "",
"signing_secret": "",
"port": 2288
},
{
"adapter": "wecomcs",
"enable": false,
"port": 2289,
"corpid": "",
"secret": "",
"token": "",
"EncodingAESKey": ""
}
],
"track-function-calls": true,

View File

@@ -31,6 +31,12 @@
],
"volcark": [
"xxxxxxxx"
],
"modelscope": [
"xxxxxxxx"
],
"ppio": [
"xxxxxxxx"
]
},
"requester": {
@@ -95,12 +101,22 @@
"args": {},
"base-url": "https://ark.cn-beijing.volces.com/api/v3",
"timeout": 120
},
"modelscope-chat-completions": {
"base-url": "https://api-inference.modelscope.cn/v1",
"args": {},
"timeout": 120
},
"ppio-chat-completions": {
"base-url": "https://api.ppinfra.com/v3/openai",
"args": {},
"timeout": 120
}
},
"model": "gpt-4o",
"prompt-mode": "normal",
"prompt": {
"default": ""
"default": "You are a helpful assistant."
},
"runner": "local-agent",
"dify-service-api": {

View File

@@ -446,6 +446,11 @@
"type": "string",
"default": "",
"description": "钉钉的robot_name"
},
"markdown_card": {
"type": "boolean",
"default": false,
"description": "是否使用 Markdown 卡片发送消息"
}
}
},
@@ -466,6 +471,41 @@
"type": "string",
"default": "",
"description": "Telegram 的 token"
},
"markdown_card": {
"type": "boolean",
"default": false,
"description": "是否使用 Markdown 卡片发送消息"
}
}
},
{
"title": "Slack 适配器",
"description": "用于接入 Slack",
"properties": {
"adapter": {
"type": "string",
"const": "slack"
},
"enable": {
"type": "boolean",
"default": false,
"description": "是否启用此适配器"
},
"bot_token": {
"type": "string",
"default": "",
"description": "Slack 的 bot_token"
},
"signing_secret": {
"type": "string",
"default": "",
"description": "Slack 的 signing_secret"
},
"port": {
"type": "integer",
"default": 2288,
"description": "监听的端口"
}
}
}

View File

@@ -368,7 +368,7 @@
"type": "string",
"title": "默认情景预设",
"description": "设置默认情景预设。值为空字符串时,将不使用情景预设(人格)",
"default": ""
"default": "You are a helpful assistant."
}
},
"patternProperties": {