Compare commits

...

114 Commits

Author SHA1 Message Date
Junyan Qin
a0b7d759ac chore: release v4.0.8.1 2025-07-06 10:46:32 +08:00
Junyan Qin
09884d3152 revert: 0203faa 2025-07-06 10:34:24 +08:00
Matthew_Astral
01f2ef5694 feat: new discord adapter (#1563) 2025-07-05 20:51:04 +08:00
Junyan Qin (Chin)
a01706d163 Feat/reset password (#1566)
* feat: reset password with recovery key

* perf: formatting and multi language
2025-07-05 17:36:35 +08:00
Junyan Qin
a8d03c98dc doc: replace comshare link 2025-07-04 11:37:31 +08:00
Junyan Qin
3f0153ea4d doc: fix incorrect 302.AI name 2025-07-03 17:26:17 +08:00
Junyan Qin
60b50a35f1 chore: release v4.0.8 2025-07-03 15:07:19 +08:00
Junyan Qin (Chin)
abd02f04af Feat/compshare requester (#1561)
* feat: add compshare requester

* doc: add compshare to README
2025-07-03 15:04:02 +08:00
Matthew_Astral
14411a8af6 Add Discord platform adapter implementation (#1560)
- Implement DiscordMessageConverter for message conversion
- Support image handling from base64, URL, and file paths
- Add DiscordEventConverter for event conversion
- Implement DiscordAdapter for Discord bot integration
- Support DM and TextChannel message handling
2025-07-02 09:48:49 +08:00
Junyan Qin
896fef8cce perf: make launch notes show async 2025-06-30 21:34:02 +08:00
Junyan Qin
89c1972abe perf: skip broken models and bots in bootstrap 2025-06-30 21:29:38 +08:00
Junyan Qin
1627d04958 fix: bad import 2025-06-30 21:13:14 +08:00
Junyan Qin (Chin)
c959c99e45 Feat/302 ai (#1558)
* feat: add 302.AI requester

* doc: add 302.AI to README
2025-06-30 21:05:32 +08:00
Junyan Qin
0203faa8c1 fix: dingtalk adapter initializer blocks boot (#1544) 2025-06-28 22:06:12 +08:00
Junyan Qin (Chin)
35f76cb7ae Perf/combine entity dialogs (#1555)
* feat: combine bot settings and bot log dialogs

* perf: dialog style when creating bot

* perf: bot creation dialog

* feat: combine pipeline dialogs

* perf: ui

* perf: move buttons

* perf: ui layout in pipeline detail dialog

* perf: remove debug button from pipeline card

* perf: open pipeline dialog after creating

* perf: placeholder in send input

* perf: no close dialog when save done

* fix: linter errors
2025-06-28 21:50:51 +08:00
fdc310
c34232a26c fix: add wechatpad image (#1551)
* add wechatpad image

* add wechatpad image

---------

Co-authored-by: fdc <you@example.com>
2025-06-27 15:41:21 +08:00
简律纯
b43dd95dc6 chore(python): Delete .python-version (#1549) 2025-06-25 22:47:02 +08:00
Junyan Qin
5331ba83d7 chore: update description of lark bot name field 2025-06-25 10:57:44 +08:00
fdc310
a2038b86f1 feat:add onebotv11 face send and accept but some face no name. (#1543)
* feat:add onebotv11 face send and accept but some face no name.

* add face annotation

* add face_code_dict

* add some face in image can't download,so pass on face

* fix:Pass the face_id to face
2025-06-19 10:38:02 +08:00
Junyan Qin
eb066f3485 revert: 3cbc823 2025-06-18 15:16:55 +08:00
Junyan Qin
bf98b82cf2 chore: release v4.0.7 2025-06-18 13:10:20 +08:00
Junyan Qin (Chin)
edd70b943d Update bug-report_en.yml 2025-06-18 09:48:42 +08:00
Junyan Qin
3cbc823085 doc: make en README as default 2025-06-17 22:51:51 +08:00
Sheldon.li
48becf2c51 refactor(ContentFilterStage): Add logic for handling empty messages (#1525)
-In the ContentFilterStage, logic for handling empty messages has been added to ensure that the pipeline continues to process even when the message is empty.
- This change enhances the robustness of content filtering, preventing potential issues caused by empty messages.
- This optimization was implemented to address the issue where, when someone is @ in a group chat and a message is sent without any content, the Source type messages in the message chain are lost.
2025-06-17 22:12:55 +08:00
devin-ai-integration[bot]
56c686cd5a feat: add Japanese (ja-JP) language support (#1537)
* feat: add Japanese (ja-JP) language support

- Add comprehensive Japanese translation file (ja-JP.ts)
- Update i18n configuration to include Japanese locale
- Add Japanese language option to login and register page dropdowns
- Implement Japanese language detection and switching logic
- Maintain fallback to en-US for missing translations in flexible components

Co-Authored-By: Junyan Qin <Chin>, 秦骏言 in Chinese, you can call me my english name Rock Chin. <rockchinq@gmail.com>

* perf: ui for ja-JP

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Junyan Qin <Chin>, 秦骏言 in Chinese, you can call me my english name Rock Chin. <rockchinq@gmail.com>
2025-06-16 21:30:57 +08:00
Junyan Qin (Chin)
208273c0dd Update README.md 2025-06-16 21:01:11 +08:00
fdc310
2ff7ca3025 feat:add file url and add onebotv11(napcat) send file and seve file in local. (#1533)
* feat:add file url and add onebotv11(napcat) send file and seve file in local.

* del print
2025-06-15 17:22:35 +08:00
fdc310
61a2361730 feat:add new messagetyps WeChatFile and add wechat file is accepted and transmitted in base64 format. (#1531) 2025-06-15 17:17:08 +08:00
Junyan Qin
f80f997a89 chore: update version field in pyproject.toml 2025-06-11 10:24:18 +08:00
Junyan Qin
18529a42c1 chore: release v4.0.6 2025-06-11 10:23:46 +08:00
Junyan Qin (Chin)
3e707b4b6e feat: reset all associated session after bot and pipeline modified (#1517) 2025-06-09 21:50:08 +08:00
Junyan Qin
62f0a938a8 chore: remove legacy test in fe 2025-06-09 17:56:37 +08:00
Junyan Qin
ad3a163d82 fix: ruff linter error in libs 2025-06-09 17:56:21 +08:00
Junyan Qin
f5a4503610 perf: add text comment on bot log button 2025-06-09 15:27:17 +08:00
Junyan Qin
ec012cf5ed doc: update README 2025-06-09 10:20:11 +08:00
Junyan Qin
d70eceb72c fix(DebugDialog): \n not supported 2025-06-08 21:41:44 +08:00
devin-ai-integration[bot]
f271608114 feat: add dynamic base URL configuration using environment variables (#1511)
- Replace hardcoded base URL in HttpClient.ts with environment variable support
- Add NEXT_PUBLIC_API_BASE_URL environment variable for dynamic configuration
- Add dev:local script for development with localhost:5300 backend
- Development: uses localhost:5300, Production: uses / (relative path)
- Eliminates need for manual code changes when switching environments

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Junyan Qin <Chin>, 秦骏言 in Chinese, you can call me my english name Rock Chin. <rockchinq@gmail.com>
2025-06-08 17:44:40 +08:00
Junyan Qin
793f0a9c10 fix: base url 2025-06-08 17:34:32 +08:00
devin-ai-integration[bot]
4f2ec195fc feat: add WebChat adapter for pipeline debugging (#1510)
* feat: add WebChat adapter for pipeline debugging

- Create WebChatAdapter for handling debug messages in pipeline testing
- Add HTTP API endpoints for debug message sending and retrieval
- Implement frontend debug dialog with session switching (private/group chat)
- Add Chinese i18n translations for debug interface
- Auto-create default WebChat bot during database initialization
- Support fixed session IDs: webchatperson and webchatgroup for testing

Co-Authored-By: Junyan Qin <Chin>, 秦骏言 in Chinese, you can call me my english name Rock Chin. <rockchinq@gmail.com>

* perf: ui for webchat

* feat: complete webchat backend

* feat: core chat apis

* perf: button style in pipeline card

* perf: log btn in bot card

* perf: webchat entities definition

* fix: bugs

* perf: web chat

* perf: dialog styles

* perf: styles

* perf: styles

* fix: group invalid in webchat

* perf: simulate real im message

* perf: group timeout toast

* feat(webchat): add supports for mentioning bot in group

* perf(webchat): at component styles

* perf: at badge display in message

* fix: linter errors

* fix: webchat was listed on adapter list

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Junyan Qin <Chin>, 秦骏言 in Chinese, you can call me my english name Rock Chin. <rockchinq@gmail.com>
2025-06-08 15:34:26 +08:00
Junyan Qin (Chin)
e6bc009414 feat: add i18n support for initialization page and fix plugin loading text (#1505)
* feat: add i18n support for initialization page and fix plugin loading text

- Add language selector to register/initialization page with Chinese and English options
- Add register section translations to both zh-Hans.ts and en-US.ts
- Replace hardcoded Chinese texts in register page with i18n translation calls
- Fix hardcoded '加载中...' text in plugin configuration dialog to use t('plugins.loading')
- Follow existing login page pattern for language selector implementation
- Maintain consistent UI/UX design with proper language switching functionality

Co-Authored-By: Junyan Qin <Chin>, 秦骏言 in Chinese, you can call me my english name Rock Chin. <rockchinq@gmail.com>

* perf: language selecting logic

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Junyan Qin <Chin>, 秦骏言 in Chinese, you can call me my english name Rock Chin. <rockchinq@gmail.com>
2025-06-06 21:29:36 +08:00
Junyan Qin
20dc8fb5ab perf: language selecting logic 2025-06-06 21:27:08 +08:00
Devin AI
9a71edfeb0 feat: add i18n support for initialization page and fix plugin loading text
- Add language selector to register/initialization page with Chinese and English options
- Add register section translations to both zh-Hans.ts and en-US.ts
- Replace hardcoded Chinese texts in register page with i18n translation calls
- Fix hardcoded '加载中...' text in plugin configuration dialog to use t('plugins.loading')
- Follow existing login page pattern for language selector implementation
- Maintain consistent UI/UX design with proper language switching functionality

Co-Authored-By: Junyan Qin <Chin>, 秦骏言 in Chinese, you can call me my english name Rock Chin. <rockchinq@gmail.com>
2025-06-06 10:50:31 +00:00
Guanchao Wang
fe3fd664af Fix/slack image (#1501)
* fix: dingtalk adapters couldn't handle images

* fix: slack adapter couldn't put the image in logger
2025-06-06 10:04:00 +08:00
Guanchao Wang
6402755ac6 fix: dingtalk adapters couldn't handle images (#1500) 2025-06-05 23:37:58 +08:00
Junyan Qin
ac8fe049de fix: uv removes it self 2025-06-05 11:12:04 +08:00
Junyan Qin
955b391253 chore: release v4.0.5 2025-06-03 16:28:55 +08:00
Junyan Qin
08c6672841 feat: allow skip plugin deps checking 2025-06-02 21:43:27 +08:00
Junyan Qin
8917050fae chore: add ppio icon 2025-05-31 20:00:18 +08:00
Junyan Qin
21daef46f7 chore: remove gemini related deps 2025-05-31 19:27:08 +08:00
Junyan Qin (Chin)
8ad60b5b64 refactor: gemini requester (#1490)
* refactor: use openai compatible api for gemini

* chore: remove codes
2025-05-31 13:11:53 +08:00
Junyan Qin
7e17c96c30 fix: linter error 2025-05-30 22:29:16 +08:00
whw174660897
f17b06767e Feature add n8 n (#1468)
* feat(n8n): 添加n8n工作流API支持

添加n8n工作流API作为新的运行器类型,支持通过webhook调用n8n工作流,并提供多种认证方式(Basic、JWT、Header)。新增N8nAuthFormComponent用于处理n8n认证表单联动,并更新相关配置文件和测试用例。

* chore: remove pip mirror url

* perf: simplify ret def of pipeline metadata

* feat(n8n): raise exc instead of ret as normal msg

* perf: add var `user_message_text`

* chore(n8n): migration and default config

* chore: required database version

---------

Co-authored-by: hengwei.wang <@>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-05-30 22:23:57 +08:00
Junyan Qin
70a29fc623 chore: f u if you dont provide enough info in issue 2025-05-29 16:51:47 +08:00
Junyan Qin
239223be3f chore: release v4.0.4 2025-05-28 12:55:15 +08:00
Junyan Qin
b112cb320c fix: bad ability name in preproc check 2025-05-28 12:54:30 +08:00
Junyan Qin
5aaf2ba3ef fix: base url 2025-05-27 22:58:31 +08:00
Junyan Qin (Chin)
f1e9f46af1 feat: event log of bots (#1441)
* feat: basic arch of event log

* feat: complete event log framework

* fix: bad struct in bot log api

* feat: add event logging to all platform adapters

Co-Authored-By: wangcham233@gmail.com <651122857@qq.com>

* feat: add event logging to client classes

Co-Authored-By: wangcham233@gmail.com <651122857@qq.com>

* refactor: bot log getting api

* perf: logger for aiocqhttp and gewechat

* fix: add ignored logger in dingtalk

* fix: seq id bug in log getting

* feat: add logger in dingtalk,QQ official,Slack, wxoa

* feat: add logger for wecom

* feat: add logger for wecomcs

* perf(event logger): image processing

* 完成机器人日志的前端部分 (#1479)

* feat: webui  bot log framework done

* feat: bot log complete

* perf(bot-log): style

* chore: fix incompleted i18n

* feat: support message session copy

* fix: filter and badge text

* perf: styles

* feat: add bot toggle switch in bot card

* fix: linter errors

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: wangcham233@gmail.com <651122857@qq.com>
Co-authored-by: HYana <65863826+KaedeSAMA@users.noreply.github.com>
2025-05-27 22:36:50 +08:00
aberry
8dfef1d118 Bugfix (#1482)
* Update modelscopechatcmpl.py

tool_call 流式输出的最后一个参数是 None,需要判断一下

* Update mcp.py

问题:闭包(closure)对循环变量 tool 的捕获,导致最终注册到 self.functions 里的所有 func,都会引用 同一个(最后一个)tool

解决:在定义 func 时,通过函数参数的 默认值 把当下的 tool “冻结”住

* Update mcp.py
2025-05-27 15:09:09 +08:00
Junyan Qin (Chin)
919a621bf8 fix: lru bug in t2i (#1445) (#1481) 2025-05-27 09:58:22 +08:00
Junyan Qin
3ac96f464d perf: show description in bot form 2025-05-23 10:31:11 +08:00
Junyan Qin
f9f03b81d1 chore: release v4.0.3.3 2025-05-22 10:49:24 +08:00
Junyan Qin
42171a9c07 fix: combine quote message not in default pipeline config 2025-05-22 10:44:33 +08:00
Junyan Qin
f1f00115c9 chore: update issue template 2025-05-22 10:42:59 +08:00
Junyan Qin
59bff61409 chore: release v4.0.3.2 2025-05-21 19:46:42 +08:00
Junyan Qin
778693a804 perf: desc of random 2025-05-21 19:45:45 +08:00
Junyan Qin
e5b2da225c perf: no longer get host ip 2025-05-21 19:42:04 +08:00
Steven Lynn
4a988b89a2 fix: update auto-reply probability description in trigger.yaml (#1463) 2025-05-21 17:50:23 +08:00
Junyan Qin
e5e8807312 perf: no longer ask for apikeys for ollama and lm studio 2025-05-20 16:01:20 +08:00
Junyan Qin
1376530c2e fix: conversation is null 2025-05-20 15:32:04 +08:00
Junyan Qin
7d34a2154b perf: unify i18n text class in frontend 2025-05-20 11:32:55 +08:00
Junyan Qin
ff335130ae chore: update CONTRIBUTING 2025-05-20 09:39:46 +08:00
Junyan Qin
0afef0ac0f chore: update pr template 2025-05-20 09:21:59 +08:00
Junyan Qin (Chin)
6447f270ea Update bug-report_en.yml 2025-05-20 09:16:30 +08:00
Junyan Qin (Chin)
81be62e1a4 Update bug-report_en.yml 2025-05-20 09:15:52 +08:00
Junyan Qin (Chin)
409909ccb1 Update bug-report_en.yml (#1456) 2025-05-20 09:14:52 +08:00
Junyan Qin
b821b69dbb chore: perf issue templates 2025-05-20 09:13:13 +08:00
Junyan Qin
7e2448655e chore: add english issue templates 2025-05-20 09:11:47 +08:00
Junyan Qin (Chin)
a7d2a68639 feat: add supports for testing llm models (#1454)
* feat: add supports for testing llm models

* fix: linter error
2025-05-19 23:10:04 +08:00
fdc310
aba51409a7 feat:add qoute message process and add Whether to enable this function (#1446)
* 更新了wechatpad接口,以及适配器

* 更新了wechatpad接口,以及适配器

* 修复一些细节问题,比如at回复,以及启动登录和启动ws长连接的线程同步

* importutil中修复了在wi上启动替换斜杠问题,login中加上了一个login,暂时没啥用。wechatpad中做出了一些细节修改

* 更新了wechatpad接口,以及适配器

* 怎加了处理图片链接转换为image_base64发送

* feat(wechatpad): 调整日志+bugfix

* feat(wechatpad): fix typo

* 修正了发送语音api参数错误,添加了发送链接处理为base64数据(好像只有一部分链接可以)

* 修复了部分手抽的typo错误

* chore: remove manager.py

* feat:add qoute message process and add Whether to enable this function

* chore: add db migration for this change

---------

Co-authored-by: shinelin <shinelinxx@gmail.com>
Co-authored-by: Junyan Qin (Chin) <rockchinq@gmail.com>
2025-05-19 22:24:18 +08:00
sheetung
5e5d37cbf1 St/webui (#1452)
* 解决webUI模型配置页面卡片溢出问题

* fix: webUI卡片文本溢出问题
2025-05-19 18:11:50 +08:00
sheetung
e5a99a0fe4 解决webUI模型配置页面卡片溢出问题 (#1451) 2025-05-19 13:14:39 +08:00
Junyan Qin
a594cc07f6 chore: release v4.0.3.1 2025-05-19 10:31:11 +08:00
Junyan Qin
0a9714fbe7 perf: no cache for fronend page 2025-05-17 19:30:26 +08:00
Junyan Qin (Chin)
1992934dce fix: user_funcs typo in ollama chat requester (#1431) 2025-05-15 20:51:58 +08:00
zejiewang
bb930aec14 fix:lark adapter listeners init problem (#1426)
Co-authored-by: wangzejie <wangzejie@meicai.cn>
2025-05-15 11:25:38 +08:00
Junyan Qin
1d7f2ab701 fix: wrong ref in HomeTitleBar 2025-05-15 10:54:22 +08:00
Junyan Qin
347da6142e perf: multi language 2025-05-15 10:40:36 +08:00
Junyan Qin
a9f4dc517a perf: remove -q params in plugin deps precheking 2025-05-15 10:24:53 +08:00
Junyan Qin (Chin)
9d45f3f3a7 updatr README.md 2025-05-15 09:04:38 +08:00
Guanchao Wang
256d24718b fix: dingtalk & wecom problems (#1424) 2025-05-14 22:55:16 +08:00
Junyan Qin
1272b8ef16 ci: update Dockerfile python version 2025-05-14 22:22:17 +08:00
Junyan Qin
696162ee52 chore: release v4.0.3 2025-05-14 22:05:03 +08:00
Junyan Qin
533f993e3a fix: bad Dockerfile CMD 2025-05-14 22:04:08 +08:00
Junyan Qin
738b0af5fb chore: release v4.0.2 2025-05-14 21:35:21 +08:00
Junyan Qin
5d9bac5e7b doc: remove gewechat 2025-05-14 21:32:05 +08:00
Junyan Qin (Chin)
f376c9703a feat: add supports for open router (#1422) 2025-05-14 21:28:33 +08:00
fdc310
20a62fcf69 feat: add wechatpad for personal wechat
* 更新了wechatpad接口,以及适配器

* 更新了wechatpad接口,以及适配器

* 修复一些细节问题,比如at回复,以及启动登录和启动ws长连接的线程同步

* importutil中修复了在wi上启动替换斜杠问题,login中加上了一个login,暂时没啥用。wechatpad中做出了一些细节修改

* 更新了wechatpad接口,以及适配器

* 怎加了处理图片链接转换为image_base64发送

* feat(wechatpad): 调整日志+bugfix

* feat(wechatpad): fix typo

* 修正了发送语音api参数错误,添加了发送链接处理为base64数据(好像只有一部分链接可以)

* 修复了部分手抽的typo错误

* chore: remove manager.py

---------

Co-authored-by: shinelin <shinelinxx@gmail.com>
Co-authored-by: Junyan Qin (Chin) <rockchinq@gmail.com>
2025-05-14 21:18:08 +08:00
devin-ai-integration[bot]
248d4beed1 fix: add super().__init__() call to EchoTextHandler to initialize logger attribute (#1421)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Junyan Qin <Chin> <rockchinq@gmail.com>
2025-05-14 20:52:27 +08:00
Junyan Qin
0e52aff363 chore: remove requirements.txt 2025-05-14 19:37:06 +08:00
Junyan Qin (Chin)
4ed854d7b8 ci: update Dockerfile (#1420)
* ci: update Dockerfile

* ci: update Dockerfile

* ci: no `--locked`
2025-05-14 19:29:44 +08:00
Junyan Qin
c6ff33c6ab chore: add google ai deps 2025-05-14 19:14:12 +08:00
简律纯
6c10cb7dca feat: support package manager(uv) (#1414)
* chore: set Python version to 3.10

* feat: add pyproject.toml for project configuration and dependencies

* style: streamline bot retrieval and update logic in PipelineService

* feat: update dependencies and configuration for ruff and pip

* chore: remove ruff configuration file

* style: change quote style from single to double in ruff configuration

* style: unify string quote style to double quotes across multiple files

* chore: update .gitignore to include .venv and uv.lock

* chore: remove unused configuration files and clean up project structure

* chore: revert quote-style to `single`

* chore: set default python version to 3.12

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-05-14 19:09:52 +08:00
Junyan Qin
130495f519 perf: missing translation in zh-Hans 2025-05-14 17:02:40 +08:00
Junyan Qin
219d328342 perf: completion some english translation 2025-05-14 17:00:03 +08:00
Junyan Qin
c835555a59 chore: change zh_CN to zh_Hans 2025-05-14 16:44:48 +08:00
Junyan Qin
6652b57a0d doc: README 2025-05-14 16:08:34 +08:00
Junyan Qin
bf51afedf6 perf: async bug in llm form 2025-05-14 15:37:58 +08:00
Junyan Qin
39f9400de7 fix: modelscope no usable 2025-05-14 15:35:37 +08:00
devin-ai-integration[bot]
ac1d39580b feat: add Google Gemini API support (#1418)
* feat: add Google Gemini API support

Co-Authored-By: Junyan Qin <Chin> <rockchinq@gmail.com>

* fix: remove unused imports

Co-Authored-By: Junyan Qin <Chin> <rockchinq@gmail.com>

* feat: add google-genai dependency

Co-Authored-By: Junyan Qin <Chin> <rockchinq@gmail.com>

* fix: update Gemini API implementation to use correct API methods

Co-Authored-By: Junyan Qin <Chin> <rockchinq@gmail.com>

* refactor: improve Gemini API implementation based on official documentation

Co-Authored-By: Junyan Qin <Chin> <rockchinq@gmail.com>

* fix: remove unsupported timeout parameter from Gemini API implementation

Co-Authored-By: Junyan Qin <Chin> <rockchinq@gmail.com>

* fix: correct Gemini API implementation based on official documentation

Co-Authored-By: Junyan Qin <Chin> <rockchinq@gmail.com>

* feat: update geminichatcmpl

* deps: add google-generativeai

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Junyan Qin <Chin> <rockchinq@gmail.com>
2025-05-14 15:05:04 +08:00
Junyan Qin
9362b34858 doc: readme images 2025-05-14 12:34:49 +08:00
Junyan Qin
c6f6c715bd doc: add demo images 2025-05-14 12:33:59 +08:00
Junyan Qin
6a8106d9ac doc: remove usage badge in README 2025-05-14 12:22:45 +08:00
Junyan Qin (Chin)
5abbcb62a2 Fix/system info 404 (#1413)
* fix: system info 404

* fix: lint error
2025-05-13 23:14:06 +08:00
devin-ai-integration[bot]
2bf94539bd Add i18n support with language selector on login page (#1410)
* feat: add i18n support with language selector on login page

Co-Authored-By: Junyan Qin <Chin> <rockchinq@gmail.com>

* feat: complete i18n implementation for all webui components

Co-Authored-By: Junyan Qin <Chin> <rockchinq@gmail.com>

* feat: complete all hardcoded text

* feat: dynamic label i18n

* fix: lint errors

* fix: lint errors

* delete sh fils

* fix: edit model dialog title

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Junyan Qin <Chin> <rockchinq@gmail.com>
2025-05-13 22:39:19 +08:00
231 changed files with 10687 additions and 1432 deletions

View File

@@ -1,5 +1,5 @@
name: 漏洞反馈
description: 报错或漏洞请使用这个模板创建不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题参考文档 https://docs.langbot.app/deploy/network-details.html
description: 【供中文用户】报错或漏洞请使用这个模板创建不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题参考文档 https://docs.langbot.app/zh/workshop/network-details.html
title: "[Bug]: "
labels: ["bug?"]
body:
@@ -7,7 +7,7 @@ body:
attributes:
label: 运行环境
description: LangBot 版本、操作系统、系统架构、**Python版本**、**主机地理位置**
placeholder: 例如v3.3.0、CentOS x64 Python 3.10.3、Docker 的系统直接写 Docker 就行
placeholder: 例如v3.3.0、CentOS x64 Python 3.10.3、Docker
validations:
required: true
- type: textarea
@@ -19,12 +19,12 @@ body:
- type: textarea
attributes:
label: 复现步骤
description: 如何重现这个问题,越详细越好;请贴上所有相关的配置文件和元数据文件(注意隐去敏感信息)
description: 提供越多信息,我们会越快解决问题,建议多提供配置截图;**如果你不认真填写(只一两句话概括),我们会很生气并且立即关闭 issue 或两年后才回复你**
validations:
required: true
required: false
- type: textarea
attributes:
label: 启用的插件
description: 有些情况可能和插件功能有关,建议提供插件启用情况。可以使用`!plugin`命令查看已启用的插件
description: 有些情况可能和插件功能有关,建议提供插件启用情况。
validations:
required: false

View File

@@ -0,0 +1,30 @@
name: Bug report
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://docs.langbot.app/en/workshop/network-details.html
title: "[Bug]: "
labels: ["bug?"]
body:
- type: input
attributes:
label: Runtime environment
description: LangBot version, operating system, system architecture, **Python version**, **host location**
placeholder: "For example: v3.3.0, CentOS x64 Python 3.10.3, Docker"
validations:
required: true
- type: textarea
attributes:
label: Exception
description: Describe the exception in detail, what happened and when it happened. **Please include log information.**
validations:
required: true
- type: textarea
attributes:
label: Reproduction steps
description: How to reproduce this problem, the more detailed the better; the more information you provide, the faster we will solve the problem. 【注意】请务必认真填写此部分,若不提供完整信息(如只有一两句话的概括),我们将不会回复!
validations:
required: false
- type: textarea
attributes:
label: Enabled plugins
description: Some cases may be related to plugin functionality, so please provide the plugin enablement status.
validations:
required: false

View File

@@ -1,7 +1,7 @@
name: 需求建议
title: "[Feature]: "
labels: ["改进"]
description: "新功能或现有功能优化请使用这个模板不符合类别的issue将被直接关闭"
labels: []
description: "【供中文用户】新功能或现有功能优化请使用这个模板不符合类别的issue将被直接关闭"
body:
- type: dropdown
attributes:

View File

@@ -0,0 +1,21 @@
name: Feature request
title: "[Feature]: "
labels: []
description: "New features or existing feature improvements should use this template; issues that do not match will be closed directly"
body:
- type: dropdown
attributes:
label: This is a?
description: New feature request or existing feature improvement
options:
- New feature
- Existing feature improvement
validations:
required: true
- type: textarea
attributes:
label: Detailed description
description: Detailed description, the more detailed the better
validations:
required: true

View File

@@ -1,7 +1,7 @@
name: 提交新插件
title: "[Plugin]: 请求登记新插件"
labels: ["独立插件"]
description: "本模板供且仅供提交新插件使用"
description: "【供中文用户】本模板供且仅供提交新插件使用"
body:
- type: input
attributes:

View File

@@ -0,0 +1,24 @@
name: Submit a new plugin
title: "[Plugin]: Request to register a new plugin"
labels: ["Independent Plugin"]
description: "This template is only for submitting new plugins"
body:
- type: input
attributes:
label: Plugin name
description: Fill in the name of the plugin
validations:
required: true
- type: textarea
attributes:
label: Plugin code repository address
description: Only support Github
validations:
required: true
- type: textarea
attributes:
label: Plugin description
description: The description of the plugin
validations:
required: true

View File

@@ -1,20 +1,21 @@
## 概述
## 概述 / Overview
实现/解决/优化的内容:
> 请在此部分填写你实现/解决/优化的内容:
> Summary of what you implemented/solved/optimized:
## 检查清单
## 检查清单 / Checklist
### PR 作者完成
### PR 作者完成 / For PR author
*请在方括号间写`x`以打勾
*请在方括号间写`x`以打勾 / Please tick the box with `x`*
- [ ] 阅读仓库[贡献指引](https://github.com/RockChinQ/LangBot/blob/master/CONTRIBUTING.md)了吗?
- [ ] 与项目所有者沟通过了吗?
- [ ] 我确定已自行测试所作的更改,确保功能符合预期。
- [ ] 阅读仓库[贡献指引](https://github.com/RockChinQ/LangBot/blob/master/CONTRIBUTING.md)了吗? / Have you read the [contribution guide](https://github.com/RockChinQ/LangBot/blob/master/CONTRIBUTING.md)?
- [ ] 与项目所有者沟通过了吗? / Have you communicated with the project maintainer?
- [ ] 我确定已自行测试所作的更改,确保功能符合预期。 / I have tested the changes and ensured they work as expected.
### 项目所有者完成
### 项目维护者完成 / For project maintainer
- [ ] 相关 issues 链接了吗?
- [ ] 配置项写好了吗?迁移写好了吗?生效了吗?
- [ ] 依赖requirements.txt 和 core/bootutils/deps.py 了吗
- [ ] 文档编写了吗?
- [ ] 相关 issues 链接了吗? / Have you linked the related issues?
- [ ] 配置项写好了吗?迁移写好了吗?生效了吗? / Have you written the configuration items? Have you written the migration? Has it taken effect?
- [ ] 依赖pyproject.toml 和 core/bootutils/deps.py 了吗 / Have you added the dependencies to pyproject.toml and core/bootutils/deps.py?
- [ ] 文档编写了吗? / Have you written the documentation?

4
.gitignore vendored
View File

@@ -40,4 +40,6 @@ botpy.log*
/libs/wecom_api/test.py
/venv
test.py
/web_ui
/web_ui
.venv/
uv.lock

View File

@@ -1,42 +0,0 @@
line-length = 120
[lint]
ignore = [
"E712", # Comparison to true should be 'if cond is true:' or 'if cond:' (E712)
"F402", # Import `loader` from line 8 shadowed by loop variable
"F403", # * used, unable to detect undefined names
"F405", # may be undefined, or defined from star imports
"E741", # Ambiguous variable name: `l`
"E722", # bare-except
"E721", # type-comparison
"F821", # undefined-all
"FURB113", # repeated-append
"FURB152", # math-constant
"UP007", # non-pep604-annotation
"UP032", # f-string
"UP045", # non-pep604-annotation-optional
"B005", # strip-with-multi-characters
"B006", # mutable-argument-default
"B007", # unused-loop-control-variable
"B026", # star-arg-unpacking-after-keyword-arg
"B903", # class-as-data-structure
"B904", # raise-without-from-inside-except
"B905", # zip-without-explicit-strict
"N806", # non-lowercase-variable-in-function
"N815", # mixed-case-variable-in-class-scope
"PT011", # pytest-raises-too-broad
"SIM102", # collapsible-if
"SIM103", # needless-bool
"SIM105", # suppressible-exception
"SIM107", # return-in-try-except-finally
"SIM108", # if-else-block-instead-of-if-exp
"SIM113", # enumerate-for-loop
"SIM117", # multiple-with-statements
"SIM210", # if-expr-with-true-false
]
[format]
# 5. Use single quotes in `ruff format`.
quote-style = "single"

View File

@@ -5,22 +5,27 @@
### 贡献形式
- 提交PR解决issues中提到的bug或期待的功能
- 提交PR实现您设想的功能请先提出issue与者沟通)
- 优化代码架构,使各个模块的组织更加整洁优雅
- 在issues中提出发现的bug或者期待的功能
- 提交PR实现您设想的功能请先提出issue与项目维护者沟通)
- 为本项目在其他社交平台撰写文章、制作视频等
- 为本项目的衍生项目作出贡献,或开发插件增加功能
### 如何开始
### 沟通语言规范
- 加入本项目交流群,一同探讨项目相关事务
- 解决本项目或衍生项目的issues中亟待解决的问题
- 阅读并完善本项目文档
- 在各个社交媒体撰写本项目教程等
- 在 PR 和 Commit Message 中请使用全英文
- 对于中文用户issue 中可以使用中文
### 代码规范
<hr/>
- 代码中的注解`务必`符合Google风格的规范
- 模块顶部的引入代码请遵循`系统模块``第三方库模块``自定义模块`的顺序进行引入
- `不要`直接引入模块的特定属性,而是引入这个模块,再通过`xxx.yyy`的形式使用属性
- 任何作用域的字段`必须`先声明后使用,并在声明处注明类型提示
## Guidelines
### Contribution
- Submit PRs to solve bugs or features in the issues
- Submit PRs to implement your ideas (Please create an issue first and communicate with the project maintainer)
- Write articles or make videos about this project on other social platforms
- Contribute to the development of derivative projects, or develop plugins to add features
### Spoken Language
- Use English in PRs and Commit Messages
- For English users, you can use English in issues

View File

@@ -6,7 +6,7 @@ COPY web ./web
RUN cd web && npm install && npm run build
FROM python:3.10.13-slim
FROM python:3.12.7-slim
WORKDIR /app
@@ -16,7 +16,8 @@ COPY --from=node /app/web/out ./web/out
RUN apt update \
&& apt install gcc -y \
&& python -m pip install -r requirements.txt \
&& python -m pip install --no-cache-dir uv \
&& uv sync \
&& touch /.dockerenv
CMD [ "python", "main.py" ]
CMD [ "uv", "run", "main.py" ]

View File

@@ -23,11 +23,10 @@
[![QQ Group](https://img.shields.io/badge/%E7%A4%BE%E5%8C%BAQQ%E7%BE%A4-966235608-blue)](https://qm.qq.com/q/JLi38whHum)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/RockChinQ/LangBot)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/RockChinQ/LangBot)](https://github.com/RockChinQ/LangBot/releases/latest)
![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.qchatgpt.rockchin.top%2Fapi%2Fv2%2Fview%2Frealtime%2Fcount_query%3Fminute%3D10080&query=%24.data.count&label=%E4%BD%BF%E7%94%A8%E9%87%8F%EF%BC%887%E6%97%A5%EF%BC%89)
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
[![star](https://gitcode.com/RockChinQ/LangBot/star/badge.svg)](https://gitcode.com/RockChinQ/LangBot)
[简体中文](README.md) / [English](README_EN.md) / [日本語](README_JP.md)
简体中文 / [English](README_EN.md) / [日本語](README_JP.md) / (PR for your language)
</div>
@@ -36,19 +35,23 @@
## ✨ 特性
- 💬 大模型对话、Agent支持多种大模型适配群聊和私聊具有多轮对话、工具调用、多模态能力并深度适配 [Dify](https://dify.ai)。目前支持 QQ、QQ频道、企业微信、个人微信、飞书、Discord、Telegram 等平台。
- 🛠️ 高稳定性、功能完备:原生支持访问控制、限速、敏感词过滤等机制;配置简单,支持多种部署方式。
- 🛠️ 高稳定性、功能完备:原生支持访问控制、限速、敏感词过滤等机制;配置简单,支持多种部署方式。支持多流水线配置,不同机器人用于不同应用场景。
- 🧩 插件扩展、活跃社区:支持事件驱动、组件扩展等插件机制;适配 Anthropic [MCP 协议](https://modelcontextprotocol.io/);目前已有数百个插件。
- 😻 Web 管理面板:支持通过浏览器管理 LangBot 实例,不再需要手动编写配置文件。
## 📦 开始使用
> [!IMPORTANT]
>
> 在您开始任何方式部署之前,请务必阅读[新手指引](https://docs.langbot.app/zh/insight/guide.html)。
#### Docker Compose 部署
适合熟悉 Docker 的用户,查看文档[Docker 部署](https://docs.langbot.app/zh/deploy/langbot/docker.html)。
```bash
git clone https://github.com/RockChinQ/LangBot
cd LangBot
docker compose up -d
```
访问 http://localhost:5300 即可开始使用。
详细文档[Docker 部署](https://docs.langbot.app/zh/deploy/langbot/docker.html)。
#### 宝塔面板部署
@@ -70,6 +73,14 @@
## 📸 效果展示
<img alt="bots" src="https://docs.langbot.app/webui/bot-page.png" width="450px"/>
<img alt="bots" src="https://docs.langbot.app/webui/create-model.png" width="450px"/>
<img alt="bots" src="https://docs.langbot.app/webui/edit-pipeline.png" width="450px"/>
<img alt="bots" src="https://docs.langbot.app/webui/plugin-market.png" width="450px"/>
<img alt="回复效果(带有联网插件)" src="https://docs.langbot.app/QChatGPT-0516.png" width="500px"/>
- WebUI Demo: https://demo.langbot.dev/
@@ -86,7 +97,7 @@
| QQ 官方机器人 | ✅ | QQ 官方机器人,支持频道、私聊、群聊 |
| 企业微信 | ✅ | |
| 企微对外客服 | ✅ | |
| 个人微信 | ✅ | 使用 [Gewechat](https://github.com/Devo919/Gewechat) 接入 |
| 个人微信 | ✅ | |
| 微信公众号 | ✅ | |
| 飞书 | ✅ | |
| 钉钉 | ✅ | |
@@ -108,7 +119,10 @@
| [Anthropic](https://www.anthropic.com/) | ✅ | |
| [xAI](https://x.ai/) | ✅ | |
| [智谱AI](https://open.bigmodel.cn/) | ✅ | |
| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | ✅ | 大模型和 GPU 资源平台 |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | 大模型和 GPU 资源平台 |
| [302.AI](https://share.302.ai/SuTG99) | ✅ | 大模型聚合平台 |
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | |
| [Dify](https://dify.ai) | ✅ | LLMOps 平台 |
| [Ollama](https://ollama.com/) | ✅ | 本地大模型运行平台 |
| [LMStudio](https://lmstudio.ai/) | ✅ | 本地大模型运行平台 |
@@ -140,3 +154,9 @@
<a href="https://github.com/RockChinQ/LangBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=RockChinQ/LangBot" />
</a>
## 😎 保持更新
点击仓库右上角 Star 和 Watch 按钮,获取最新动态。
![star gif](https://docs.langbot.app/star.gif)

View File

@@ -8,8 +8,8 @@
<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://langbot.app">Home</a>
<a href="https://docs.langbot.app/zh/insight/guide.html">Deployment</a>
<a href="https://docs.langbot.app/zh/plugin/plugin-intro.html">Plugin</a>
<a href="https://docs.langbot.app/en/insight/guide.html">Deployment</a>
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">Plugin</a>
<a href="https://github.com/RockChinQ/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">Submit Plugin</a>
<div align="center">
@@ -22,10 +22,9 @@
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/RockChinQ/LangBot)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/RockChinQ/LangBot)](https://github.com/RockChinQ/LangBot/releases/latest)
![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.qchatgpt.rockchin.top%2Fapi%2Fv2%2Fview%2Frealtime%2Fcount_query%3Fminute%3D10080&query=%24.data.count&label=Usage(7days))
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
[简体中文](README.md) / [English](README_EN.md) / [日本語](README_JP.md)
[简体中文](README.md) / English / [日本語](README_JP.md) / (PR for your language)
</div>
@@ -34,31 +33,33 @@
## ✨ Features
- 💬 Chat with LLM / Agent: Supports multiple LLMs, adapt to group chats and private chats; Supports multi-round conversations, tool calls, and multi-modal capabilities. Deeply integrates with [Dify](https://dify.ai). Currently supports QQ, QQ Channel, WeCom, personal WeChat, Lark, DingTalk, Discord, Telegram, etc.
- 🛠️ High Stability, Feature-rich: Native access control, rate limiting, sensitive word filtering, etc. mechanisms; Easy to use, supports multiple deployment methods.
- 🛠️ High Stability, Feature-rich: Native access control, rate limiting, sensitive word filtering, etc. mechanisms; Easy to use, supports multiple deployment methods. Supports multiple pipeline configurations, different bots can be used for different scenarios.
- 🧩 Plugin Extension, Active Community: Support event-driven, component extension, etc. plugin mechanisms; Integrate Anthropic [MCP protocol](https://modelcontextprotocol.io/); Currently has hundreds of plugins.
- 😻 [New] Web UI: Support management LangBot instance through the browser. No need to manually write configuration files.
## 📦 Getting Started
> [!IMPORTANT]
>
> - Before you start deploying in any way, please read the [New User Guide](https://docs.langbot.app/zh/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
Suitable for users familiar with Docker, see the [Docker Deployment](https://docs.langbot.app/zh/deploy/langbot/docker.html) documentation.
```bash
git clone https://github.com/RockChinQ/LangBot
cd LangBot
docker compose up -d
```
Visit http://localhost:5300 to start using it.
Detailed documentation [Docker Deployment](https://docs.langbot.app/en/deploy/langbot/docker.html).
#### One-click Deployment on BTPanel
LangBot has been listed on the BTPanel, if you have installed the BTPanel, you can use the [document](https://docs.langbot.app/zh/deploy/langbot/one-click/bt.html) to use it.
LangBot has been listed on the BTPanel, if you have installed the BTPanel, you can use the [document](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) to use it.
#### Zeabur Cloud Deployment
Community contributed Zeabur template.
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
#### Railway Cloud Deployment
@@ -66,10 +67,18 @@ Community contributed Zeabur template.
#### Other Deployment Methods
Directly use the released version to run, see the [Manual Deployment](https://docs.langbot.app/zh/deploy/langbot/manual.html) documentation.
Directly use the released version to run, see the [Manual Deployment](https://docs.langbot.app/en/deploy/langbot/manual.html) documentation.
## 📸 Demo
<img alt="bots" src="https://docs.langbot.app/webui/bot-page.png" width="400px"/>
<img alt="bots" src="https://docs.langbot.app/webui/create-model.png" width="400px"/>
<img alt="bots" src="https://docs.langbot.app/webui/edit-pipeline.png" width="400px"/>
<img alt="bots" src="https://docs.langbot.app/webui/plugin-market.png" width="400px"/>
<img alt="Reply Effect (with Internet Plugin)" src="https://docs.langbot.app/QChatGPT-0516.png" width="500px"/>
- WebUI Demo: https://demo.langbot.dev/
@@ -86,7 +95,7 @@ Directly use the released version to run, see the [Manual Deployment](https://do
| QQ Official API | ✅ | |
| WeCom | ✅ | |
| WeComCS | ✅ | |
| Personal WeChat | ✅ | Use [Gewechat](https://github.com/Devo919/Gewechat) to access |
| Personal WeChat | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |
| Discord | ✅ | |
@@ -107,8 +116,11 @@ Directly use the released version to run, see the [Manual Deployment](https://do
| [Anthropic](https://www.anthropic.com/) | ✅ | |
| [xAI](https://x.ai/) | ✅ | |
| [Zhipu AI](https://open.bigmodel.cn/) | ✅ | |
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | ✅ | LLM and GPU resource platform |
| [Dify](https://dify.ai) | ✅ | LLMOps platform |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | LLM and GPU resource platform |
| [302.AI](https://share.302.ai/SuTG99) | ✅ | LLM gateway(MaaS) |
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | |
| [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) |
@@ -125,3 +137,9 @@ Thank you for the following [code contributors](https://github.com/RockChinQ/Lan
<a href="https://github.com/RockChinQ/LangBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=RockChinQ/LangBot" />
</a>
## 😎 Stay Ahead
Click the Star and Watch button in the upper right corner of the repository to get the latest updates.
![star gif](https://docs.langbot.app/star.gif)

View File

@@ -8,8 +8,8 @@
<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://langbot.app">ホーム</a>
<a href="https://docs.langbot.app/zh/insight/guide.html">デプロイ</a>
<a href="https://docs.langbot.app/zh/plugin/plugin-intro.html">プラグイン</a>
<a href="https://docs.langbot.app/en/insight/guide.html">デプロイ</a>
<a href="https://docs.langbot.app/en/plugin/plugin-intro.html">プラグイン</a>
<a href="https://github.com/RockChinQ/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">プラグインの提出</a>
<div align="center">
@@ -21,10 +21,9 @@
[![Discord](https://img.shields.io/discord/1335141740050649118?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb)](https://discord.gg/wdNEHETs87)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/RockChinQ/LangBot)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/RockChinQ/LangBot)](https://github.com/RockChinQ/LangBot/releases/latest)
![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.qchatgpt.rockchin.top%2Fapi%2Fv2%2Fview%2Frealtime%2Fcount_query%3Fminute%3D10080&query=%24.data.count&label=Usage(7days))
<img src="https://img.shields.io/badge/python-3.10 ~ 3.13 -blue.svg" alt="python">
[简体中文](README.md) / [English](README_EN.md) / [日本語](README_JP.md)
[简体中文](README_CN.md) / [English](README.md) / [日本語](README_JP.md) / (PR for your language)
</div>
@@ -33,31 +32,33 @@
## ✨ 機能
- 💬 LLM / エージェントとのチャット: 複数のLLMをサポートし、グループチャットとプライベートチャットに対応。マルチラウンドの会話、ツールの呼び出し、マルチモーダル機能をサポート。 [Dify](https://dify.ai) と深く統合。現在、QQ、QQ チャンネル、WeChat、個人 WeChat、Lark、DingTalk、Discord、Telegram など、複数のプラットフォームをサポートしています。
- 🛠️ 高い安定性、豊富な機能: ネイティブのアクセス制御、レート制限、敏感な単語のフィルタリングなどのメカニズムをサポート。使いやすく、複数のデプロイ方法をサポート。
- 🛠️ 高い安定性、豊富な機能: ネイティブのアクセス制御、レート制限、敏感な単語のフィルタリングなどのメカニズムをサポート。使いやすく、複数のデプロイ方法をサポート。複数のパイプライン設定をサポートし、異なるボットを異なる用途に使用できます。
- 🧩 プラグイン拡張、活発なコミュニティ: イベント駆動、コンポーネント拡張などのプラグインメカニズムをサポート。適配 Anthropic [MCP プロトコル](https://modelcontextprotocol.io/);豊富なエコシステム、現在数百のプラグインが存在。
- 😻 Web UI: ブラウザを通じてLangBotインスタンスを管理することをサポート。
## 📦 始め方
> [!IMPORTANT]
>
> - どのデプロイ方法を始める前に、必ず[新規ユーザーガイド](https://docs.langbot.app/zh/insight/guide.html)をお読みください。
> - すべてのドキュメントは中国語で提供されています。近い将来、i18nバージョンを提供する予定です。
> - Read [the auto-generated wiki on DeepWiki](https://deepwiki.com/RockChinQ/LangBot)。
#### Docker Compose デプロイ
Dockerに慣れているユーザーに適しています。[Dockerデプロイ](https://docs.langbot.app/zh/deploy/langbot/docker.html)のドキュメントを参照してください。
```bash
git clone https://github.com/RockChinQ/LangBot
cd LangBot
docker compose up -d
```
http://localhost:5300 にアクセスして使用を開始します。
詳細なドキュメントは[Dockerデプロイ](https://docs.langbot.app/en/deploy/langbot/docker.html)を参照してください。
#### BTPanelでのワンクリックデプロイ
LangBotはBTPanelにリストされています。BTPanelをインストールしている場合は、[ドキュメント](https://docs.langbot.app/zh/deploy/langbot/one-click/bt.html)を使用して使用できます。
LangBotはBTPanelにリストされています。BTPanelをインストールしている場合は、[ドキュメント](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html)を使用して使用できます。
#### Zeaburクラウドデプロイ
コミュニティが提供するZeaburテンプレート。
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
#### Railwayクラウドデプロイ
@@ -65,10 +66,18 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
#### その他のデプロイ方法
リリースバージョンを直接使用して実行します。[手動デプロイ](https://docs.langbot.app/zh/deploy/langbot/manual.html)のドキュメントを参照してください。
リリースバージョンを直接使用して実行します。[手動デプロイ](https://docs.langbot.app/en/deploy/langbot/manual.html)のドキュメントを参照してください。
## 📸 デモ
<img alt="bots" src="https://docs.langbot.app/webui/bot-page.png" width="400px"/>
<img alt="bots" src="https://docs.langbot.app/webui/create-model.png" width="400px"/>
<img alt="bots" src="https://docs.langbot.app/webui/edit-pipeline.png" width="400px"/>
<img alt="bots" src="https://docs.langbot.app/webui/plugin-market.png" width="400px"/>
<img alt="返信効果(インターネットプラグイン付き)" src="https://docs.langbot.app/QChatGPT-0516.png" width="500px"/>
- WebUIデモ: https://demo.langbot.dev/
@@ -85,7 +94,7 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
| QQ公式API | ✅ | |
| WeCom | ✅ | |
| WeComCS | ✅ | |
| 個人WeChat | ✅ | [Gewechat](https://github.com/Devo919/Gewechat)を使用して接続 |
| 個人WeChat | ✅ | |
| Lark | ✅ | |
| DingTalk | ✅ | |
| Discord | ✅ | |
@@ -106,7 +115,10 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
| [Anthropic](https://www.anthropic.com/) | ✅ | |
| [xAI](https://x.ai/) | ✅ | |
| [Zhipu AI](https://open.bigmodel.cn/) | ✅ | |
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | ✅ | 大模型とGPUリソースプラットフォーム |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | 大模型とGPUリソースプラットフォーム |
| [302.AI](https://share.302.ai/SuTG99) | ✅ | LLMゲートウェイ(MaaS) |
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | ✅ | |
| [Dify](https://dify.ai) | ✅ | LLMOpsプラットフォーム |
| [Ollama](https://ollama.com/) | ✅ | ローカルLLM実行プラットフォーム |
| [LMStudio](https://lmstudio.ai/) | ✅ | ローカルLLM実行プラットフォーム |
@@ -124,3 +136,9 @@ LangBot への貢献に対して、以下の [コード貢献者](https://github
<a href="https://github.com/RockChinQ/LangBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=RockChinQ/LangBot" />
</a>
## 😎 最新情報を入手
リポジトリの右上にある Star と Watch ボタンをクリックして、最新の更新を取得してください。
![star gif](https://docs.langbot.app/star.gif)

View File

@@ -4,7 +4,7 @@ metadata:
name: builtin-components
label:
en_US: Builtin Components
zh_CN: 内置组件
zh_Hans: 内置组件
spec:
components:
ComponentTemplate:

View File

@@ -1,4 +1,4 @@
from v1 import client
from v1 import client # type: ignore
import asyncio
@@ -8,19 +8,13 @@ import json
class TestDifyClient:
async def test_chat_messages(self):
cln = client.AsyncDifyServiceClient(
api_key=os.getenv('DIFY_API_KEY'), base_url=os.getenv('DIFY_BASE_URL')
)
cln = client.AsyncDifyServiceClient(api_key=os.getenv('DIFY_API_KEY'), base_url=os.getenv('DIFY_BASE_URL'))
async for chunk in cln.chat_messages(
inputs={}, query='调用工具查看现在几点?', user='test'
):
async for chunk in cln.chat_messages(inputs={}, query='调用工具查看现在几点?', user='test'):
print(json.dumps(chunk, ensure_ascii=False, indent=4))
async def test_upload_file(self):
cln = client.AsyncDifyServiceClient(
api_key=os.getenv('DIFY_API_KEY'), base_url=os.getenv('DIFY_BASE_URL')
)
cln = client.AsyncDifyServiceClient(api_key=os.getenv('DIFY_API_KEY'), base_url=os.getenv('DIFY_BASE_URL'))
file_bytes = open('img.png', 'rb').read()
@@ -32,9 +26,7 @@ class TestDifyClient:
print(json.dumps(resp, ensure_ascii=False, indent=4))
async def test_workflow_run(self):
cln = client.AsyncDifyServiceClient(
api_key=os.getenv('DIFY_API_KEY'), base_url=os.getenv('DIFY_BASE_URL')
)
cln = client.AsyncDifyServiceClient(api_key=os.getenv('DIFY_API_KEY'), base_url=os.getenv('DIFY_BASE_URL'))
# resp = await cln.workflow_run(inputs={}, user="test")
# # print(json.dumps(resp, ensure_ascii=False, indent=4))

View File

@@ -1,10 +1,11 @@
import asyncio
import dingtalk_stream
import dingtalk_stream # type: ignore
from dingtalk_stream import AckMessage
class EchoTextHandler(dingtalk_stream.ChatbotHandler):
def __init__(self, client):
super().__init__() # Call parent class initializer to set up logger
self.msg_id = ''
self.incoming_message = None
self.client = client # 用于更新 DingTalkClient 中的 incoming_message
@@ -26,9 +27,3 @@ class EchoTextHandler(dingtalk_stream.ChatbotHandler):
await asyncio.sleep(0.1) # 异步等待,避免阻塞
return self.incoming_message
async def get_dingtalk_client(client_id, client_secret):
from api import DingTalkClient # 延迟导入,避免循环导入
return DingTalkClient(client_id, client_secret)

View File

@@ -2,7 +2,7 @@ import base64
import json
import time
from typing import Callable
import dingtalk_stream
import dingtalk_stream # type: ignore
from .EchoHandler import EchoTextHandler
from .dingtalkevent import DingTalkEvent
import httpx
@@ -17,6 +17,7 @@ class DingTalkClient:
robot_name: str,
robot_code: str,
markdown_card: bool,
logger: None,
):
"""初始化 WebSocket 连接并自动启动"""
self.credential = dingtalk_stream.Credential(client_id, client_secret)
@@ -34,6 +35,7 @@ class DingTalkClient:
self.robot_code = robot_code
self.access_token_expiry_time = ''
self.markdown_card = markdown_card
self.logger = logger
async def get_access_token(self):
url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken'
@@ -47,8 +49,8 @@ class DingTalkClient:
self.access_token = response_data.get('accessToken')
expires_in = int(response_data.get('expireIn', 7200))
self.access_token_expiry_time = time.time() + expires_in - 60
except Exception as e:
raise Exception(e)
except Exception:
await self.logger.error('failed to get access token in dingtalk')
async def is_token_expired(self):
"""检查token是否过期"""
@@ -73,7 +75,7 @@ class DingTalkClient:
result = response.json()
download_url = result.get('downloadUrl')
else:
raise Exception(f'Error: {response.status_code}, {response.text}')
await self.logger.error(f'failed to get download url: {response.json()}')
if download_url:
return await self.download_url_to_base64(download_url)
@@ -84,10 +86,11 @@ class DingTalkClient:
if response.status_code == 200:
file_bytes = response.content
base64_str = base64.b64encode(file_bytes).decode('utf-8') # 返回字符串格式
return base64_str
mime_type = response.headers.get('Content-Type', 'application/octet-stream')
base64_str = base64.b64encode(file_bytes).decode('utf-8')
return f'data:{mime_type};base64,{base64_str}'
else:
raise Exception('获取文件失败')
await self.logger.error(f'failed to get files: {response.json()}')
async def get_audio_url(self, download_code: str):
if not await self.check_access_token():
@@ -103,7 +106,7 @@ class DingTalkClient:
if download_url:
return await self.download_url_to_base64(download_url)
else:
raise Exception('获取音频失败')
await self.logger.error(f'failed to get audio: {response.json()}')
else:
raise Exception(f'Error: {response.status_code}, {response.text}')
@@ -115,13 +118,20 @@ class DingTalkClient:
if event:
await self._handle_message(event)
async def send_message(self, content: str, incoming_message):
async def send_message(self, content: str, incoming_message, at: bool):
if self.markdown_card:
self.EchoTextHandler.reply_markdown(
title=self.robot_name + '的回答',
text=content,
incoming_message=incoming_message,
)
if at:
self.EchoTextHandler.reply_markdown(
title='@' + incoming_message.sender_nick + ' ' + content,
text='@' + incoming_message.sender_nick + ' ' + content,
incoming_message=incoming_message,
)
else:
self.EchoTextHandler.reply_markdown(
title=content,
text=content,
incoming_message=incoming_message,
)
else:
self.EchoTextHandler.reply_text(content, incoming_message)
@@ -184,7 +194,10 @@ class DingTalkClient:
del copy_message_data['IncomingMessage']
# print("message_data:", json.dumps(copy_message_data, indent=4, ensure_ascii=False))
except Exception:
traceback.print_exc()
if self.logger:
await self.logger.error(f'Error in get_message: {traceback.format_exc()}')
else:
traceback.print_exc()
return message_data
@@ -207,9 +220,12 @@ class DingTalkClient:
}
try:
async with httpx.AsyncClient() as client:
await client.post(url, headers=headers, json=data)
response = await client.post(url, headers=headers, json=data)
if response.status_code == 200:
return
except Exception:
traceback.print_exc()
await self.logger.error(f'failed to send proactive massage to person: {traceback.format_exc()}')
raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}')
async def send_proactive_message_to_group(self, target_id: str, content: str):
if not await self.check_access_token():
@@ -230,9 +246,12 @@ class DingTalkClient:
}
try:
async with httpx.AsyncClient() as client:
await client.post(url, headers=headers, json=data)
response = await client.post(url, headers=headers, json=data)
if response.status_code == 200:
return
except Exception:
traceback.print_exc()
await self.logger.error(f'failed to send proactive massage to group: {traceback.format_exc()}')
raise Exception(f'failed to send proactive massage to group: {traceback.format_exc()}')
async def start(self):
"""启动 WebSocket 连接,监听消息"""

View File

@@ -1,5 +1,5 @@
from typing import Dict, Any, Optional
import dingtalk_stream
import dingtalk_stream # type: ignore
class DingTalkEvent(dict):

View File

@@ -1,7 +1,7 @@
# 微信公众号的加解密算法与企业微信一样,所以直接使用企业微信的加解密算法文件
import time
import traceback
from ..wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt
from libs.wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt
import xml.etree.ElementTree as ET
from quart import Quart, request
import hashlib
@@ -23,7 +23,7 @@ xml_template = """
class OAClient:
def __init__(self, token: str, EncodingAESKey: str, AppID: str, Appsecret: str):
def __init__(self, token: str, EncodingAESKey: str, AppID: str, Appsecret: str, logger: None):
self.token = token
self.aes = EncodingAESKey
self.appid = AppID
@@ -43,6 +43,7 @@ class OAClient:
self.access_token_expiry_time = None
self.msg_id_map = {}
self.generated_content = {}
self.logger = logger
async def handle_callback_request(self):
try:
@@ -54,6 +55,7 @@ class OAClient:
echostr = request.args.get('echostr', '')
msg_signature = request.args.get('msg_signature', '')
if msg_signature is None:
await self.logger.error('msg_signature不在请求体中')
raise Exception('msg_signature不在请求体中')
if request.method == 'GET':
@@ -64,6 +66,7 @@ class OAClient:
if check_signature == signature:
return echostr # 验证成功返回echostr
else:
await self.logger.error('拒绝请求')
raise Exception('拒绝请求')
elif request.method == 'POST':
encryt_msg = await request.data
@@ -72,6 +75,7 @@ class OAClient:
xml_msg = xml_msg.decode('utf-8')
if ret != 0:
await self.logger.error('消息解密失败')
raise Exception('消息解密失败')
message_data = await self.get_message(xml_msg)
@@ -114,6 +118,7 @@ class OAClient:
return ''
except Exception:
await self.logger.error(f'handle_callback_request失败: {traceback.format_exc()}')
traceback.print_exc()
async def get_message(self, xml_msg: str):
@@ -176,6 +181,7 @@ class OAClientForLongerResponse:
AppID: str,
Appsecret: str,
LoadingMessage: str,
logger: None,
):
self.token = token
self.aes = EncodingAESKey
@@ -197,6 +203,7 @@ class OAClientForLongerResponse:
self.loading_message = LoadingMessage
self.msg_queue = {}
self.user_msg_queue = {}
self.logger = logger
async def handle_callback_request(self):
try:
@@ -207,6 +214,7 @@ class OAClientForLongerResponse:
msg_signature = request.args.get('msg_signature', '')
if msg_signature is None:
await self.logger.error('msg_signature不在请求体中')
raise Exception('msg_signature不在请求体中')
if request.method == 'GET':
@@ -221,6 +229,7 @@ class OAClientForLongerResponse:
xml_msg = xml_msg.decode('utf-8')
if ret != 0:
await self.logger.error('消息解密失败')
raise Exception('消息解密失败')
# 解析 XML
@@ -270,6 +279,7 @@ class OAClientForLongerResponse:
return response_xml
except Exception:
await self.logger.error(f'handle_callback_request失败: {traceback.format_exc()}')
traceback.print_exc()
async def get_message(self, xml_msg: str):

View File

@@ -34,7 +34,7 @@ def handle_validation(body: dict, bot_secret: str):
class QQOfficialClient:
def __init__(self, secret: str, token: str, app_id: str):
def __init__(self, secret: str, token: str, app_id: str, logger: None):
self.app = Quart(__name__)
self.app.add_url_rule(
'/callback/command',
@@ -49,6 +49,7 @@ class QQOfficialClient:
self.base_url = 'https://api.sgroup.qq.com'
self.access_token = ''
self.access_token_expiry_time = None
self.logger = logger
async def check_access_token(self):
"""检查access_token是否存在"""
@@ -77,6 +78,7 @@ class QQOfficialClient:
if access_token:
self.access_token = access_token
except Exception as e:
await self.logger.error(f'获取access_token失败: {response_data}')
raise Exception(f'获取access_token失败: {e}')
async def handle_callback_request(self):
@@ -102,7 +104,7 @@ class QQOfficialClient:
return {'code': 0, 'message': 'success'}
except Exception as e:
traceback.print_exc()
await self.logger.error(f"Error in handle_callback_request: {traceback.format_exc()}")
return {'error': str(e)}, 400
async def run_task(self, host: str, port: int, *args, **kwargs):
@@ -166,6 +168,7 @@ class QQOfficialClient:
if not await self.check_access_token():
await self.get_access_token()
url = self.base_url + '/v2/users/' + user_openid + '/messages'
async with httpx.AsyncClient() as client:
headers = {
@@ -178,9 +181,11 @@ class QQOfficialClient:
'msg_id': msg_id,
}
response = await client.post(url, headers=headers, json=data)
response_data = response.json()
if response.status_code == 200:
return
else:
await self.logger.error(f'发送私聊消息失败: {response_data}')
raise ValueError(response)
async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str):
@@ -188,6 +193,7 @@ class QQOfficialClient:
if not await self.check_access_token():
await self.get_access_token()
url = self.base_url + '/v2/groups/' + group_openid + '/messages'
async with httpx.AsyncClient() as client:
headers = {
@@ -203,6 +209,7 @@ class QQOfficialClient:
if response.status_code == 200:
return
else:
await self.logger.error(f"发送群聊消息失败:{response.json()}")
raise Exception(response.read().decode())
async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str):
@@ -210,6 +217,7 @@ class QQOfficialClient:
if not await self.check_access_token():
await self.get_access_token()
url = self.base_url + '/channels/' + channel_id + '/messages'
async with httpx.AsyncClient() as client:
headers = {
@@ -225,12 +233,14 @@ class QQOfficialClient:
if response.status_code == 200:
return True
else:
await self.logger.error(f'发送频道群聊消息失败: {response.json()}')
raise Exception(response)
async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str):
"""发送频道私聊消息"""
if not await self.check_access_token():
await self.get_access_token()
url = self.base_url + '/dms/' + guild_id + '/messages'
async with httpx.AsyncClient() as client:
@@ -247,6 +257,7 @@ class QQOfficialClient:
if response.status_code == 200:
return True
else:
await self.logger.error(f'发送频道私聊消息失败: {response.json()}')
raise Exception(response)
async def is_token_expired(self):

View File

@@ -1,4 +1,5 @@
import json
import traceback
from quart import Quart, jsonify, request
from slack_sdk.web.async_client import AsyncWebClient
from .slackevent import SlackEvent
@@ -7,7 +8,7 @@ from pkg.platform.types import events as platform_events
class SlackClient:
def __init__(self, bot_token: str, signing_secret: str):
def __init__(self, bot_token: str, signing_secret: str, logger: None):
self.bot_token = bot_token
self.signing_secret = signing_secret
self.app = Quart(__name__)
@@ -19,6 +20,7 @@ class SlackClient:
'example': [],
}
self.bot_user_id = None # 避免机器人回复自己的消息
self.logger = logger
async def handle_callback_request(self):
try:
@@ -32,6 +34,7 @@ class SlackClient:
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']:
@@ -49,6 +52,7 @@ class SlackClient:
return jsonify({'status': 'ok'})
except Exception as e:
await self.logger.error(f"Error in handle_callback_request: {traceback.format_exc()}")
raise (e)
async def _handle_message(self, event: SlackEvent):
@@ -78,6 +82,7 @@ class SlackClient:
self.bot_user_id = response['message']['bot_id']
return
except Exception as e:
await self.logger.error(f"Error in send_message: {e}")
raise e
async def send_message_to_one(self, text: str, user_id: str):
@@ -88,6 +93,7 @@ class SlackClient:
return
except Exception as e:
await self.logger.error(f"Error in send_message: {traceback.format_exc()}")
raise e
async def run_task(self, host: str, port: int, *args, **kwargs):

201
libs/wechatpad_api/LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,38 @@
# wechatpad-python
## 此项目时准备对接wechatpadpro 的pythonsdk
## 未完工接口
* 关于好友的接口
* 关于群管理的接口
* 关于下载的接口
* 关于用户的部分接口
* 关于消息的部分接口
* 关于支付的
* 关于朋友圈的
* 关于标签的
* 关于收藏的
* 暂时只写了一部分接口
## 已完工接口
1. 获取普通token
2. 登录二维码(只是返回数据,暂时还未打印二维码)
3. 获取登录状态
4. 唤醒登录
5. 退出登录
6. 获取用户信息
7. 获取用户二维码
8. 上传用户头像
9. 获取设备信息
10. 发送文本消息
11. 发送图片消息
12. 发送语音消息
13. 发送app消息
14. 发送emoji消息
15. 发送名片消息
16. 撤回消息

View File

@@ -0,0 +1 @@
from .client import WeChatPadClient

View File

@@ -0,0 +1,14 @@
from libs.wechatpad_api.util.http_util import async_request, post_json
class ChatRoomApi:
def __init__(self, base_url, token):
self.base_url = base_url
self.token = token
def get_chatroom_member_detail(self, chatroom_name):
params = {
"ChatRoomName": chatroom_name
}
url = self.base_url + '/group/GetChatroomMemberDetail'
return post_json(url, token=self.token, data=params)

View File

@@ -0,0 +1,39 @@
from libs.wechatpad_api.util.http_util import async_request, post_json
import httpx
import base64
class DownloadApi:
def __init__(self, base_url, token):
self.base_url = base_url
self.token = token
def send_download(self, aeskey, file_type, file_url):
json_data = {
"AesKey": aeskey,
"FileType": file_type,
"FileURL": file_url
}
url = self.base_url + "/message/SendCdnDownload"
return post_json(url, token=self.token, data=json_data)
def get_msg_voice(self,buf_id, length, new_msgid):
json_data = {
"Bufid": buf_id,
"Length": length,
"NewMsgId": new_msgid,
"ToUserName": ""
}
url = self.base_url + "/message/GetMsgVoice"
return post_json(url, token=self.token, data=json_data)
async def download_url_to_base64(self, download_url):
async with httpx.AsyncClient() as client:
response = await client.get(download_url)
if response.status_code == 200:
file_bytes = response.content
base64_str = base64.b64encode(file_bytes).decode('utf-8') # 返回字符串格式
return base64_str
else:
raise Exception('获取文件失败')

View File

@@ -0,0 +1,11 @@
from libs.wechatpad_api.util.http_util import post_json,async_request
from typing import List, Dict, Any, Optional
class FriendApi:
"""联系人API类处理所有与联系人相关的操作"""
def __init__(self, base_url: str, token: str):
self.base_url = base_url
self.token = token

View File

@@ -0,0 +1,102 @@
from libs.wechatpad_api.util.http_util import async_request,post_json,get_json
class LoginApi:
def __init__(self, base_url: str, token: str = None, admin_key: str = None):
'''
Args:
base_url: 原始路径
token: token
admin_key: 管理员key
'''
self.base_url = base_url
self.token = token
# self.admin_key = admin_key
def get_token(self, admin_key, day: int=365):
# 获取普通token
url = f"{self.base_url}/admin/GenAuthKey1"
json_data = {
"Count": 1,
"Days": day
}
return post_json(base_url=url, token=admin_key, data=json_data)
def get_login_qr(self, Proxy: str = ""):
'''
Args:
Proxy:异地使用时代理
Returns:json数据
'''
"""
{
"Code": 200,
"Data": {
"Key": "3141312",
"QrCodeUrl": "https://1231x/g6bMlv2dX8zwNbqE6-Zs",
"Txt": "建议返回data=之后内容自定义生成二维码",
"baseResp": {
"ret": 0,
"errMsg": {}
}
},
"Text": ""
}
"""
#获取登录二维码
url = f"{self.base_url}/login/GetLoginQrCodeNew"
check = False
if Proxy != "":
check = True
json_data = {
"Check": check,
"Proxy": Proxy
}
return post_json(base_url=url, token=self.token, data=json_data)
def get_login_status(self):
# 获取登录状态
url = f'{self.base_url}/login/GetLoginStatus'
return get_json(base_url=url, token=self.token)
def logout(self):
# 退出登录
url = f'{self.base_url}/login/LogOut'
return post_json(base_url=url, token=self.token)
def wake_up_login(self, Proxy: str = ""):
# 唤醒登录
url = f'{self.base_url}/login/WakeUpLogin'
check = False
if Proxy != "":
check = True
json_data = {
"Check": check,
"Proxy": ""
}
return post_json(base_url=url, token=self.token, data=json_data)
def login(self,admin_key):
login_status = self.get_login_status()
if login_status["Code"] == 300 and login_status["Text"] == "你已退出微信":
print("token已经失效重新获取")
token_data = self.get_token(admin_key)
self.token = token_data["Data"][0]

View File

@@ -0,0 +1,123 @@
from libs.wechatpad_api.util.http_util import async_request, post_json
class MessageApi:
def __init__(self, base_url, token):
self.base_url = base_url
self.token = token
def post_text(self, to_wxid, content, ats: list= []):
'''
Args:
app_id: 微信id
to_wxid: 发送方的微信id
content: 内容
ats: at
Returns:
'''
url = self.base_url + "/message/SendTextMessage"
"""发送文字消息"""
json_data = {
"MsgItem": [
{
"AtWxIDList": ats,
"ImageContent": "",
"MsgType": 0,
"TextContent": content,
"ToUserName": to_wxid
}
]
}
return post_json(base_url=url, token=self.token, data=json_data)
def post_image(self, to_wxid, img_url, ats: list= []):
"""发送图片消息"""
# 这里好像可以尝试发送多个暂时未测试
json_data = {
"MsgItem": [
{
"AtWxIDList": ats,
"ImageContent": img_url,
"MsgType": 0,
"TextContent": '',
"ToUserName": to_wxid
}
]
}
url = self.base_url + "/message/SendImageMessage"
return post_json(base_url=url, token=self.token, data=json_data)
def post_voice(self, to_wxid, voice_data, voice_forma, voice_duration):
"""发送语音消息"""
json_data = {
"ToUserName": to_wxid,
"VoiceData": voice_data,
"VoiceFormat": voice_forma,
"VoiceSecond": voice_duration
}
url = self.base_url + "/message/SendVoice"
return post_json(base_url=url, token=self.token, data=json_data)
def post_name_card(self, alias, to_wxid, nick_name, name_card_wxid, flag):
"""发送名片消息"""
param = {
"CardAlias": alias,
"CardFlag": flag,
"CardNickName": nick_name,
"CardWxId": name_card_wxid,
"ToUserName": to_wxid
}
url = f"{self.base_url}/message/ShareCardMessage"
return post_json(base_url=url, token=self.token, data=param)
def post_emoji(self, to_wxid, emoji_md5, emoji_size:int=0):
"""发送emoji消息"""
json_data = {
"EmojiList": [
{
"EmojiMd5": emoji_md5,
"EmojiSize": emoji_size,
"ToUserName": to_wxid
}
]
}
url = f"{self.base_url}/message/SendEmojiMessage"
return post_json(base_url=url, token=self.token, data=json_data)
def post_app_msg(self, to_wxid,xml_data, contenttype:int=0):
"""发送appmsg消息"""
json_data = {
"AppList": [
{
"ContentType": contenttype,
"ContentXML": xml_data,
"ToUserName": to_wxid
}
]
}
url = f"{self.base_url}/message/SendAppMessage"
return post_json(base_url=url, token=self.token, data=json_data)
def revoke_msg(self, to_wxid, msg_id, new_msg_id, create_time):
"""撤回消息"""
param = {
"ClientMsgId": msg_id,
"CreateTime": create_time,
"NewMsgId": new_msg_id,
"ToUserName": to_wxid
}
url = f"{self.base_url}/message/RevokeMsg"
return post_json(base_url=url, token=self.token, data=param)

View File

@@ -0,0 +1,37 @@
from libs.wechatpad_api.util.http_util import post_json, async_request, get_json
class UserApi:
def __init__(self, base_url, token):
self.base_url = base_url
self.token = token
def get_profile(self):
"""获取个人资料"""
url = f'{self.base_url}/user/GetProfile'
return get_json(base_url=url, token=self.token)
def get_qr_code(self, recover:bool=True, style:int=8):
"""获取自己的二维码"""
param = {
"Recover": recover,
"Style": style
}
url = f'{self.base_url}/user/GetMyQRCode'
return post_json(base_url=url, token=self.token, data=param)
def get_safety_info(self):
"""获取设备记录"""
url = f'{self.base_url}/equipment/GetSafetyInfo'
return post_json(base_url=url, token=self.token)
async def update_head_img(self, head_img_base64):
"""修改头像"""
param = {
"Base64": head_img_base64
}
url = f'{self.base_url}/user/UploadHeadImage'
return await async_request(base_url=url, token_key=self.token, json=param)

View File

@@ -0,0 +1,98 @@
from libs.wechatpad_api.api.login import LoginApi
from libs.wechatpad_api.api.friend import FriendApi
from libs.wechatpad_api.api.message import MessageApi
from libs.wechatpad_api.api.user import UserApi
from libs.wechatpad_api.api.downloadpai import DownloadApi
from libs.wechatpad_api.api.chatroom import ChatRoomApi
class WeChatPadClient:
def __init__(self, base_url, token, logger=None):
self._login_api = LoginApi(base_url, token)
self._friend_api = FriendApi(base_url, token)
self._message_api = MessageApi(base_url, token)
self._user_api = UserApi(base_url, token)
self._download_api = DownloadApi(base_url, token)
self._chatroom_api = ChatRoomApi(base_url, token)
self.logger = logger
def get_token(self,admin_key, day: int):
'''获取token'''
return self._login_api.get_token(admin_key, day)
def get_login_qr(self, Proxy:str=""):
"""登录二维码"""
return self._login_api.get_login_qr(Proxy=Proxy)
def awaken_login(self, Proxy:str=""):
'''唤醒登录'''
return self._login_api.wake_up_login(Proxy=Proxy)
def log_out(self):
"""退出登录"""
return self._login_api.logout()
def get_login_status(self):
"""获取登录状态"""
return self._login_api.get_login_status()
def send_text_message(self, to_wxid, message, ats: list=[]):
"""发送文本消息"""
return self._message_api.post_text(to_wxid, message, ats)
def send_image_message(self, to_wxid, img_url, ats: list=[]):
"""发送图片消息"""
return self._message_api.post_image(to_wxid, img_url, ats)
def send_voice_message(self, to_wxid, voice_data, voice_forma, voice_duration):
"""发送音频消息"""
return self._message_api.post_voice(to_wxid, voice_data, voice_forma, voice_duration)
def send_app_message(self, to_wxid, app_message, type):
"""发送app消息"""
return self._message_api.post_app_msg(to_wxid, app_message, type)
def send_emoji_message(self, to_wxid, emoji_md5, emoji_size):
"""发送emoji消息"""
return self._message_api.post_emoji(to_wxid,emoji_md5,emoji_size)
def revoke_msg(self, to_wxid, msg_id, new_msg_id, create_time):
"""撤回消息"""
return self._message_api.revoke_msg(to_wxid, msg_id, new_msg_id, create_time)
def get_profile(self):
"""获取用户信息"""
return self._user_api.get_profile()
def get_qr_code(self, recover:bool=True, style:int=8):
"""获取用户二维码"""
return self._user_api.get_qr_code(recover=recover, style=style)
def get_safety_info(self):
"""获取设备信息"""
return self._user_api.get_safety_info()
def update_head_img(self, head_img_base64):
"""上传用户头像"""
return self._user_api.update_head_img(head_img_base64)
def cdn_download(self, aeskey, file_type, file_url):
"""cdn下载"""
return self._download_api.send_download( aeskey, file_type, file_url)
def get_msg_voice(self,buf_id, length, msgid):
"""下载语音"""
return self._download_api.get_msg_voice(buf_id, length, msgid)
async def download_base64(self,url):
return await self._download_api.download_url_to_base64(download_url=url)
def get_chatroom_member_detail(self, chatroom_name):
"""查看群成员详情"""
return self._chatroom_api.get_chatroom_member_detail(chatroom_name)

View File

View File

@@ -0,0 +1,92 @@
import requests
def post_json(base_url, token, data=None):
headers = {
'Content-Type': 'application/json'
}
url = base_url + f'?key={token}'
try:
response = requests.post(url, json=data, headers=headers, timeout=60)
response.raise_for_status()
result = response.json()
if result:
return result
else:
raise RuntimeError(response.text)
except Exception as e:
print(f"http请求失败, url={url}, exception={e}")
raise RuntimeError(str(e))
def get_json(base_url, token):
headers = {
'Content-Type': 'application/json'
}
url = base_url + f'?key={token}'
try:
response = requests.get(url, headers=headers, timeout=60)
response.raise_for_status()
result = response.json()
if result:
return result
else:
raise RuntimeError(response.text)
except Exception as e:
print(f"http请求失败, url={url}, exception={e}")
raise RuntimeError(str(e))
import aiohttp
import asyncio
async def async_request(
base_url: str,
token_key: str,
method: str = 'POST',
params: dict = None,
# headers: dict = None,
data: dict = None,
json: dict = None
):
"""
通用异步请求函数
:param base_url: 请求URL
:param token_key: 请求token
:param method: HTTP方法 (GET, POST, PUT, DELETE等)
:param params: URL查询参数
# :param headers: 请求头
:param data: 表单数据
:param json: JSON数据
:return: 响应文本
"""
headers = {
'Content-Type': 'application/json'
}
url = f"{base_url}?key={token_key}"
async with aiohttp.ClientSession() as session:
async with session.request(
method=method,
url=url,
params=params,
headers=headers,
data=data,
json=json
) as response:
response.raise_for_status() # 如果状态码不是200抛出异常
result = await response.json()
# print(result)
return result
# if result.get('Code') == 200:
#
# return await result
# else:
# raise RuntimeError("请求失败",response.text)

View File

@@ -0,0 +1,31 @@
import qrcode
def print_green(text):
print(f"\033[32m{text}\033[0m")
def print_yellow(text):
print(f"\033[33m{text}\033[0m")
def print_red(text):
print(f"\033[31m{text}\033[0m")
def make_and_print_qr(url):
"""生成并打印二维码
Args:
url: 需要生成二维码的URL字符串
Returns:
None
功能:
1. 在终端打印二维码的ASCII图形
2. 同时提供在线二维码生成链接作为备选
"""
print_green("请扫描下方二维码登录")
qr = qrcode.QRCode()
qr.add_data(url)
qr.make()
qr.print_ascii(invert=True)
print_green(f"也可以访问下方链接获取二维码:\nhttps://api.qrserver.com/v1/create-qr-code/?data={url}")

View File

@@ -3,6 +3,7 @@ from .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
@@ -19,6 +20,7 @@ class WecomClient:
token: str,
EncodingAESKey: str,
contacts_secret: str,
logger: None,
):
self.corpid = corpid
self.secret = secret
@@ -28,8 +30,8 @@ class WecomClient:
self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin'
self.access_token = ''
self.secret_for_contacts = contacts_secret
self.logger = logger
self.app = Quart(__name__)
self.wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid)
self.app.add_url_rule(
'/callback/command',
'handle_callback',
@@ -55,6 +57,7 @@ class WecomClient:
if 'access_token' in data:
return data['access_token']
else:
await self.logger.error(f"获取accesstoken失败:{response.json()}")
raise Exception(f'未获取access token: {data}')
async def get_users(self):
@@ -126,6 +129,7 @@ class WecomClient:
response = await client.post(url, json=params)
data = response.json()
except Exception as e:
await self.logger.error(f"发送图片失败:{data}")
raise Exception('Failed to send image: ' + str(e))
# 企业微信错误码40014和42001代表accesstoken问题
@@ -160,6 +164,7 @@ class WecomClient:
self.access_token = await self.get_access_token(self.secret)
return await self.send_private_msg(user_id, agent_id, content)
if data['errcode'] != 0:
await self.logger.error(f"发送消息失败:{data}")
raise Exception('Failed to send message: ' + str(data))
async def handle_callback_request(self):
@@ -171,18 +176,22 @@ class WecomClient:
timestamp = request.args.get('timestamp')
nonce = request.args.get('nonce')
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid)
if request.method == 'GET':
echostr = request.args.get('echostr')
ret, reply_echo_str = self.wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
if ret != 0:
await self.logger.error("验证失败")
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)
ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
if ret != 0:
await self.logger.error("消息解密失败")
raise Exception(f'消息解密失败,错误码: {ret}')
# 解析消息并处理
message_data = await self.get_message(xml_msg)
@@ -193,6 +202,7 @@ class WecomClient:
return 'success'
except Exception as e:
await self.logger.error(f"Error in handle_callback_request: {traceback.format_exc()}")
return f'Error processing request: {str(e)}', 400
async def run_task(self, host: str, port: int, *args, **kwargs):
@@ -291,6 +301,7 @@ class WecomClient:
except binascii.Error as e:
raise ValueError(f'Invalid base64 string: {str(e)}')
else:
await self.logger.error("Image对象出错")
raise ValueError('image对象出错')
# 设置 multipart/form-data 格式的文件
@@ -314,6 +325,7 @@ class WecomClient:
self.access_token = await self.get_access_token(self.secret)
media_id = await self.upload_to_work(image)
if data.get('errcode', 0) != 0:
await self.logger.error(f"上传图片失败:{data}")
raise Exception('failed to upload file')
media_id = data.get('media_id')

View File

@@ -13,7 +13,7 @@ import aiofiles
class WecomCSClient:
def __init__(self, corpid: str, secret: str, token: str, EncodingAESKey: str):
def __init__(self, corpid: str, secret: str, token: str, EncodingAESKey: str, logger: None):
self.corpid = corpid
self.secret = secret
self.access_token_for_contacts = ''
@@ -21,6 +21,7 @@ class WecomCSClient:
self.aes = EncodingAESKey
self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin'
self.access_token = ''
self.logger = logger
self.app = Quart(__name__)
self.app.add_url_rule(
'/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST']
@@ -186,6 +187,7 @@ class WecomCSClient:
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:
await self.logger.error(f"发送消息失败:{data}")
raise Exception('Failed to send message')
return data
@@ -224,7 +226,10 @@ class WecomCSClient:
return 'success'
except Exception as e:
traceback.print_exc()
if self.logger:
await self.logger.error(f"Error in handle_callback_request: {traceback.format_exc()}")
else:
traceback.print_exc()
return f'Error processing request: {str(e)}', 400
async def run_task(self, host: str, port: int, *args, **kwargs):

22
main.py
View File

@@ -1,4 +1,5 @@
import asyncio
import argparse
# LangBot 终端启动入口
# 在此层级解决依赖项检查。
# LangBot/main.py
@@ -10,12 +11,16 @@ asciiart = r"""
|____\__,_|_||_\__, |___/\___/\__|
|___/
⭐️开源地址: https://github.com/RockChinQ/LangBot
📖文档地址: https://docs.langbot.app
⭐️ Open Source 开源地址: https://github.com/RockChinQ/LangBot
📖 Documentation 文档地址: https://docs.langbot.app
"""
async def main_entry(loop: asyncio.AbstractEventLoop):
parser = argparse.ArgumentParser(description='LangBot')
parser.add_argument('--skip-plugin-deps-check', action='store_true', help='跳过插件依赖项检查', default=False)
args = parser.parse_args()
print(asciiart)
import sys
@@ -28,14 +33,19 @@ async def main_entry(loop: asyncio.AbstractEventLoop):
if missing_deps:
print('以下依赖包未安装,将自动安装,请完成后重启程序:')
print(
'These dependencies are missing, they will be installed automatically, please restart the program after completion:'
)
for dep in missing_deps:
print('-', dep)
await deps.install_deps(missing_deps)
print('已自动安装缺失的依赖包,请重启程序。')
print('The missing dependencies have been installed automatically, please restart the program.')
sys.exit(0)
# check plugin deps
await deps.precheck_plugin_deps()
if not args.skip_plugin_deps_check:
await deps.precheck_plugin_deps()
# 检查pydantic版本如果没有 pydantic.v1则把 pydantic 映射为 v1
import pydantic.version
@@ -53,6 +63,7 @@ async def main_entry(loop: asyncio.AbstractEventLoop):
if generated_files:
print('以下文件不存在,已自动生成:')
print('Following files do not exist and have been automatically generated:')
for file in generated_files:
print('-', file)
@@ -69,9 +80,10 @@ if __name__ == '__main__':
if sys.version_info < (3, 10, 1):
print('需要 Python 3.10.1 及以上版本,当前 Python 版本为:', sys.version)
input('按任意键退出...')
print('Your Python version is not supported. Please exit the program by pressing any key.')
exit(1)
# 检查本目录是否有main.py且包含LangBot字符串
# Check if the current directory is the LangBot project root directory
invalid_pwd = False
if not os.path.exists('main.py'):
@@ -84,6 +96,8 @@ if __name__ == '__main__':
if invalid_pwd:
print('请在 LangBot 项目根目录下以命令形式运行此程序。')
input('按任意键退出...')
print('Please run this program in the LangBot project root directory in command form.')
print('Press any key to exit...')
exit(1)
loop = asyncio.new_event_loop()

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
import quart
import mimetypes
from .. import group
@group.group_class('files', '/api/v1/files')
class FilesRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/image/<image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
async def _(image_key: str) -> quart.Response:
if not await self.ap.storage_mgr.storage_provider.exists(image_key):
return quart.Response(status=404)
image_bytes = await self.ap.storage_mgr.storage_provider.load(image_key)
mime_type = mimetypes.guess_type(image_key)[0]
if mime_type is None:
mime_type = 'image/jpeg'
return quart.Response(image_bytes, mimetype=mime_type)

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import quart
from .. import group
from ... import group
@group.group_class('pipelines', '/api/v1/pipelines')

View File

@@ -0,0 +1,79 @@
import quart
from ... import group
@group.group_class('webchat', '/api/v1/pipelines/<pipeline_uuid>/chat')
class WebChatDebugRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/send', methods=['POST'])
async def send_message(pipeline_uuid: str) -> str:
"""发送调试消息到流水线"""
try:
data = await quart.request.get_json()
session_type = data.get('session_type', 'person')
message_chain_obj = data.get('message', [])
if not message_chain_obj:
return self.http_status(400, -1, 'message is required')
if session_type not in ['person', 'group']:
return self.http_status(400, -1, 'session_type must be person or group')
webchat_adapter = self.ap.platform_mgr.webchat_proxy_bot.adapter
if not webchat_adapter:
return self.http_status(404, -1, 'WebChat adapter not found')
result = await webchat_adapter.send_webchat_message(pipeline_uuid, session_type, message_chain_obj)
return self.success(
data={
'message': result,
}
)
except Exception as e:
return self.http_status(500, -1, f'Internal server error: {str(e)}')
@self.route('/messages/<session_type>', methods=['GET'])
async def get_messages(pipeline_uuid: str, session_type: str) -> str:
"""获取调试消息历史"""
try:
if session_type not in ['person', 'group']:
return self.http_status(400, -1, 'session_type must be person or group')
webchat_adapter = self.ap.platform_mgr.webchat_proxy_bot.adapter
if not webchat_adapter:
return self.http_status(404, -1, 'WebChat adapter not found')
messages = webchat_adapter.get_webchat_messages(pipeline_uuid, session_type)
return self.success(data={'messages': messages})
except Exception as e:
return self.http_status(500, -1, f'Internal server error: {str(e)}')
@self.route('/reset/<session_type>', methods=['POST'])
async def reset_session(session_type: str) -> str:
"""重置调试会话"""
try:
if session_type not in ['person', 'group']:
return self.http_status(400, -1, 'session_type must be person or group')
webchat_adapter = None
for bot in self.ap.platform_mgr.bots:
if hasattr(bot.adapter, '__class__') and bot.adapter.__class__.__name__ == 'WebChatAdapter':
webchat_adapter = bot.adapter
break
if not webchat_adapter:
return self.http_status(404, -1, 'WebChat adapter not found')
webchat_adapter.reset_debug_session(session_type)
return self.success(data={'message': 'Session reset successfully'})
except Exception as e:
return self.http_status(500, -1, f'Internal server error: {str(e)}')

View File

@@ -29,3 +29,16 @@ class BotsRouterGroup(group.RouterGroup):
elif quart.request.method == 'DELETE':
await self.ap.bot_service.delete_bot(bot_uuid)
return self.success()
@self.route('/<bot_uuid>/logs', methods=['POST'])
async def _(bot_uuid: str) -> str:
json_data = await quart.request.json
from_index = json_data.get('from_index', -1)
max_count = json_data.get('max_count', 10)
logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count)
return self.success(
data={
'logs': logs,
'total_count': total_count,
}
)

View File

@@ -36,3 +36,11 @@ class LLMModelsRouterGroup(group.RouterGroup):
await self.ap.model_service.delete_llm_model(model_uuid)
return self.success()
@self.route('/<model_uuid>/test', methods=['POST'])
async def _(model_uuid: str) -> str:
json_data = await quart.request.json
await self.ap.model_service.test_llm_model(model_uuid, json_data)
return self.success()

View File

@@ -1,5 +1,6 @@
import quart
import argon2
import asyncio
from .. import group
@@ -40,3 +41,29 @@ class UserRouterGroup(group.RouterGroup):
token = await self.ap.user_service.generate_jwt_token(user_email)
return self.success(data={'token': token})
@self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
json_data = await quart.request.json
user_email = json_data['user']
recovery_key = json_data['recovery_key']
new_password = json_data['new_password']
# hard sleep 3s for security
await asyncio.sleep(3)
if not await self.ap.user_service.is_initialized():
return self.http_status(400, -1, 'system not initialized')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(400, -1, 'user not found')
if recovery_key != self.ap.instance_config.data['system']['recovery_key']:
return self.http_status(403, -1, 'invalid recovery key')
await self.ap.user_service.reset_password(user_email, new_password)
return self.success(data={'user': user_email})

View File

@@ -13,10 +13,12 @@ from . import groups
from . import group
from .groups import provider as groups_provider
from .groups import platform as groups_platform
from .groups import pipelines as groups_pipelines
importutil.import_modules_in_pkg(groups)
importutil.import_modules_in_pkg(groups_provider)
importutil.import_modules_in_pkg(groups_platform)
importutil.import_modules_in_pkg(groups_pipelines)
class HTTPController:
@@ -107,4 +109,8 @@ class HTTPController:
elif path.endswith('.txt'):
mimetype = 'text/plain'
return await quart.send_from_directory(frontend_path, path, mimetype=mimetype)
response = await quart.send_from_directory(frontend_path, path, mimetype=mimetype)
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import uuid
import sqlalchemy
import typing
from ....core import app
from ....entity.persistence import bot as persistence_bot
@@ -92,9 +93,25 @@ class BotService:
if runtime_bot.enable:
await runtime_bot.run()
# update all conversation that use this bot
for session in self.ap.sess_mgr.session_list:
if session.using_conversation is not None and session.using_conversation.bot_uuid == bot_uuid:
session.using_conversation = None
async def delete_bot(self, bot_uuid: str) -> None:
"""删除机器人"""
await self.ap.platform_mgr.remove_bot(bot_uuid)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid)
)
async def list_event_logs(
self, bot_uuid: str, from_index: int, max_count: int
) -> typing.Tuple[list[dict], int, int, int]:
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
if runtime_bot is None:
raise Exception('Bot not found')
logs, total_count = await runtime_bot.logger.get_logs(from_index, max_count)
return [log.to_json() for log in logs], total_count

View File

@@ -6,6 +6,8 @@ import sqlalchemy
from ....core import app
from ....entity.persistence import model as persistence_model
from ....entity.persistence import pipeline as persistence_pipeline
from ....provider.modelmgr import requester as model_requester
from ....provider import entities as llm_entities
class ModelsService:
@@ -78,3 +80,26 @@ class ModelsService:
)
await self.ap.model_mgr.remove_llm_model(model_uuid)
async def test_llm_model(self, model_uuid: str, model_data: dict) -> None:
runtime_llm_model: model_requester.RuntimeLLMModel | None = None
if model_uuid != '_':
for model in self.ap.model_mgr.llm_models:
if model.model_entity.uuid == model_uuid:
runtime_llm_model = model
break
if runtime_llm_model is None:
raise Exception('model not found')
else:
runtime_llm_model = await self.ap.model_mgr.init_runtime_llm_model(model_data)
await runtime_llm_model.requester.invoke_llm(
query=None,
model=runtime_llm_model,
messages=[llm_entities.Message(role='user', content='Hello, world!')],
funcs=[],
extra_args={},
)

View File

@@ -98,15 +98,13 @@ class PipelineService:
if 'name' in pipeline_data:
from ....entity.persistence import bot as persistence_bot
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bot.Bot).where(
persistence_bot.Bot.use_pipeline_uuid == pipeline_uuid
)
sqlalchemy.select(persistence_bot.Bot).where(persistence_bot.Bot.use_pipeline_uuid == pipeline_uuid)
)
bots = result.all()
for bot in bots:
bot_data = {'use_pipeline_name': pipeline_data['name']}
await self.ap.bot_service.update_bot(bot.uuid, bot_data)
@@ -114,6 +112,11 @@ class PipelineService:
await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)
await self.ap.pipeline_mgr.load_pipeline(pipeline)
# update all conversation that use this pipeline
for session in self.ap.sess_mgr.session_list:
if session.using_conversation is not None and session.using_conversation.pipeline_uuid == pipeline_uuid:
session.using_conversation = None
async def delete_pipeline(self, pipeline_uuid: str) -> None:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_pipeline.LegacyPipeline).where(

View File

@@ -73,3 +73,12 @@ class UserService:
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
return jwt.decode(token, jwt_secret, algorithms=['HS256'])['user']
async def reset_password(self, user_email: str, new_password: str) -> None:
ph = argon2.PasswordHasher()
hashed_password = ph.hash(new_password)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
)

View File

@@ -23,7 +23,8 @@ from ..api.http.service import model as model_service
from ..api.http.service import pipeline as pipeline_service
from ..api.http.service import bot as bot_service
from ..discover import engine as discover_engine
from ..utils import logcache, ip
from ..storage import mgr as storagemgr
from ..utils import logcache
from . import taskmgr
from . import entities as core_entities
@@ -96,6 +97,8 @@ class Application:
log_cache: logcache.LogCache = None
storage_mgr: storagemgr.StorageMgr = None
# ========= HTTP Services =========
user_service: user_service.UserService = None
@@ -158,28 +161,24 @@ class Application:
"""打印访问 webui 的提示"""
if not os.path.exists(os.path.join('.', 'web/out')):
self.logger.warning('WebUI 文件缺失,请根据文档获取https://docs.langbot.app/webui/intro.html')
self.logger.warning('WebUI 文件缺失,请根据文档部署https://docs.langbot.app/zh')
self.logger.warning(
'WebUI files are missing, please deploy according to the documentation: https://docs.langbot.app/en'
)
return
host_ip = '127.0.0.1'
public_ip = await ip.get_myip()
port = self.instance_config.data['api']['port']
tips = f"""
=======================================
您可通过以下方式访问管理面板
Access WebUI / 访问管理面板
🏠 本地地址:http://{host_ip}:{port}/
🌐 公网地址http://{public_ip}:{port}/
🏠 Local Address: http://{host_ip}:{port}/
🌐 Public Address: http://<Your Public IP>:{port}/
📌 如果您在容器中运行此程序,请确保容器的 {port} 端口已对外暴露
🔗 若要使用公网地址访问,请阅读以下须知
1. 公网地址仅供参考,请以您的主机公网 IP 为准;
2. 要使用公网地址访问,请确保您的主机具有公网 IP并且系统防火墙已放行 {port} 端口;
🤯 WebUI 仍处于 Beta 测试阶段,如有问题或建议请反馈到 https://github.com/RockChinQ/LangBot/issues
📌 Running this program in a container? Please ensure that the {port} port is exposed
=======================================
""".strip()
for line in tips.split('\n'):

View File

@@ -1,4 +1,4 @@
from __future__ import print_function
from __future__ import annotations
import traceback
import asyncio

View File

@@ -74,5 +74,5 @@ async def precheck_plugin_deps():
if 'requirements.txt' in os.listdir(subdir):
pkgmgr.install_requirements(
os.path.join(subdir, 'requirements.txt'),
extra_params=['-q', '-q', '-q'],
extra_params=[],
)

View File

@@ -137,6 +137,12 @@ class Conversation(pydantic.BaseModel):
use_funcs: typing.Optional[list[tools_entities.LLMFunction]]
pipeline_uuid: str
"""流水线UUID。"""
bot_uuid: str
"""机器人UUID。"""
uuid: typing.Optional[str] = None
"""该对话的 uuid在创建时不会自动生成。而是当使用 Dify API 等由外部管理对话信息的服务时,用于绑定外部的会话。具体如何使用,取决于 Runner。"""

View File

@@ -17,6 +17,7 @@ from ...api.http.service import model as model_service
from ...api.http.service import pipeline as pipeline_service
from ...api.http.service import bot as bot_service
from ...discover import engine as discover_engine
from ...storage import mgr as storagemgr
from ...utils import logcache
from .. import taskmgr
@@ -50,6 +51,10 @@ class BuildAppStage(stage.BootingStage):
log_cache = logcache.LogCache()
ap.log_cache = log_cache
storage_mgr_inst = storagemgr.StorageMgr(ap)
await storage_mgr_inst.initialize()
ap.storage_mgr = storage_mgr_inst
persistence_mgr_inst = persistencemgr.PersistenceManager(ap)
ap.persistence_mgr = persistence_mgr_inst
await persistence_mgr_inst.initialize()

View File

@@ -15,3 +15,10 @@ class GenKeysStage(stage.BootingStage):
if not ap.instance_config.data['system']['jwt']['secret']:
ap.instance_config.data['system']['jwt']['secret'] = secrets.token_hex(16)
await ap.instance_config.dump_config()
if 'recovery_key' not in ap.instance_config.data['system']:
ap.instance_config.data['system']['recovery_key'] = ''
if not ap.instance_config.data['system']['recovery_key']:
ap.instance_config.data['system']['recovery_key'] = secrets.token_hex(3).upper()
await ap.instance_config.dump_config()

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
import asyncio
from .. import stage, app, note
from ...utils import importutil
@@ -20,11 +22,15 @@ class ShowNotesStage(stage.BootingStage):
try:
note_inst = note_cls(ap)
if await note_inst.need_show():
async for ret in note_inst.yield_note():
if not ret:
continue
msg, level = ret
if msg:
ap.logger.log(level, msg)
async def ayield_note(note_inst: note.LaunchNote):
async for ret in note_inst.yield_note():
if not ret:
continue
msg, level = ret
if msg:
ap.logger.log(level, msg)
asyncio.create_task(ayield_note(note_inst))
except Exception:
continue

View File

@@ -15,7 +15,7 @@ class I18nString(pydantic.BaseModel):
en_US: str
"""英文"""
zh_CN: typing.Optional[str] = None
zh_Hans: typing.Optional[str] = None
"""中文"""
ja_JP: typing.Optional[str] = None
@@ -26,8 +26,8 @@ class I18nString(pydantic.BaseModel):
dic = {}
if self.en_US is not None:
dic['en_US'] = self.en_US
if self.zh_CN is not None:
dic['zh_CN'] = self.zh_CN
if self.zh_Hans is not None:
dic['zh_Hans'] = self.zh_Hans
if self.ja_JP is not None:
dic['ja_JP'] = self.ja_JP
return dic

View File

View File

@@ -0,0 +1,9 @@
from __future__ import annotations
class AdapterNotFoundError(Exception):
def __init__(self, adapter_name: str):
self.adapter_name = adapter_name
def __str__(self):
return f'Adapter {self.adapter_name} not found'

View File

@@ -0,0 +1,9 @@
from __future__ import annotations
class RequesterNotFoundError(Exception):
def __init__(self, requester_name: str):
self.requester_name = requester_name
def __str__(self):
return f'Requester {self.requester_name} not found'

View File

@@ -66,13 +66,15 @@ class PersistenceManager:
# write default pipeline
result = await self.execute_async(sqlalchemy.select(pipeline.LegacyPipeline))
default_pipeline_uuid = None
if result.first() is None:
self.ap.logger.info('Creating default pipeline...')
pipeline_config = json.load(open('templates/default-pipeline-config.json', 'r', encoding='utf-8'))
default_pipeline_uuid = str(uuid.uuid4())
pipeline_data = {
'uuid': str(uuid.uuid4()),
'uuid': default_pipeline_uuid,
'for_version': self.ap.ver_mgr.get_current_version(),
'stages': pipeline_service.default_stage_order,
'is_default': True,
@@ -82,6 +84,7 @@ class PersistenceManager:
}
await self.execute_async(sqlalchemy.insert(pipeline.LegacyPipeline).values(pipeline_data))
# =================================
# run migrations

View File

@@ -0,0 +1,41 @@
from .. import migration
import sqlalchemy
from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(2)
class DBMigrateCombineQuoteMsgConfig(migration.DBMigration):
"""引用消息合并配置"""
async def upgrade(self):
"""升级"""
# read all pipelines
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
for pipeline in pipelines:
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
config = serialized_pipeline['config']
if 'misc' not in config['trigger']:
config['trigger']['misc'] = {}
if 'combine-quote-message' not in config['trigger']['misc']:
config['trigger']['misc']['combine-quote-message'] = False
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
.values(
{
'config': config,
'for_version': self.ap.ver_mgr.get_current_version(),
}
)
)
async def downgrade(self):
"""降级"""
pass

View File

@@ -0,0 +1,49 @@
from .. import migration
import sqlalchemy
from ...entity.persistence import pipeline as persistence_pipeline
@migration.migration_class(3)
class DBMigrateN8nConfig(migration.DBMigration):
"""N8n配置"""
async def upgrade(self):
"""升级"""
# read all pipelines
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
for pipeline in pipelines:
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
config = serialized_pipeline['config']
if 'n8n-service-api' not in config['ai']:
config['ai']['n8n-service-api'] = {
'webhook-url': 'http://your-n8n-webhook-url',
'auth-type': 'none',
'basic-username': '',
'basic-password': '',
'jwt-secret': '',
'jwt-algorithm': 'HS256',
'header-name': '',
'header-value': '',
'timeout': 120,
'output-key': 'response',
}
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
.values(
{
'config': config,
'for_version': self.ap.ver_mgr.get_current_version(),
}
)
)
async def downgrade(self):
"""降级"""
pass

View File

@@ -66,6 +66,8 @@ class ContentFilterStage(stage.PipelineStage):
if query.pipeline_config['safety']['content-filter']['scope'] == 'output-msg':
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
if not message.strip():
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
else:
for filter in self.filter_chain:
if filter_entities.EnableStage.PRE in filter.enable_stages:

View File

@@ -51,11 +51,10 @@ class Controller:
# find pipeline
# Here firstly find the bot, then find the pipeline, in case the bot adapter's config is not the latest one.
# Like aiocqhttp, once a client is connected, even the adapter was updated and restarted, the existing client connection will not be affected.
bot = await self.ap.platform_mgr.get_bot_by_uuid(selected_query.bot_uuid)
if bot:
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(
bot.bot_entity.use_pipeline_uuid
)
pipeline_uuid = selected_query.pipeline_uuid
if pipeline_uuid:
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
if pipeline:
await pipeline.run(selected_query)

View File

@@ -20,9 +20,9 @@ class Text2ImageStrategy(strategy_model.LongTextStrategy):
pass
@functools.lru_cache(maxsize=16)
def get_font(self, query: core_entities.Query):
def get_font(self, font_path: str):
return ImageFont.truetype(
query.pipeline_config['output']['long-text-processing']['font-path'],
font_path,
32,
encoding='utf-8',
)
@@ -146,7 +146,9 @@ class Text2ImageStrategy(strategy_model.LongTextStrategy):
self.ap.logger.debug('lines: {}, text_width: {}'.format(lines, text_width))
for line in lines:
# 如果长了就分割
line_width = self.get_font(query).getlength(line)
line_width = self.get_font(query.pipeline_config['output']['long-text-processing']['font-path']).getlength(
line
)
self.ap.logger.debug('line_width: {}'.format(line_width))
if line_width < text_width:
final_lines.append(line)
@@ -167,7 +169,9 @@ class Text2ImageStrategy(strategy_model.LongTextStrategy):
final_lines.append(rest_text[:point])
rest_text = rest_text[point:]
line_width = self.text_render_font.getlength(rest_text)
line_width = self.get_font(
query.pipeline_config['output']['long-text-processing']['font-path']
).getlength(rest_text)
if line_width < text_width:
final_lines.append(rest_text)
break
@@ -187,7 +191,7 @@ class Text2ImageStrategy(strategy_model.LongTextStrategy):
(offset_x, offset_y + 35 * line_number),
final_line,
fill=(0, 0, 0),
font=self.text_render_font,
font=self.get_font(query.pipeline_config['output']['long-text-processing']['font-path']),
)
# 遍历此行,检查是否有emoji
idx_in_line = 0

View File

@@ -35,6 +35,7 @@ class QueryPool:
message_event: platform_events.MessageEvent,
message_chain: platform_message.MessageChain,
adapter: msadapter.MessagePlatformAdapter,
pipeline_uuid: typing.Optional[str] = None,
) -> entities.Query:
async with self.condition:
query = entities.Query(
@@ -48,6 +49,7 @@ class QueryPool:
resp_messages=[],
resp_message_chain=[],
adapter=adapter,
pipeline_uuid=pipeline_uuid,
)
self.queries.append(query)
self.query_id_counter += 1

View File

@@ -45,6 +45,8 @@ class PreProcessor(stage.PipelineStage):
query,
session,
query.pipeline_config['ai']['local-agent']['prompt'],
query.pipeline_uuid,
query.bot_uuid,
)
conversation.use_llm_model = llm_model
@@ -58,7 +60,7 @@ class PreProcessor(stage.PipelineStage):
if selected_runner == 'local-agent':
query.use_funcs = (
conversation.use_funcs if query.use_llm_model.model_entity.abilities.__contains__('tool_call') else None
conversation.use_funcs if query.use_llm_model.model_entity.abilities.__contains__('func_call') else None
)
query.variables = {
@@ -81,6 +83,7 @@ class PreProcessor(stage.PipelineStage):
content_list = []
plain_text = ''
qoute_msg = query.pipeline_config['trigger'].get('misc', '').get('combine-quote-message')
for me in query.message_chain:
if isinstance(me, platform_message.Plain):
@@ -92,6 +95,16 @@ class PreProcessor(stage.PipelineStage):
):
if me.base64 is not None:
content_list.append(llm_entities.ContentElement.from_image_base64(me.base64))
elif isinstance(me, platform_message.Quote) and qoute_msg:
for msg in me.origin:
if isinstance(msg, platform_message.Plain):
content_list.append(llm_entities.ContentElement.from_text(msg.text))
elif isinstance(msg, platform_message.Image):
if selected_runner != 'local-agent' or query.use_llm_model.model_entity.abilities.__contains__(
'vision'
):
if msg.base64 is not None:
content_list.append(llm_entities.ContentElement.from_image_base64(msg.base64))
query.variables['user_message_text'] = plain_text

View File

@@ -7,11 +7,11 @@ from ..core import app, entities as core_entities
from . import entities
preregistered_stages: dict[str, PipelineStage] = {}
preregistered_stages: dict[str, type[PipelineStage]] = {}
def stage_class(name: str):
def decorator(cls):
def stage_class(name: str) -> typing.Callable[[type[PipelineStage]], type[PipelineStage]]:
def decorator(cls: type[PipelineStage]) -> type[PipelineStage]:
preregistered_stages[name] = cls
return cls

View File

@@ -8,6 +8,7 @@ import abc
from ..core import app
from .types import message as platform_message
from .types import events as platform_events
from .logger import EventLogger
class MessagePlatformAdapter(metaclass=abc.ABCMeta):
@@ -22,7 +23,9 @@ class MessagePlatformAdapter(metaclass=abc.ABCMeta):
ap: app.Application
def __init__(self, config: dict, ap: app.Application):
logger: EventLogger
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
"""初始化适配器
Args:
@@ -31,6 +34,7 @@ class MessagePlatformAdapter(metaclass=abc.ABCMeta):
"""
self.config = config
self.ap = ap
self.logger = logger
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
"""主动发送消息

View File

@@ -4,7 +4,7 @@ metadata:
name: MessagePlatformAdapter
label:
en_US: Message Platform Adapter
zh_CN: 消息平台适配器模板类
zh_Hans: 消息平台适配器模板类
spec:
type:
- python

View File

@@ -5,17 +5,20 @@ import asyncio
import traceback
import sqlalchemy
# FriendMessage, Image, MessageChain, Plain
from . import adapter as msadapter
from ..core import app, entities as core_entities, taskmgr
from .types import events as platform_events
from .types import events as platform_events, message as platform_message
from ..discover import engine
from ..entity.persistence import bot as persistence_bot
from ..entity.errors import platform as platform_errors
from .logger import EventLogger
# 处理 3.4 移除了 YiriMirai 之后,插件的兼容性问题
from . import types as mirai
@@ -37,23 +40,37 @@ class RuntimeBot:
task_context: taskmgr.TaskContext
logger: EventLogger
def __init__(
self,
ap: app.Application,
bot_entity: persistence_bot.Bot,
adapter: msadapter.MessagePlatformAdapter,
logger: EventLogger,
):
self.ap = ap
self.bot_entity = bot_entity
self.enable = bot_entity.enable
self.adapter = adapter
self.task_context = taskmgr.TaskContext()
self.logger = logger
async def initialize(self):
async def on_friend_message(
event: platform_events.FriendMessage,
adapter: msadapter.MessagePlatformAdapter,
):
image_components = [
component for component in event.message_chain if isinstance(component, platform_message.Image)
]
await self.logger.info(
f'{event.message_chain}',
images=image_components,
message_session_id=f'person_{event.sender.id}',
)
await self.ap.query_pool.add_query(
bot_uuid=self.bot_entity.uuid,
launcher_type=core_entities.LauncherTypes.PERSON,
@@ -62,12 +79,23 @@ class RuntimeBot:
message_event=event,
message_chain=event.message_chain,
adapter=adapter,
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
)
async def on_group_message(
event: platform_events.GroupMessage,
adapter: msadapter.MessagePlatformAdapter,
):
image_components = [
component for component in event.message_chain if isinstance(component, platform_message.Image)
]
await self.logger.info(
f'{event.message_chain}',
images=image_components,
message_session_id=f'group_{event.group.id}',
)
await self.ap.query_pool.add_query(
bot_uuid=self.bot_entity.uuid,
launcher_type=core_entities.LauncherTypes.GROUP,
@@ -76,6 +104,7 @@ class RuntimeBot:
message_event=event,
message_chain=event.message_chain,
adapter=adapter,
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
)
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
@@ -92,10 +121,7 @@ class RuntimeBot:
self.task_context.set_current_action('Exited.')
return
self.task_context.set_current_action('Exited with error.')
self.task_context.log(f'平台适配器运行出错: {e}')
self.task_context.log(f'Traceback: {traceback.format_exc()}')
self.ap.logger.error(f'平台适配器运行出错: {e}')
self.ap.logger.debug(f'Traceback: {traceback.format_exc()}')
await self.logger.error(f'平台适配器运行出错:\n{e}\n{traceback.format_exc()}')
self.task_wrapper = self.ap.task_mgr.create_task(
exception_wrapper(),
@@ -121,6 +147,8 @@ class PlatformManager:
bots: list[RuntimeBot]
webchat_proxy_bot: RuntimeBot
adapter_components: list[engine.Component]
adapter_dict: dict[str, type[msadapter.MessagePlatformAdapter]]
@@ -138,6 +166,31 @@ class PlatformManager:
adapter_dict[component.metadata.name] = component.get_python_component_class()
self.adapter_dict = adapter_dict
webchat_adapter_class = self.adapter_dict['webchat']
# initialize webchat adapter
webchat_logger = EventLogger(name='webchat-adapter', ap=self.ap)
webchat_adapter_inst = webchat_adapter_class(
{},
self.ap,
webchat_logger,
)
self.webchat_proxy_bot = RuntimeBot(
ap=self.ap,
bot_entity=persistence_bot.Bot(
uuid='webchat-proxy-bot',
name='WebChat',
description='',
adapter='webchat',
adapter_config={},
enable=True,
),
adapter=webchat_adapter_inst,
logger=webchat_logger,
)
await self.webchat_proxy_bot.initialize()
await self.load_bots_from_db()
def get_running_adapters(self) -> list[msadapter.MessagePlatformAdapter]:
@@ -154,7 +207,12 @@ class PlatformManager:
for bot in bots:
# load all bots here, enable or disable will be handled in runtime
await self.load_bot(bot)
try:
await self.load_bot(bot)
except platform_errors.AdapterNotFoundError as e:
self.ap.logger.warning(f'Adapter {e.adapter_name} not found, skipping bot {bot.uuid}')
except Exception as e:
self.ap.logger.error(f'Failed to load bot {bot.uuid}: {e}\n{traceback.format_exc()}')
async def load_bot(
self,
@@ -166,9 +224,18 @@ class PlatformManager:
elif isinstance(bot_entity, dict):
bot_entity = persistence_bot.Bot(**bot_entity)
adapter_inst = self.adapter_dict[bot_entity.adapter](bot_entity.adapter_config, self.ap)
logger = EventLogger(name=f'platform-adapter-{bot_entity.name}', ap=self.ap)
runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst)
if bot_entity.adapter not in self.adapter_dict:
raise platform_errors.AdapterNotFoundError(bot_entity.adapter)
adapter_inst = self.adapter_dict[bot_entity.adapter](
bot_entity.adapter_config,
self.ap,
logger,
)
runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst, logger=logger)
await runtime_bot.initialize()
@@ -191,7 +258,9 @@ class PlatformManager:
return
def get_available_adapters_info(self) -> list[dict]:
return [component.to_plain_dict() for component in self.adapter_components]
return [
component.to_plain_dict() for component in self.adapter_components if component.metadata.name != 'webchat'
]
def get_available_adapter_info_by_name(self, name: str) -> dict | None:
for component in self.adapter_components:
@@ -244,6 +313,8 @@ class PlatformManager:
async def run(self):
# This method will only be called when the application launching
await self.webchat_proxy_bot.run()
for bot in self.bots:
if bot.enable:
await bot.run()

233
pkg/platform/logger.py Normal file
View File

@@ -0,0 +1,233 @@
from __future__ import annotations
import typing
import mimetypes
import time
import enum
import pydantic
import traceback
import uuid
from ..core import app
from .types import message as platform_message
class EventLogLevel(enum.Enum):
"""日志级别"""
DEBUG = 'debug'
INFO = 'info'
WARNING = 'warning'
ERROR = 'error'
class EventLog(pydantic.BaseModel):
seq_id: int
"""日志序号"""
timestamp: int
"""日志时间戳"""
level: EventLogLevel
"""日志级别"""
text: str
"""日志文本"""
images: typing.Optional[list[str]] = None
"""日志图片 URL 列表,需要通过 /api/v1/image/{uuid} 获取图片"""
message_session_id: typing.Optional[str] = None
"""消息会话ID仅收发消息事件有值"""
def to_json(self) -> dict:
return {
'seq_id': self.seq_id,
'timestamp': self.timestamp,
'level': self.level.value,
'text': self.text,
'images': self.images,
'message_session_id': self.message_session_id,
}
MAX_LOG_COUNT = 200
DELETE_COUNT_PER_TIME = 50
class EventLogger:
"""used for logging bot events"""
ap: app.Application
seq_id_inc: int
logs: list[EventLog]
def __init__(
self,
name: str,
ap: app.Application,
):
self.name = name
self.ap = ap
self.logs = []
self.seq_id_inc = 0
async def get_logs(self, from_seq_id: int, max_count: int) -> typing.Tuple[list[EventLog], int]:
"""
获取日志,从 from_seq_id 开始获取 max_count 条历史日志
Args:
from_seq_id: 起始序号,-1 表示末尾
max_count: 最大数量
Returns:
Tuple[list[EventLog], int]: 日志列表,日志总数
"""
if len(self.logs) == 0:
return [], 0
if from_seq_id <= -1:
from_seq_id = self.logs[-1].seq_id
min_seq_id_in_logs = self.logs[0].seq_id
max_seq_id_in_logs = self.logs[-1].seq_id
if from_seq_id < min_seq_id_in_logs: # 需要的整个范围都已经被删除
return [], len(self.logs)
if (
from_seq_id > max_seq_id_in_logs and from_seq_id - max_count > max_seq_id_in_logs
): # 需要的整个范围都还没生成
return [], len(self.logs)
end_index = 1
for i, log in enumerate(self.logs):
if log.seq_id >= from_seq_id:
end_index = i + 1
break
start_index = max(0, end_index - max_count)
if max_count > 0:
return self.logs[start_index:end_index], len(self.logs)
else:
return [], len(self.logs)
async def _truncate_logs(self):
if len(self.logs) > MAX_LOG_COUNT:
for i in range(DELETE_COUNT_PER_TIME):
for image_key in self.logs[i].images:
await self.ap.storage_mgr.storage_provider.delete(image_key)
self.logs = self.logs[DELETE_COUNT_PER_TIME:]
async def _add_log(
self,
level: EventLogLevel,
text: str,
images: typing.Optional[list[platform_message.Image]] = None,
message_session_id: typing.Optional[str] = None,
no_throw: bool = True,
):
try:
image_keys = []
if images is None:
images = []
if message_session_id is None:
message_session_id = ''
if not isinstance(message_session_id, str):
message_session_id = str(message_session_id)
for img in images:
img_bytes, mime_type = await img.get_bytes()
extension = mimetypes.guess_extension(mime_type)
if extension is None:
extension = '.jpg'
image_key = f'{message_session_id}-{uuid.uuid4()}{extension}'
await self.ap.storage_mgr.storage_provider.save(image_key, img_bytes)
image_keys.append(image_key)
self.logs.append(
EventLog(
seq_id=self.seq_id_inc,
timestamp=int(time.time()),
level=level,
text=text,
images=image_keys,
message_session_id=message_session_id,
)
)
self.seq_id_inc += 1
await self._truncate_logs()
except Exception as e:
if not no_throw:
raise e
else:
traceback.print_exc()
async def info(
self,
text: str,
images: typing.Optional[list[platform_message.Image]] = None,
message_session_id: typing.Optional[str] = None,
no_throw: bool = True,
):
await self._add_log(
level=EventLogLevel.INFO,
text=text,
images=images,
message_session_id=message_session_id,
no_throw=no_throw,
)
async def debug(
self,
text: str,
images: typing.Optional[list[platform_message.Image]] = None,
message_session_id: typing.Optional[str] = None,
no_throw: bool = True,
):
await self._add_log(
level=EventLogLevel.DEBUG,
text=text,
images=images,
message_session_id=message_session_id,
no_throw=no_throw,
)
async def warning(
self,
text: str,
images: typing.Optional[list[platform_message.Image]] = None,
message_session_id: typing.Optional[str] = None,
no_throw: bool = True,
):
await self._add_log(
level=EventLogLevel.WARNING,
text=text,
images=images,
message_session_id=message_session_id,
no_throw=no_throw,
)
async def error(
self,
text: str,
images: typing.Optional[list[platform_message.Image]] = None,
message_session_id: typing.Optional[str] = None,
no_throw: bool = True,
):
await self._add_log(
level=EventLogLevel.ERROR,
text=text,
images=images,
message_session_id=message_session_id,
no_throw=no_throw,
)

View File

@@ -12,9 +12,11 @@ from ..types import message as platform_message
from ..types import events as platform_events
from ..types import entities as platform_entities
from ...utils import image
from ..logger import EventLogger
class AiocqhttpMessageConverter(adapter.MessageConverter):
@staticmethod
async def yiri2target(
message_chain: platform_message.MessageChain,
@@ -59,6 +61,16 @@ class AiocqhttpMessageConverter(adapter.MessageConverter):
elif type(msg) is platform_message.Forward:
for node in msg.node_list:
msg_list.extend((await AiocqhttpMessageConverter.yiri2target(node.message_chain))[0])
elif isinstance(msg, platform_message.File):
msg_list.append({"type":"file", "data":{'file': msg.url, "name": msg.name}})
elif isinstance(msg, platform_message.Face):
if msg.face_type=='face':
msg_list.append(aiocqhttp.MessageSegment.face(msg.face_id))
elif msg.face_type=='rps':
msg_list.append(aiocqhttp.MessageSegment.rps())
elif msg.face_type=='dice':
msg_list.append(aiocqhttp.MessageSegment.dice())
else:
msg_list.append(aiocqhttp.MessageSegment.text(str(msg)))
@@ -66,14 +78,77 @@ class AiocqhttpMessageConverter(adapter.MessageConverter):
return msg_list, msg_id, msg_time
@staticmethod
async def target2yiri(message: str, message_id: int = -1):
async def target2yiri(message: str, message_id: int = -1,bot=None):
print(message)
message = aiocqhttp.Message(message)
def get_face_name(face_id):
face_code_dict = {
"2": '好色',
"4": "得意", "5": "流泪", "8": "", "9": "大哭", "10": "尴尬", "12": "调皮", "14": "微笑", "16": "",
"21": "可爱",
"23": "傲慢", "24": "饥饿", "25": "", "26": "惊恐", "27": "流汗", "28": "憨笑", "29": "悠闲",
"30": "奋斗",
"32": "疑问", "33": "", "34": "", "38": "敲打", "39": "再见", "41": "发抖", "42": "爱情",
"43": "跳跳",
"49": "拥抱", "53": "蛋糕", "60": "咖啡", "63": "玫瑰", "66": "爱心", "74": "太阳", "75": "月亮",
"76": "",
"78": "握手", "79": "胜利", "85": "飞吻", "89": "西瓜", "96": "冷汗", "97": "擦汗", "98": "抠鼻",
"99": "鼓掌",
"100": "糗大了", "101": "坏笑", "102": "左哼哼", "103": "右哼哼", "104": "哈欠", "106": "委屈",
"109": "左亲亲",
"111": "可怜", "116": "示爱", "118": "抱拳", "120": "拳头", "122": "爱你", "123": "NO", "124": "OK",
"125": "转圈",
"129": "挥手", "144": "喝彩", "147": "棒棒糖", "171": "", "173": "泪奔", "174": "无奈", "175": "卖萌",
"176": "小纠结", "179": "doge", "180": "惊喜", "181": "骚扰", "182": "笑哭", "183": "我最美",
"201": "点赞",
"203": "托脸", "212": "托腮", "214": "啵啵", "219": "蹭一蹭", "222": "抱抱", "227": "拍手",
"232": "佛系",
"240": "喷脸", "243": "甩头", "246": "加油抱抱", "262": "脑阔疼", "264": "捂脸", "265": "辣眼睛",
"266": "哦哟",
"267": "头秃", "268": "问号脸", "269": "暗中观察", "270": "emm", "271": "吃瓜", "272": "呵呵哒",
"273": "我酸了",
"277": "汪汪", "278": "", "281": "无眼笑", "282": "敬礼", "284": "面无表情", "285": "摸鱼",
"287": "",
"289": "睁眼", "290": "敲开心", "293": "摸锦鲤", "294": "期待", "297": "拜谢", "298": "元宝",
"299": "牛啊",
"305": "右亲亲", "306": "牛气冲天", "307": "喵喵", "314": "仔细分析", "315": "加油", "318": "崇拜",
"319": "比心",
"320": "庆祝", "322": "拒绝", "324": "吃糖", "326": "生气"
}
return face_code_dict.get(face_id,'')
async def process_message_data(msg_data, reply_list):
if msg_data["type"] == "image":
image_base64, image_format = await image.qq_image_url_to_base64(msg_data["data"]['url'])
reply_list.append(
platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))
elif msg_data["type"] == "text":
reply_list.append(platform_message.Plain(text=msg_data["data"]["text"]))
elif msg_data["type"] == "forward": # 这里来应该传入转发消息组暂时传入qoute
for forward_msg_datas in msg_data["data"]["content"]:
for forward_msg_data in forward_msg_datas["message"]:
await process_message_data(forward_msg_data, reply_list)
elif msg_data["type"] == "at":
if msg_data["data"]['qq'] == 'all':
reply_list.append(platform_message.AtAll())
else:
reply_list.append(
platform_message.At(
target=msg_data["data"]['qq'],
)
)
yiri_msg_list = []
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
for msg in message:
reply_list = []
if msg.type == 'at':
if msg.data['qq'] == 'all':
yiri_msg_list.append(platform_message.AtAll())
@@ -86,22 +161,82 @@ class AiocqhttpMessageConverter(adapter.MessageConverter):
elif msg.type == 'text':
yiri_msg_list.append(platform_message.Plain(text=msg.data['text']))
elif msg.type == 'image':
image_base64, image_format = await image.qq_image_url_to_base64(msg.data['url'])
yiri_msg_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))
emoji_id = msg.data.get("emoji_package_id", None)
if emoji_id:
face_id = emoji_id
face_name = msg.data.get("summary", '')
image_msg = platform_message.Face(face_id=face_id, face_name=face_name)
else:
image_base64, image_format = await image.qq_image_url_to_base64(msg.data['url'])
image_msg = platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}')
yiri_msg_list.append(image_msg)
elif msg.type == 'forward':
# 暂时不太合理
# msg_datas = await bot.get_msg(message_id=message_id)
# print(msg_datas)
# for msg_data in msg_datas["message"]:
# await process_message_data(msg_data, yiri_msg_list)
pass
elif msg.type == 'reply': # 此处处理引用消息传入Qoute
msg_datas = await bot.get_msg(message_id=msg.data["id"])
for msg_data in msg_datas["message"]:
await process_message_data(msg_data, reply_list)
reply_msg = platform_message.Quote(message_id=msg.data["id"],sender_id=msg_datas["user_id"],origin=reply_list)
yiri_msg_list.append(reply_msg)
elif msg.type == 'file':
# file_name = msg.data['file']
file_id = msg.data['file_id']
file_data = await bot.get_file(file_id=file_id)
file_name = file_data.get('file_name')
file_path = file_data.get('file')
file_url = file_data.get('file_url')
file_size = file_data.get('file_size')
yiri_msg_list.append(platform_message.File(id=file_id, name=file_name,url=file_url,size=file_size))
elif msg.type == 'face':
face_id = msg.data['id']
face_name = msg.data['raw']['faceText']
if not face_name:
face_name = get_face_name(face_id)
yiri_msg_list.append(platform_message.Face(face_id=int(face_id),face_name=face_name.replace('/','')))
elif msg.type == 'rps':
face_id = msg.data['result']
yiri_msg_list.append(platform_message.Face(face_type="rps",face_id=int(face_id),face_name='猜拳'))
elif msg.type == 'dice':
face_id = msg.data['result']
yiri_msg_list.append(platform_message.Face(face_type='dice',face_id=int(face_id),face_name='骰子'))
chain = platform_message.MessageChain(yiri_msg_list)
return chain
class AiocqhttpEventConverter(adapter.EventConverter):
@staticmethod
async def yiri2target(event: platform_events.MessageEvent, bot_account_id: int):
return event.source_platform_object
@staticmethod
async def target2yiri(event: aiocqhttp.Event):
yiri_chain = await AiocqhttpMessageConverter.target2yiri(event.message, event.message_id)
async def target2yiri(event: aiocqhttp.Event,bot=None):
yiri_chain = await AiocqhttpMessageConverter.target2yiri(event.message, event.message_id,bot)
if event.message_type == 'group':
permission = 'MEMBER'
@@ -156,8 +291,11 @@ class AiocqhttpAdapter(adapter.MessagePlatformAdapter):
ap: app.Application
def __init__(self, config: dict, ap: app.Application):
on_websocket_connection_event_cache: typing.List[typing.Callable[[aiocqhttp.Event], None]] = []
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
self.config = config
self.logger = logger
async def shutdown_trigger_placeholder():
while True:
@@ -166,6 +304,7 @@ class AiocqhttpAdapter(adapter.MessagePlatformAdapter):
self.config['shutdown_trigger'] = shutdown_trigger_placeholder
self.ap = ap
self.on_websocket_connection_event_cache = []
if 'access-token' in config:
self.bot = aiocqhttp.CQHttp(access_token=config['access-token'])
@@ -177,6 +316,7 @@ class AiocqhttpAdapter(adapter.MessagePlatformAdapter):
aiocq_msg = (await AiocqhttpMessageConverter.yiri2target(message))[0]
if target_type == 'group':
await self.bot.send_group_msg(group_id=int(target_id), message=aiocq_msg)
elif target_type == 'person':
await self.bot.send_private_msg(user_id=int(target_id), message=aiocq_msg)
@@ -205,14 +345,28 @@ class AiocqhttpAdapter(adapter.MessagePlatformAdapter):
async def on_message(event: aiocqhttp.Event):
self.bot_account_id = event.self_id
try:
return await callback(await self.event_converter.target2yiri(event), self)
return await callback(await self.event_converter.target2yiri(event,self.bot), self)
except Exception:
await self.logger.error(f'Error in on_message: {traceback.format_exc()}')
traceback.print_exc()
if event_type == platform_events.GroupMessage:
self.bot.on_message('group')(on_message)
# self.bot.on_notice()(on_message)
elif event_type == platform_events.FriendMessage:
self.bot.on_message('private')(on_message)
# self.bot.on_notice()(on_message)
# print(event_type)
async def on_websocket_connection(event: aiocqhttp.Event):
for event in self.on_websocket_connection_event_cache:
if event.self_id == event.self_id and event.time == event.time:
return
self.on_websocket_connection_event_cache.append(event)
await self.logger.info(f'WebSocket connection established, bot id: {event.self_id}')
self.bot.on_websocket_connection(on_websocket_connection)
def unregister_listener(
self,

View File

@@ -4,40 +4,40 @@ metadata:
name: aiocqhttp
label:
en_US: OneBot v11
zh_CN: OneBot v11
zh_Hans: OneBot v11
description:
en_US: OneBot v11 Adapter
zh_CN: OneBot v11 适配器,请查看文档了解使用方式
zh_Hans: OneBot v11 适配器,请查看文档了解使用方式
icon: onebot.png
spec:
config:
- name: host
label:
en_US: Host
zh_CN: 主机
zh_Hans: 主机
description:
en_US: The host that OneBot v11 listens on for reverse WebSocket connections. Unless you know what you're doing, use 0.0.0.0
zh_CN: OneBot v11 监听的反向 WS 主机,除非你知道自己在做什么,否则请写 0.0.0.0
zh_Hans: OneBot v11 监听的反向 WS 主机,除非你知道自己在做什么,否则请写 0.0.0.0
type: string
required: true
default: 0.0.0.0
- name: port
label:
en_US: Port
zh_CN: 端口
zh_Hans: 端口
description:
en_US: Port
zh_CN: 监听的端口
zh_Hans: 监听的端口
type: integer
required: true
default: 2280
- name: access-token
label:
en_US: Access Token
zh_CN: 访问令牌
zh_Hans: 访问令牌
description:
en_US: Custom connection token for the protocol endpoint. If the protocol endpoint is not set, don't fill it
zh_CN: 自定义的与协议端的连接令牌,若协议端未设置,则不填
zh_Hans: 自定义的与协议端的连接令牌,若协议端未设置,则不填
type: string
required: false
default: ""

View File

@@ -9,14 +9,20 @@ from ..types import events as platform_events
from ..types import entities as platform_entities
from libs.dingtalk_api.api import DingTalkClient
import datetime
from ..logger import EventLogger
class DingTalkMessageConverter(adapter.MessageConverter):
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain):
content = ''
at = False
for msg in message_chain:
if type(msg) is platform_message.At:
at = True
if type(msg) is platform_message.Plain:
return msg.text
content += msg.text
return content, at
@staticmethod
async def target2yiri(event: DingTalkEvent, bot_name: str):
@@ -94,9 +100,10 @@ class DingTalkAdapter(adapter.MessagePlatformAdapter):
event_converter: DingTalkEventConverter = DingTalkEventConverter()
config: dict
def __init__(self, config: dict, ap: app.Application):
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
self.config = config
self.ap = ap
self.logger = logger
required_keys = [
'client_id',
'client_secret',
@@ -115,6 +122,7 @@ class DingTalkAdapter(adapter.MessagePlatformAdapter):
robot_name=config['robot_name'],
robot_code=config['robot_code'],
markdown_card=config['markdown_card'],
logger=self.logger,
)
async def reply_message(
@@ -128,8 +136,8 @@ class DingTalkAdapter(adapter.MessagePlatformAdapter):
)
incoming_message = event.incoming_message
content = await DingTalkMessageConverter.yiri2target(message)
await self.bot.send_message(content, incoming_message)
content, at = await DingTalkMessageConverter.yiri2target(message)
await self.bot.send_message(content, incoming_message, at)
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
content = await DingTalkMessageConverter.yiri2target(message)
@@ -150,7 +158,7 @@ class DingTalkAdapter(adapter.MessagePlatformAdapter):
self,
)
except Exception:
traceback.print_exc()
await self.logger.error(f'Error in dingtalk callback: {traceback.format_exc()}')
if event_type == platform_events.FriendMessage:
self.bot.on_message('FriendMessage')(on_message)

View File

@@ -4,45 +4,45 @@ metadata:
name: dingtalk
label:
en_US: DingTalk
zh_CN: 钉钉
zh_Hans: 钉钉
description:
en_US: DingTalk Adapter
zh_CN: 钉钉适配器,请查看文档了解使用方式
zh_Hans: 钉钉适配器,请查看文档了解使用方式
icon: dingtalk.svg
spec:
config:
- name: client_id
label:
en_US: Client ID
zh_CN: 客户端ID
zh_Hans: 客户端ID
type: string
required: true
default: ""
- name: client_secret
label:
en_US: Client Secret
zh_CN: 客户端密钥
zh_Hans: 客户端密钥
type: string
required: true
default: ""
- name: robot_code
label:
en_US: Robot Code
zh_CN: 机器人代码
zh_Hans: 机器人代码
type: string
required: true
default: ""
- name: robot_name
label:
en_US: Robot Name
zh_CN: 机器人名称
zh_Hans: 机器人名称
type: string
required: true
default: ""
- name: markdown_card
label:
en_US: Markdown Card
zh_CN: 是否使用 Markdown 卡片
zh_Hans: 是否使用 Markdown 卡片
type: boolean
required: false
default: true

View File

@@ -8,15 +8,593 @@ import base64
import uuid
import os
import datetime
import io
import asyncio
from enum import Enum
import aiohttp
from .. import adapter
from ...core import app
from ..logger import EventLogger
from ..types import message as platform_message
from ..types import events as platform_events
from ..types import entities as platform_entities
# 语音功能相关异常定义
class VoiceConnectionError(Exception):
"""语音连接基础异常"""
def __init__(self, message: str, error_code: str = None, guild_id: int = None):
super().__init__(message)
self.error_code = error_code
self.guild_id = guild_id
self.timestamp = datetime.datetime.now()
class VoicePermissionError(VoiceConnectionError):
"""语音权限异常"""
def __init__(self, message: str, missing_permissions: list = None, user_id: int = None, channel_id: int = None):
super().__init__(message, "PERMISSION_ERROR")
self.missing_permissions = missing_permissions or []
self.user_id = user_id
self.channel_id = channel_id
class VoiceNetworkError(VoiceConnectionError):
"""语音网络异常"""
def __init__(self, message: str, retry_count: int = 0):
super().__init__(message, "NETWORK_ERROR")
self.retry_count = retry_count
self.last_attempt = datetime.datetime.now()
class VoiceConnectionStatus(Enum):
"""语音连接状态枚举"""
IDLE = "idle"
CONNECTING = "connecting"
CONNECTED = "connected"
PLAYING = "playing"
RECONNECTING = "reconnecting"
FAILED = "failed"
class VoiceConnectionInfo:
"""
语音连接信息类
用于存储和管理单个语音连接的详细信息,包括连接状态、时间戳、
频道信息等。提供连接信息的标准化数据结构。
@author: @ydzat
@version: 1.0
@since: 2025-07-04
"""
def __init__(self, guild_id: int, channel_id: int, channel_name: str = None):
"""
初始化语音连接信息
@author: @ydzat
Args:
guild_id (int): 服务器ID
channel_id (int): 语音频道ID
channel_name (str, optional): 语音频道名称
"""
self.guild_id = guild_id
self.channel_id = channel_id
self.channel_name = channel_name or f"Channel-{channel_id}"
self.connected = False
self.connection_time: datetime.datetime = None
self.last_activity = datetime.datetime.now()
self.status = VoiceConnectionStatus.IDLE
self.user_count = 0
self.latency = 0.0
self.connection_health = "unknown"
self.voice_client = None
def update_status(self, status: VoiceConnectionStatus):
"""
更新连接状态
@author: @ydzat
Args:
status (VoiceConnectionStatus): 新的连接状态
"""
self.status = status
self.last_activity = datetime.datetime.now()
if status == VoiceConnectionStatus.CONNECTED:
self.connected = True
if self.connection_time is None:
self.connection_time = datetime.datetime.now()
elif status in [VoiceConnectionStatus.IDLE, VoiceConnectionStatus.FAILED]:
self.connected = False
self.connection_time = None
self.voice_client = None
def to_dict(self) -> dict:
"""
转换为字典格式
@author: @ydzat
Returns:
dict: 连接信息的字典表示
"""
return {
"guild_id": self.guild_id,
"channel_id": self.channel_id,
"channel_name": self.channel_name,
"connected": self.connected,
"connection_time": self.connection_time.isoformat() if self.connection_time else None,
"last_activity": self.last_activity.isoformat(),
"status": self.status.value,
"user_count": self.user_count,
"latency": self.latency,
"connection_health": self.connection_health
}
class VoiceConnectionManager:
"""
语音连接管理器
负责管理多个服务器的语音连接,提供连接建立、断开、状态查询等功能。
采用单例模式确保全局只有一个连接管理器实例。
@author: @ydzat
@version: 1.0
@since: 2025-07-04
"""
def __init__(self, bot: discord.Client, logger: EventLogger):
"""
初始化语音连接管理器
@author: @ydzat
Args:
bot (discord.Client): Discord 客户端实例
logger (EventLogger): 事件日志记录器
"""
self.bot = bot
self.logger = logger
self.connections: typing.Dict[int, VoiceConnectionInfo] = {}
self._connection_lock = asyncio.Lock()
self._cleanup_task = None
self._monitoring_enabled = True
async def join_voice_channel(self, guild_id: int, channel_id: int,
user_id: int = None) -> discord.VoiceClient:
"""
加入语音频道
验证用户权限和频道状态后,建立到指定语音频道的连接。
支持连接复用和自动重连机制。
@author: @ydzat
Args:
guild_id (int): 服务器ID
channel_id (int): 语音频道ID
user_id (int, optional): 请求用户ID用于权限验证
Returns:
discord.VoiceClient: 语音客户端实例
Raises:
VoicePermissionError: 权限不足时抛出
VoiceNetworkError: 网络连接失败时抛出
VoiceConnectionError: 其他连接错误时抛出
"""
async with self._connection_lock:
try:
# 获取服务器和频道对象
guild = self.bot.get_guild(guild_id)
if not guild:
raise VoiceConnectionError(
f"无法找到服务器 {guild_id}",
"GUILD_NOT_FOUND",
guild_id
)
channel = guild.get_channel(channel_id)
if not channel or not isinstance(channel, discord.VoiceChannel):
raise VoiceConnectionError(
f"无法找到语音频道 {channel_id}",
"CHANNEL_NOT_FOUND",
guild_id
)
# 验证用户是否在语音频道中如果提供了用户ID
if user_id:
await self._validate_user_in_channel(guild, channel, user_id)
# 验证机器人权限
await self._validate_bot_permissions(channel)
# 检查是否已有连接
if guild_id in self.connections:
existing_conn = self.connections[guild_id]
if existing_conn.connected and existing_conn.voice_client:
if existing_conn.channel_id == channel_id:
# 已连接到相同频道,返回现有连接
await self.logger.info(f"复用现有语音连接: {guild.name} -> {channel.name}")
return existing_conn.voice_client
else:
# 连接到不同频道,先断开旧连接
await self._disconnect_internal(guild_id)
# 建立新连接
voice_client = await channel.connect()
# 更新连接信息
conn_info = VoiceConnectionInfo(guild_id, channel_id, channel.name)
conn_info.voice_client = voice_client
conn_info.update_status(VoiceConnectionStatus.CONNECTED)
conn_info.user_count = len(channel.members)
self.connections[guild_id] = conn_info
await self.logger.info(f"成功连接到语音频道: {guild.name} -> {channel.name}")
return voice_client
except discord.ClientException as e:
raise VoiceNetworkError(f"Discord 客户端错误: {str(e)}")
except discord.opus.OpusNotLoaded as e:
raise VoiceConnectionError(f"Opus 编码器未加载: {str(e)}", "OPUS_NOT_LOADED", guild_id)
except Exception as e:
await self.logger.error(f"连接语音频道时发生未知错误: {str(e)}")
raise VoiceConnectionError(f"连接失败: {str(e)}", "UNKNOWN_ERROR", guild_id)
async def leave_voice_channel(self, guild_id: int) -> bool:
"""
离开语音频道
断开指定服务器的语音连接,清理相关资源和状态信息。
确保音频播放停止后再断开连接。
@author: @ydzat
Args:
guild_id (int): 服务器ID
Returns:
bool: 断开是否成功
"""
async with self._connection_lock:
return await self._disconnect_internal(guild_id)
async def _disconnect_internal(self, guild_id: int) -> bool:
"""
内部断开连接方法
@author: @ydzat
Args:
guild_id (int): 服务器ID
Returns:
bool: 断开是否成功
"""
if guild_id not in self.connections:
return True
conn_info = self.connections[guild_id]
try:
if conn_info.voice_client and conn_info.voice_client.is_connected():
# 停止当前播放
if conn_info.voice_client.is_playing():
conn_info.voice_client.stop()
# 等待播放完全停止
await asyncio.sleep(0.1)
# 断开连接
await conn_info.voice_client.disconnect()
conn_info.update_status(VoiceConnectionStatus.IDLE)
del self.connections[guild_id]
await self.logger.info(f"已断开语音连接: Guild {guild_id}")
return True
except Exception as e:
await self.logger.error(f"断开语音连接时发生错误: {str(e)}")
# 即使出错也要清理连接记录
conn_info.update_status(VoiceConnectionStatus.FAILED)
if guild_id in self.connections:
del self.connections[guild_id]
return False
async def get_voice_client(self, guild_id: int) -> typing.Optional[discord.VoiceClient]:
"""
获取语音客户端
返回指定服务器的语音客户端实例,如果未连接则返回 None。
会验证连接的有效性,自动清理无效连接。
@author: @ydzat
Args:
guild_id (int): 服务器ID
Returns:
Optional[discord.VoiceClient]: 语音客户端实例或 None
"""
if guild_id not in self.connections:
return None
conn_info = self.connections[guild_id]
# 验证连接是否仍然有效
if conn_info.voice_client and not conn_info.voice_client.is_connected():
# 连接已失效,清理状态
await self._disconnect_internal(guild_id)
return None
return conn_info.voice_client if conn_info.connected else None
async def is_connected_to_voice(self, guild_id: int) -> bool:
"""
检查是否连接到语音频道
@author: @ydzat
Args:
guild_id (int): 服务器ID
Returns:
bool: 是否已连接
"""
if guild_id not in self.connections:
return False
conn_info = self.connections[guild_id]
# 检查实际连接状态
if conn_info.voice_client and not conn_info.voice_client.is_connected():
# 连接已失效,清理状态
await self._disconnect_internal(guild_id)
return False
return conn_info.connected
async def get_connection_status(self, guild_id: int) -> typing.Optional[dict]:
"""
获取连接状态信息
@author: @ydzat
Args:
guild_id (int): 服务器ID
Returns:
Optional[dict]: 连接状态信息字典或 None
"""
if guild_id not in self.connections:
return None
conn_info = self.connections[guild_id]
# 更新实时信息
if conn_info.voice_client and conn_info.voice_client.is_connected():
conn_info.latency = conn_info.voice_client.latency * 1000 # 转换为毫秒
conn_info.connection_health = "good" if conn_info.latency < 100 else "poor"
# 更新频道用户数
guild = self.bot.get_guild(guild_id)
if guild:
channel = guild.get_channel(conn_info.channel_id)
if channel and isinstance(channel, discord.VoiceChannel):
conn_info.user_count = len(channel.members)
return conn_info.to_dict()
async def list_active_connections(self) -> typing.List[dict]:
"""
列出所有活跃连接
@author: @ydzat
Returns:
List[dict]: 活跃连接列表
"""
active_connections = []
for guild_id, conn_info in self.connections.items():
if conn_info.connected:
status = await self.get_connection_status(guild_id)
if status:
active_connections.append(status)
return active_connections
async def get_voice_channel_info(self, guild_id: int, channel_id: int) -> typing.Optional[dict]:
"""
获取语音频道信息
@author: @ydzat
Args:
guild_id (int): 服务器ID
channel_id (int): 频道ID
Returns:
Optional[dict]: 频道信息字典或 None
"""
guild = self.bot.get_guild(guild_id)
if not guild:
return None
channel = guild.get_channel(channel_id)
if not channel or not isinstance(channel, discord.VoiceChannel):
return None
# 获取用户信息
users = []
for member in channel.members:
users.append({
"id": member.id,
"name": member.display_name,
"status": str(member.status),
"is_bot": member.bot
})
# 获取权限信息
bot_member = guild.me
permissions = channel.permissions_for(bot_member)
return {
"channel_id": channel_id,
"channel_name": channel.name,
"guild_id": guild_id,
"guild_name": guild.name,
"user_limit": channel.user_limit,
"current_users": users,
"user_count": len(users),
"bitrate": channel.bitrate,
"permissions": {
"connect": permissions.connect,
"speak": permissions.speak,
"use_voice_activation": permissions.use_voice_activation,
"priority_speaker": permissions.priority_speaker
}
}
async def _validate_user_in_channel(self, guild: discord.Guild,
channel: discord.VoiceChannel, user_id: int):
"""
验证用户是否在语音频道中
@author: @ydzat
Args:
guild: Discord 服务器对象
channel: 语音频道对象
user_id: 用户ID
Raises:
VoicePermissionError: 用户不在频道中时抛出
"""
member = guild.get_member(user_id)
if not member:
raise VoicePermissionError(
f"无法找到用户 {user_id}",
["member_not_found"],
user_id,
channel.id
)
if not member.voice or member.voice.channel != channel:
raise VoicePermissionError(
f"用户 {member.display_name} 不在语音频道 {channel.name}",
["user_not_in_channel"],
user_id,
channel.id
)
async def _validate_bot_permissions(self, channel: discord.VoiceChannel):
"""
验证机器人权限
@author: @ydzat
Args:
channel: 语音频道对象
Raises:
VoicePermissionError: 权限不足时抛出
"""
bot_member = channel.guild.me
permissions = channel.permissions_for(bot_member)
missing_permissions = []
if not permissions.connect:
missing_permissions.append("connect")
if not permissions.speak:
missing_permissions.append("speak")
if missing_permissions:
raise VoicePermissionError(
f"机器人在频道 {channel.name} 中缺少权限: {', '.join(missing_permissions)}",
missing_permissions,
channel_id=channel.id
)
async def cleanup_inactive_connections(self):
"""
清理无效连接
定期检查并清理已断开或无效的语音连接,释放资源。
@author: @ydzat
"""
cleanup_guilds = []
for guild_id, conn_info in self.connections.items():
if not conn_info.voice_client or not conn_info.voice_client.is_connected():
cleanup_guilds.append(guild_id)
for guild_id in cleanup_guilds:
await self._disconnect_internal(guild_id)
if cleanup_guilds:
await self.logger.info(f"清理了 {len(cleanup_guilds)} 个无效的语音连接")
async def start_monitoring(self):
"""
开始连接监控
@author: @ydzat
"""
if self._cleanup_task is None and self._monitoring_enabled:
self._cleanup_task = asyncio.create_task(self._monitoring_loop())
async def stop_monitoring(self):
"""
停止连接监控
@author: @ydzat
"""
self._monitoring_enabled = False
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
self._cleanup_task = None
async def _monitoring_loop(self):
"""
监控循环
@author: @ydzat
"""
try:
while self._monitoring_enabled:
await asyncio.sleep(60) # 每分钟检查一次
await self.cleanup_inactive_connections()
except asyncio.CancelledError:
pass
async def disconnect_all(self):
"""
断开所有连接
@author: @ydzat
"""
async with self._connection_lock:
guild_ids = list(self.connections.keys())
for guild_id in guild_ids:
await self._disconnect_internal(guild_id)
await self.stop_monitoring()
class DiscordMessageConverter(adapter.MessageConverter):
@staticmethod
@@ -34,28 +612,88 @@ class DiscordMessageConverter(adapter.MessageConverter):
for ele in message_chain:
if isinstance(ele, platform_message.Image):
image_bytes = None
filename = f'{uuid.uuid4()}.png' # 默认文件名
if ele.base64:
image_bytes = base64.b64decode(ele.base64)
# 处理base64编码的图片
if ele.base64.startswith('data:'):
# 从data URL中提取文件类型
data_header = ele.base64.split(',')[0]
if 'jpeg' in data_header or 'jpg' in data_header:
filename = f'{uuid.uuid4()}.jpg'
elif 'gif' in data_header:
filename = f'{uuid.uuid4()}.gif'
elif 'webp' in data_header:
filename = f'{uuid.uuid4()}.webp'
# 去掉data:image/xxx;base64,前缀
base64_data = ele.base64.split(',')[1]
else:
base64_data = ele.base64
image_bytes = base64.b64decode(base64_data)
elif ele.url:
# 从URL下载图片
async with aiohttp.ClientSession() as session:
async with session.get(ele.url) as response:
image_bytes = await response.read()
# 从URL或Content-Type推断文件类型
content_type = response.headers.get('Content-Type', '')
if 'jpeg' in content_type or 'jpg' in content_type:
filename = f'{uuid.uuid4()}.jpg'
elif 'gif' in content_type:
filename = f'{uuid.uuid4()}.gif'
elif 'webp' in content_type:
filename = f'{uuid.uuid4()}.webp'
elif ele.url.lower().endswith(('.jpg', '.jpeg')):
filename = f'{uuid.uuid4()}.jpg'
elif ele.url.lower().endswith('.gif'):
filename = f'{uuid.uuid4()}.gif'
elif ele.url.lower().endswith('.webp'):
filename = f'{uuid.uuid4()}.webp'
elif ele.path:
with open(ele.path, 'rb') as f:
image_bytes = f.read()
# 从文件路径读取图片
# 确保路径没有空字节
clean_path = ele.path.replace('\x00', '')
clean_path = os.path.abspath(clean_path)
if not os.path.exists(clean_path):
continue # 跳过不存在的文件
try:
with open(clean_path, 'rb') as f:
image_bytes = f.read()
# 从文件路径获取文件名,保持原始扩展名
original_filename = os.path.basename(clean_path)
if original_filename and '.' in original_filename:
# 保持原始文件名的扩展名
ext = original_filename.split('.')[-1].lower()
filename = f'{uuid.uuid4()}.{ext}'
else:
# 如果没有扩展名,尝试从文件内容检测
if image_bytes.startswith(b'\xff\xd8\xff'):
filename = f'{uuid.uuid4()}.jpg'
elif image_bytes.startswith(b'GIF'):
filename = f'{uuid.uuid4()}.gif'
elif image_bytes.startswith(b'RIFF') and b'WEBP' in image_bytes[:20]:
filename = f'{uuid.uuid4()}.webp'
# 默认保持PNG
except Exception as e:
print(f"Error reading image file {clean_path}: {e}")
continue # 跳过读取失败的文件
image_files.append(discord.File(fp=image_bytes, filename=f'{uuid.uuid4()}.png'))
if image_bytes:
# 使用BytesIO创建文件对象避免路径问题
import io
image_files.append(discord.File(fp=io.BytesIO(image_bytes), filename=filename))
elif isinstance(ele, platform_message.Plain):
text_string += ele.text
elif isinstance(ele, platform_message.Forward):
for node in ele.node_list:
(
text_string,
image_files,
node_text,
node_images,
) = await DiscordMessageConverter.yiri2target(node.message_chain)
text_string += text_string
image_files.extend(image_files)
text_string += node_text
image_files.extend(node_images)
return text_string, image_files
@@ -170,11 +808,15 @@ class DiscordAdapter(adapter.MessagePlatformAdapter):
typing.Callable[[platform_events.Event, adapter.MessagePlatformAdapter], None],
] = {}
def __init__(self, config: dict, ap: app.Application):
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
self.config = config
self.ap = ap
self.logger = logger
self.bot_account_id = self.config['client_id']
# 初始化语音连接管理器
self.voice_manager: VoiceConnectionManager = None
adapter_self = self
@@ -195,9 +837,192 @@ class DiscordAdapter(adapter.MessagePlatformAdapter):
args['proxy'] = os.getenv('http_proxy')
self.bot = MyClient(intents=intents, **args)
# Voice functionality methods
async def join_voice_channel(self, guild_id: int, channel_id: int,
user_id: int = None) -> discord.VoiceClient:
"""
加入语音频道
为指定服务器的语音频道建立连接,支持用户权限验证和连接复用。
@author: @ydzat
@version: 1.0
@since: 2025-07-04
Args:
guild_id (int): Discord 服务器ID
channel_id (int): 语音频道ID
user_id (int, optional): 请求用户ID用于权限验证
Returns:
discord.VoiceClient: 语音客户端实例
Raises:
VoicePermissionError: 权限不足
VoiceNetworkError: 网络连接失败
VoiceConnectionError: 其他连接错误
"""
if not self.voice_manager:
raise VoiceConnectionError("语音管理器未初始化", "MANAGER_NOT_READY")
return await self.voice_manager.join_voice_channel(guild_id, channel_id, user_id)
async def leave_voice_channel(self, guild_id: int) -> bool:
"""
离开语音频道
断开指定服务器的语音连接,清理相关资源。
@author: @ydzat
@version: 1.0
@since: 2025-07-04
Args:
guild_id (int): Discord 服务器ID
Returns:
bool: 是否成功断开连接
"""
if not self.voice_manager:
return False
return await self.voice_manager.leave_voice_channel(guild_id)
async def get_voice_client(self, guild_id: int) -> typing.Optional[discord.VoiceClient]:
"""
获取语音客户端
返回指定服务器的语音客户端实例,用于音频播放控制。
@author: @ydzat
@version: 1.0
@since: 2025-07-04
Args:
guild_id (int): Discord 服务器ID
Returns:
Optional[discord.VoiceClient]: 语音客户端实例或 None
"""
if not self.voice_manager:
return None
return await self.voice_manager.get_voice_client(guild_id)
async def is_connected_to_voice(self, guild_id: int) -> bool:
"""
检查语音连接状态
@author: @ydzat
@version: 1.0
@since: 2025-07-04
Args:
guild_id (int): Discord 服务器ID
Returns:
bool: 是否已连接到语音频道
"""
if not self.voice_manager:
return False
return await self.voice_manager.is_connected_to_voice(guild_id)
async def get_voice_connection_status(self, guild_id: int) -> typing.Optional[dict]:
"""
获取语音连接详细状态
返回包含连接时间、延迟、用户数等详细信息的状态字典。
@author: @ydzat
@version: 1.0
@since: 2025-07-04
Args:
guild_id (int): Discord 服务器ID
Returns:
Optional[dict]: 连接状态信息或 None
"""
if not self.voice_manager:
return None
return await self.voice_manager.get_connection_status(guild_id)
async def list_active_voice_connections(self) -> typing.List[dict]:
"""
列出所有活跃的语音连接
@author: @ydzat
@version: 1.0
@since: 2025-07-04
Returns:
List[dict]: 活跃语音连接列表
"""
if not self.voice_manager:
return []
return await self.voice_manager.list_active_connections()
async def get_voice_channel_info(self, guild_id: int, channel_id: int) -> typing.Optional[dict]:
"""
获取语音频道详细信息
包括频道名称、用户列表、权限信息等。
@author: @ydzat
@version: 1.0
@since: 2025-07-04
Args:
guild_id (int): Discord 服务器ID
channel_id (int): 语音频道ID
Returns:
Optional[dict]: 频道信息字典或 None
"""
if not self.voice_manager:
return None
return await self.voice_manager.get_voice_channel_info(guild_id, channel_id)
async def cleanup_voice_connections(self):
"""
清理无效的语音连接
手动触发语音连接清理,移除已断开或无效的连接。
@author: @ydzat
@version: 1.0
@since: 2025-07-04
"""
if self.voice_manager:
await self.voice_manager.cleanup_inactive_connections()
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
pass
msg_to_send, image_files = await self.message_converter.yiri2target(message)
try:
# 获取频道对象
channel = self.bot.get_channel(int(target_id))
if channel is None:
# 如果本地缓存中没有尝试从API获取
channel = await self.bot.fetch_channel(int(target_id))
args = {
'content': msg_to_send,
}
if len(image_files) > 0:
args['files'] = image_files
await channel.send(**args)
except Exception as e:
await self.logger.error(f"Discord send_message failed: {e}")
raise e
async def reply_message(
self,
@@ -241,9 +1066,32 @@ class DiscordAdapter(adapter.MessagePlatformAdapter):
self.listeners.pop(event_type)
async def run_async(self):
"""
启动 Discord 适配器
初始化语音管理器并启动 Discord 客户端连接。
@author: @ydzat (修改)
"""
async with self.bot:
# 初始化语音管理器
self.voice_manager = VoiceConnectionManager(self.bot, self.logger)
await self.voice_manager.start_monitoring()
await self.logger.info("Discord 适配器语音功能已启用")
await self.bot.start(self.config['token'], reconnect=True)
async def kill(self) -> bool:
"""
关闭 Discord 适配器
清理语音连接并关闭 Discord 客户端。
@author: @ydzat (修改)
"""
if self.voice_manager:
await self.voice_manager.disconnect_all()
await self.bot.close()
return True

View File

@@ -4,24 +4,24 @@ metadata:
name: discord
label:
en_US: Discord
zh_CN: Discord
zh_Hans: Discord
description:
en_US: Discord Adapter
zh_CN: Discord 适配器,请查看文档了解使用方式
zh_Hans: Discord 适配器,请查看文档了解使用方式
icon: discord.svg
spec:
config:
- name: client_id
label:
en_US: Client ID
zh_CN: 客户端ID
zh_Hans: 客户端ID
type: string
required: true
default: ""
- name: token
label:
en_US: Token
zh_CN: 令牌
zh_Hans: 令牌
type: string
required: true
default: ""

View File

@@ -20,6 +20,7 @@ from ...utils import image
import xml.etree.ElementTree as ET
from typing import Optional, Tuple
from functools import partial
from ..logger import EventLogger
class GewechatMessageConverter(adapter.MessageConverter):
@@ -371,7 +372,7 @@ class GewechatMessageConverter(adapter.MessageConverter):
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}')
print(f'Error in gewechat _ats_bot: {e}')
finally:
return ats_bot
@@ -477,9 +478,10 @@ class GeWeChatAdapter(adapter.MessagePlatformAdapter):
typing.Callable[[platform_events.Event, adapter.MessagePlatformAdapter], None],
] = {}
def __init__(self, config: dict, ap: app.Application):
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
self.config = config
self.ap = ap
self.logger = logger
self.quart_app = quart.Quart(__name__)
self.message_converter = GewechatMessageConverter(config)
@@ -503,7 +505,7 @@ class GeWeChatAdapter(adapter.MessagePlatformAdapter):
try:
event = await self.event_converter.target2yiri(data.copy(), self.bot_account_id)
except Exception:
traceback.print_exc()
await self.logger.error(f'Error in gewechat callback: {traceback.format_exc()}')
if event.__class__ in self.listeners:
await self.listeners[event.__class__](event, self)

View File

@@ -4,52 +4,52 @@ metadata:
name: gewechat
label:
en_US: GeWeChat
zh_CN: GeWeChat个人微信
zh_Hans: GeWeChat个人微信
description:
en_US: GeWeChat Adapter
zh_CN: GeWeChat 适配器,请查看文档了解使用方式
zh_Hans: GeWeChat 适配器,请查看文档了解使用方式
icon: gewechat.png
spec:
config:
- name: gewechat_url
label:
en_US: GeWeChat URL
zh_CN: GeWeChat URL
zh_Hans: GeWeChat URL
type: string
required: true
default: ""
- name: gewechat_file_url
label:
en_US: GeWeChat file download URL
zh_CN: GeWeChat 文件下载URL
zh_Hans: GeWeChat 文件下载URL
type: string
required: true
default: ""
- name: port
label:
en_US: Port
zh_CN: 端口
zh_Hans: 端口
type: integer
required: true
default: 2286
- name: callback_url
label:
en_US: Callback URL
zh_CN: 回调URL
zh_Hans: 回调URL
type: string
required: true
default: ""
- name: app_id
label:
en_US: App ID
zh_CN: 应用ID
zh_Hans: 应用ID
type: string
required: true
default: ""
- name: token
label:
en_US: Token
zh_CN: 令牌
zh_Hans: 令牌
type: string
required: true
default: ""

View File

@@ -23,6 +23,7 @@ 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 ..logger import EventLogger
class AESCipher(object):
@@ -332,16 +333,18 @@ class LarkAdapter(adapter.MessagePlatformAdapter):
listeners: typing.Dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, adapter.MessagePlatformAdapter], None],
] = {}
]
config: dict
quart_app: quart.Quart
ap: app.Application
def __init__(self, config: dict, ap: app.Application):
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
self.config = config
self.ap = ap
self.logger = logger
self.quart_app = quart.Quart(__name__)
self.listeners = {}
@self.quart_app.route('/lark/callback', methods=['POST'])
async def lark_callback():
@@ -375,15 +378,15 @@ class LarkAdapter(adapter.MessagePlatformAdapter):
if 'im.message.receive_v1' == type:
try:
event = await self.event_converter.target2yiri(p2v1, self.api_client)
except Exception:
traceback.print_exc()
except Exception as e:
await self.logger.error(f"Error in lark callback: {traceback.format_exc()}")
if event.__class__ in self.listeners:
await self.listeners[event.__class__](event, self)
return {'code': 200, 'message': 'ok'}
except Exception:
traceback.print_exc()
except Exception as e:
await self.logger.error(f"Error in lark callback: {traceback.format_exc()}")
return {'code': 500, 'message': 'error'}
async def on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
@@ -417,7 +420,7 @@ class LarkAdapter(adapter.MessagePlatformAdapter):
lark_message = await self.message_converter.yiri2target(message, self.api_client)
final_content = {
'zh_cn': {
'zh_Hans': {
'title': '',
'content': lark_message,
},

View File

@@ -4,61 +4,64 @@ metadata:
name: lark
label:
en_US: Lark
zh_CN: 飞书
zh_Hans: 飞书
description:
en_US: Lark Adapter
zh_CN: 飞书适配器,请查看文档了解使用方式
zh_Hans: 飞书适配器,请查看文档了解使用方式
icon: lark.svg
spec:
config:
- name: app_id
label:
en_US: App ID
zh_CN: 应用ID
zh_Hans: 应用ID
type: string
required: true
default: ""
- name: app_secret
label:
en_US: App Secret
zh_CN: 应用密钥
zh_Hans: 应用密钥
type: string
required: true
default: ""
- name: bot_name
label:
en_US: Bot Name
zh_CN: 机器人名称
zh_Hans: 机器人名称
description:
en_US: Must be the same as the name of the bot in Lark, otherwise the bot will not be able to receive messages in the group
zh_Hans: 必须与飞书机器人名称一致,否则机器人将无法在群内正常接收消息
type: string
required: true
default: ""
- name: enable-webhook
label:
en_US: Enable Webhook Mode
zh_CN: 启用Webhook模式
zh_Hans: 启用Webhook模式
description:
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode
zh_CN: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式
type: boolean
required: true
default: false
- name: port
label:
en_US: Webhook Port
zh_CN: Webhook端口
zh_Hans: Webhook端口
description:
en_US: Only valid when webhook mode is enabled, please fill in the webhook port
zh_CN: 仅在启用 Webhook 模式时有效,请填写 Webhook 端口
zh_Hans: 仅在启用 Webhook 模式时有效,请填写 Webhook 端口
type: integer
required: true
default: 2285
- name: encrypt-key
label:
en_US: Encrypt Key
zh_CN: 加密密钥
zh_Hans: 加密密钥
description:
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
zh_CN: 仅在启用 Webhook 模式时有效,请填写加密密钥
zh_Hans: 仅在启用 Webhook 模式时有效,请填写加密密钥
type: string
required: true
default: ""

View File

@@ -14,6 +14,7 @@ from ...pipeline.longtext.strategies import forward
from ...platform.types import message as platform_message
from ...platform.types import entities as platform_entities
from ...platform.types import events as platform_events
from ..logger import EventLogger
class NakuruProjectMessageConverter(adapter_model.MessageConverter):
@@ -71,9 +72,8 @@ class NakuruProjectMessageConverter(adapter_model.MessageConverter):
content=content_list,
)
nakuru_forward_node_list.append(nakuru_forward_node)
except Exception:
except Exception as e:
import traceback
traceback.print_exc()
nakuru_msg_list.append(nakuru_forward_node_list)
@@ -178,12 +178,13 @@ class NakuruAdapter(adapter_model.MessagePlatformAdapter):
cfg: dict
def __init__(self, cfg: dict, ap):
def __init__(self, cfg: dict, ap, logger: EventLogger):
"""初始化nakuru-project的对象"""
cfg['port'] = cfg['ws_port']
del cfg['ws_port']
self.cfg = cfg
self.ap = ap
self.logger = logger
self.listener_list = []
self.bot = nakuru.CQHTTP(**self.cfg)
@@ -275,7 +276,7 @@ class NakuruAdapter(adapter_model.MessagePlatformAdapter):
# 注册监听器
self.bot.receiver(source_cls.__name__)(listener_wrapper)
except Exception as e:
traceback.print_exc()
self.logger.error(f"Error in nakuru register_listener: {traceback.format_exc()}")
raise e
def unregister_listener(

View File

@@ -4,38 +4,38 @@ metadata:
name: nakuru
label:
en_US: Nakuru
zh_CN: Nakuru
zh_Hans: Nakuru
description:
en_US: Nakuru Adapter
zh_CN: Nakuru 适配器(go-cqhttp),请查看文档了解使用方式
zh_Hans: Nakuru 适配器(go-cqhttp),请查看文档了解使用方式
icon: nakuru.png
spec:
config:
- name: host
label:
en_US: Host
zh_CN: 主机
zh_Hans: 主机
type: string
required: true
default: "127.0.0.1"
- name: http_port
label:
en_US: HTTP Port
zh_CN: HTTP端口
zh_Hans: HTTP端口
type: integer
required: true
default: 5700
- name: ws_port
label:
en_US: WebSocket Port
zh_CN: WebSocket端口
zh_Hans: WebSocket端口
type: integer
required: true
default: 8080
- name: token
label:
en_US: Token
zh_CN: 令牌
zh_Hans: 令牌
type: string
required: true
default: ""

View File

@@ -13,6 +13,7 @@ from .. import adapter
from ...core import app
from ..types import entities as platform_entities
from ...command.errors import ParamNotEnoughError
from ..logger import EventLogger
class OAMessageConverter(adapter.MessageConverter):
@@ -63,10 +64,10 @@ class OfficialAccountAdapter(adapter.MessagePlatformAdapter):
event_converter: OAEventConverter = OAEventConverter()
config: dict
def __init__(self, config: dict, ap: app.Application):
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
self.config = config
self.ap = ap
self.logger = logger
required_keys = [
'token',
@@ -85,6 +86,7 @@ class OfficialAccountAdapter(adapter.MessagePlatformAdapter):
EncodingAESKey=config['EncodingAESKey'],
Appsecret=config['AppSecret'],
AppID=config['AppID'],
logger=self.logger,
)
elif self.config['Mode'] == 'passive':
self.bot = OAClientForLongerResponse(
@@ -93,6 +95,7 @@ class OfficialAccountAdapter(adapter.MessagePlatformAdapter):
Appsecret=config['AppSecret'],
AppID=config['AppID'],
LoadingMessage=config['LoadingMessage'],
logger=self.logger,
)
else:
raise KeyError('请设置微信公众号通信模式')
@@ -122,8 +125,8 @@ class OfficialAccountAdapter(adapter.MessagePlatformAdapter):
self.bot_account_id = event.receiver_id
try:
return await callback(await self.event_converter.target2yiri(event), self)
except Exception:
traceback.print_exc()
except Exception as e:
await self.logger.error(f"Error in officialaccount callback: {traceback.format_exc()}")
if event_type == platform_events.FriendMessage:
self.bot.on_message('text')(on_message)

View File

@@ -4,66 +4,69 @@ metadata:
name: officialaccount
label:
en_US: Official Account
zh_CN: 微信公众号
zh_Hans: 微信公众号
description:
en_US: Official Account Adapter
zh_CN: 微信公众号适配器,请查看文档了解使用方式
zh_Hans: 微信公众号适配器,请查看文档了解使用方式
icon: officialaccount.png
spec:
config:
- name: token
label:
en_US: Token
zh_CN: 令牌
zh_Hans: 令牌
type: string
required: true
default: ""
- name: EncodingAESKey
label:
en_US: EncodingAESKey
zh_CN: 消息加解密密钥
zh_Hans: 消息加解密密钥
type: string
required: true
default: ""
- name: AppID
label:
en_US: App ID
zh_CN: 应用ID
zh_Hans: 应用ID
type: string
required: true
default: ""
- name: AppSecret
label:
en_US: App Secret
zh_CN: 应用密钥
zh_Hans: 应用密钥
type: string
required: true
default: ""
- name: Mode
label:
en_US: Mode
zh_CN: 接入模式
zh_Hans: 接入模式
type: string
required: true
default: "drop"
- name: LoadingMessage
label:
en_US: Loading Message
zh_CN: 加载消息
zh_Hans: 加载消息
type: string
required: true
default: "AI正在思考中请发送任意内容获取回复。"
- name: host
label:
en_US: Host
zh_CN: 监听主机
zh_Hans: 监听主机
description:
en_US: The host that Official Account listens on for Webhook connections.
zh_Hans: 微信公众号监听的主机,除非你知道自己在做什么,否则请写 0.0.0.0
type: string
required: true
default: 0.0.0.0
- name: port
label:
en_US: Port
zh_CN: 监听端口
zh_Hans: 监听端口
type: integer
required: true
default: 2287

View File

@@ -17,6 +17,7 @@ from ...config import manager as cfg_mgr
from ...platform.types import entities as platform_entities
from ...platform.types import events as platform_events
from ...platform.types import message as platform_message
from ..logger import EventLogger
class OfficialGroupMessage(platform_events.GroupMessage):
@@ -357,10 +358,11 @@ class OfficialAdapter(adapter_model.MessagePlatformAdapter):
group_msg_seq = None
c2c_msg_seq = None
def __init__(self, cfg: dict, ap: app.Application):
def __init__(self, cfg: dict, ap: app.Application, logger: EventLogger):
"""初始化适配器"""
self.cfg = cfg
self.ap = ap
self.logger = logger
self.group_msg_seq = 1
self.c2c_msg_seq = 1
@@ -499,7 +501,7 @@ class OfficialAdapter(adapter_model.MessagePlatformAdapter):
for event_handler in event_handler_mapping[event_type]:
setattr(self.bot, event_handler, wrapper)
except Exception as e:
traceback.print_exc()
self.logger.error(f"Error in qqbotpy callback: {traceback.format_exc()}")
raise e
def unregister_listener(

View File

@@ -4,31 +4,31 @@ metadata:
name: qq-botpy
label:
en_US: QQBotPy
zh_CN: QQBotPy
zh_Hans: QQBotPy
description:
en_US: QQ Official API (WebSocket)
zh_CN: QQ 官方 API (WebSocket),请查看文档了解使用方式
zh_Hans: QQ 官方 API (WebSocket),请查看文档了解使用方式
icon: qqbotpy.svg
spec:
config:
- name: appid
label:
en_US: App ID
zh_CN: 应用ID
zh_Hans: 应用ID
type: string
required: true
default: ""
- name: secret
label:
en_US: Secret
zh_CN: 密钥
zh_Hans: 密钥
type: string
required: true
default: ""
- name: intents
label:
en_US: Intents
zh_CN: 权限
zh_Hans: 权限
type: array
required: true
default: []

View File

@@ -14,6 +14,7 @@ from ...command.errors import ParamNotEnoughError
from libs.qq_official_api.api import QQOfficialClient
from libs.qq_official_api.qqofficialevent import QQOfficialEvent
from ...utils import image
from ..logger import EventLogger
class QQOfficialMessageConverter(adapter.MessageConverter):
@@ -139,9 +140,10 @@ class QQOfficialAdapter(adapter.MessagePlatformAdapter):
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
def __init__(self, config: dict, ap: app.Application):
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
self.config = config
self.ap = ap
self.logger = logger
required_keys = [
'appid',
@@ -155,6 +157,7 @@ class QQOfficialAdapter(adapter.MessagePlatformAdapter):
app_id=config['appid'],
secret=config['secret'],
token=config['token'],
logger=self.logger
)
async def reply_message(
@@ -221,8 +224,8 @@ class QQOfficialAdapter(adapter.MessagePlatformAdapter):
self.bot_account_id = 'justbot'
try:
return await callback(await self.event_converter.target2yiri(event), self)
except Exception:
traceback.print_exc()
except Exception as e:
await self.logger.error(f"Error in qqofficial callback: {traceback.format_exc()}")
if event_type == platform_events.FriendMessage:
self.bot.on_message('DIRECT_MESSAGE_CREATE')(on_message)

View File

@@ -4,38 +4,38 @@ metadata:
name: qqofficial
label:
en_US: QQ Official API
zh_CN: QQ 官方 API
zh_Hans: QQ 官方 API
description:
en_US: QQ Official API (Webhook)
zh_CN: QQ 官方 API (Webhook),请查看文档了解使用方式
zh_Hans: QQ 官方 API (Webhook),请查看文档了解使用方式
icon: qqofficial.svg
spec:
config:
- name: appid
label:
en_US: App ID
zh_CN: 应用ID
zh_Hans: 应用ID
type: string
required: true
default: ""
- name: secret
label:
en_US: Secret
zh_CN: 密钥
zh_Hans: 密钥
type: string
required: true
default: ""
- name: port
label:
en_US: Port
zh_CN: 监听端口
zh_Hans: 监听端口
type: integer
required: true
default: 2284
- name: token
label:
en_US: Token
zh_CN: 令牌
zh_Hans: 令牌
type: string
required: true
default: ""

View File

@@ -14,6 +14,7 @@ from .. import adapter
from ..types import entities as platform_entities
from ...command.errors import ParamNotEnoughError
from ...utils import image
from ..logger import EventLogger
class SlackMessageConverter(adapter.MessageConverter):
@@ -91,9 +92,10 @@ class SlackAdapter(adapter.MessagePlatformAdapter):
event_converter: SlackEventConverter = SlackEventConverter()
config: dict
def __init__(self, config: dict, ap: app.Application):
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
self.config = config
self.ap = ap
self.logger = logger
required_keys = [
'bot_token',
'signing_secret',
@@ -102,7 +104,7 @@ class SlackAdapter(adapter.MessagePlatformAdapter):
if missing_keys:
raise ParamNotEnoughError('Slack机器人缺少相关配置项请查看文档或联系管理员')
self.bot = SlackClient(bot_token=self.config['bot_token'], signing_secret=self.config['signing_secret'])
self.bot = SlackClient(bot_token=self.config['bot_token'], signing_secret=self.config['signing_secret'], logger=self.logger)
async def reply_message(
self,
@@ -137,8 +139,8 @@ class SlackAdapter(adapter.MessagePlatformAdapter):
self.bot_account_id = 'SlackBot'
try:
return await callback(await self.event_converter.target2yiri(event, self.bot), self)
except:
traceback.print_exc()
except Exception as e:
await self.logger.error(f"Error in slack callback: {traceback.format_exc()}")
if event_type == platform_events.FriendMessage:
self.bot.on_message('im')(on_message)

View File

@@ -4,31 +4,31 @@ metadata:
name: slack
label:
en_US: Slack
zh_CN: Slack
zh_Hans: Slack
description:
en_US: Slack Adapter
zh_CN: Slack 适配器,请查看文档了解使用方式
zh_Hans: Slack 适配器,请查看文档了解使用方式
icon: slack.png
spec:
config:
- name: bot_token
label:
en_US: Bot Token
zh_CN: 机器人令牌
zh_Hans: 机器人令牌
type: string
required: true
default: ""
- name: signing_secret
label:
en_US: signing_secret
zh_CN: 密钥
zh_Hans: 密钥
type: string
required: true
default: ""
- name: port
label:
en_US: Port
zh_CN: 监听端口
zh_Hans: 监听端口
type: int
required: true
default: 2288

View File

@@ -17,6 +17,7 @@ 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 ..logger import EventLogger
class TelegramMessageConverter(adapter.MessageConverter):
@@ -147,9 +148,10 @@ class TelegramAdapter(adapter.MessagePlatformAdapter):
typing.Callable[[platform_events.Event, adapter.MessagePlatformAdapter], None],
] = {}
def __init__(self, config: dict, ap: app.Application):
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
self.config = config
self.ap = ap
self.logger = logger
async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
if update.message.from_user.is_bot:
@@ -158,8 +160,8 @@ class TelegramAdapter(adapter.MessagePlatformAdapter):
try:
lb_event = await self.event_converter.target2yiri(update, self.bot, self.bot_account_id)
await self.listeners[type(lb_event)](lb_event, self)
except Exception:
print(traceback.format_exc())
except Exception as e:
await self.logger.error(f"Error in telegram callback: {traceback.format_exc()}")
self.application = ApplicationBuilder().token(self.config['token']).build()
self.bot = self.application.bot

View File

@@ -4,24 +4,24 @@ metadata:
name: telegram
label:
en_US: Telegram
zh_CN: 电报
zh_Hans: 电报
description:
en_US: Telegram Adapter
zh_CN: 电报适配器,请查看文档了解使用方式
zh_Hans: 电报适配器,请查看文档了解使用方式
icon: telegram.svg
spec:
config:
- name: token
label:
en_US: Token
zh_CN: 令牌
zh_Hans: 令牌
type: string
required: true
default: ""
- name: markdown_card
label:
en_US: Markdown Card
zh_CN: 是否使用 Markdown 卡片
zh_Hans: 是否使用 Markdown 卡片
type: boolean
required: false
default: true

View File

@@ -0,0 +1,209 @@
import asyncio
import logging
import typing
from datetime import datetime
from pydantic import BaseModel
from .. import adapter as msadapter
from ..types import events as platform_events, message as platform_message, entities as platform_entities
from ...core import app
from ..logger import EventLogger
logger = logging.getLogger(__name__)
class WebChatMessage(BaseModel):
id: int
role: str
content: str
message_chain: list[dict]
timestamp: str
class WebChatSession:
id: str
message_lists: dict[str, list[WebChatMessage]] = {}
resp_waiters: dict[int, asyncio.Future[WebChatMessage]]
def __init__(self, id: str):
self.id = id
self.message_lists = {}
self.resp_waiters = {}
def get_message_list(self, pipeline_uuid: str) -> list[WebChatMessage]:
if pipeline_uuid not in self.message_lists:
self.message_lists[pipeline_uuid] = []
return self.message_lists[pipeline_uuid]
class WebChatAdapter(msadapter.MessagePlatformAdapter):
"""WebChat调试适配器用于流水线调试"""
webchat_person_session: WebChatSession
webchat_group_session: WebChatSession
listeners: typing.Dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, msadapter.MessagePlatformAdapter], None],
] = {}
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
self.ap = ap
self.logger = logger
self.config = config
self.webchat_person_session = WebChatSession(id='webchatperson')
self.webchat_group_session = WebChatSession(id='webchatgroup')
self.bot_account_id = 'webchatbot'
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
) -> dict:
"""发送消息到调试会话"""
session_key = target_id
if session_key not in self.debug_messages:
self.debug_messages[session_key] = []
message_data = {
'id': len(self.debug_messages[session_key]) + 1,
'type': 'bot',
'content': str(message),
'timestamp': datetime.now().isoformat(),
'message_chain': [component.__dict__ for component in message],
}
self.debug_messages[session_key].append(message_data)
await self.logger.info(f'Send message to {session_key}: {message}')
return message_data
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
) -> dict:
"""回复消息"""
message_data = WebChatMessage(
id=-1,
role='assistant',
content=str(message),
message_chain=[component.__dict__ for component in message],
timestamp=datetime.now().isoformat(),
)
# notify waiter
if isinstance(message_source, platform_events.FriendMessage):
self.webchat_person_session.resp_waiters[message_source.message_chain.message_id].set_result(message_data)
elif isinstance(message_source, platform_events.GroupMessage):
self.webchat_group_session.resp_waiters[message_source.message_chain.message_id].set_result(message_data)
return message_data.model_dump()
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
func: typing.Callable[[platform_events.Event, msadapter.MessagePlatformAdapter], typing.Awaitable[None]],
):
"""注册事件监听器"""
self.listeners[event_type] = func
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
func: typing.Callable[[platform_events.Event, msadapter.MessagePlatformAdapter], typing.Awaitable[None]],
):
"""取消注册事件监听器"""
del self.listeners[event_type]
async def run_async(self):
"""运行适配器"""
await self.logger.info('WebChat调试适配器已启动')
try:
while True:
await asyncio.sleep(1)
except asyncio.CancelledError:
await self.logger.info('WebChat调试适配器已停止')
raise
async def kill(self):
"""停止适配器"""
await self.logger.info('WebChat调试适配器正在停止')
async def send_webchat_message(
self, pipeline_uuid: str, session_type: str, message_chain_obj: typing.List[dict]
) -> dict:
"""发送调试消息到流水线"""
if session_type == 'person':
use_session = self.webchat_person_session
else:
use_session = self.webchat_group_session
message_chain = platform_message.MessageChain.parse_obj(message_chain_obj)
message_id = len(use_session.get_message_list(pipeline_uuid)) + 1
use_session.get_message_list(pipeline_uuid).append(
WebChatMessage(
id=message_id,
role='user',
content=str(message_chain),
message_chain=message_chain_obj,
timestamp=datetime.now().isoformat(),
)
)
message_chain.insert(0, platform_message.Source(id=message_id, time=datetime.now().timestamp()))
if session_type == 'person':
sender = platform_entities.Friend(id='webchatperson', nickname='User')
event = platform_events.FriendMessage(
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
)
else:
group = platform_entities.Group(
id='webchatgroup', name='Group', permission=platform_entities.Permission.Member
)
sender = platform_entities.GroupMember(
id='webchatperson',
member_name='User',
group=group,
permission=platform_entities.Permission.Member,
)
event = platform_events.GroupMessage(
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
)
self.ap.platform_mgr.webchat_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
if event.__class__ in self.listeners:
await self.listeners[event.__class__](event, self)
# set waiter
waiter = asyncio.Future[WebChatMessage]()
use_session.resp_waiters[message_id] = waiter
waiter.add_done_callback(lambda future: use_session.resp_waiters.pop(message_id))
resp_message = await waiter
resp_message.id = len(use_session.get_message_list(pipeline_uuid)) + 1
use_session.get_message_list(pipeline_uuid).append(resp_message)
return resp_message.model_dump()
def get_webchat_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]:
"""获取调试消息历史"""
if session_type == 'person':
return [message.model_dump() for message in self.webchat_person_session.get_message_list(pipeline_uuid)]
else:
return [message.model_dump() for message in self.webchat_group_session.get_message_list(pipeline_uuid)]

View File

@@ -0,0 +1,16 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: webchat
label:
en_US: "WebChat Debug"
zh_Hans: "网页聊天调试"
description:
en_US: "WebChat adapter for pipeline debugging"
zh_Hans: "用于流水线调试的网页聊天适配器"
icon: ""
spec: {}
execution:
python:
path: "webchat.py"
attr: "WebChatAdapter"

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Some files were not shown because too many files have changed in this diff Show More