Compare commits

...

792 Commits
1.0.6 ... 1.5.6

Author SHA1 Message Date
zhayujie
d0fd78497d Merge pull request #1680 from V-know/patch-1
Doc: 优化【服务器部署】
2024-01-26 16:29:11 +08:00
zhayujie
8045019603 feat: add 4-turbo-preview model 2024-01-26 16:21:11 +08:00
zhayujie
7d92b9435e Merge pull request #1678 from goldfishh/master
tool 0.5.0
2024-01-26 11:17:15 +08:00
zhayujie
1e0822703a fix: image num 2024-01-25 18:00:02 +08:00
zhayujie
0403ff88ef feat: image num limit 2024-01-25 15:45:24 +08:00
zhayujie
78376d591b fix: image limit 2024-01-25 15:40:52 +08:00
zhayujie
8e23d0df20 Merge branch 'master' of github.com:zhayujie/chatgpt-on-wechat 2024-01-25 15:39:41 +08:00
zhayujie
9e281d20ab fix: image num limit 2024-01-25 15:34:59 +08:00
zhayujie
644bd4a106 Merge pull request #1698 from 6vision/6vision-patch-1
Update wework_message.py
2024-01-23 20:09:16 +08:00
zhayujie
7729e66a96 docs: update README.md 2024-01-23 20:01:55 +08:00
zhayujie
d67d6b7948 feat: knowledge base send file 2024-01-22 18:03:04 +08:00
vision
4c4a46bfbe Update wework_message.py 2024-01-22 13:38:11 +08:00
zhayujie
4536f9c177 feat: client mng 2024-01-19 14:38:14 +08:00
zhayujie
eae95dfef5 fix: api base bug 2024-01-17 18:25:57 +08:00
Cancellara
b67d4460ca Doc: 优化【服务器部署】
不必单独创建nohup.out文件
nohup 命令执行时会自动创建
2024-01-17 01:13:39 +08:00
goldfishh
3dea8311b1 change chatgpt_tool_hub version to 0.5.0 2024-01-16 23:39:40 +08:00
zhayujie
11f6e98874 Merge branch 'master' of github.com:zhayujie/chatgpt-on-wechat 2024-01-16 23:22:10 +08:00
zhayujie
2609e595f4 fix: client host 2024-01-16 22:38:33 +08:00
zhayujie
ac6e41abc8 Merge pull request #1644 from PoseidonLi0514/master
Image generation supports custom endpoint
2024-01-16 22:35:57 +08:00
zhayujie
9c17e16d0a fix: optimize code format 2024-01-16 19:17:32 +08:00
goldfishh
55e9064307 tool ver0.5
1. 新增工具pure模式,支持单个工具调用
2. 新增消息转发工具:email, sms, wechat, 可以根据规则向其他平台发送消息
3. 替换visual-dl(更名为visual)实现,目前识别图片链接效果较好。
4. 修复了0.4版本大部分工具返回结果不可靠问题
2024-01-16 01:13:40 +08:00
zhayujie
91cabd7d49 Merge pull request #1628 from huiwenTT/dingdinggpt
添加语音发送消息
2024-01-15 22:45:46 +08:00
zhayujie
7456950530 Merge pull request #1658 from I-E-E-E/patch-1
fixed a typo
2024-01-15 22:41:12 +08:00
zhayujie
8fcdda625d Merge pull request #1675 from zhayujie/feat-client
feat: channel client
2024-01-15 22:37:53 +08:00
zhayujie
40a10ee926 Merge branch 'master' into feat-client 2024-01-15 22:37:47 +08:00
zhayujie
c3f7e2645c feat: channel client 2024-01-15 22:35:30 +08:00
I-E-E-E
b264af1892 fixed a typo 2024-01-08 17:51:15 +08:00
Haikui Yang
43e93e8e22 Update open_ai_image.py 2024-01-01 22:43:03 +08:00
Haikui Yang
d6c4789688 Merge branch 'zhayujie:master' into master 2024-01-01 22:42:10 +08:00
惠文
cb31ee6f01 Merge branch 'dingdinggpt' of github.com:huiwenTT/chatgpt-on-wechat-1 into dingdinggpt 2023-12-26 15:56:35 +08:00
huiwen
f7b694ac56 添加语音发送消息和修复上下文的关联 2023-12-26 14:48:54 +08:00
zhayujie
eb809055d4 Merge pull request #1559 from huiwenTT/dingdinggpt
钉钉机器人
2023-12-25 18:15:33 +08:00
zhayujie
78d9be82b2 fix: add gemini dependency 2023-12-19 11:47:33 +08:00
Haikui Yang
76a95c0226 Update open_ai_image.py 2023-12-17 19:50:06 +08:00
huiwen
d3ab8fb04a Merge branch 'dingdinggpt' of 47.98.110.173:/opt/python_app/gpt into dingdinggpt 2023-12-17 09:52:24 +08:00
huiwen
f7a0b63a00 Merge branch 'zhayujie:master' into dingdinggpt 2023-12-17 09:27:30 +08:00
huiwen
a21dd97786 钉钉app_id,变更为_client_id,和逻辑优化 2023-12-17 09:23:15 +08:00
zhayujie
04943c0bfa Update README.md 2023-12-16 01:11:05 +08:00
zhayujie
203d4d8bfb Update README.md 2023-12-15 19:16:13 +08:00
zhayujie
c049a619dc chore: remove useless code 2023-12-15 16:49:23 +08:00
zhayujie
cc1b14b607 Merge branch 'master' of github.com:zhayujie/chatgpt-on-wechat 2023-12-15 14:44:54 +08:00
zhayujie
e04a12a8f4 Merge branch 'hanfangyuan4396-master' 2023-12-15 14:40:34 +08:00
zhayujie
a2c82bc583 Merge branch 'master' of https://github.com/hanfangyuan4396/chatgpt-on-wechat into hanfangyuan4396-master 2023-12-15 14:40:15 +08:00
zhayujie
b4dc382f7c Merge pull request #1598 from zhayujie/feat-gemini
feat: support gemini model
2023-12-15 14:24:26 +08:00
zhayujie
eca1892e2a fix: gemini no content bug 2023-12-15 14:23:36 +08:00
zhayujie
23a237074e feat: support gemini model 2023-12-15 10:19:48 +08:00
zhayujie
219e9eca4f Merge pull request #1595 from 6vision/master
企微优化
2023-12-14 12:00:28 +08:00
6vision
413e09fb9e 1、企微个人号支持文件和链接消息
2、修复企微个人号群名获取bug
2023-12-14 00:50:34 +08:00
zhayujie
3514c37e4c fix: railway fork does not need action 2023-12-13 20:57:04 +08:00
zhayujie
95260e303c fix: process markdown url in knowledge base 2023-12-11 20:48:13 +08:00
hanfangyuan4396
0cef34bdfa Merge branch 'zhayujie:master' into master 2023-12-09 19:41:01 +08:00
Han Fangyuan
9838979bbd refactor: update class name of qwen bot 2023-12-09 19:40:07 +08:00
Han Fangyuan
c8910b8e14 fix: set correct top_p params of ali qwen model 2023-12-09 19:26:11 +08:00
Han Fangyuan
207fa1d019 feat: hot reload conf of ali qwen model 2023-12-09 18:40:17 +08:00
zhayujie
be0bb591e7 fix: do not draw when text_to_image is empty 2023-12-09 17:12:08 +08:00
Han Fangyuan
bfacdb9c3b feat: support character description of ali qwen model 2023-12-09 12:39:09 +08:00
zhayujie
ae4077ed6c fix: config adjust 2023-12-08 14:29:14 +08:00
zhayujie
6eb3c90e18 feat: qwen model modify 2023-12-08 14:12:21 +08:00
zhayujie
8c2a53a504 Merge pull request #1573 from chazzjimel/master
add ali voice output
2023-12-08 13:34:54 +08:00
zhayujie
74db1e0308 Merge pull request #1537 from hanfangyuan4396/master
支持阿里云百炼平台通义千问模型
2023-12-08 13:27:52 +08:00
zhayujie
b9dfdcef3d Merge pull request #1577 from xyshell/patch-1
Update chat_gpt_bot.py retry APIConnectionError
2023-12-08 13:26:59 +08:00
zhayujie
9d4afeac31 feat: speech support app_code bind 2023-12-07 22:44:43 +08:00
zhayujie
14ae2f169a fix: hello plugin trigger app bug 2023-12-07 19:41:50 +08:00
You Xie
55df19142f Update chat_gpt_bot.py retry APIConnectionError 2023-12-06 02:27:22 -06:00
zhayujie
40fd545b2c fix: exit group optimize 2023-12-06 10:51:47 +08:00
zhayujie
95fb07343e Merge pull request #1570 from erayyym/master
adding features: 退群提醒
2023-12-06 10:42:15 +08:00
erayyym
4d87906559 增加了配置项
本地跑没有问题,用户打开这个功能需要在config.json加入  "group_chat_exit_group": true,

(但是不确定写的对不对,刚开始学cs哈哈,之前没搞过这个)
2023-12-05 13:18:42 -05:00
跃迁
6b30dced43 Merge branch 'zhayujie:master' into master 2023-12-06 00:44:18 +08:00
chazzjimel
293a03b7c8 add ali voice output
增加阿里云语音输出接口
2023-12-06 00:43:19 +08:00
zhayujie
c010549f17 Merge pull request #1563 from malsony/master
Update xunfei_spark_bot.py
2023-12-06 00:40:23 +08:00
zhayujie
cc0be22026 Merge branch 'master' of github.com:zhayujie/chatgpt-on-wechat 2023-12-06 00:31:59 +08:00
zhayujie
e5ba26febe fix: tts voice base url 2023-12-06 00:31:31 +08:00
erayyym
36f9680eec adding features: 退群提醒
后面还打算想办法加用户自己退出的提醒,目前版本是可以在群主(且群主/管理员自己是bot)踢人时候发出提醒
2023-12-05 03:58:42 -05:00
zhayujie
f4f5be5b08 Create LICENSE 2023-12-04 11:14:55 +08:00
chazzjimel
d89b056886 add ali voice output
增加阿里云语音输出支持。
2023-12-03 18:19:03 +08:00
malsony
65424c7db9 Update xunfei_spark_bot.py
update API URL for v3.0 version of Xunfei Spark.
2023-12-01 16:09:15 +08:00
huiwen
32a8a847fc 修复小bug 2023-11-30 12:09:03 +08:00
zhayujie
88fb3dbf60 fix: generate break by bug 2023-11-30 11:51:04 +08:00
惠文
f6bee3aa58 新增钉钉机器人(Stream模式) 2023-11-30 10:41:34 +08:00
zhayujie
5f19f37dcb feat: hello plugin support app code 2023-11-29 23:15:31 +08:00
zhayujie
dd36d8ce9e Merge branch 'master' of github.com:zhayujie/chatgpt-on-wechat 2023-11-29 17:41:44 +08:00
zhayujie
865e4b5349 feat: hello plugin support system prompt 2023-11-29 17:41:14 +08:00
hanfangyuan4396
e70564752b Merge branch 'master' into master 2023-11-29 10:16:50 +08:00
zhayujie
6e0d2f9437 fix: remove unuse log and add plugin config in docker config 2023-11-28 16:29:32 +08:00
zhayujie
291f936097 Update README.md 2023-11-27 20:24:42 +08:00
zhayujie
0b2ce48586 Update README.md 2023-11-27 18:20:52 +08:00
zhayujie
da87fd9e20 feat: add single chat blacklist 2023-11-27 14:45:25 +08:00
zhayujie
d4da4d2575 fix: nick name config name 2023-11-27 14:38:45 +08:00
zhayujie
bad20ff483 Merge pull request #1538 from dividduang/blacklist
Blacklist
2023-11-27 14:29:06 +08:00
zhayujie
21ad51ffbf fix: remove repeat util 2023-11-27 14:24:26 +08:00
zhayujie
697c6d5fbe Merge pull request #1541 from Saboteur7/master
新增飞书应用通道
2023-11-27 14:22:23 +08:00
zhayujie
293c659053 Merge pull request #1553 from zhayujie/feat-11-27
feat: add image chat and fix session discard
2023-11-27 14:21:53 +08:00
zhayujie
a12507abbd feat: default close image summary 2023-11-27 14:07:14 +08:00
zhayujie
4e675b84fb feat: image input and session optimize 2023-11-27 12:47:00 +08:00
Han Fangyuan
c1022feab8 fix: add tongyi model to model list 2023-11-25 10:06:10 +08:00
Saboteur7
ddcfcf21fe 群聊只有艾特机器人才回复 2023-11-23 22:05:10 +08:00
Saboteur7
86a58c3d80 新增飞书应用通道
- 支持自建机器人的私聊和群聊
 - 支持图片生成
 - 支持文件总结
2023-11-21 22:41:54 +08:00
divid
abf9a9048d feat:blasklist 2023-11-20 21:59:00 +08:00
divid
b1030a527a blacklist 2023-11-20 21:51:59 +08:00
Han Fangyuan
8d07ba6332 fix: add tongyi type when init bridge 2023-11-19 23:00:18 +08:00
Han Fangyuan
4ce37f84e4 feat: support Tongyi Qwen model of alibaba 2023-11-19 22:42:44 +08:00
zhayujie
061d8a3a5f Merge pull request #1488 from yy1781051483/master
add xunfei v3.0
2023-11-17 16:29:39 +08:00
zhayujie
374cd5dbb8 feat: support send knowledge base image 2023-11-17 16:27:44 +08:00
zhayujie
5ad53c2b9c fix: reduce error noise when converting speech to text 2023-11-16 10:54:24 +08:00
zhayujie
a2ec1a063d fix: typo 2023-11-10 17:16:15 +08:00
zhayujie
e431dbe2df docs: update readme.md 2023-11-10 17:13:13 +08:00
zhayujie
7218463f9e docs: update README 2023-11-10 16:06:58 +08:00
zhayujie
aeb09a95b0 fix: image vision temporarily cancel error logging 2023-11-10 14:31:07 +08:00
zhayujie
0c8f292e12 feat: add tts speech model 2023-11-10 10:48:52 +08:00
zhayujie
f001ac6903 feat: add dalle3 gpt-4-turbo model change 2023-11-10 10:11:02 +08:00
zhayujie
db8e506de0 feat: add gpt-4-turbo tokens calc 2023-11-07 23:10:39 +08:00
zhayujie
099f859dd4 fix: limit openai sdk version to prevent compatibility issues 2023-11-07 10:34:46 +08:00
Daydreamer
b7684c1c2b add xunfei v3.0 2023-10-29 17:38:56 +08:00
zhayujie
058c167f79 docs: trim help cmd 2023-10-27 14:30:33 +08:00
zhayujie
49446d4872 feat: add wenxin 4.0 model 2023-10-27 14:18:55 +08:00
zhayujie
ced560e1e1 Merge pull request #1485 from zhayujie/feat-agent
feat: show thought and plugin in agent process
2023-10-27 13:27:38 +08:00
zhayujie
339102c3cd Merge pull request #1482 from 6vision/master
自定义入群欢迎语和apilot插件
2023-10-27 12:35:11 +08:00
zhayujie
6331350239 Merge branch 'master' into feat-agent 2023-10-27 12:32:35 +08:00
zhayujie
34e06fcbf8 feat: show thought and plugin in agent process 2023-10-27 12:28:34 +08:00
vision
70aac312ff Merge branch 'zhayujie:master' into master 2023-10-25 21:12:48 +08:00
zhayujie
5e00704152 Merge branch 'master' of github.com:zhayujie/chatgpt-on-wechat 2023-10-23 21:09:54 +08:00
zhayujie
1a9edb6907 fix: plugin config not exist warning 2023-10-23 21:09:18 +08:00
zhayujie
0c18c3a6dd docs: update demo vedio 2023-10-19 21:51:57 +08:00
6vision
847bb51ce4 增加Apilot插件 2023-10-19 19:34:36 +08:00
6vision
fa60a5dc63 增加新人入群自定义欢迎语参数 2023-10-19 19:20:41 +08:00
zhayujie
aaed3f9839 fix: ignore system message 2023-10-18 11:14:44 +08:00
zhayujie
21b956b983 fix: mj open auth bug 2023-10-16 16:44:06 +08:00
zhayujie
792e940279 fix: knowledge base miss suffix bug 2023-10-13 19:12:23 +08:00
zhayujie
c2477b26c0 fix: summary no user_id bug 2023-10-13 18:58:13 +08:00
zhayujie
4b27de809b fix: image create prefix 2023-10-13 18:10:05 +08:00
zhayujie
572932d8e8 docs: update README.md 2023-10-13 16:31:02 +08:00
zhayujie
270dd778d9 docs: update config-template and readme 2023-10-13 16:26:29 +08:00
zhayujie
dd04287b0a Merge pull request #1454 from befantasy/patch-5
Update chat_channel.py fix SHARING Type 报错。
2023-10-13 15:45:00 +08:00
zhayujie
36ac6d005a Merge pull request #1457 from befantasy/master
新增”ContextType.ACCEPT_FRIEND“,方便插件对“同意好友请求”后的事件进行处理。
2023-10-13 15:44:25 +08:00
zhayujie
701daedf49 feat: multi agent plugin 2023-10-13 15:36:20 +08:00
zhayujie
238f05f453 fix: summary plugin group enable bug 2023-10-07 10:50:59 +08:00
zhayujie
dd082bd212 fix: search miss config 2023-09-30 20:02:26 +08:00
zhayujie
cfd2f27b0b feat: knowledge base search miss config 2023-09-30 15:21:26 +08:00
zhayujie
a2160d135e feat: knowledge base miss prefix 2023-09-30 15:14:42 +08:00
zhayujie
16d7836369 fix: summary failed tips 2023-09-29 17:00:47 +08:00
zhayujie
f3de4dcc5f fix: remove mini-program url 2023-09-29 16:37:21 +08:00
zhayujie
e34523028f fix: admin auth bug 2023-09-29 15:52:34 +08:00
zhayujie
efe2fbacd6 Merge branch 'master' of github.com:zhayujie/chatgpt-on-wechat 2023-09-28 16:27:52 +08:00
zhayujie
2fa1df29be fix: file size calc bug 2023-09-28 16:26:53 +08:00
befantasy
f72cd13fba Update wechat_message.py 2023-09-28 16:18:04 +08:00
befantasy
5b552dffbf Update wechat_channel.py 新增 ContextType.ACCEPT_FRIEND 2023-09-28 16:16:30 +08:00
befantasy
a0ae2d13dc Update context.py 新增ContextType "ACCEPT_FRIEND" 2023-09-28 16:11:09 +08:00
befantasy
f7262a0a3a Update chat_channel.py fix SHARING Type 报错。
chatgpt-on-wechat    | [ERROR][2023-09-27 18:48:41][chat_channel.py:211] - [WX] unknown context type: SHARING
2023-09-27 19:26:47 +08:00
zhayujie
9736f121eb Update README.md 2023-09-26 18:43:25 +08:00
zhayujie
7c8fb7eacc Merge pull request #1428 from scut-chenzk/chenzk
修复收到从微信发出的图片消息保存到本地失败的问题
2023-09-26 15:59:23 +08:00
zhayujie
b45eea5908 Merge pull request #1427 from befantasy/master
itchat通道增加ReplyType.FILE/ReplyType.VIDEO/ReplyType.VIDEO_URL,以方便插件的开发。keyword插件增加文件和视频匹配回复
2023-09-26 01:27:35 +08:00
zhayujie
6babf4ee6c Merge pull request #1445 from befantasy/patch-3
Update godcmd.py 增加debug模式的关闭
2023-09-26 00:37:17 +08:00
zhayujie
576526d4ee Merge pull request #1446 from 6vision/master
个人订阅号消息存储优化
2023-09-26 00:36:36 +08:00
zhayujie
c03e31b7be fix: linkai instruction bug 2023-09-25 23:15:59 +08:00
zhayujie
a1aa925019 fix: no summary config bug 2023-09-25 18:30:19 +08:00
zhayujie
a5a234ed97 fix: remove file after summary 2023-09-25 16:42:36 +08:00
zhayujie
5b5dbcd78b feat: remove file word calc and support url link 2023-09-24 14:33:39 +08:00
zhayujie
bd1c6361d3 Update README.md 2023-09-24 12:54:34 +08:00
zhayujie
1fc1febf03 Merge pull request #1450 from zhayujie/feat-doc-chat
feat: 文档总结和与内容对话
2023-09-24 12:30:45 +08:00
zhayujie
55cc35efa9 feat: document summary and chat with content 2023-09-24 12:27:09 +08:00
vision
5ba8fdc5e7 fix 2023-09-23 14:31:54 +08:00
vision
6ea295e227 Merge pull request #1 from 6vision/feat
个人订阅号长语音支持
2023-09-23 13:46:25 +08:00
befantasy
5010c76ef7 Update godcmd.py 增加debug模式的关闭 2023-09-23 13:37:01 +08:00
6vision
79c7f0c29f 个人订阅号长语音支持 2023-09-23 13:27:36 +08:00
6vision
2b3e643786 适配一次请求多条回复 2023-09-23 11:59:01 +08:00
chenzhenkun
90cdff327c 修复收到从微信发出的图片消息保存到本地失败的问题 2023-09-15 19:07:52 +08:00
zhayujie
55c116e727 Update README.md 2023-09-15 18:42:56 +08:00
befantasy
3dd83aa6b7 Update chat_channel.py 2023-09-15 18:38:31 +08:00
befantasy
a74aa12641 Update wechat_channel.py 2023-09-15 18:37:05 +08:00
befantasy
151e8c69f9 Update keyword.py 2023-09-15 18:22:10 +08:00
befantasy
d8bfa77705 Update keyword.py 2023-09-15 16:56:51 +08:00
befantasy
6bd286e8d5 Update wechat_channel.py to support ReplyType.FILE 2023-09-15 16:22:46 +08:00
befantasy
905532b681 Update chat_channel.py to support ReplyType.FILE 2023-09-15 16:21:27 +08:00
zhayujie
04d5c1ab01 Delete .github/ISSUE_TEMPLATE/config.yml 2023-09-15 15:45:23 +08:00
zhayujie
28be141dc7 Merge pull request #1422 from scut-chenzk/chenzk
修复接语音回复失效的问题
2023-09-15 15:14:00 +08:00
chenzk
652b786baf Merge branch 'zhayujie:master' into chenzk 2023-09-14 23:42:00 +08:00
chenzhenkun
ba6c671051 修复收到图片消息保存到本地失败的问题 2023-09-14 23:39:07 +08:00
chenzhenkun
ca25d0433f 修复接语音回复失效的问题 2023-09-14 17:52:11 +08:00
zhayujie
5338106dfa Merge pull request #1308 from leesonchen/master
企业服务号的语音输出进行切割
2023-09-12 18:18:17 +08:00
zhayujie
b6b76be4f6 fix: add summary plugin bot type 2023-09-06 16:50:23 +08:00
zhayujie
03d94fcfa0 fix: not enable user_image_create_prefix by default 2023-09-06 12:02:13 +08:00
zhayujie
b2c5f0d455 feat: mj use default config 2023-09-06 11:53:33 +08:00
zhayujie
54f60dd38c chore: remove dependencies that can only be used under windows 2023-09-04 11:14:48 +08:00
zhayujie
42f181aca2 Merge pull request #1394 from resphinas/claude_bot
Update claude_ai_bot.py
2023-09-04 10:47:02 +08:00
resphina
9c3a27894f Update claude_ai_bot.py 2023-09-03 19:12:27 +08:00
resphina
f7cd348912 Update claude_ai_bot.py 2023-09-03 19:04:43 +08:00
zhayujie
aeaeb75d3b Merge pull request #1396 from 6vision/master
Optimize image download and storage logic
2023-09-03 17:32:30 +08:00
vision
96542b532e Update requirements-optional.txt 2023-09-03 17:14:28 +08:00
vision
139295fe0d Update requirements-optional.txt
增加企微个人号channel所需依赖
2023-09-03 16:47:25 +08:00
vision
13217b2ce2 Merge pull request #1 from 6vision/patch-1
Optimize image download and storage logic
2023-09-03 16:35:01 +08:00
vision
5cc8b56a7c Optimize image download and storage logic
- Implement new compression logic for files larger than 10MB to improve storage efficiency.
- Switch from JPEG to PNG to enhance image quality and compatibility.
2023-09-03 16:29:19 +08:00
resphina
e23e01c95e Update claude_ai_bot.py 2023-09-03 15:40:08 +08:00
resphina
bca8ba12c7 Update claude_ai_bot.py 2023-09-03 15:22:25 +08:00
vision
3c44bdbe1c Update requirements-optional.txt 2023-09-03 15:10:05 +08:00
zhayujie
db93ed025b Merge branch 'master' of github.com:zhayujie/chatgpt-on-wechat 2023-09-02 21:50:28 +08:00
zhayujie
4209e108d0 fix: wework single chat no prefix circle reply 2023-09-02 21:49:43 +08:00
zhayujie
14cbf011af Merge pull request #1391 from resphinas/claude_bot
Rename claude_ai_session to claude_ai_session.py
2023-09-02 10:42:29 +08:00
resphina
03a41ec199 Rename claude_ai_session to claude_ai_session.py 2023-09-02 02:40:57 +08:00
zhayujie
125fe2a026 Merge pull request #1390 from scut-chenzk/chenzk
Chenzk
2023-09-01 19:42:21 +08:00
chenzhenkun
ac4adac29e 兼容微信艾特的情况 2023-09-01 19:37:19 +08:00
chenzhenkun
ac449d078e Merge remote-tracking branch 'origin/chenzk' into chenzk
# Conflicts:
#	channel/chat_channel.py
2023-09-01 19:22:02 +08:00
chenzhenkun
79be4530d4 防止命中前缀导致死循环的情况 2023-09-01 19:18:53 +08:00
chenzk
85ce52d70c Merge branch 'zhayujie:master' into chenzk 2023-09-01 18:57:52 +08:00
chenzhenkun
7ab56b9076 添加日志以方便定位问题 2023-09-01 18:56:24 +08:00
zhayujie
dedf976375 Merge pull request #1389 from scut-chenzk/chenzk
修复自己艾特自己会死循环的问题
2023-09-01 18:42:41 +08:00
chenzhenkun
89f438208a 修复自己艾特自己会死循环的问题 2023-09-01 18:39:31 +08:00
zhayujie
ffbc5080ae Merge pull request #1388 from resphinas/claude_bot
实现claude对接配置中的 共享上下文开关
2023-09-01 18:34:43 +08:00
resphina
4167f13bac Update README.md 2023-09-01 18:12:48 +08:00
resphina
6ba0baabb0 Update claude_ai_bot.py 2023-09-01 18:04:39 +08:00
resphina
081003df47 Update config.py 2023-09-01 17:55:09 +08:00
resphina
559194ffb2 Update config.py 2023-09-01 17:54:03 +08:00
resphina
97a26d4a46 Update README.md 2023-09-01 17:53:21 +08:00
resphina
503c6c9b7e Update claude_ai_bot.py 2023-09-01 17:31:30 +08:00
resphina
9a1e10deff Create claude_ai_session 2023-09-01 17:30:31 +08:00
zhayujie
054f927c05 fix: at_list bug in wechat channel 2023-09-01 13:45:04 +08:00
resphina
22210747d0 Update README.md 2023-09-01 12:40:09 +08:00
resphina
53b2deb72c 更新机器人相关接口文档说明 2023-09-01 12:38:58 +08:00
zhayujie
6fc158e7d6 hotfix: config.py format 2023-09-01 11:32:58 +08:00
zhayujie
a23a65c731 Merge pull request #1382 from resphinas/claude_bot
新增Claude聊天机器人接口(逆向cookie实现,稳定不失效)
2023-09-01 10:48:33 +08:00
resphina
7dc7105ee2 Update requirements-optional.txt 2023-09-01 10:32:33 +08:00
resphina
bac70108b2 Update requirements.txt 2023-09-01 10:32:03 +08:00
resphina
297404b21e Update config-template.json 2023-09-01 10:31:45 +08:00
resphina
33a7f8b558 Delete chatgpt-on-wechat-master.iml 2023-09-01 10:08:34 +08:00
resphina
4a670b7df7 Update config-template.json 2023-09-01 09:40:26 +08:00
resphina
79e4af315e Update log.py 2023-09-01 09:39:45 +08:00
resphina
c6e31b2fdc Update chat_gpt_bot.py 2023-09-01 09:39:08 +08:00
resphina
91dc44df53 Update const.py 2023-09-01 09:38:47 +08:00
resphina
7e57f8f157 Merge branch 'master' into claude_bot 2023-09-01 09:37:10 +08:00
zhayujie
15f6b7c6d3 Merge pull request #1385 from scut-chenzk/chenzk
支持wework企业微信机器人
2023-08-31 22:44:17 +08:00
chenzhenkun
b213ba541d 新增wework企业微信机器人支持插件功能 2023-08-31 21:02:00 +08:00
chenzhenkun
7c6ed9944e 支持wework企业微信机器人 2023-08-30 20:49:00 +08:00
resphinas
a5a825e439 system role remove 2023-08-29 06:45:21 +08:00
resphinas
a4ab547f77 proxy update 2023-08-29 05:59:59 +08:00
resphinas
76ed763abe proxy update 2023-08-29 05:58:39 +08:00
resphinas
b9e3125610 格式纠正2 2023-08-28 18:04:28 +08:00
resphina
8d9d5b7b6f Update claude_ai_bot.py 2023-08-28 17:40:27 +08:00
resphina
187601da1e Update config-template.json 2023-08-28 17:30:03 +08:00
resphina
cc3a0fc367 Update config-template.json 2023-08-28 17:28:13 +08:00
resphinas
44cc4165d1 claude_bot 2023-08-28 17:22:20 +08:00
resphinas
f98b43514e claude_bot 2023-08-28 17:18:00 +08:00
resphinas
3c9b1a14e9 claude bot update 2023-08-28 16:43:26 +08:00
zhayujie
827e8eddf8 chore: remove dockerhub in arm build 2023-08-27 12:28:10 +08:00
zhayujie
7bc27d6167 fix: remove docker hub register in arm build 2023-08-27 12:10:08 +08:00
zhayujie
ba06edd63a fix: remove pysilk_mod 2023-08-26 17:32:52 +08:00
zhayujie
cacf553a5b feat: add arm workflows 2023-08-26 17:17:03 +08:00
zhayujie
d89091a8ea fix: git action deploy 2023-08-26 14:14:32 +08:00
zhayujie
01a56e1155 feat: try arm docker image 2023-08-26 12:45:16 +08:00
zhayujie
a64d7c42b1 fix: xunfei ws error log 2023-08-26 11:46:01 +08:00
zhayujie
36b6cc58bf fix: on_close params 2023-08-26 11:37:27 +08:00
zhayujie
5ac8a257e7 fix: add gpt-3.5-turbo in model_list 2023-08-26 10:50:31 +08:00
zhayujie
74119d0372 fix: websocket version 2023-08-25 23:57:59 +08:00
zhayujie
4e162c73e5 fix: update websocket version 2023-08-25 23:10:47 +08:00
zhayujie
5ff753a492 feat: add global model check 2023-08-25 17:26:40 +08:00
zhayujie
89400630c0 fix: xunfei client bug 2023-08-25 16:55:32 +08:00
zhayujie
3899c0cfe3 Merge pull request #1371 from uezhenxiang2023/Peter
add ElevenLabs TTS to voice factory
2023-08-25 16:15:18 +08:00
zhayujie
a086f1989f feat: add xunfei spark bot 2023-08-25 16:06:55 +08:00
zhayujie
1171b04e93 fix: wenxin token discard bug 2023-08-25 12:24:16 +08:00
uezhenxiang2023
c55d81825a Merge branch 'zhayujie:master' into Peter 2023-08-25 12:12:06 +08:00
zhayujie
2dcd026e9f logs: add baidu reply log 2023-08-25 11:19:00 +08:00
zhayujie
cdf8609d24 Merge pull request #1360 from zyqfork/master
dockerfile fallback debian11,fix azure cognitiveservices speech error
2023-08-25 01:24:34 +08:00
zhayujie
36580c5f7f Merge pull request #1363 from iRedScarf/master
把温度值设置默认放进config.json
2023-08-25 01:24:02 +08:00
zhayujie
1cff2521f4 fix: add web.py and linkai base url 2023-08-22 11:09:01 +08:00
uezhenxiang2023
db4998a56b replace requests with elevenlabs for audio generation 2023-08-20 10:58:26 +08:00
uezhenxiang2023
acbd506568 add ElevenLabs TTS to voice factory 2023-08-19 11:20:47 +08:00
eks
0cf8e3be73 Merge branch 'zhayujie:master' into master 2023-08-16 16:54:34 +08:00
zhayujie
2473334dfc fix: channel send compatibility and add log 2023-08-14 23:09:51 +08:00
eks
1ff72d1d37 Merge branch 'zhayujie:master' into master 2023-08-11 13:50:11 +08:00
eks
241fad5524 Update config-template.json
把温度值默认放进config.json
2023-08-11 13:49:47 +08:00
zouyq
1b48cea50a dockerfile fallback debian11,fix azure cognitiveservices speech error
Python 3.10-slim based Debian 12, using Azure TextToVoice may result in an error. the Speech SDK does not currently support OpenSSL 3.0, which is the default version in Ubuntu 22.04 and Debian 12
2023-08-10 17:39:25 +08:00
zhayujie
88bf345b91 docs: update plugin README 2023-08-08 17:03:18 +08:00
zhayujie
ab4ff3d1a3 config: reduce the config of baidu-wenxin 2023-08-08 16:04:25 +08:00
zhayujie
3502e0d643 Merge pull request #1336 from kevin808/master
添加百度文心一言接口
2023-08-08 15:46:47 +08:00
zhayujie
995894d3aa Merge branch 'master' into master 2023-08-08 15:46:07 +08:00
zhayujie
4da8714124 Merge pull request #1358 from zhayujie/feat-1.3.5
feat: add midjourney variation and reset
2023-08-08 11:21:35 +08:00
zhayujie
6b247ae880 feat: add midjourney variation and reset 2023-08-07 19:14:09 +08:00
zhayujie
176941ea3b Merge pull request #1357 from zhayujie/feat-1.3.5
feat: add plugin instructions and fix some issues
2023-08-07 14:44:03 +08:00
zhayujie
5176b56d3b fix: global plugin read encoding 2023-08-07 14:42:24 +08:00
zhayujie
8abf18ab25 feat: add knowledge base and midjourney switch instruction 2023-08-06 17:57:07 +08:00
zhayujie
395edbd9f4 fix: only filter messages sent by the bot itself in private chat 2023-08-06 16:02:02 +08:00
zhayujie
2386eb8fc2 fix: unable to use plugin when group nickname is set 2023-08-06 15:44:48 +08:00
zhayujie
68208f82a0 docs: update README.md 2023-08-01 00:08:39 +08:00
zhayujie
ca916b7ce5 fix: default to fast mode 2023-07-31 21:40:50 +08:00
zhayujie
01e02934da Merge pull request #1334 from zyqfork/master
azure api add api-version https://learn.microsoft.com/zh-cn/azure/ai-serv…
2023-07-31 18:40:06 +08:00
zhayujie
c81a79f7b9 Merge pull request #1104 from mari1995/feat_my_msg
feat: 手机上回复消息,不触发机器人
2023-07-31 18:02:41 +08:00
zhayujie
1133648bf6 Merge branch 'master' of github.com:zhayujie/chatgpt-on-wechat 2023-07-31 17:58:06 +08:00
zhayujie
e05bc541d7 Merge pull request #1346 from befantasy/patch-1
Update keyword.py 增加返回图片的功能
2023-07-31 17:53:46 +08:00
zhayujie
d689d20482 docs: update README.md 2023-07-31 17:52:05 +08:00
zhayujie
39dd99b272 Merge pull request #1343 from zhayujie/feat-1.3.4
feat: add midjourney and app manager plugin
2023-07-31 17:15:22 +08:00
zhayujie
cda21acb43 feat: use new linkai completion api 2023-07-31 16:11:33 +08:00
zhayujie
9bd7d09f20 fix: remove relax mode temporarily 2023-07-31 14:42:50 +08:00
zhayujie
b22994c2d2 fix: some image bug 2023-07-30 19:55:56 +08:00
zhayujie
e027286b6d fix: midjourney check task thread 2023-07-30 15:16:19 +08:00
befantasy
d6e16995e0 Update keyword.py 增加返回图片的功能
增加返回图片的功能。以http/https开头,且以.jpg/.jpeg/.png/.gif结尾的内容,识别为URL,自动以图片发送。
2023-07-30 14:40:07 +08:00
zhayujie
782bff3a51 fix: add debug log 2023-07-29 12:22:45 +08:00
zhayujie
de26dc0597 fix: fast mode and relax mode checkout 2023-07-28 18:50:21 +08:00
zhayujie
233b24ab0f feat: add global admin config 2023-07-28 16:33:41 +08:00
zhayujie
2f9e5b1219 feat: check app_code dynamically 2023-07-28 12:40:06 +08:00
zhayujie
dd36b8b150 config: add config template 2023-07-27 21:29:50 +08:00
zhayujie
f81ac31fe1 feat: add linkai plugin to support midjourney and distinguish app between groups 2023-07-27 21:21:36 +08:00
Kevin Li
24b63bc5bd Add Baidu access token validation 2023-07-25 11:11:02 +08:00
Kevin Li
1817a972c6 Add Baidu Wenxin Bot 2023-07-25 09:52:47 +08:00
zyqcn@live.com
74a253f521 azure api add api-version:https://learn.microsoft.com/zh-cn/azure/ai-services/openai/reference 2023-07-24 16:28:05 +08:00
zhayujie
41762a1c57 Merge pull request #1332 from zhayujie/feat-1.3.3
fix: reduce memory usage
2023-07-21 17:18:56 +08:00
zhayujie
a786fa4b75 fix: reduce the expiration time and avoid storing the original message text to decrease memory usage 2023-07-21 17:16:34 +08:00
zhayujie
e4c7602c0c docs: update README.md 2023-07-21 17:14:11 +08:00
zhayujie
e0d2e34980 Merge pull request #1328 from zhayujie/feat-1.3.3
feat: support global plugin config for docker env
2023-07-21 10:50:16 +08:00
zhayujie
9ef8e1be3f feat: move loading config method to base class 2023-07-20 16:08:19 +08:00
zhayujie
aae9b64833 fix: reduce unnecessary error traceback logs 2023-07-20 14:46:41 +08:00
zhayujie
4bab4299f2 fix: global plugin config read 2023-07-20 14:24:40 +08:00
zhayujie
954e55f4b4 feat: add plugin global config to support docker volumes 2023-07-20 11:36:02 +08:00
zhayujie
2361e3c28c docs: update README for railway cancelled free service 2023-07-19 18:23:59 +08:00
leeson
8224c2fc16 企业服务号的语音输出进行切割 2023-07-08 23:58:07 +08:00
zhayujie
8aac86f0a9 Merge pull request #1291 from 6vision/master
(tool)fix azure model
2023-07-05 01:44:06 +08:00
vision
6384e9310b plugin(tool): 更新0.4.6
1、temp fix summary tool not ending bug
2、兼容0613 gpt-3.5
3、add azure's model name: gpt-35-turbo
2023-07-05 01:06:53 +08:00
vision
7a9205dfba fix azure model
更新chatgpt_tool_hub至0.4.6,拉取最新代码。tool即可使用azure接口!
2023-07-05 01:01:46 +08:00
Jianglang
94b47a56f4 Merge pull request #1282 from haikerapples/master_haiker_timetask
内置 timetask 插件
2023-07-01 18:37:07 +08:00
zhayujie
709b5be634 fix: group voice config and azure model calc support 2023-07-01 13:17:08 +08:00
haikerwang
f970b2c168 内置 timetask 插件 2023-06-29 00:58:57 +08:00
zhayujie
973acb37ed docs: update README.md 2023-06-27 22:28:51 +08:00
zhayujie
1c9020a565 docs: update README.md 2023-06-26 23:52:32 +08:00
zhayujie
c5f1d0042c docs: update README.md 2023-06-26 20:11:35 +08:00
zhayujie
fa706e8b1d Merge pull request #1275 from zhayujie/feat-docker
chore: remove useless docker files
2023-06-26 14:16:18 +08:00
zhayujie
12c170f227 chore: remove useless docker files 2023-06-26 14:05:08 +08:00
zhayujie
db27dfe227 docs: modify docker deploy steps 2023-06-26 13:10:51 +08:00
zhayujie
2db4673392 chore: fixed openai version 2023-06-26 12:29:09 +08:00
zhayujie
38619db629 Merge pull request #1274 from zhayujie/feat-dockerhub
feat: modify docker-compose file to pull image from dockerhub
2023-06-26 12:00:57 +08:00
zhayujie
930fd436ea feat: modify docker-compose file to pull image from dockerhub 2023-06-26 11:58:55 +08:00
zhayujie
98b8ff2fc8 Merge pull request #1271 from zhayujie/feat-dockerhub
feat: publish to dockerhub in github CI simultaneously
2023-06-26 01:24:24 +08:00
zhayujie
d0662683f9 feat: publish to dockerhub in github CI simultaneously 2023-06-26 01:20:04 +08:00
zhayujie
957f2574a9 Merge pull request #1257 from 6vision/master
add reply_suffix
2023-06-17 16:50:11 +08:00
vision
109b362ebd Update config.py 2023-06-17 16:42:52 +08:00
vision
ff3fdfa738 add reply_suffix 2023-06-17 16:36:08 +08:00
vision
e2636ed54a add replay_suffix
增加自动回复后缀的可选配置参数
2023-06-17 15:53:49 +08:00
vision
dbe2f17e1a add reply_suffix
增加私聊和群聊回复后缀的可选配置
2023-06-17 15:46:03 +08:00
zhayujie
4dc535673f Merge pull request #1252 from 6vision/master
Update Tool README.md
2023-06-16 15:48:04 +08:00
vision
f414b6408e Update README.md 2023-06-16 15:08:57 +08:00
lanvent
3aa2e6a04d fix: caclucate tokens correctly for *0613 models 2023-06-16 00:51:29 +08:00
lanvent
1963ff273f chore(hello): change plugin logic 2023-06-14 13:40:20 +08:00
lanvent
bb737a71d5 feat: update counting tokens for new models 2023-06-14 13:36:07 +08:00
zhayujie
a582a46ce9 fix: call super init 2023-06-12 14:05:47 +08:00
zhayujie
abf80a3266 docs: update README 2023-06-12 13:52:49 +08:00
Jianglang
d768f5c66d Update README.md 2023-06-11 00:02:18 +08:00
lanvent
b25e843351 feat(link_ai_bot.py): add support for creating images using OpenAI's DALL-E API 2023-06-10 23:52:25 +08:00
lanvent
419a3e518e feat: make plugin compatible with LINKAI in most cases 2023-06-10 23:42:43 +08:00
lanvent
d1b867a7c0 feat: support scene without app code in linkai 2023-06-10 21:28:15 +08:00
lanvent
c34d70b3cb fix: add warning log when pysilk module is not installed 2023-06-10 11:22:12 +08:00
lanvent
a33df9312f fix: warning message when using azure model 2023-06-10 11:06:50 +08:00
Jianglang
ebf8db0b37 Merge pull request #1238 from chenzefeng09/fix_baidu_voice_init
fix: baidu voice init params type error
2023-06-10 00:48:41 +08:00
chenzefeng.09
e539ae3b69 fix: baidu voice init params type error 2023-06-09 18:54:58 +08:00
lanvent
4c5e8850aa fix: env vars type error (#1127) 2023-06-09 14:46:43 +08:00
zhayujie
94c0af3037 feat: support scen without app code 2023-06-08 23:57:59 +08:00
zhayujie
165182c68f config: remove the config temporarily and consider integrating it as a plugin 2023-06-08 20:58:59 +08:00
Jianglang
65b9542599 Merge pull request #1221 from Zhaoyi-Yan/patch-3
add \n after @nickname for group chat
2023-06-08 11:53:14 +08:00
Jianglang
d01d1f8830 Merge pull request #1220 from Zhaoyi-Yan/patch-2
Add azure_deployment_id to Readme for Azure chatgpt.
2023-06-08 11:48:44 +08:00
Jianglang
ad3e9f3d42 Update README.md 2023-06-08 11:44:17 +08:00
Jianglang
4589974095 Update README.md 2023-06-08 11:42:39 +08:00
Jianglang
ed4553ddf8 Update README.md 2023-06-08 11:42:12 +08:00
Zhaoyi-Yan
ff97ae73f1 add \n after @nickname for group chat 2023-06-06 15:16:57 +08:00
Zhaoyi-Yan
f96b4d2781 Add azure_deployment_id to Readme for Azure chatgpt. 2023-06-06 14:44:09 +08:00
zhayujie
ce32cfffdb docs: update README.md 2023-06-06 14:02:32 +08:00
zhayujie
f66df8531e Update README.md 2023-06-06 09:54:34 +08:00
zhayujie
dfe1c23e76 Merge pull request #1218 from zhayujie/feature-app-market
feat: no quota hint and add group qrcode
2023-06-05 23:55:25 +08:00
zhayujie
07fd81919f docs: udapte readme 2023-06-05 23:53:34 +08:00
zhayujie
210042bb81 feat: no quota hint and add group qrcode 2023-06-05 23:21:24 +08:00
lanvent
12dc7427e9 make railway happy 2023-06-02 22:15:20 +08:00
lanvent
b476085110 fix: custom GPT model bug 2023-05-30 23:42:06 +08:00
zhayujie
776cdaf63c Merge pull request #1168 from zhayujie/feature-app-market
fix: config name optimize
2023-05-29 16:36:38 +08:00
zhayujie
69b6855745 fix: comment modify 2023-05-29 15:55:48 +08:00
zhayujie
3590babd8b fix: config name optimize 2023-05-29 15:52:26 +08:00
zhayujie
c29d391c1d Merge pull request #1167 from zhayujie/feature-app-market
feature:  support online knowledge base
2023-05-29 15:41:12 +08:00
zhayujie
50e44dbb2a fix: session save 2023-05-28 22:12:36 +08:00
zhayujie
34277a3940 feat: add app market 2023-05-28 19:08:23 +08:00
lanvent
f1a00d58ca chore(Dockerfile.latest): comment out the sed command to replace apt source with tuna mirror
The sed command to replace the apt source with the tuna mirror has been commented out. This is because the command is not necessary for the current build and may cause issues in the future.
2023-05-17 22:24:25 +08:00
Jianglang
d1a5f17ae8 Merge pull request #1102 from goldfishh/master
plugin(tool): 更新0.4.4
2023-05-17 16:13:03 +08:00
SSMario
4dbc54fa15 Revert "feat: 增加eleventLabs"
This reverts commit 1d4ff796d7.
2023-05-16 12:00:05 +08:00
SSMario
1d4ff796d7 feat: 增加eleventLabs 2023-05-16 11:50:54 +08:00
SSMario
44cb54a9ea feat: 手机上回复消息,不触发机器人 2023-05-16 09:38:38 +08:00
goldfishh
6409f49609 plugin(tool): 更新0.4.4
1. 支持azure、api转发服务
2. 修复browser代理无前缀报错的问题
3. 优化core prompt
4. 修复系列issue提到的问题
2023-05-16 00:22:32 +08:00
Jianglang
9ee0ea88b5 Merge pull request #1089 from taoguoliang/master-fork
feat(命令): 添加set_gpt_model、set_gpt_model、set_gpt_model 几个命令的使用
2023-05-15 23:34:04 +08:00
Jianglang
a3819d8673 Merge pull request #1096 from lichengzhe/master
处理cloudflare Bad Gateway异常,自动重试。
2023-05-15 23:32:03 +08:00
lichengzhe
2d7dd71a3d Bad Gateway exception retry 2023-05-15 14:04:55 +08:00
lichengzhe
0e8195ae61 Bad Gateway exception retry 2023-05-15 13:55:14 +08:00
taoguoliang
3e92d07618 feat(命令): 添加set_gpt_model、set_gpt_model、set_gpt_model 几个命令的使用 2023-05-13 16:57:02 +08:00
Jianglang
e59597280d Merge pull request #1079 from 6vision/6vision-patch-1
Update README.md
2023-05-11 20:21:05 +08:00
vision
f2e3d69d8a Update README.md
新闻类工具整合后,工具名称变更了,调整一下位置,更能引起注意
2023-05-11 15:49:55 +08:00
lanvent
9d2cb75c84 fix(docker): chown /usr/local/lib in debian dockerfile 2023-05-10 23:12:43 +08:00
Jianglang
f971505c4a Update README.md 2023-05-09 23:29:03 +08:00
lanvent
2133c1d6af fix(Dockerfile): create /home/noroot directory and change ownership of it 2023-05-09 23:08:20 +08:00
Jianglang
0bf06ddfd3 Merge pull request #1046 from theLastWinner/master
fix(企业微信):补充缺失依赖textwrap
2023-05-08 17:33:46 +08:00
Jianglang
024a50d642 Merge pull request #1045 from wqh0109663/master
fix docker entrypoint
2023-05-08 17:33:22 +08:00
林督翔
e4eebd64d1 fix(企业微信):补充缺失依赖textwrap 2023-05-08 09:39:32 +08:00
wuqih
c9055989e9 fix 2023-05-08 09:09:46 +08:00
lanvent
4f1ed197ce fix: compatible with python 3.7 2023-05-07 23:36:35 +08:00
Jianglang
3e710aa2a1 Merge pull request #1032 from wqh0109663/master
修复docker入口错误
2023-05-06 17:16:06 +08:00
wuqih
b6226a45bb fix 2023-05-06 14:29:36 +08:00
lanvent
3001ba9266 fix: azure dalle generate image 2023-04-28 11:06:17 +08:00
lanvent
b0a401a1ed fix(azure_dalle): use openai.api_base 2023-04-28 10:53:30 +08:00
Jianglang
6b4dc37428 Update README.md 2023-04-28 01:24:26 +08:00
lanvent
8528c9b262 feat(tool.py): add new configuration options for think_depth, arxiv_summary, and morning_news_use_llm 2023-04-28 00:24:07 +08:00
lanvent
7222a5c2f4 feat: add VERSION constant 2023-04-28 00:13:13 +08:00
lanvent
59050001ef Update README.md 2023-04-28 00:10:57 +08:00
lanvent
2ba8f18724 feat: add railway method for wechatcomapp 2023-04-28 00:04:55 +08:00
lanvent
fb22e01b89 fix: send voice in wechatcomapp rightly 2023-04-27 23:04:24 +08:00
lanvent
76a81d5360 feat(wechatcomapp): add support for splitting long audio files 2023-04-27 22:47:50 +08:00
lanvent
3314b05648 feat: add support for azure dalle 2023-04-27 22:16:42 +08:00
lanvent
45b89218de fix: support set_openai_api_key for all channels 2023-04-27 20:43:12 +08:00
lanvent
beb7bda243 fix(docker): use debian.latest as latest image 2023-04-27 19:45:51 +08:00
lanvent
bef2896f50 add libavcodec-extra to Dockerfile 2023-04-27 15:09:24 +08:00
lanvent
9fea949b25 fix(azure_voice.py): log error details instead of cancellation details 2023-04-27 11:42:19 +08:00
lanvent
be258e5b05 fix: add more log in itchat 2023-04-27 11:23:28 +08:00
lanvent
008178d737 fix(login.py): add error message when retry count is exceeded 2023-04-27 11:03:08 +08:00
lanvent
527d5e1dbc fix(itchat): add error log when hot reload fails and log out before logging in normally 2023-04-27 02:46:53 +08:00
lanvent
9b47e2d6f9 fix: output itchat error msg rightly 2023-04-26 22:54:53 +08:00
lanvent
8781b1e976 fix: role,dungeon,godcmd support azure bot 2023-04-26 01:05:23 +08:00
Jianglang
38c653d8d8 Merge pull request #957 from goldfishh/master
plugin(tool): 更新0.4.2
2023-04-26 00:53:07 +08:00
lanvent
74e48bb137 Update README.md 2023-04-26 00:49:40 +08:00
goldfishh
c3aaa1f735 plugin(tool): 更新0.4.2 2023-04-26 00:48:54 +08:00
lanvent
bead2aa228 fix: a typo in template 2023-04-26 00:23:08 +08:00
Jianglang
dc52ab8aa9 Merge pull request #944 from zhayujie/wechatcom-app
添加企业微信应用号部署方式,支持插件,支持语音图片交互
2023-04-26 00:02:31 +08:00
lanvent
20b71f206b feat: add subscribe_msg option for wechatmp, wechatmp_service, and wechatcom_app channels 2023-04-26 00:01:04 +08:00
lanvent
73c87d5959 fix(wechatcomapp): split long text messages into multiple parts 2023-04-25 01:48:15 +08:00
lanvent
c6601aaeed fix: ensure get access_token thread-safe 2023-04-25 01:11:50 +08:00
lanvent
6e14fce1fe docs: update README.md for wechatcom_app 2023-04-25 00:44:16 +08:00
lanvent
be5a62f1b8 Merge Pull Request #936 into wechatcom-app 2023-04-24 22:41:42 +08:00
Jianglang
1fa8cefaea Add contact link in ISSUE_TEMPLATE 2023-04-24 16:38:19 +08:00
Jianglang
d7c251ac83 Update README.md 2023-04-24 02:21:44 +08:00
lanvent
d03229a183 Update ISSUE_TEMPLATE 2023-04-24 02:06:34 +08:00
lanvent
243482e829 Update ISSUE_TEMPLATE 2023-04-24 02:02:16 +08:00
lanvent
79d10be8a0 fix(wechatmp): add clear_quota_lock to ensure thread safe 2023-04-24 00:38:34 +08:00
JS00000
dca5c058e0 fix: Avoid the same filename under multithreading (#933) 2023-04-23 23:56:32 +08:00
lanvent
9163ce71fd fix: enable plugins for wechatcom_app 2023-04-23 16:51:16 +08:00
lanvent
2ec5374765 feat:modify wechatcom to wechatcom_app 2023-04-23 15:40:28 +08:00
lanvent
d6a4b35cd3 chore: add numpy version constraint 2023-04-23 15:07:38 +08:00
lanvent
8205d2552c fix(Dockerfile): add extra-index-url to pip install command 2023-04-23 15:01:10 +08:00
lanvent
9a99caeb9d chore: add fetch_translate method to Bridge class 2023-04-23 05:12:50 +08:00
lanvent
1e09bd0e76 feat(azure_voice): add language detection, support mulitple languages 2023-04-23 04:28:46 +08:00
lanvent
cae12eb187 feat: add baidu translate api 2023-04-23 03:54:16 +08:00
zhayujie
8bb36e0eb6 Merge pull request #926 from zhayujie/dev
docs: update README
2023-04-22 18:04:04 +08:00
zhayujie
d183204caa docs: update README.md 2023-04-22 18:02:12 +08:00
zhayujie
4a22ae6b61 docs: update README.md 2023-04-22 17:53:43 +08:00
lanvent
a52f54d988 docs(wechatmp): Update README.md 2023-04-22 12:15:56 +08:00
lanvent
618c94edb8 formatting: run precommit on all files 2023-04-22 12:01:29 +08:00
lanvent
eaf4e9174f style(linting): increase max-line-length to 176
The max-line-length configuration was increased to 176 in both .flake8 and pyproject.toml files to allow for longer lines of code.
2023-04-22 11:59:12 +08:00
lanvent
4af2c7f3d7 fix: escape regex pattern 2023-04-22 11:39:59 +08:00
lanvent
361f599df0 fix: escape regex patterns when matching name 2023-04-22 11:29:41 +08:00
Jianglang
ffe4ea5e4c Update README.md 2023-04-22 11:12:30 +08:00
Jianglang
9461e3e01a Merge pull request #912 from zhayujie/wechatmp
公众号功能优化:支持图片输入、消息加密模式、用户体验优化
2023-04-22 11:08:08 +08:00
lanvent
7c85c6f742 feat(wechatmp): add support for message encryption
- Add support for message encryption in WeChat MP channel.
- Add `wechatmp_aes_key` configuration item to `config.json`.
2023-04-22 02:33:51 +08:00
lanvent
b5df6faadf feat: verify server when receive message in wechatmp 2023-04-22 01:30:21 +08:00
lanvent
7cefe2d825 fix: split long text messages into multiple parts in wechatmp_service 2023-04-21 21:03:38 +08:00
lanvent
350633b69b Merge Purll Request #920 into wechatmp 2023-04-21 20:46:16 +08:00
JS00000
1cd6a71ce0 fix the bug of pytts in linux 2023-04-21 18:31:20 +08:00
JS00000
3a08b002a0 Merge remote-tracking branch 'origin/wechatmp' into wechatmp 2023-04-21 16:20:57 +08:00
lanvent
665001732b feat: add image compression
Add image compression feature to WechatComAppChannel to compress images larger than 10MB before uploading to WeChat server. The compression is done using the `compress_imgfile` function in `utils.py`. The `fsize` function is also added to `utils.py` to calculate the size of a file or buffer.
2023-04-21 15:29:59 +08:00
lanvent
cca49da730 fix: fix subscribe_msg 2023-04-21 13:49:51 +08:00
lanvent
f6d370ad29 fix: check if event is subscribe 2023-04-21 13:43:01 +08:00
lanvent
c9131b333b feat: add clear_quota_v2 method to clear API quota when it's used up 2023-04-21 13:41:21 +08:00
lanvent
e44161bf42 fix: voice_reply_voice not work 2023-04-21 03:28:31 +08:00
lanvent
a26189fb25 chore: remove passive_reply_message.py 2023-04-21 03:04:50 +08:00
lanvent
89dd8a1db6 refactor(wechatmp): use wechatpy to handle wechatmp messages
feat(wechatmp): add support for image and voice replies
2023-04-21 02:47:33 +08:00
JS00000
650e0b4ad4 wechatmp: adjust log 2023-04-21 02:16:13 +08:00
lanvent
c60f0517fb refactor(audio_convert.py): remove redundant functions 2023-04-20 23:22:08 +08:00
lanvent
0f8dc91a8b fix: add check for empty command and return error message if so 2023-04-20 23:13:07 +08:00
lanvent
b58feb5d8e Merge Pull Request #904 into master 2023-04-20 23:06:17 +08:00
JS00000
71c8043699 update README 2023-04-20 12:35:54 +08:00
JS00000
40264bc9cb fix: delete permanent media 2023-04-20 12:03:48 +08:00
JS00000
a7772316f9 feat: wechatmp channel support voice/image reply 2023-04-20 10:26:58 +08:00
JS00000
34209021c8 fix: pytts second round not work 2023-04-20 09:04:42 +08:00
lanvent
3e9e8d442a docs: add README.md for wechatcomapp channel 2023-04-20 08:43:17 +08:00
lanvent
d2bf90c6c7 refactor: rename WechatComChannel to WechatComAppChannel 2023-04-20 08:31:42 +08:00
JS00000
1e58c1ad2b fix: wechatmp channel now do not need client 2023-04-20 04:35:06 +08:00
JS00000
8cea022ec5 Merge branch 'master' into wechatmp 2023-04-20 03:41:37 +08:00
JS00000
f32f8aa08e Update readme, and make the structure more clear 2023-04-20 03:18:21 +08:00
lanvent
3ea8781381 feat(wechatcom): add support for sending image 2023-04-20 02:14:52 +08:00
lanvent
ab83dacb76 feat(wechatcom): add support for sending voice messages 2023-04-20 01:46:23 +08:00
lanvent
4cbf46fd4d feat: add support for wechatcom channel 2023-04-20 01:03:04 +08:00
goldfish菌
0a7d6e4577 plugin(tool) ver0.4.1 (#891)
* plugin(tool) fix bugs

* plugin(tool) tool插件更新至0.4.1 版本
2023-04-19 10:05:28 +08:00
JS00000
df4c1f0401 wechatmp: logic simplification 2023-04-19 01:56:25 +08:00
JS00000
9a86a67984 update readme 2023-04-19 01:54:20 +08:00
lanvent
a0cbe9c3e2 feat(azure_voice.py): improve error logging in voiceToText method 2023-04-19 00:55:22 +08:00
lanvent
a83e5a9b65 feat(azure_voice.py): improve error logging in textToVoice method 2023-04-19 00:51:52 +08:00
lanvent
de33911460 feat: add support for PATPAT context 2023-04-18 23:34:08 +08:00
lanvent
0be56e5b25 Merge branch Pull Request #882 into master 2023-04-18 14:26:16 +08:00
lanvent
abcbb34b1c fix(chat_gpt_bot.py, open_ai_bot.py): increase retry time to 20 seconds when encountering RateLimitError 2023-04-18 14:18:22 +08:00
林督翔
6a13dd04a3 feat(插件开发):新增关键字匹配插件 2023-04-18 13:57:20 +08:00
lanvent
f2e29f3f2e fix: banwords help 2023-04-18 11:43:34 +08:00
JS00000
68361cddd2 wechatmp_service: image and voice reply supported 2023-04-18 03:08:18 +08:00
lanvent
6404332adc feat: itchat support joingroup message 2023-04-18 02:21:41 +08:00
JS00000
e060b6fea2 Merge branch 'master' into wechatmp 2023-04-17 20:11:41 +08:00
lanvent
e8aae27ee9 fix: missing lib in banwords 2023-04-17 15:41:29 +08:00
lanvent
2f732e5493 fix: toolhub request_timeout should be str 2023-04-17 12:00:28 +08:00
lanvent
65f20ff2c1 Merge Pull Request #860 into master 2023-04-17 01:24:39 +08:00
lanvent
8f72e8c3e6 formatting code 2023-04-17 01:01:02 +08:00
lanvent
3b8972ce1f add pre-commit hook 2023-04-17 00:57:48 +08:00
李超
fc5d3e4e9c feat: Make the size parameter of the resulting picture configurable 2023-04-16 22:31:53 +08:00
李超
29fbf69945 feat: Add configuration items to support custom data directories and facilitate the storage of itchat.pkl 2023-04-16 22:31:53 +08:00
lanvent
583440b82b banwords: move WordsSearch to lib 2023-04-16 19:04:21 +08:00
lanvent
720de9d73f chore: strip content 2023-04-16 00:47:32 +08:00
lanvent
78332d882b Update source.json 2023-04-13 21:34:04 +08:00
lanvent
2dfbc840b3 chore: save model args as a dict 2023-04-13 20:44:08 +08:00
lanvent
0b4bf15163 Update nixpacks.toml 2023-04-13 20:08:53 +08:00
lanvent
2989249e4b chore: add calc_tokens method on session 2023-04-13 20:06:33 +08:00
lanvent
9cef559a05 feat: support receive_message event 2023-04-13 10:50:18 +08:00
zhayujie
47fe16c92a Merge pull request #818 from goldfishh/master
plugin(tool): 新增morning-news tool
2023-04-12 23:09:43 +08:00
goldfishh
36b5c821ff plugin(tool): 新增morning-news tool 2023-04-12 22:23:00 +08:00
lanvent
82ec440b45 banwords: support reply filter 2023-04-12 20:16:21 +08:00
JS00000
88f4a45cae 微信公众号语音输入支持 (#808) 2023-04-12 15:10:51 +08:00
JS00000
7fb4f72b84 update wechatmp README 2023-04-12 05:52:26 +08:00
JS00000
d4fc322101 Merge branch 'master' into wechatmp 2023-04-12 05:43:05 +08:00
JS00000
8fa3da9ca5 wechatmp: voice input support 2023-04-12 05:41:48 +08:00
JS00000
68ef5aa3ae ctrl+c exit 2023-04-12 05:35:31 +08:00
lanvent
28bd917c9f Update config.py 2023-04-11 19:01:40 +08:00
zhayujie
0eb1b94300 docs: update README.md 2023-04-11 00:23:43 +08:00
JS00000
15e6cf850b Merge branch 'master' into wechatmp 2023-04-10 18:57:01 +08:00
lanvent
ee91c86a29 Update README.md 2023-04-10 14:52:06 +08:00
lanvent
48c08f4aad unset default timeout 2023-04-10 14:50:34 +08:00
lanvent
fceabb8e67 Merge Pull Request #787 into master 2023-04-09 20:11:21 +08:00
lanvent
fcfafb05f1 fix: wechatmp's deadloop when reply is None from @JS00000 #789 2023-04-09 20:01:03 +08:00
lanvent
f1e8344beb fix: no old signal handler 2023-04-09 19:15:28 +08:00
JS00000
f687b2b6f4 remove _success_callback 2023-04-09 18:32:09 +08:00
JS00000
8ee7a48151 fix: wechatmp's deadloop when reply is None 2023-04-09 18:00:34 +08:00
yubai
89e8f385b4 bugfix for azure chatgpt adapting 2023-04-09 18:00:05 +08:00
lanvent
bf4ae9a051 fix: create tmpdir 2023-04-09 17:37:19 +08:00
lanvent
6bd1242d43 chore: update requirements and config-template 2023-04-09 16:16:54 +08:00
lanvent
8779eab36b feat: itchat support picture msg 2023-04-09 00:45:42 +08:00
lanvent
3174b1158c chore: merge itchat msg 2023-04-08 23:32:37 +08:00
lanvent
18740093d1 Merge branch 'master' of https://github.com/zhayujie/chatgpt-on-wechat into master-dev 2023-04-08 01:25:59 +08:00
lanvent
8c7d1d4010 Merge Pull Request #774 into master 2023-04-08 01:23:54 +08:00
lanvent
8c48a27e1a Merge branch 'master' of https://github.com/zhayujie/chatgpt-on-wechat into master-dev 2023-04-07 23:42:30 +08:00
lanvent
4278d2b8ef feat: add updatep command 2023-04-07 23:31:07 +08:00
lanvent
3a3affd3ec fix: wechatmp event and query timeout 2023-04-07 20:53:21 +08:00
JS00000
45d72b8b9b Update README 2023-04-07 20:46:00 +08:00
JS00000
03b908c079 Merge branch 'master' into wechatmp 2023-04-07 20:28:08 +08:00
JS00000
d35d01f980 Add wechatmp_service channel 2023-04-07 19:47:50 +08:00
Jianglang
9c208ffa2c Update README.md 2023-04-07 18:29:16 +08:00
lanvent
bea4416f12 fix: wechatmp subscribe event 2023-04-07 18:23:52 +08:00
lanvent
2ea8b4ef73 fix: chat when single_chat_prefix is None 2023-04-07 16:30:38 +08:00
lanvent
e6946ef989 modify default value of concurrency_in_session 2023-04-07 16:03:59 +08:00
lanvent
9aeb60f66d feat: add replicate to source.json 2023-04-07 15:15:40 +08:00
lanvent
d687f9329e fix: add maxsplit=1 in wechatmp 2023-04-07 12:28:01 +08:00
lanvent
3207258fd9 fix: check duplicate in wechatmp 2023-04-07 12:22:24 +08:00
lanvent
d8b75206fe feat: maxmize message length 2023-04-07 12:15:29 +08:00
lanvent
88e8dd5162 chroe: specify necessary property in chatmessage 2023-04-07 01:22:30 +08:00
lanvent
c9306633b2 fix: read source.json with utf-8 2023-04-07 01:15:31 +08:00
Jianglang
c50d1cc99d Update README.md 2023-04-07 01:09:16 +08:00
Jianglang
9a20c1cb02 Update README.md 2023-04-07 00:43:47 +08:00
Jianglang
176f77ba5b Update README.md 2023-04-07 00:35:06 +08:00
lanvent
484de6237b feat: terminal support plugins 2023-04-06 23:55:25 +08:00
lanvent
898aa30b1d godcmd: add temp passwd 2023-04-06 21:57:02 +08:00
lanvent
8b73a74609 fix: bug when reinstall plugin 2023-04-06 21:54:38 +08:00
lanvent
3c6d42b22e feat: add installp/uninstallp command 2023-04-06 21:54:38 +08:00
lanvent
40563c1e96 plugins: remove sdwebui 2023-04-06 21:54:37 +08:00
lanvent
cb0c86ec1c fix: a typo in sdwebui 2023-04-06 21:25:07 +08:00
Jianglang
614f3b1ea4 Update README.md 2023-04-06 14:15:49 +08:00
lanvent
938e3b5cf2 role: add tags for role 2023-04-06 14:02:41 +08:00
Jianglang
5fe8d9a855 Update README.md 2023-04-06 11:34:39 +08:00
lanvent
8193ecf5f6 fix: wrap old handler 2023-04-06 11:27:50 +08:00
lanvent
1dff630257 fix: avoid channel to generate not support reply 2023-04-06 02:05:36 +08:00
lanvent
eaac3e3579 feat: add min simularity to match role 2023-04-06 01:36:28 +08:00
lanvent
d3758968d0 feat: optimize args in help text 2023-04-06 01:28:59 +08:00
Jianglang
020f9a8d98 Update README.md 2023-04-06 01:09:47 +08:00
lanvent
9d8ae80548 feat: support set wechatmp_port 2023-04-06 00:48:49 +08:00
lanvent
7e7484a27d Merge Pull Ruquest #757 into master 2023-04-05 23:37:02 +08:00
JS00000
0adf8d6e5d Merge branch 'master' into wechatmp 2023-04-05 20:55:56 +08:00
JS00000
1a981ea970 Refactor: inherit ChatChannel 2023-04-05 20:55:24 +08:00
lanvent
5bd9f50818 feat: disable plugin when init failed 2023-04-05 18:05:28 +08:00
JS00000
44f6892cb7 Merge branch 'master' into wechatmp
tool update
2023-04-05 14:30:55 +08:00
JS00000
fdf6b0dc6b fix: web server port 2023-04-05 14:29:18 +08:00
JS00000
a7914279a9 Merge branch 'master' into wechatmp 2023-04-05 14:13:49 +08:00
goldfish菌
2cf71dd6f2 完善tool文档 & 增加tool过滤、tool参数构建 (#751) 2023-04-05 13:00:48 +08:00
lanvent
62e3baba20 feat: add plugin_trigger_prefix option 2023-04-05 05:37:06 +08:00
lanvent
e00c99c1d7 fix: typo in plugin role 2023-04-05 04:57:21 +08:00
lanvent
31d5b95611 Update requirements-optional.txt 2023-04-05 04:22:52 +08:00
lanvent
cc881adda6 Merge Pull Request #686 into master 2023-04-05 04:18:06 +08:00
JS00000
78d4c58b70 Merge branch 'master' into wechatmp 2023-04-05 00:37:09 +08:00
lanvent
eca369532d Merge Pull Request #663 into master 2023-04-04 22:54:17 +08:00
Jianglang
9520d94b13 Update README.md 2023-04-04 20:01:10 +08:00
lanvent
f973bc3fe2 add requirements-optional.txt 2023-04-04 19:44:50 +08:00
zhayujie
94004b095b fix: no debug config #744 2023-04-04 15:59:56 +08:00
lanvent
f652d592bd fix: typo in dequeue 2023-04-04 15:10:35 +08:00
lanvent
186e18fe94 godcmd: load clear_memory_commands 2023-04-04 14:58:51 +08:00
lanvent
28eb67bc24 feat: reset will cancel unprocessed messages 2023-04-04 14:57:38 +08:00
lanvent
6c7e4aaf37 feat: prioritize handling commands 2023-04-04 14:29:03 +08:00
lanvent
709a1317ef feat: add debug option 2023-04-04 14:02:14 +08:00
lanvent
371e38cfa6 add concurrency_in_session,request_timeout options 2023-04-04 13:33:01 +08:00
lanvent
5a221848e9 feat: avoid disorder by producer-consumer model 2023-04-04 05:18:09 +08:00
JS00000
6901c5ba56 Plugins: register function add namecn 2023-04-04 03:17:19 +08:00
JS00000
21a3b0d9a1 using pickle instead of redis 2023-04-04 03:17:19 +08:00
JS00000
29422edcc9 SSL support 2023-04-04 03:17:19 +08:00
JS00000
2da1c18b71 remark 2023-04-04 03:17:19 +08:00
JS00000
be592cc290 update readme 2023-04-04 03:17:19 +08:00
JS00000
ce8635dd99 pull request ready 2023-04-04 03:17:19 +08:00
JS00000
76783f0ad3 private openai_api_key 2023-04-04 03:17:19 +08:00
JS00000
441228e200 plugins optimization 2023-04-04 03:17:19 +08:00
JS00000
45a131aa0d support plugins 2023-04-04 03:17:19 +08:00
JS00000
a7900d4b2c fix bug 2023-04-04 03:17:19 +08:00
JS00000
a4b1d7446a wechatmp 2023-04-04 03:17:19 +08:00
lanvent
7458a6298f feat: add trigger_by_self option 2023-04-03 23:58:19 +08:00
lanvent
b0f54bb8b7 fix: dirty message including at and prefix 2023-04-03 23:53:58 +08:00
lanvent
acddadc406 feat: add convert pcm32 to pcm16 2023-04-03 22:55:39 +08:00
goldfishh
761fb20dd9 plugin(tool) fix type error in old python ver 2023-04-03 09:01:51 +08:00
lanvent
b74274b96b fix: old code in hello plugin 2023-04-03 02:00:33 +08:00
goldfishh
7835379f8f plugin(tool) add a config.json template and fix something 2023-04-02 23:17:21 +08:00
lanvent
49ba278316 fix: use english filename 2023-04-02 16:50:11 +08:00
lanvent
388058467c fix: delete same file twice 2023-04-02 14:55:45 +08:00
lanvent
cf25bd7869 feat: itchat show qrcode using viewer 2023-04-02 14:45:38 +08:00
lanvent
02a95345aa fix: add more qrcode api 2023-04-02 14:13:38 +08:00
lanvent
6076e2ed0a fix: voice longer than 60s cannot be sent 2023-04-02 12:29:10 +08:00
lanvent
cec674cb47 update qrcode 2023-04-02 04:44:08 +08:00
Jianglang
c5a90823fa Update README.md 2023-04-02 04:30:40 +08:00
Jianglang
18d82bc1f0 Update README.md 2023-04-02 04:23:13 +08:00
lanvent
a68af990ea update Readme.md 2023-04-02 04:19:50 +08:00
lanvent
e71c600d10 feat: new itchat qrcode generator 2023-04-02 03:46:09 +08:00
lanvent
d7f1f7182c feat: add always_reply_voice option 2023-04-01 22:27:11 +08:00
lanvent
dfb2e460b4 fix: voice length bug in wechaty 2023-04-01 21:58:55 +08:00
lanvent
5badef8ba9 fix: correct sample rate when convert to silk 2023-04-01 20:59:52 +08:00
lanvent
18aa5ce75c fix: get correct audio format in pytts 2023-04-01 20:58:06 +08:00
lanvent
1545a9f262 feat: support azure voice 2023-04-01 16:36:27 +08:00
Jianglang
47cc65a787 Merge pull request #720 from lanvent/master
wechaty支持插件
2023-04-01 05:01:31 +08:00
lanvent
cda9d5873d plugins: support wechaty channel 2023-04-01 04:26:34 +08:00
lanvent
02cd553990 refactor: using one processing logic in chat channel 2023-04-01 04:24:00 +08:00
goldfishh
71d288f550 fix docs, break context 2023-04-01 01:32:03 +08:00
lanvent
87df588c80 refactor: stripp processing unrelated to channel 2023-03-31 22:31:10 +08:00
lanvent
4ad2997717 compatibility: lower boolean values in env 2023-03-31 15:25:02 +08:00
lanvent
50a03e7c15 refactor: wrap wechaty message 2023-03-31 05:36:53 +08:00
lanvent
4f3d12129c refactor: wrap wechat msg to make logic general 2023-03-31 05:36:52 +08:00
lanvent
37a95980d4 feat: support at everywhere 2023-03-31 05:27:19 +08:00
goldfishh
f49806558e 修复readme部分有误描述 2023-03-31 00:53:31 +08:00
goldfishh
8da362d6fe plugin(tool) update doc 2023-03-31 00:36:18 +08:00
goldfishh
bf02a59aec minor change 2023-03-30 23:58:04 +08:00
goldfishh
461777cad3 fix: plugin tool: add reply to session 2023-03-30 20:02:11 +08:00
goldfishh
0597ba20d2 minor change 2023-03-30 20:02:11 +08:00
goldfishh
0b5fd27cd8 fix get_session error 2023-03-30 20:02:11 +08:00
goldfishh
f5f8033d4d plugin tool: big fix 2023-03-30 20:02:11 +08:00
goldfishh
a5f7dec011 plugin(tool): 新增tool插件 2023-03-30 20:02:11 +08:00
lanvent
d9ef5a6612 fix: 无前缀触发bug 2023-03-30 18:26:44 +08:00
lanvent
66a81cd47c fix: 修复群语音触发bug 2023-03-30 16:26:01 +08:00
lanvent
81edd13470 Merge branch 'master' of https://github.com/zhayujie/chatgpt-on-wechat into master-dev 2023-03-30 16:07:29 +08:00
lanvent
7a94745b8a fix: group chat bug 2023-03-30 16:06:57 +08:00
zhanws
06b02f5df8 解决百度语音合成的一些问题和参数化设置 (#676)
* 解决百度语音合成的一些问题和参数化设置

* 补充百度语音说明
2023-03-30 14:59:52 +08:00
lanvent
83136e3142 feat: refactor handle function 2023-03-30 14:44:45 +08:00
lanvent
950a9f2ee0 docker: add Dockerfile 2023-03-30 02:13:03 +08:00
lanvent
a26c10fee8 feat: add git action for publish image 2023-03-30 02:04:30 +08:00
lanvent
4bcd76fe93 feat: update python in docker to 3.10 2023-03-30 00:32:40 +08:00
lanvent
90ccb091ca fix: change order in requirement.txt 2023-03-30 00:24:31 +08:00
lanvent
62df27eaa1 fix: retry when send failed 2023-03-30 00:23:57 +08:00
lanvent
349115b948 fix: use mp3 file when mp3_to_wav failed 2023-03-29 17:05:32 +08:00
lanvent
4fd7e4be67 fix: ignore remove file failed 2023-03-29 16:46:55 +08:00
lanvent
947e892916 feat: retry when timeout 2023-03-29 15:12:27 +08:00
lanvent
d62b7d1a99 feat: merge chat related sessions 2023-03-29 12:25:31 +08:00
lanvent
432b39a9c4 fix: single voice to text bug 2023-03-29 11:32:30 +08:00
zhanws
26540bfb63 补充语音单聊前缀判断过滤 (#661) 2023-03-29 01:41:36 +08:00
lanvent
fd64f88a7e fix: import openai.error 2023-03-28 22:18:29 +08:00
lanvent
72994bc9ef fix: voice to text bug 2023-03-28 18:56:36 +08:00
lanvent
7e1138af50 Merge branch 'master' of https://github.com/zhayujie/chatgpt-on-wechat into master-dev 2023-03-28 17:36:35 +08:00
lanvent
72dbddb7f7 sdwebui: add use_https startarg 2023-03-28 17:36:05 +08:00
Jianglang
10dba50843 Update ISSUE_TEMPLATE.md 2023-03-28 17:09:41 +08:00
lanvent
d6af1b5827 bdunit: update README.md 2023-03-28 15:27:37 +08:00
zhanws
6c362a9b4b 增加利用百度UNIT实现智能对话插件 (#642)
* 利用百度UNIT实现智能对话插件

* 更新参数

* 增加BDunit配置参数模板
2023-03-28 15:17:09 +08:00
lanvent
9a0584d649 Update README.md 2023-03-28 13:42:52 +08:00
Jianglang
5ab5211c95 Update ISSUE_TEMPLATE.md 2023-03-28 13:28:45 +08:00
zhayujie
f644682be7 fix: add voice dependency compatibility #641 2023-03-28 10:15:21 +08:00
lanvent
ffad8e4d26 feat: add railway template 2023-03-28 06:48:08 +08:00
lanvent
8f07e6304a fix: update requirement.txt 2023-03-28 05:56:26 +08:00
lanvent
834c03359f feat: support railway template 2023-03-28 05:28:28 +08:00
lanvent
3e2c68ba49 Merge branch 'zwssunny-master' into master 2023-03-28 03:20:41 +08:00
lanvent
2a21941b68 feat: modify requirement.txt 2023-03-28 03:16:05 +08:00
lanvent
e78886fb35 feat: new voice class pytts 2023-03-28 03:14:26 +08:00
lanvent
80bf6a0c7a Merge branch 'master' of github.com:zwssunny/chatgpt-on-wechat into zwssunny-master 2023-03-28 01:29:21 +08:00
Jianglang
48e066b677 Update readme.md 2023-03-28 01:26:27 +08:00
lanvent
dcb9d7fc2a fix: empty content issue 2023-03-28 01:16:29 +08:00
lanvent
279f0f0234 fix: incomplete qr code for railway 2023-03-28 01:13:29 +08:00
zwssunny
b3c8a7d8de check_prefix函数跑到外面了 2023-03-27 19:58:29 +08:00
zwssunny
1baf1a79e5 合并冲突 2023-03-27 19:38:19 +08:00
lanvent
35160e717e role: add roles 2023-03-27 19:35:56 +08:00
zhanws
a12f2d8fbd Merge branch 'master' into master 2023-03-27 19:27:46 +08:00
doublet44
6b7c17374b role: add new roles in roles.json (#618)
* 更新 roles.json

新增role角色
2023-03-27 19:03:18 +08:00
lanvent
9b3585e795 feat: support image create for group voice 2023-03-27 18:57:47 +08:00
lanvent
74f383a7d4 Merge pull request #629 from Chiaki-Chan/master
ItChat-uos方案下添加对群组语音消息的响应
2023-03-27 18:56:40 +08:00
zhanws
820fbeed18 Merge branch 'zhayujie:master' into master 2023-03-27 18:29:05 +08:00
zwssunny
f76e8d9a77 根据参数创建频道 2023-03-27 18:25:54 +08:00
zwssunny
5b85e60d5d 增加群组语言功能 2023-03-27 18:24:39 +08:00
zwssunny
24de670c2c 解决语音的识别和转换兼容性 2023-03-27 16:53:59 +08:00
Chiaki
42aca71763 1.更新redeme 2023-03-27 16:50:50 +08:00
Chiaki
9b4ef85174 Merge branch 'master' of github.com:Chiaki-Chan/chatgpt-on-wechat 2023-03-27 16:47:00 +08:00
Chiaki
9b389ffc33 1.itchat添加群组语音回复文本功能;2.itchat添加群组语音回复语音功能;3.更新redeme 2023-03-27 16:46:53 +08:00
zwssunny
b3cb81aa52 wx频道增加群语音聊天功能 2023-03-27 16:13:58 +08:00
zwssunny
61865bc408 修改google_voice为google合成,解决系统兼容性 2023-03-27 14:54:00 +08:00
zwssunny
c2ea6214a9 增加百度语音识别 2023-03-27 14:40:19 +08:00
zwssunny
b6684fe7a3 增加声音转换函数 2023-03-27 14:11:05 +08:00
lanvent
b50ebc05a0 fix: username not in itchat msg 2023-03-27 13:46:48 +08:00
lanvent
dbb0648c39 fix: typo in README.md 2023-03-27 02:30:44 +08:00
zhayujie
5fc0987cc3 Merge pull request #623 from Chiaki-Chan/master
Wechaty方案下添加对群组语音消息的响应
2023-03-27 01:42:00 +08:00
Chiaki
7c4037147c 1.wechaty添加群组语音回复文本功能;2.wechaty添加群组语音回复语音功能;3.更新config.py和readme; 2023-03-27 01:34:41 +08:00
zhayujie
f76cb1231e Merge pull request #621 from lanvent/dev2
refactor and support plugins for OpenAIBot
2023-03-27 01:32:59 +08:00
Chiaki
6701d8c5e6 1.wechaty添加群组语音回复文本功能;2.wechaty添加群组语音回复语音功能;3.更新config.py和readme; 2023-03-27 01:25:54 +08:00
lanvent
ff3d143185 plugins: support openaibot 2023-03-26 23:33:29 +08:00
lanvent
ea95ab9062 refactor: decouple openai session 2023-03-26 23:09:05 +08:00
lanvent
38c901a1c5 fix: init image api for bot 2023-03-26 21:49:07 +08:00
lanvent
0c9753b7cd refactor: decouple chatgpt session 2023-03-26 21:46:33 +08:00
lanvent
721b36c7f7 refactor: reuse openai image interface 2023-03-26 20:08:04 +08:00
zhayujie
f8e0716474 Merge pull request #614 from lanvent/dev2
feat: support calc tokens precisely
2023-03-26 19:56:49 +08:00
lanvent
3d428ee844 fix: avoid possible dead loop when discarding 2023-03-26 18:07:28 +08:00
lanvent
a3be1fcd8f feat: support calc tokens precisely 2023-03-26 17:49:49 +08:00
lanvent
167f10c9f9 plugins : provide help url when plugin init failed 2023-03-26 15:27:15 +08:00
lanvent
b3cabd9621 fix: restore the original sleep time in itchat 2023-03-26 15:12:27 +08:00
lanvent
709468d281 fix: wechaty voice_to_text 2023-03-26 14:58:20 +08:00
lanvent
c3a2bd70ff docker: remove unused command 2023-03-26 14:57:56 +08:00
lanvent
92caeed7ab feat: intergrate itchat to lib 2023-03-26 13:35:46 +08:00
zhayujie
3c91575ebe Merge pull request #605 from lanvent/dev2
docker: use environment vars  to set parameters
2023-03-26 11:51:23 +08:00
lanvent
46a6223a43 docker: use environment args to set parameters 2023-03-26 03:31:29 +08:00
zhayujie
e226c93eeb fix: json parse error in docker #600 #601 2023-03-26 02:31:38 +08:00
zhayujie
5aedce647f fix: docker permission bug 2023-03-26 02:00:35 +08:00
lanvent
4881f7b01c fix: use relpath in plugin manager 2023-03-25 23:35:23 +08:00
lanvent
bebe8c1b1d fix : group_chat_reply_prefix not in config 2023-03-25 20:23:00 +08:00
lanvent
b03e8f7c71 fix: group_name_keyword_white_list not in config 2023-03-25 19:14:20 +08:00
lanvent
fa0d5592d6 Merge branch 'master' of https://github.com/zhayujie/chatgpt-on-wechat into master-dev 2023-03-25 19:00:19 +08:00
lanvent
bcf3ce9adf fix: group_chat_keyword group_at_off not in config 2023-03-25 18:54:17 +08:00
zhayujie
14dd4f19aa Merge pull request #591 from lanvent/dev2
feat: add options to set voice bot
2023-03-25 18:44:14 +08:00
lanvent
cd86801eac feat: add options to set voice bot 2023-03-25 18:08:37 +08:00
zhayujie
da18e3312a Merge pull request #586 from fangpin/railway
Support Railway deployment
2023-03-25 15:17:09 +08:00
zhayujie
fea56a0ddf Merge branch 'master' into railway 2023-03-25 15:16:49 +08:00
zhayujie
d3cc52b794 docs: update README 2023-03-25 14:27:36 +08:00
zhayujie
f805b29a8c Merge pull request #587 from lanvent/dev2
fix: request qrscan when hotreload failed
2023-03-25 14:10:21 +08:00
lanvent
3f78e43bbf fix: increase timeout for itchat 2023-03-25 13:29:09 +08:00
lanvent
ab6670b3af fix: request qrscan when hotreload failed 2023-03-25 13:10:51 +08:00
zhayujie
797a160856 Merge pull request #583 from lanvent/dev2
enhance: improve writing for README
2023-03-25 12:23:09 +08:00
Pin Fang
2d0935741c Support online railway deployment 2023-03-25 11:45:00 +08:00
lanvent
8a645cd47b godcmd: add usage to README 2023-03-25 11:31:30 +08:00
lanvent
f189694c78 godcmd: add more instruction to README 2023-03-25 03:19:11 +08:00
lanvent
63701c182a enhance: improve writing for README 2023-03-25 03:06:35 +08:00
zhayujie
efd12dac35 fix: single reply in no prefix 2023-03-25 02:54:46 +08:00
zhayujie
e071b6c1b4 fix: time type bug 2023-03-25 01:15:56 +08:00
zhayujie
b590e889a7 fix: datetime is not defined 2023-03-25 01:11:39 +08:00
zhayujie
17ea48f25d docs: update README for plugins 2023-03-25 00:22:27 +08:00
Pin Fang
04fec4a585 Support Azure hosted chatgpt service 2023-03-25 00:07:08 +08:00
zhayujie
ae06cf844d fix: chat_time_module compatible logic 2023-03-24 23:04:15 +08:00
zhayujie
f3daa8e3bf Merge pull request #569 from a5225662/master
时间管理模块加入到common目录,并增加了3条关于时间管理的config配置
2023-03-24 23:00:55 +08:00
zhayujie
3f0b80d48e Merge branch 'master' into master 2023-03-24 23:00:44 +08:00
zhayujie
4a5e3e433b Merge pull request #555 from lihuaiyu0131/master
1.Docker支持部署最新版本; 2.优化构建速度,无须再次下载包;
2023-03-24 22:53:44 +08:00
zhayujie
5ffeac6683 Merge branch 'master' into master 2023-03-24 22:53:32 +08:00
zhayujie
71f2db30da Merge pull request #565 from zhayujie/dev
feat: support plugins
2023-03-24 22:49:35 +08:00
zhayujie
61c6d01af2 Merge pull request #576 from lanvent/dev
plugins: add helpp command for godcmd
2023-03-24 22:44:43 +08:00
lanvent
30aedf04d7 plugins: add helpp command for godcmd 2023-03-24 22:37:48 +08:00
zhayujie
f791c7eafd Merge pull request #568 from lanvent/dev
Fix: merge plugins to dev
2023-03-24 12:34:01 +08:00
lanvent
2f78c072d7 fix: merge plugins to dev 2023-03-24 12:17:23 +08:00
lanvent
50c91b428d role: add roles 2023-03-24 11:41:07 +08:00
lanvent
52abe0893a fix: merge plugins to dev 2023-03-24 11:40:45 +08:00
zhayujie
42f3f4403c Merge branch 'plugins' into dev 2023-03-24 01:40:13 +08:00
zhayujie
0a1cc91c0c Merge pull request #564 from lanvent/dev
plugins: fix bug after session expiration
2023-03-24 01:13:35 +08:00
zhayujie
518cac7ab9 Merge branch 'plugins' into dev 2023-03-24 01:13:20 +08:00
zhayujie
8c4a62b9c6 fix: use try catch instead of config 2023-03-24 00:59:26 +08:00
zhayujie
c1d1e923cd feat: add plugins config 2023-03-24 00:22:09 +08:00
a5225662
18e9aca3b1 调用#更新配置,替代md5校验,减少重复代码,提高程序效率,将print替换为logger 2023-03-24 00:01:46 +08:00
a5225662
9fe59f2949 加入时间管理模块,使用md5验证实现热加载config.json变化 2023-03-23 22:47:26 +08:00
Look_World
ea5f7173bd 1.Docker支持部署最新版本; 2.优化构建速度,无须再次下载包; 2023-03-23 16:27:36 +08:00
Look_World
d5611b185b Merge branch 'zhayujie:master' into master 2023-03-23 14:46:01 +08:00
Look_World
b260029cd9 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	common/expired_dict.py
2023-03-21 16:14:38 +08:00
Look_World
240b4b540b Revert "fix: Optimize session expiration"
This reverts commit 695302d.
2023-03-21 16:09:29 +08:00
Look_World
695302d407 Revert "fix: Optimize session expiration"
This reverts commit e1ede58094.
2023-03-21 14:16:47 +08:00
lanvent
be13400bc0 role: modify help text 2023-03-21 12:13:57 +08:00
Look_World
efc27192fa Merge branch 'zhayujie:master' into master 2023-03-21 11:42:52 +08:00
Look_World
e1ede58094 fix: Optimize session expiration 2023-03-21 11:41:06 +08:00
lanvent
ff21a50f7f plugin: avoid mess after session expiration 2023-03-21 11:32:32 +08:00
zhayujie
4f5f65086f Merge pull request #524 from lanvent/dev
plugin: 添加`Role`插件,让机器人角色扮演。
2023-03-20 23:07:14 +08:00
lanvent
8b28866d53 doc: modify doc for Role plugin 2023-03-20 20:49:10 +08:00
lanvent
77046000e8 plugin: add Role plugin 2023-03-20 20:43:02 +08:00
zhayujie
48a6807851 Merge pull request #518 from lanvent/dev
Plugin: 增加插件编写文档,添加Dungeon插件
2023-03-20 00:33:42 +08:00
lanvent
5a46e09358 plugin: add doc 2023-03-19 17:57:57 +08:00
lanvent
61d66dd8b3 plugin: add dungeon plugin 2023-03-16 01:08:19 +08:00
zhayujie
2cb30b5f59 Merge pull request #442 from lanvent/dev
简易支持插件,添加sdwebui(novelai画图), godcmd(管理员指令增强)插件,Banwords(敏感词过滤)插件
2023-03-14 23:57:34 +08:00
lanvent
2568322879 plugin: ignore cases when manage plugins 2023-03-14 18:02:07 +08:00
lanvent
8915149d36 plugin: add banwords plugin 2023-03-14 17:30:30 +08:00
lanvent
300b7b9687 plugins: support reload plugin 2023-03-14 15:59:52 +08:00
lanvent
c782b38ba1 sdwebui: modify README.md 2023-03-14 15:31:26 +08:00
lanvent
e6b65437e4 sdwebui : add help reply 2023-03-14 12:07:53 +08:00
lanvent
e6d148e729 plugins: add sdwebui(stable diffusion) plugin 2023-03-14 00:49:28 +08:00
lanvent
dce9c4dccb compatible with openai bot 2023-03-13 19:58:35 +08:00
lanvent
ad6ae0b32a refactor: use enum to specify type 2023-03-13 19:44:24 +08:00
lanvent
1dc3f85a66 plugin: support priority to decide trigger order 2023-03-13 15:32:28 +08:00
lanvent
cb7bf446e3 plugin: godcmd support manage plugins 2023-03-13 01:50:37 +08:00
lanvent
8d2e81815c compatible for voice 2023-03-13 00:12:34 +08:00
lanvent
cee57e4ffc plugin: add godcmd plugin 2023-03-12 23:05:28 +08:00
lanvent
475ada22e7 catch thread exception 2023-03-12 22:49:07 +08:00
lanvent
8847b5b674 create bot when first need 2023-03-12 13:25:23 +08:00
lanvent
73de429af1 import file with the same name as plugin 2023-03-12 12:57:27 +08:00
lanvent
d9b902f6ee add a plugin example 2023-03-12 11:53:47 +08:00
lanvent
0fcf0824dc feat: support plugins 2023-03-12 11:53:06 +08:00
lanvent
9e07703eb1 formatting code 2023-03-12 01:25:28 +08:00
lanvent
9ae7b7773e simple compatibility for wechaty 2023-03-12 01:10:18 +08:00
lanvent
d6037422ac decouple message processing process 2023-03-12 00:58:49 +08:00
lanvent
38c8ceba12 avoid repeatedly instantiating bot 2023-03-11 02:51:07 +08:00
190 changed files with 16928 additions and 1458 deletions

13
.flake8 Normal file
View File

@@ -0,0 +1,13 @@
[flake8]
max-line-length = 176
select = E303,W293,W291,W292,E305,E231,E302
exclude =
.tox,
__pycache__,
*.pyc,
.env
venv/*
.venv/*
reports/*
dist/*
lib/*

View File

@@ -1,28 +0,0 @@
### 前置确认
1. 网络能够访问openai接口
2. python 已安装:版本在 3.7 ~ 3.10 之间,依赖已安装
3. 在已有 issue 中未搜索到类似问题
4. [FAQS](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs) 中无类似问题
### 问题描述
> 简要说明、截图、复现步骤等,也可以是需求或想法
### 终端日志 (如有报错)
```
[在此处粘贴终端日志]
```
### 环境
- 操作系统类型 (Mac/Windows/Linux)
- Python版本 ( 执行 `python3 -V` )
- pip版本 ( 依赖问题此项必填,执行 `pip3 -V`)

133
.github/ISSUE_TEMPLATE/1.bug.yml vendored Normal file
View File

@@ -0,0 +1,133 @@
name: Bug report 🐛
description: 项目运行中遇到的Bug或问题。
labels: ['status: needs check']
body:
- type: markdown
attributes:
value: |
### ⚠️ 前置确认
1. 网络能够访问openai接口
2. python 已安装:版本在 3.7 ~ 3.10 之间
3. `git pull` 拉取最新代码
4. 执行`pip3 install -r requirements.txt`,检查依赖是否满足
5. 拓展功能请执行`pip3 install -r requirements-optional.txt`,检查依赖是否满足
6. [FAQS](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs) 中无类似问题
- type: checkboxes
attributes:
label: 前置确认
options:
- label: 我确认我运行的是最新版本的代码,并且安装了所需的依赖,在[FAQS](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs)中也未找到类似问题。
required: true
- type: checkboxes
attributes:
label: ⚠️ 搜索issues中是否已存在类似问题
description: >
请在 [历史issue](https://github.com/zhayujie/chatgpt-on-wechat/issues) 中清空输入框,搜索你的问题
或相关日志的关键词来查找是否存在类似问题。
options:
- label: 我已经搜索过issues和disscussions没有跟我遇到的问题相关的issue
required: true
- type: markdown
attributes:
value: |
请在上方的`title`中填写你对你所遇到问题的简略总结,这将帮助其他人更好的找到相似问题,谢谢❤️。
- type: dropdown
attributes:
label: 操作系统类型?
description: >
请选择你运行程序的操作系统类型。
options:
- Windows
- Linux
- MacOS
- Docker
- Railway
- Windows Subsystem for Linux (WSL)
- Other (请在问题中说明)
validations:
required: true
- type: dropdown
attributes:
label: 运行的python版本是?
description: |
请选择你运行程序的`python`版本。
注意:在`python 3.7`中,有部分可选依赖无法安装。
经过长时间的观察,我们认为`python 3.8`是兼容性最好的版本。
`python 3.7`~`python 3.10`以外版本的issue将视情况直接关闭。
options:
- python 3.7
- python 3.8
- python 3.9
- python 3.10
- other
validations:
required: true
- type: dropdown
attributes:
label: 使用的chatgpt-on-wechat版本是?
description: |
请确保你使用的是 [releases](https://github.com/zhayujie/chatgpt-on-wechat/releases) 中的最新版本。
如果你使用git, 请使用`git branch`命令来查看分支。
options:
- Latest Release
- Master (branch)
validations:
required: true
- type: dropdown
attributes:
label: 运行的`channel`类型是?
description: |
请确保你正确配置了该`channel`所需的配置项,所有可选的配置项都写在了[该文件中](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/config.py),请将所需配置项填写在根目录下的`config.json`文件中。
options:
- wx(个人微信, itchat)
- wxy(个人微信, wechaty)
- wechatmp(公众号, 订阅号)
- wechatmp_service(公众号, 服务号)
- terminal
- other
validations:
required: true
- type: textarea
attributes:
label: 复现步骤 🕹
description: |
**⚠️ 不能复现将会关闭issue.**
- type: textarea
attributes:
label: 问题描述 😯
description: 详细描述出现的问题,或提供有关截图。
- type: textarea
attributes:
label: 终端日志 📒
description: |
在此处粘贴终端日志,可在主目录下`run.log`文件中找到这会帮助我们更好的分析问题注意隐去你的API key。
如果在配置文件中加入`"debug": true`,打印出的日志会更有帮助。
<details>
<summary><i>示例</i></summary>
```log
[DEBUG][2023-04-16 00:23:22][plugin_manager.py:157] - Plugin SUMMARY triggered by event Event.ON_HANDLE_CONTEXT
[DEBUG][2023-04-16 00:23:22][main.py:221] - [Summary] on_handle_context. content: $总结前100条消息
[DEBUG][2023-04-16 00:23:24][main.py:240] - [Summary] limit: 100, duration: -1 seconds
[ERROR][2023-04-16 00:23:24][chat_channel.py:244] - Worker return exception: name 'start_date' is not defined
Traceback (most recent call last):
File "C:\ProgramData\Anaconda3\lib\concurrent\futures\thread.py", line 57, in run
result = self.fn(*self.args, **self.kwargs)
File "D:\project\chatgpt-on-wechat\channel\chat_channel.py", line 132, in _handle
reply = self._generate_reply(context)
File "D:\project\chatgpt-on-wechat\channel\chat_channel.py", line 142, in _generate_reply
e_context = PluginManager().emit_event(EventContext(Event.ON_HANDLE_CONTEXT, {
File "D:\project\chatgpt-on-wechat\plugins\plugin_manager.py", line 159, in emit_event
instance.handlers[e_context.event](e_context, *args, **kwargs)
File "D:\project\chatgpt-on-wechat\plugins\summary\main.py", line 255, in on_handle_context
records = self._get_records(session_id, start_time, limit)
File "D:\project\chatgpt-on-wechat\plugins\summary\main.py", line 96, in _get_records
c.execute("SELECT * FROM chat_records WHERE sessionid=? and timestamp>? ORDER BY timestamp DESC LIMIT ?", (session_id, start_date, limit))
NameError: name 'start_date' is not defined
[INFO][2023-04-16 00:23:36][app.py:14] - signal 2 received, exiting...
```
</details>
value: |
```log
<此处粘贴终端日志>
```

28
.github/ISSUE_TEMPLATE/2.feature.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Feature request 🚀
description: 提出你对项目的新想法或建议。
labels: ['status: needs check']
body:
- type: markdown
attributes:
value: |
请在上方的`title`中填写简略总结,谢谢❤️。
- type: checkboxes
attributes:
label: ⚠️ 搜索是否存在类似issue
description: >
请在 [历史issue](https://github.com/zhayujie/chatgpt-on-wechat/issues) 中清空输入框搜索关键词查找是否存在相似issue。
options:
- label: 我已经搜索过issues和disscussions没有发现相似issue
required: true
- type: textarea
attributes:
label: 总结
description: 描述feature的功能。
- type: textarea
attributes:
label: 举例
description: 提供聊天示例,草图或相关网址。
- type: textarea
attributes:
label: 动机
description: 描述你提出该feature的动机比如没有这项feature对你的使用造成了怎样的影响。 请提供更详细的场景描述,这可能会帮助我们发现并提出更好的解决方案。

72
.github/workflows/deploy-image-arm.yml vendored Normal file
View File

@@ -0,0 +1,72 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# GitHub recommends pinning actions to a commit SHA.
# To get a newer version, you will need to update the SHA.
# You can also reference a tag or branch, but the action may change without warning.
name: Create and publish a Docker image
on:
push:
branches: ['master']
create:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-image:
if: github.repository == 'zhayujie/chatgpt-on-wechat'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Available platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Log in to the Container registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@v3
with:
context: .
push: true
file: ./docker/Dockerfile.latest
platforms: linux/arm64
tags: ${{ steps.meta.outputs.tags }}-arm64
labels: ${{ steps.meta.outputs.labels }}
- uses: actions/delete-package-versions@v4
with:
package-name: 'chatgpt-on-wechat'
package-type: 'container'
min-versions-to-keep: 10
delete-only-untagged-versions: 'true'
token: ${{ secrets.GITHUB_TOKEN }}

68
.github/workflows/deploy-image.yml vendored Normal file
View File

@@ -0,0 +1,68 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# GitHub recommends pinning actions to a commit SHA.
# To get a newer version, you will need to update the SHA.
# You can also reference a tag or branch, but the action may change without warning.
name: Create and publish a Docker image
on:
push:
branches: ['master']
create:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-image:
if: github.repository == 'zhayujie/chatgpt-on-wechat'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the Container registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
${{ env.IMAGE_NAME }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@v3
with:
context: .
push: true
file: ./docker/Dockerfile.latest
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- uses: actions/delete-package-versions@v4
with:
package-name: 'chatgpt-on-wechat'
package-type: 'container'
min-versions-to-keep: 10
delete-only-untagged-versions: 'true'
token: ${{ secrets.GITHUB_TOKEN }}

23
.gitignore vendored
View File

@@ -1,5 +1,8 @@
.DS_Store
.idea
.vscode
.venv
.vs
.wechaty/
__pycache__/
venv*
@@ -8,3 +11,23 @@ config.json
QR.png
nohup.out
tmp
plugins.json
itchat.pkl
*.log
user_datas.pkl
chatgpt_tool_hub/
plugins/**/
!plugins/bdunit
!plugins/dungeon
!plugins/finish
!plugins/godcmd
!plugins/tool
!plugins/banwords
!plugins/banwords/**/
plugins/banwords/__pycache__
plugins/banwords/lib/__pycache__
!plugins/hello
!plugins/role
!plugins/keyword
!plugins/linkai
client_config.json

30
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,30 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: fix-byte-order-marker
- id: check-case-conflict
- id: check-merge-conflict
- id: debug-statements
- id: pretty-format-json
types: [text]
files: \.json(.template)?$
args: [ --autofix , --no-ensure-ascii, --indent=2, --no-sort-keys]
- id: trailing-whitespace
exclude: '(\/|^)lib\/'
args: [ --markdown-linebreak-ext=md ]
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
hooks:
- id: isort
exclude: '(\/|^)lib\/'
- repo: https://github.com/psf/black
rev: 23.3.0
hooks:
- id: black
exclude: '(\/|^)lib\/'
- repo: https://github.com/PyCQA/flake8
rev: 6.0.0
hooks:
- id: flake8
exclude: '(\/|^)lib\/'

3
Dockerfile Normal file
View File

@@ -0,0 +1,3 @@
FROM ghcr.io/zhayujie/chatgpt-on-wechat:latest
ENTRYPOINT ["/entrypoint.sh"]

215
README.md
View File

@@ -1,66 +1,87 @@
# 简介
> ChatGPT近期以强大的对话和信息整合能力风靡全网可以写代码、改论文、讲故事几乎无所不能这让人不禁有个大胆的想法能否用他的对话模型把我们的微信打造成一个智能机器人可以在与好友对话中给出意想不到的回应而且再也不用担心女朋友影响我们 ~~打游戏~~ 工作了
> 本项目是基于大模型的智能对话机器人支持微信、企业微信、公众号、飞书、钉钉接入可选择GPT3.5/GPT4.0/Claude/文心一言/讯飞星火/通义千问/Gemini/LinkAI能处理文本、语音和图片通过插件访问操作系统和互联网等外部资源支持基于自有知识库定制企业AI应用
基于ChatGPT的微信聊天机器人通过 [ChatGPT](https://github.com/openai/openai-python) 接口生成对话内容,使用 [itchat](https://github.com/littlecodersh/ItChat) 实现微信消息的接收和自动回复。已实现的特性如下:
最新版本支持的功能如下:
- [x] **文本对话** 接收私聊及群组中的微信消息使用ChatGPT生成回复内容完成自动回复
- [x] **规则定制化** 支持私聊中按指定规则触发自动回复,支持对群组设置自动回复白名单
- [x] **多账号** 支持多微信账号同时运行
- [x] **图片生成** 支持根据描述生成图片,并自动发送至个人聊天或群聊
- [x] **上下文记忆**支持多轮对话记忆,且为每个好友维护独立的上下会话
- [x] **语音识别** 支持接收和处理语音消息,通过文字或语音回复
- [x] **多端部署** 有多种部署方式可选择且功能完备,目前已支持个人微信、微信公众号和、企业微信、飞书、钉钉等部署方式
- [x] **基础对话** 私聊及群聊的消息智能回复,支持多轮会话上下文记忆,支持 GPT-3.5, GPT-4, claude, Gemini, 文心一言, 讯飞星火, 通义千问
- [x] **语音能力** 可识别语音消息,通过文字或语音回复,支持 azure, baidu, google, openai(whisper/tts) 等多种语音模型
- [x] **图像能力** 支持图片生成图片识别、图生图(如照片修复),可选择 Dall-E-3, stable diffusion, replicate, midjourney, vision模型
- [x] **丰富插件:** 支持个性化插件扩展,已实现多角色切换、文字冒险、敏感词过滤、聊天记录总结、文档总结和对话、联网搜索等插件
- [x] **知识库** 通过上传知识库文件自定义专属机器人,可作为数字分身、智能客服、私域助手使用,基于 [LinkAI](https://link-ai.tech) 实现
# 演示
https://github.com/zhayujie/chatgpt-on-wechat/assets/26161723/d5154020-36e3-41db-8706-40ce9f3f1b1e
Demo made by [Visionn](https://www.wangpc.cc/)
# 商业支持
> 我们还提供企业级的 **AI应用平台**包含知识库、Agent插件、应用管理等能力支持多平台聚合的应用接入、客户端管理、对话管理以及提供
SaaS服务、私有化部署、稳定托管接入 等多种模式。
>
> 目前已在私域运营、智能客服、企业效率助手等场景积累了丰富的 AI 解决方案, 在电商、文教、健康、新消费等各行业沉淀了 AI 落地的最佳实践,致力于打造助力中小企业拥抱 AI 的一站式平台。
企业服务和商用咨询可联系产品顾问:
<img width="240" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/product-manager-qrcode.jpg">
# 开源社区
添加小助手微信加入开源项目交流群:
<img width="240" src="./docs/images/contact.jpg">
# 更新日志
>**2023.03.09** 基于 `whisper API` 实现对微信语音消息的解析和回复,添加配置项 `"speech_recognition":true` 即可启用,使用参考 [#415](https://github.com/zhayujie/chatgpt-on-wechat/issues/415)。(contributed by [wanggang1987](https://github.com/wanggang1987) in [#385](https://github.com/zhayujie/chatgpt-on-wechat/pull/385))
>**2023.11.11** [1.5.3版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/1.5.3) [1.5.4版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/1.5.4)新增Google Gemini、通义千问模型
>**2023.03.02** 接入[ChatGPT API](https://platform.openai.com/docs/guides/chat) (gpt-3.5-turbo)默认使用该模型进行对话需升级openai依赖 (`pip3 install --upgrade openai`)。网络问题参考 [#351](https://github.com/zhayujie/chatgpt-on-wechat/issues/351)
>**2023.11.10** [1.5.2版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/1.5.2),新增飞书通道、图像识别对话、黑名单配置
>**2023.02.20** 增加 [python-wechaty](https://github.com/wechaty/python-wechaty) 作为可选渠道使用Pad协议但Token收费 (使用参考[#244](https://github.com/zhayujie/chatgpt-on-wechat/pull/244)contributed by [ZQ7](https://github.com/ZQ7))
>**2023.11.10** [1.5.0版本](https://github.com/zhayujie/chatgpt-on-wechat/releases/tag/1.5.0),新增 `gpt-4-turbo`, `dall-e-3`, `tts` 模型接入,完善图像理解&生成、语音识别&生成的多模态能力
>**2023.02.09** 扫码登录存在封号风险,请谨慎使用,参考[#58](https://github.com/AutumnWhj/ChatGPT-wechat-bot/issues/158)
>**2023.10.16** 支持通过意图识别使用LinkAI联网搜索、数学计算、网页访问等插件,参考[插件文档](https://docs.link-ai.tech/platform/plugins)
>**2023.02.05** 在openai官方接口方案中 (GPT-3模型) 实现上下文对话
>**2023.09.26** 插件增加 文件/文章链接 一键总结和对话的功能,使用参考:[插件说明](https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins/linkai#3%E6%96%87%E6%A1%A3%E6%80%BB%E7%BB%93%E5%AF%B9%E8%AF%9D%E5%8A%9F%E8%83%BD)
>**2022.12.19** 引入 [itchat-uos](https://github.com/why2lyj/ItChat-UOS) 替换 itchat解决由于不能登录网页微信而无法使用的问题且解决Python3.9的兼容问题
>**2023.08.08** 接入百度文心一言模型,通过 [插件](https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins/linkai) 支持 Midjourney 绘图
>**2022.12.18** 支持根据描述生成图片并发送openai版本需大于0.25.0
>**2023.06.12** 接入 [LinkAI](https://link-ai.tech/console) 平台,可在线创建领域知识库,并接入微信、公众号及企业微信中,打造专属客服机器人。使用参考 [接入文档](https://link-ai.tech/platform/link-app/wechat)。
>**2022.12.17** 原来的方案是从 [ChatGPT页面](https://chat.openai.com/chat) 获取session_token使用 [revChatGPT](https://github.com/acheong08/ChatGPT) 直接访问web接口但随着ChatGPT接入Cloudflare人机验证这一方案难以在服务器顺利运行。 所以目前使用的方案是调用 OpenAI 官方提供的 [API](https://beta.openai.com/docs/api-reference/introduction)回复质量上基本接近于ChatGPT的内容劣势是暂不支持有上下文记忆的对话优势是稳定性和响应速度较好。
>**2023.04.26** 支持企业微信应用号部署,兼容插件,并支持语音图片交互,私人助理理想选择,[使用文档](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/channel/wechatcom/README.md)。(contributed by [@lanvent](https://github.com/lanvent) in [#944](https://github.com/zhayujie/chatgpt-on-wechat/pull/944))
# 使用效果
>**2023.04.05** 支持微信公众号部署,兼容插件,并支持语音图片交互,[使用文档](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/channel/wechatmp/README.md)。(contributed by [@JS00000](https://github.com/JS00000) in [#686](https://github.com/zhayujie/chatgpt-on-wechat/pull/686))
### 个人聊天
>**2023.04.05** 增加能让ChatGPT使用工具的`tool`插件,[使用文档](https://github.com/goldfishh/chatgpt-on-wechat/blob/master/plugins/tool/README.md)。工具相关issue可反馈至[chatgpt-tool-hub](https://github.com/goldfishh/chatgpt-tool-hub)。(contributed by [@goldfishh](https://github.com/goldfishh) in [#663](https://github.com/zhayujie/chatgpt-on-wechat/pull/663))
![single-chat-sample.jpg](docs/images/single-chat-sample.jpg)
>**2023.03.25** 支持插件化开发,目前已实现 多角色切换、文字冒险游戏、管理员指令、Stable Diffusion等插件使用参考 [#578](https://github.com/zhayujie/chatgpt-on-wechat/issues/578)。(contributed by [@lanvent](https://github.com/lanvent) in [#565](https://github.com/zhayujie/chatgpt-on-wechat/pull/565))
### 群组聊天
![group-chat-sample.jpg](docs/images/group-chat-sample.jpg)
### 图片生成
![group-chat-sample.jpg](docs/images/image-create-sample.jpg)
>**2023.03.09** 基于 `whisper API`(后续已接入更多的语音`API`服务) 实现对微信语音消息的解析和回复,添加配置项 `"speech_recognition":true` 即可启用,使用参考 [#415](https://github.com/zhayujie/chatgpt-on-wechat/issues/415)。(contributed by [wanggang1987](https://github.com/wanggang1987) in [#385](https://github.com/zhayujie/chatgpt-on-wechat/pull/385))
>**2023.02.09** 扫码登录存在账号限制风险,请谨慎使用,参考[#58](https://github.com/AutumnWhj/ChatGPT-wechat-bot/issues/158)
# 快速开始
快速开始文档:[项目搭建文档](https://docs.link-ai.tech/cow/quick-start)
## 准备
### 1. OpenAI账号注册
### 1. 账号注册
前往 [OpenAI注册页面](https://beta.openai.com/signup) 创建账号,参考这篇 [教程](https://www.pythonthree.com/register-openai-chatgpt/) 可以通过虚拟手机号来接收验证码。创建完账号则前往 [API管理页面](https://beta.openai.com/account/api-keys) 创建一个 API Key 并保存下来后面需要在项目中配置这个key。
项目默认使用OpenAI接口前往 [OpenAI注册页面](https://beta.openai.com/signup) 创建账号,创建完账号则前往 [API管理页面](https://beta.openai.com/account/api-keys) 创建一个 API Key 并保存下来后面需要在项目中配置这个key。接口需要海外网络访问及绑定信用卡支付。
> 项目中使用的对话模型是 davinci计费方式是约每 750 字 (包含请求和回复) 消耗 $0.02,图片生成是每张消耗 $0.016,账号创建有免费的 $18 额度,使用完可以更换邮箱重新注册
> 默认对话模型是 openai 的 gpt-3.5-turbo计费方式是约每 1000tokens (约750个英文单词 或 500汉字包含请求和回复) 消耗 $0.002图片生成是Dell E模型每张消耗 $0.016。
项目同时也支持使用 LinkAI 接口,无需代理,可使用 文心、讯飞、GPT-3、GPT-4 等模型,支持 定制化知识库、联网搜索、MJ绘图、文档总结和对话等能力。修改配置即可一键切换参考 [接入文档](https://link-ai.tech/platform/link-app/wechat)。
### 2.运行环境
支持 Linux、MacOS、Windows 系统可在Linux服务器上长期运行),同时需安装 `Python`
> 建议Python版本在 3.7.1~3.9.X 之间3.10及以上版本在 MacOS 可用,其他系统上不确定能否正常运行。
支持 Linux、MacOS、Windows 系统可在Linux服务器上长期运行),同时需安装 `Python`
> 建议Python版本在 3.7.1~3.9.X 之间,推荐3.8版本,3.10及以上版本在 MacOS 可用,其他系统上不确定能否正常运行。
> 注意Docker 或 Railway 部署无需安装python环境和下载源码可直接快进到下一节。
**(1) 克隆项目代码:**
@@ -69,18 +90,20 @@ git clone https://github.com/zhayujie/chatgpt-on-wechat
cd chatgpt-on-wechat/
```
注: 如遇到网络问题可选择国内镜像 https://gitee.com/zhayujie/chatgpt-on-wechat
**(2) 安装核心依赖 (必选)**
> 能够使用`itchat`创建机器人,并具有文字交流功能所需的最小依赖集合。
```bash
pip3 install -r requirements.txt
```
**(3) 拓展依赖 (可选,建议安装)**
```bash
pip3 install itchat-uos==1.5.0.dev0
pip3 install --upgrade openai
pip3 install -r requirements-optional.txt
```
注:`itchat-uos`使用指定版本1.5.0.dev0`openai`使用最新版本需高于0.27.0。
**(3) 拓展依赖 (可选)**
语音识别及语音回复相关依赖:[#415](https://github.com/zhayujie/chatgpt-on-wechat/issues/415)。
> 如果某项依赖安装失败可注释掉对应的行再继续
## 配置
@@ -90,22 +113,32 @@ pip3 install --upgrade openai
cp config-template.json config.json
```
然后在`config.json`中填入配置,以下是对默认配置的说明,可根据需要进行自定义修改:
然后在`config.json`中填入配置,以下是对默认配置的说明,可根据需要进行自定义修改(请去掉注释)
```bash
# config.json文件内容示例
{
{
"open_ai_api_key": "YOUR API KEY", # 填入上面创建的 OpenAI API KEY
"model": "gpt-3.5-turbo", # 模型名称
"proxy": "127.0.0.1:7890", # 代理客户端的ip和端口
"model": "gpt-3.5-turbo", # 模型名称, 支持 gpt-3.5-turbo, gpt-3.5-turbo-16k, gpt-4, wenxin, xunfei
"proxy": "", # 代理客户端的ip和端口,国内环境开启代理的需要填写该项,如 "127.0.0.1:7890"
"single_chat_prefix": ["bot", "@bot"], # 私聊时文本需要包含该前缀才能触发机器人回复
"single_chat_reply_prefix": "[bot] ", # 私聊时自动回复的前缀,用于区分真人
"group_chat_prefix": ["@bot"], # 群聊时包含该前缀则会触发机器人回复
"group_name_white_list": ["ChatGPT测试群", "ChatGPT测试群2"], # 开启自动回复的群名称列表
"group_chat_in_one_session": ["ChatGPT测试群"], # 支持会话上下文共享的群名称
"image_create_prefix": ["画", "看", "找"], # 开启图片回复的前缀
"conversation_max_tokens": 1000, # 支持上下文记忆的最多字符数
"speech_recognition": false, # 是否开启语音识别
"group_speech_recognition": false, # 是否开启群组语音识别
"use_azure_chatgpt": false, # 是否使用Azure ChatGPT service代替openai ChatGPT service. 当设置为true时需要设置 open_ai_api_base如 https://xxx.openai.azure.com/
"azure_deployment_id": "", # 采用Azure ChatGPT时模型部署名称
"azure_api_version": "", # 采用Azure ChatGPT时API版本
"character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。", # 人格描述
# 订阅消息公众号和企业微信channel中请填写当被订阅时会自动回复可使用特殊占位符。目前支持的占位符有{trigger_prefix}在程序中它会自动替换成bot的触发词。
"subscribe_msg": "感谢您的关注!\n这里是ChatGPT可以自由对话。\n支持语音对话。\n支持图片输出画字开头的消息将按要求创作图片。\n支持角色扮演和文字冒险等丰富插件。\n输入{trigger_prefix}#help 查看详细指令。",
"use_linkai": false, # 是否使用LinkAI接口默认关闭开启后可国内访问使用知识库和MJ
"linkai_api_key": "", # LinkAI Api Key
"linkai_app_code": "" # LinkAI 应用code
}
```
**配置说明:**
@@ -120,25 +153,35 @@ pip3 install --upgrade openai
+ 群组聊天中,群名称需配置在 `group_name_white_list ` 中才能开启群聊自动回复。如果想对所有群聊生效,可以直接填写 `"group_name_white_list": ["ALL_GROUP"]`
+ 默认只要被人 @ 就会触发机器人自动回复;另外群聊天中只要检测到以 "@bot" 开头的内容,同样会自动回复(方便自己触发),这对应配置项 `group_chat_prefix`
+ 可选配置: `group_name_keyword_white_list`配置项支持模糊匹配群名称,`group_chat_keyword`配置项则支持模糊匹配群消息内容用法与上述两个配置项相同。Contributed by [evolay](https://github.com/evolay))
+ `group_chat_in_one_session`:使群聊共享一个会话上下文,配置 `["ALL_GROUP"]` 则作用于所有群聊
**3.语音识别**
+ 添加 `"speech_recognition": true` 将开启语音识别默认使用openai的whisper模型识别为文字同时以文字回复目前只支持私聊 (注意由于语音消息无法匹配前缀,一旦开启将对所有语音自动回复)
+ 添加 `"voice_reply_voice": true` 将开启语音回复语音但是需要配置对应语音合成平台的key由于itchat协议的限制只能发送语音mp3文件若使用wechaty则回复的是微信语音。
+ 添加 `"speech_recognition": true` 将开启语音识别默认使用openai的whisper模型识别为文字同时以文字回复该参数仅支持私聊 (注意由于语音消息无法匹配前缀,一旦开启将对所有语音自动回复,支持语音触发画图)
+ 添加 `"group_speech_recognition": true` 将开启群组语音识别默认使用openai的whisper模型识别为文字同时以文字回复参数仅支持群聊 (会匹配group_chat_prefix和group_chat_keyword, 支持语音触发画图)
+ 添加 `"voice_reply_voice": true` 将开启语音回复语音同时作用于私聊和群聊但是需要配置对应语音合成平台的key由于itchat协议的限制只能发送语音mp3文件若使用wechaty则回复的是微信语音。
**4.其他配置**
+ `model`: 模型名称,目前支持 `gpt-3.5-turbo`, `text-davinci-003`, `gpt-4`, `gpt-4-32k` (其中gpt-4 api暂未开放)
+ `temperature`,`frequency_penalty`,`presence_penalty`: Chat API接口参数详情参考[OpenAI官方文档。](https://platform.openai.com/docs/api-reference/chat)
+ `model`: 模型名称,目前支持 `gpt-3.5-turbo`, `text-davinci-003`, `gpt-4`, `gpt-4-32k`, `wenxin` , `claude` , `xunfei`(其中gpt-4 api暂未完全开放,申请通过后可使用)
+ `temperature`,`frequency_penalty`,`presence_penalty`: Chat API接口参数详情参考[OpenAI官方文档。](https://platform.openai.com/docs/api-reference/chat)
+ `proxy`:由于目前 `openai` 接口国内无法访问,需配置代理客户端的地址,详情参考 [#351](https://github.com/zhayujie/chatgpt-on-wechat/issues/351)
+ 对于图像生成,在满足个人或群组触发条件外,还需要额外的关键词前缀来触发,对应配置 `image_create_prefix `
+ 关于OpenAI对话及图片接口的参数配置内容自由度、回复字数限制、图片大小等可以参考 [对话接口](https://beta.openai.com/docs/api-reference/completions) 和 [图像接口](https://beta.openai.com/docs/api-reference/completions) 文档直接在 [代码](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/bot/openai/open_ai_bot.py) `bot/openai/open_ai_bot.py` 中进行调整
+ 关于OpenAI对话及图片接口的参数配置内容自由度、回复字数限制、图片大小等可以参考 [对话接口](https://beta.openai.com/docs/api-reference/completions) 和 [图像接口](https://beta.openai.com/docs/api-reference/completions) 文档,在[`config.py`](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/config.py)中检查哪些参数在本项目中是可配置的
+ `conversation_max_tokens`:表示能够记忆的上下文最大字数(一问一答为一组对话,如果累积的对话字数超出限制,就会优先移除最早的一组对话)
+ `rate_limit_chatgpt``rate_limit_dalle`:每分钟最高问答速率、画图速率,超速后排队按序处理。
+ `clear_memory_commands`: 对话内指令,主动清空前文记忆,字符串数组可自定义指令别名。
+ `hot_reload`: 程序退出后,暂存微信扫码状态,默认关闭。
+ `character_desc` 配置中保存着你对机器人说的一段话,他会记住这段话并作为他的设定,你可以为他定制任何人格 (关于会话上下文的更多内容参考该 [issue](https://github.com/zhayujie/chatgpt-on-wechat/issues/43))
+ `subscribe_msg`订阅消息公众号和企业微信channel中请填写当被订阅时会自动回复 可使用特殊占位符。目前支持的占位符有{trigger_prefix}在程序中它会自动替换成bot的触发词。
**5.LinkAI配置 (可选)**
+ `use_linkai`: 是否使用LinkAI接口开启后可国内访问使用知识库和 `Midjourney` 绘画, 参考 [文档](https://link-ai.tech/platform/link-app/wechat)
+ `linkai_api_key`: LinkAI Api Key可在 [控制台](https://link-ai.tech/console/interface) 创建
+ `linkai_app_code`: LinkAI 应用code选填
**本说明文档可能会未及时更新,当前所有可选的配置项均在该[`config.py`](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/config.py)中列出。**
## 运行
@@ -147,40 +190,92 @@ pip3 install --upgrade openai
如果是开发机 **本地运行**,直接在项目根目录下执行:
```bash
python3 app.py
python3 app.py # windows环境下该命令通常为 python app.py
```
终端输出二维码后,使用微信进行扫码,当输出 "Start auto replying" 时表示自动回复程序已经成功运行了(注意:用于登录的微信需要在支付处已完成实名认证)。扫码登录后你的账号就成为机器人了,可以在微信手机端通过配置的关键词触发自动回复 (任意好友发送消息给你,或是自己发消息给好友),参考[#142](https://github.com/zhayujie/chatgpt-on-wechat/issues/142)。
终端输出二维码后,使用微信进行扫码,当输出 "Start auto replying" 时表示自动回复程序已经成功运行了(注意:用于登录的微信需要在支付处已完成实名认证)。扫码登录后你的账号就成为机器人了,可以在微信手机端通过配置的关键词触发自动回复 (任意好友发送消息给你,或是自己发消息给好友),参考[#142](https://github.com/zhayujie/chatgpt-on-wechat/issues/142)。
### 2.服务器部署
使用nohup命令在后台运行程序
```bash
touch nohup.out # 首次运行需要新建日志文件
nohup python3 app.py & tail -f nohup.out # 在后台运行程序并通过日志输出二维码
```
扫码登录后程序即可运行于服务器后台,此时可通过 `ctrl+c` 关闭日志,不会影响后台程序的运行。使用 `ps -ef | grep app.py | grep -v grep` 命令可查看运行于后台的进程,如果想要重新启动程序可以先 `kill` 掉对应的进程。日志关闭后如果想要再次打开只需输入 `tail -f nohup.out`。此外,`scripts` 目录下有一键运行、关闭程序的脚本供使用。
> **注意:** 如果 扫码后手机提示登录验证需要等待5s而终端的二维码再次刷新并提示 `Log in time out, reloading QR code`,此时需参考此 [issue](https://github.com/zhayujie/chatgpt-on-wechat/issues/8) 修改一行代码即可解决
> **多账号支持:** 将项目复制多份,分别启动程序,用不同账号扫码登录即可实现同时运行
> **多账号支持:** 将 项目复制多份,分别启动程序,用不同账号扫码登录即可实现同时运行
> **特殊指令:** 用户向机器人发送 **#清除记忆** 即可清空该用户的上下文记忆。
> **特殊指令:** 用户向机器人发送 **#reset** 即可清空该用户的上下文记忆
### 3.Docker部署
参考文档 [Docker部署](https://github.com/limccn/chatgpt-on-wechat/wiki/Docker%E9%83%A8%E7%BD%B2) (Contributed by [limccn](https://github.com/limccn))
> 使用docker部署无需下载源码和安装依赖只需要获取 docker-compose.yml 配置文件并启动容器即可
> 前提是需要安装好 `docker` 及 `docker-compose`,安装成功的表现是执行 `docker -v` 和 `docker-compose version` (或 docker compose version) 可以查看到版本号,可前往 [docker官网](https://docs.docker.com/engine/install/) 进行下载。
#### (1) 下载 docker-compose.yml 文件
```bash
wget https://open-1317903499.cos.ap-guangzhou.myqcloud.com/docker-compose.yml
```
下载完成后打开 `docker-compose.yml` 修改所需配置,如 `OPEN_AI_API_KEY``GROUP_NAME_WHITE_LIST` 等。
#### (2) 启动容器
`docker-compose.yml` 所在目录下执行以下命令启动容器:
```bash
sudo docker compose up -d
```
运行 `sudo docker ps` 能查看到 NAMES 为 chatgpt-on-wechat 的容器即表示运行成功。
注意:
- 如果 `docker-compose` 是 1.X 版本 则需要执行 `sudo docker-compose up -d` 来启动容器
- 该命令会自动去 [docker hub](https://hub.docker.com/r/zhayujie/chatgpt-on-wechat) 拉取 latest 版本的镜像latest 镜像会在每次项目 release 新的版本时生成
最后运行以下命令可查看容器运行日志,扫描日志中的二维码即可完成登录:
```bash
sudo docker logs -f chatgpt-on-wechat
```
#### (3) 插件使用
如果需要在docker容器中修改插件配置可通过挂载的方式完成将 [插件配置文件](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/plugins/config.json.template)
重命名为 `config.json`,放置于 `docker-compose.yml` 相同目录下,并在 `docker-compose.yml` 中的 `chatgpt-on-wechat` 部分下添加 `volumes` 映射:
```
volumes:
- ./config.json:/app/plugins/config.json
```
### 4. Railway部署
> Railway 每月提供5刀和最多500小时的免费额度。 (07.11更新: 目前大部分账号已无法免费部署)
1. 进入 [Railway](https://railway.app/template/qApznZ?referralCode=RC3znh)
2. 点击 `Deploy Now` 按钮。
3. 设置环境变量来重载程序运行的参数,例如`open_ai_api_key`, `character_desc`
**一键部署:**
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/qApznZ?referralCode=RC3znh)
## 常见问题
FAQs <https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs>
或直接在线咨询 [项目小助手](https://link-ai.tech/app/Kv2fXJcH) (beta版本语料完善中回复仅供参考)
## 开发
欢迎接入更多应用,参考 [Terminal代码](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/channel/terminal/terminal_channel.py) 实现接收和发送消息逻辑即可接入。 同时欢迎增加新的插件,参考 [插件说明文档](https://github.com/zhayujie/chatgpt-on-wechat/tree/master/plugins)。
## 联系
欢迎提交PR、Issues以及Star支持一下。程序运行遇到问题优先查看 [常见问题列表](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs) ,其次前往 [Issues](https://github.com/zhayujie/chatgpt-on-wechat/issues) 中搜索若无相似问题可创建Issue或加微信 eijuyahz 交流。
欢迎提交PR、Issues以及Star支持一下。程序运行遇到问题可以查看 [常见问题列表](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs) ,其次前往 [Issues](https://github.com/zhayujie/chatgpt-on-wechat/issues) 中搜索。个人开发者可加入开源交流群参与更多讨论,企业用户可联系[产品顾问](https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/product-manager-qrcode.jpg)咨询。

56
app.py
View File

@@ -1,20 +1,66 @@
# encoding:utf-8
import config
import os
import signal
import sys
from channel import channel_factory
from common.log import logger
from common import const
from config import load_config
from plugins import *
import threading
if __name__ == '__main__':
def sigterm_handler_wrap(_signo):
old_handler = signal.getsignal(_signo)
def func(_signo, _stack_frame):
logger.info("signal {} received, exiting...".format(_signo))
conf().save_user_datas()
if callable(old_handler): # check old_handler
return old_handler(_signo, _stack_frame)
sys.exit(0)
signal.signal(_signo, func)
def run():
try:
# load config
config.load_config()
load_config()
# ctrl + c
sigterm_handler_wrap(signal.SIGINT)
# kill signal
sigterm_handler_wrap(signal.SIGTERM)
# create channel
channel = channel_factory.create_channel("wx")
channel_name = conf().get("channel_type", "wx")
if "--cmd" in sys.argv:
channel_name = "terminal"
if channel_name == "wxy":
os.environ["WECHATY_LOG"] = "warn"
# os.environ['WECHATY_PUPPET_SERVICE_ENDPOINT'] = '127.0.0.1:9001'
channel = channel_factory.create_channel(channel_name)
if channel_name in ["wx", "wxy", "terminal", "wechatmp", "wechatmp_service", "wechatcom_app", "wework", const.FEISHU,const.DINGTALK]:
PluginManager().load_plugins()
if conf().get("use_linkai"):
try:
from common import linkai_client
threading.Thread(target=linkai_client.start, args=(channel, )).start()
except Exception as e:
pass
# startup channel
channel.startup()
except Exception as e:
logger.error("App startup failed!")
logger.exception(e)
if __name__ == "__main__":
run()

214
bot/ali/ali_qwen_bot.py Normal file
View File

@@ -0,0 +1,214 @@
# encoding:utf-8
import json
import time
from typing import List, Tuple
import openai
import openai.error
import broadscope_bailian
from broadscope_bailian import ChatQaMessage
from bot.bot import Bot
from bot.ali.ali_qwen_session import AliQwenSession
from bot.session_manager import SessionManager
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from common.log import logger
from common import const
from config import conf, load_config
class AliQwenBot(Bot):
def __init__(self):
super().__init__()
self.api_key_expired_time = self.set_api_key()
self.sessions = SessionManager(AliQwenSession, model=conf().get("model", const.QWEN))
def api_key_client(self):
return broadscope_bailian.AccessTokenClient(access_key_id=self.access_key_id(), access_key_secret=self.access_key_secret())
def access_key_id(self):
return conf().get("qwen_access_key_id")
def access_key_secret(self):
return conf().get("qwen_access_key_secret")
def agent_key(self):
return conf().get("qwen_agent_key")
def app_id(self):
return conf().get("qwen_app_id")
def node_id(self):
return conf().get("qwen_node_id", "")
def temperature(self):
return conf().get("temperature", 0.2 )
def top_p(self):
return conf().get("top_p", 1)
def reply(self, query, context=None):
# acquire reply content
if context.type == ContextType.TEXT:
logger.info("[QWEN] query={}".format(query))
session_id = context["session_id"]
reply = None
clear_memory_commands = conf().get("clear_memory_commands", ["#清除记忆"])
if query in clear_memory_commands:
self.sessions.clear_session(session_id)
reply = Reply(ReplyType.INFO, "记忆已清除")
elif query == "#清除所有":
self.sessions.clear_all_session()
reply = Reply(ReplyType.INFO, "所有人记忆已清除")
elif query == "#更新配置":
load_config()
reply = Reply(ReplyType.INFO, "配置已更新")
if reply:
return reply
session = self.sessions.session_query(query, session_id)
logger.debug("[QWEN] session query={}".format(session.messages))
reply_content = self.reply_text(session)
logger.debug(
"[QWEN] new_query={}, session_id={}, reply_cont={}, completion_tokens={}".format(
session.messages,
session_id,
reply_content["content"],
reply_content["completion_tokens"],
)
)
if reply_content["completion_tokens"] == 0 and len(reply_content["content"]) > 0:
reply = Reply(ReplyType.ERROR, reply_content["content"])
elif reply_content["completion_tokens"] > 0:
self.sessions.session_reply(reply_content["content"], session_id, reply_content["total_tokens"])
reply = Reply(ReplyType.TEXT, reply_content["content"])
else:
reply = Reply(ReplyType.ERROR, reply_content["content"])
logger.debug("[QWEN] reply {} used 0 tokens.".format(reply_content))
return reply
else:
reply = Reply(ReplyType.ERROR, "Bot不支持处理{}类型的消息".format(context.type))
return reply
def reply_text(self, session: AliQwenSession, retry_count=0) -> dict:
"""
call bailian's ChatCompletion to get the answer
:param session: a conversation session
:param retry_count: retry count
:return: {}
"""
try:
prompt, history = self.convert_messages_format(session.messages)
self.update_api_key_if_expired()
# NOTE 阿里百炼的call()函数未提供temperature参数考虑到temperature和top_p参数作用相同取两者较小的值作为top_p参数传入详情见文档 https://help.aliyun.com/document_detail/2587502.htm
response = broadscope_bailian.Completions().call(app_id=self.app_id(), prompt=prompt, history=history, top_p=min(self.temperature(), self.top_p()))
completion_content = self.get_completion_content(response, self.node_id())
completion_tokens, total_tokens = self.calc_tokens(session.messages, completion_content)
return {
"total_tokens": total_tokens,
"completion_tokens": completion_tokens,
"content": completion_content,
}
except Exception as e:
need_retry = retry_count < 2
result = {"completion_tokens": 0, "content": "我现在有点累了,等会再来吧"}
if isinstance(e, openai.error.RateLimitError):
logger.warn("[QWEN] RateLimitError: {}".format(e))
result["content"] = "提问太快啦,请休息一下再问我吧"
if need_retry:
time.sleep(20)
elif isinstance(e, openai.error.Timeout):
logger.warn("[QWEN] Timeout: {}".format(e))
result["content"] = "我没有收到你的消息"
if need_retry:
time.sleep(5)
elif isinstance(e, openai.error.APIError):
logger.warn("[QWEN] Bad Gateway: {}".format(e))
result["content"] = "请再问我一次"
if need_retry:
time.sleep(10)
elif isinstance(e, openai.error.APIConnectionError):
logger.warn("[QWEN] APIConnectionError: {}".format(e))
need_retry = False
result["content"] = "我连接不到你的网络"
else:
logger.exception("[QWEN] Exception: {}".format(e))
need_retry = False
self.sessions.clear_session(session.session_id)
if need_retry:
logger.warn("[QWEN] 第{}次重试".format(retry_count + 1))
return self.reply_text(session, retry_count + 1)
else:
return result
def set_api_key(self):
api_key, expired_time = self.api_key_client().create_token(agent_key=self.agent_key())
broadscope_bailian.api_key = api_key
return expired_time
def update_api_key_if_expired(self):
if time.time() > self.api_key_expired_time:
self.api_key_expired_time = self.set_api_key()
def convert_messages_format(self, messages) -> Tuple[str, List[ChatQaMessage]]:
history = []
user_content = ''
assistant_content = ''
system_content = ''
for message in messages:
role = message.get('role')
if role == 'user':
user_content += message.get('content')
elif role == 'assistant':
assistant_content = message.get('content')
history.append(ChatQaMessage(user_content, assistant_content))
user_content = ''
assistant_content = ''
elif role =='system':
system_content += message.get('content')
if user_content == '':
raise Exception('no user message')
if system_content != '':
# NOTE 模拟系统消息,测试发现人格描述以"你需要扮演ChatGPT"开头能够起作用,而以"你是ChatGPT"开头模型会直接否认
system_qa = ChatQaMessage(system_content, '好的,我会严格按照你的设定回答问题')
history.insert(0, system_qa)
logger.debug("[QWEN] converted qa messages: {}".format([item.to_dict() for item in history]))
logger.debug("[QWEN] user content as prompt: {}".format(user_content))
return user_content, history
def get_completion_content(self, response, node_id):
if not response['Success']:
return f"[ERROR]\n{response['Code']}:{response['Message']}"
text = response['Data']['Text']
if node_id == '':
return text
# TODO: 当使用流程编排创建大模型应用时,响应结构如下,最终结果在['finalResult'][node_id]['response']['text']中,暂时先这么写
# {
# 'Success': True,
# 'Code': None,
# 'Message': None,
# 'Data': {
# 'ResponseId': '9822f38dbacf4c9b8daf5ca03a2daf15',
# 'SessionId': 'session_id',
# 'Text': '{"finalResult":{"LLM_T7islK":{"params":{"modelId":"qwen-plus-v1","prompt":"${systemVars.query}${bizVars.Text}"},"response":{"text":"作为一个AI语言模型我没有年龄因为我没有生日。\n我只是一个程序没有生命和身体。"}}}}',
# 'Thoughts': [],
# 'Debug': {},
# 'DocReferences': []
# },
# 'RequestId': '8e11d31551ce4c3f83f49e6e0dd998b0',
# 'Failed': None
# }
text_dict = json.loads(text)
completion_content = text_dict['finalResult'][node_id]['response']['text']
return completion_content
def calc_tokens(self, messages, completion_content):
completion_tokens = len(completion_content)
prompt_tokens = 0
for message in messages:
prompt_tokens += len(message["content"])
return completion_tokens, prompt_tokens + completion_tokens

View File

@@ -0,0 +1,62 @@
from bot.session_manager import Session
from common.log import logger
"""
e.g.
[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Who won the world series in 2020?"},
{"role": "assistant", "content": "The Los Angeles Dodgers won the World Series in 2020."},
{"role": "user", "content": "Where was it played?"}
]
"""
class AliQwenSession(Session):
def __init__(self, session_id, system_prompt=None, model="qianwen"):
super().__init__(session_id, system_prompt)
self.model = model
self.reset()
def discard_exceeding(self, max_tokens, cur_tokens=None):
precise = True
try:
cur_tokens = self.calc_tokens()
except Exception as e:
precise = False
if cur_tokens is None:
raise e
logger.debug("Exception when counting tokens precisely for query: {}".format(e))
while cur_tokens > max_tokens:
if len(self.messages) > 2:
self.messages.pop(1)
elif len(self.messages) == 2 and self.messages[1]["role"] == "assistant":
self.messages.pop(1)
if precise:
cur_tokens = self.calc_tokens()
else:
cur_tokens = cur_tokens - max_tokens
break
elif len(self.messages) == 2 and self.messages[1]["role"] == "user":
logger.warn("user message exceed max_tokens. total_tokens={}".format(cur_tokens))
break
else:
logger.debug("max_tokens={}, total_tokens={}, len(messages)={}".format(max_tokens, cur_tokens, len(self.messages)))
break
if precise:
cur_tokens = self.calc_tokens()
else:
cur_tokens = cur_tokens - max_tokens
return cur_tokens
def calc_tokens(self):
return num_tokens_from_messages(self.messages, self.model)
def num_tokens_from_messages(messages, model):
"""Returns the number of tokens used by a list of messages."""
# 官方token计算规则"对于中文文本来说1个token通常对应一个汉字对于英文文本来说1个token通常对应3至4个字母或1个单词"
# 详情请产看文档https://help.aliyun.com/document_detail/2586397.html
# 目前根据字符串长度粗略估计token数不影响正常使用
tokens = 0
for msg in messages:
tokens += len(msg["content"])
return tokens

View File

@@ -1,26 +1,36 @@
# encoding:utf-8
import requests
from bot.bot import Bot
from bridge.reply import Reply, ReplyType
# Baidu Unit对话接口 (可用, 但能力较弱)
class BaiduUnitBot(Bot):
def reply(self, query, context=None):
token = self.get_token()
url = 'https://aip.baidubce.com/rpc/2.0/unit/service/v3/chat?access_token=' + token
post_data = "{\"version\":\"3.0\",\"service_id\":\"S73177\",\"session_id\":\"\",\"log_id\":\"7758521\",\"skill_ids\":[\"1221886\"],\"request\":{\"terminal_id\":\"88888\",\"query\":\"" + query + "\", \"hyper_params\": {\"chat_custom_bot_profile\": 1}}}"
url = "https://aip.baidubce.com/rpc/2.0/unit/service/v3/chat?access_token=" + token
post_data = (
'{"version":"3.0","service_id":"S73177","session_id":"","log_id":"7758521","skill_ids":["1221886"],"request":{"terminal_id":"88888","query":"'
+ query
+ '", "hyper_params": {"chat_custom_bot_profile": 1}}}'
)
print(post_data)
headers = {'content-type': 'application/x-www-form-urlencoded'}
headers = {"content-type": "application/x-www-form-urlencoded"}
response = requests.post(url, data=post_data.encode(), headers=headers)
if response:
return response.json()['result']['context']['SYS_PRESUMED_HIST'][1]
reply = Reply(
ReplyType.TEXT,
response.json()["result"]["context"]["SYS_PRESUMED_HIST"][1],
)
return reply
def get_token(self):
access_key = 'YOUR_ACCESS_KEY'
secret_key = 'YOUR_SECRET_KEY'
host = 'https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=' + access_key + '&client_secret=' + secret_key
access_key = "YOUR_ACCESS_KEY"
secret_key = "YOUR_SECRET_KEY"
host = "https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=" + access_key + "&client_secret=" + secret_key
response = requests.get(host)
if response:
print(response.json())
return response.json()['access_token']
return response.json()["access_token"]

107
bot/baidu/baidu_wenxin.py Normal file
View File

@@ -0,0 +1,107 @@
# encoding:utf-8
import requests, json
from bot.bot import Bot
from bot.session_manager import SessionManager
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from common.log import logger
from config import conf
from bot.baidu.baidu_wenxin_session import BaiduWenxinSession
BAIDU_API_KEY = conf().get("baidu_wenxin_api_key")
BAIDU_SECRET_KEY = conf().get("baidu_wenxin_secret_key")
class BaiduWenxinBot(Bot):
def __init__(self):
super().__init__()
wenxin_model = conf().get("baidu_wenxin_model") or "eb-instant"
if conf().get("model") and conf().get("model") == "wenxin-4":
wenxin_model = "completions_pro"
self.sessions = SessionManager(BaiduWenxinSession, model=wenxin_model)
def reply(self, query, context=None):
# acquire reply content
if context and context.type:
if context.type == ContextType.TEXT:
logger.info("[BAIDU] query={}".format(query))
session_id = context["session_id"]
reply = None
if query == "#清除记忆":
self.sessions.clear_session(session_id)
reply = Reply(ReplyType.INFO, "记忆已清除")
elif query == "#清除所有":
self.sessions.clear_all_session()
reply = Reply(ReplyType.INFO, "所有人记忆已清除")
else:
session = self.sessions.session_query(query, session_id)
result = self.reply_text(session)
total_tokens, completion_tokens, reply_content = (
result["total_tokens"],
result["completion_tokens"],
result["content"],
)
logger.debug(
"[BAIDU] new_query={}, session_id={}, reply_cont={}, completion_tokens={}".format(session.messages, session_id, reply_content, completion_tokens)
)
if total_tokens == 0:
reply = Reply(ReplyType.ERROR, reply_content)
else:
self.sessions.session_reply(reply_content, session_id, total_tokens)
reply = Reply(ReplyType.TEXT, reply_content)
return reply
elif context.type == ContextType.IMAGE_CREATE:
ok, retstring = self.create_img(query, 0)
reply = None
if ok:
reply = Reply(ReplyType.IMAGE_URL, retstring)
else:
reply = Reply(ReplyType.ERROR, retstring)
return reply
def reply_text(self, session: BaiduWenxinSession, retry_count=0):
try:
logger.info("[BAIDU] model={}".format(session.model))
access_token = self.get_access_token()
if access_token == 'None':
logger.warn("[BAIDU] access token 获取失败")
return {
"total_tokens": 0,
"completion_tokens": 0,
"content": 0,
}
url = "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/" + session.model + "?access_token=" + access_token
headers = {
'Content-Type': 'application/json'
}
payload = {'messages': session.messages}
response = requests.request("POST", url, headers=headers, data=json.dumps(payload))
response_text = json.loads(response.text)
logger.info(f"[BAIDU] response text={response_text}")
res_content = response_text["result"]
total_tokens = response_text["usage"]["total_tokens"]
completion_tokens = response_text["usage"]["completion_tokens"]
logger.info("[BAIDU] reply={}".format(res_content))
return {
"total_tokens": total_tokens,
"completion_tokens": completion_tokens,
"content": res_content,
}
except Exception as e:
need_retry = retry_count < 2
logger.warn("[BAIDU] Exception: {}".format(e))
need_retry = False
self.sessions.clear_session(session.session_id)
result = {"completion_tokens": 0, "content": "出错了: {}".format(e)}
return result
def get_access_token(self):
"""
使用 AKSK 生成鉴权签名Access Token
:return: access_token或是None(如果错误)
"""
url = "https://aip.baidubce.com/oauth/2.0/token"
params = {"grant_type": "client_credentials", "client_id": BAIDU_API_KEY, "client_secret": BAIDU_SECRET_KEY}
return str(requests.post(url, params=params).json().get("access_token"))

View File

@@ -0,0 +1,53 @@
from bot.session_manager import Session
from common.log import logger
"""
e.g. [
{"role": "user", "content": "Who won the world series in 2020?"},
{"role": "assistant", "content": "The Los Angeles Dodgers won the World Series in 2020."},
{"role": "user", "content": "Where was it played?"}
]
"""
class BaiduWenxinSession(Session):
def __init__(self, session_id, system_prompt=None, model="gpt-3.5-turbo"):
super().__init__(session_id, system_prompt)
self.model = model
# 百度文心不支持system prompt
# self.reset()
def discard_exceeding(self, max_tokens, cur_tokens=None):
precise = True
try:
cur_tokens = self.calc_tokens()
except Exception as e:
precise = False
if cur_tokens is None:
raise e
logger.debug("Exception when counting tokens precisely for query: {}".format(e))
while cur_tokens > max_tokens:
if len(self.messages) >= 2:
self.messages.pop(0)
self.messages.pop(0)
else:
logger.debug("max_tokens={}, total_tokens={}, len(messages)={}".format(max_tokens, cur_tokens, len(self.messages)))
break
if precise:
cur_tokens = self.calc_tokens()
else:
cur_tokens = cur_tokens - max_tokens
return cur_tokens
def calc_tokens(self):
return num_tokens_from_messages(self.messages, self.model)
def num_tokens_from_messages(messages, model):
"""Returns the number of tokens used by a list of messages."""
tokens = 0
for msg in messages:
# 官方token计算规则暂不明确 "大约为 token数为 "中文字 + 其他语种单词数 x 1.3"
# 这里先直接根据字数粗略估算吧,暂不影响正常使用,仅在判断是否丢弃历史会话的时候会有偏差
tokens += len(msg["content"])
return tokens

View File

@@ -3,8 +3,12 @@ Auto-replay chat robot abstract class
"""
from bridge.context import Context
from bridge.reply import Reply
class Bot(object):
def reply(self, query, context=None):
def reply(self, query, context: Context = None) -> Reply:
"""
bot auto-reply content
:param req: received message

View File

@@ -6,14 +6,16 @@ from common import const
def create_bot(bot_type):
"""
create a channel instance
:param channel_type: channel type code
:return: channel instance
create a bot_type instance
:param bot_type: bot type code
:return: bot instance
"""
if bot_type == const.BAIDU:
# Baidu Unit对话接口
from bot.baidu.baidu_unit_bot import BaiduUnitBot
return BaiduUnitBot()
# 替换Baidu Unit为Baidu文心千帆对话接口
# from bot.baidu.baidu_unit_bot import BaiduUnitBot
# return BaiduUnitBot()
from bot.baidu.baidu_wenxin import BaiduWenxinBot
return BaiduWenxinBot()
elif bot_type == const.CHATGPT:
# ChatGPT 网页端web接口
@@ -24,4 +26,30 @@ def create_bot(bot_type):
# OpenAI 官方对话模型API
from bot.openai.open_ai_bot import OpenAIBot
return OpenAIBot()
elif bot_type == const.CHATGPTONAZURE:
# Azure chatgpt service https://azure.microsoft.com/en-in/products/cognitive-services/openai-service/
from bot.chatgpt.chat_gpt_bot import AzureChatGPTBot
return AzureChatGPTBot()
elif bot_type == const.XUNFEI:
from bot.xunfei.xunfei_spark_bot import XunFeiBot
return XunFeiBot()
elif bot_type == const.LINKAI:
from bot.linkai.link_ai_bot import LinkAIBot
return LinkAIBot()
elif bot_type == const.CLAUDEAI:
from bot.claude.claude_ai_bot import ClaudeAIBot
return ClaudeAIBot()
elif bot_type == const.QWEN:
from bot.ali.ali_qwen_bot import AliQwenBot
return AliQwenBot()
elif bot_type == const.GEMINI:
from bot.gemini.google_gemini_bot import GoogleGeminiBot
return GoogleGeminiBot()
raise RuntimeError

View File

@@ -1,197 +1,194 @@
# encoding:utf-8
from bot.bot import Bot
from config import conf, load_config
from common.log import logger
from common.token_bucket import TokenBucket
from common.expired_dict import ExpiredDict
import openai
import time
if conf().get('expires_in_seconds'):
all_sessions = ExpiredDict(conf().get('expires_in_seconds'))
else:
all_sessions = dict()
import openai
import openai.error
import requests
from bot.bot import Bot
from bot.chatgpt.chat_gpt_session import ChatGPTSession
from bot.openai.open_ai_image import OpenAIImage
from bot.session_manager import SessionManager
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from common.log import logger
from common.token_bucket import TokenBucket
from config import conf, load_config
# OpenAI对话模型API (可用)
class ChatGPTBot(Bot):
class ChatGPTBot(Bot, OpenAIImage):
def __init__(self):
openai.api_key = conf().get('open_ai_api_key')
if conf().get('open_ai_api_base'):
openai.api_base = conf().get('open_ai_api_base')
proxy = conf().get('proxy')
super().__init__()
# set the default api_key
openai.api_key = conf().get("open_ai_api_key")
if conf().get("open_ai_api_base"):
openai.api_base = conf().get("open_ai_api_base")
proxy = conf().get("proxy")
if proxy:
openai.proxy = proxy
if conf().get('rate_limit_chatgpt'):
self.tb4chatgpt = TokenBucket(conf().get('rate_limit_chatgpt', 20))
if conf().get('rate_limit_dalle'):
self.tb4dalle = TokenBucket(conf().get('rate_limit_dalle', 50))
if conf().get("rate_limit_chatgpt"):
self.tb4chatgpt = TokenBucket(conf().get("rate_limit_chatgpt", 20))
self.sessions = SessionManager(ChatGPTSession, model=conf().get("model") or "gpt-3.5-turbo")
self.args = {
"model": conf().get("model") or "gpt-3.5-turbo", # 对话模型的名称
"temperature": conf().get("temperature", 0.9), # 值在[0,1]之间,越大表示回复越具有不确定性
# "max_tokens":4096, # 回复最大的字符数
"top_p": conf().get("top_p", 1),
"frequency_penalty": conf().get("frequency_penalty", 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容
"presence_penalty": conf().get("presence_penalty", 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容
"request_timeout": conf().get("request_timeout", None), # 请求超时时间openai接口默认设置为600对于难问题一般需要较长时间
"timeout": conf().get("request_timeout", None), # 重试超时时间,在这个时间内,将会自动重试
}
def reply(self, query, context=None):
# acquire reply content
if not context or not context.get('type') or context.get('type') == 'TEXT':
logger.info("[OPEN_AI] query={}".format(query))
session_id = context.get('session_id') or context.get('from_user_id')
clear_memory_commands = conf().get('clear_memory_commands', ['#清除记忆'])
if context.type == ContextType.TEXT:
logger.info("[CHATGPT] query={}".format(query))
session_id = context["session_id"]
reply = None
clear_memory_commands = conf().get("clear_memory_commands", ["#清除记忆"])
if query in clear_memory_commands:
Session.clear_session(session_id)
return '记忆已清除'
elif query == '#清除所有':
Session.clear_all_session()
return '所有人记忆已清除'
elif query == '#更新配置':
self.sessions.clear_session(session_id)
reply = Reply(ReplyType.INFO, "记忆已清除")
elif query == "#清除所有":
self.sessions.clear_all_session()
reply = Reply(ReplyType.INFO, "所有人记忆已清除")
elif query == "#更新配置":
load_config()
return '配置已更新'
session = Session.build_session_query(query, session_id)
logger.debug("[OPEN_AI] session query={}".format(session))
reply = Reply(ReplyType.INFO, "配置已更新")
if reply:
return reply
session = self.sessions.session_query(query, session_id)
logger.debug("[CHATGPT] session query={}".format(session.messages))
api_key = context.get("openai_api_key")
model = context.get("gpt_model")
new_args = None
if model:
new_args = self.args.copy()
new_args["model"] = model
# if context.get('stream'):
# # reply in stream
# return self.reply_text_stream(query, new_query, session_id)
reply_content = self.reply_text(session, session_id, 0)
logger.debug("[OPEN_AI] new_query={}, session_id={}, reply_cont={}".format(session, session_id, reply_content["content"]))
if reply_content["completion_tokens"] > 0:
Session.save_session(reply_content["content"], session_id, reply_content["total_tokens"])
return reply_content["content"]
reply_content = self.reply_text(session, api_key, args=new_args)
logger.debug(
"[CHATGPT] new_query={}, session_id={}, reply_cont={}, completion_tokens={}".format(
session.messages,
session_id,
reply_content["content"],
reply_content["completion_tokens"],
)
)
if reply_content["completion_tokens"] == 0 and len(reply_content["content"]) > 0:
reply = Reply(ReplyType.ERROR, reply_content["content"])
elif reply_content["completion_tokens"] > 0:
self.sessions.session_reply(reply_content["content"], session_id, reply_content["total_tokens"])
reply = Reply(ReplyType.TEXT, reply_content["content"])
else:
reply = Reply(ReplyType.ERROR, reply_content["content"])
logger.debug("[CHATGPT] reply {} used 0 tokens.".format(reply_content))
return reply
elif context.get('type', None) == 'IMAGE_CREATE':
return self.create_img(query, 0)
elif context.type == ContextType.IMAGE_CREATE:
ok, retstring = self.create_img(query, 0)
reply = None
if ok:
reply = Reply(ReplyType.IMAGE_URL, retstring)
else:
reply = Reply(ReplyType.ERROR, retstring)
return reply
else:
reply = Reply(ReplyType.ERROR, "Bot不支持处理{}类型的消息".format(context.type))
return reply
def reply_text(self, session, session_id, retry_count=0) ->dict:
'''
def reply_text(self, session: ChatGPTSession, api_key=None, args=None, retry_count=0) -> dict:
"""
call openai's ChatCompletion to get the answer
:param session: a conversation session
:param session_id: session id
:param retry_count: retry count
:return: {}
'''
"""
try:
if conf().get('rate_limit_chatgpt') and not self.tb4chatgpt.get_token():
return {"completion_tokens": 0, "content": "提问太快啦,请休息一下再问我吧"}
response = openai.ChatCompletion.create(
model= conf().get("model") or "gpt-3.5-turbo", # 对话模型的名称
messages=session,
temperature=conf().get('temperature', 0.9), # 值在[0,1]之间,越大表示回复越具有不确定性
#max_tokens=4096, # 回复最大的字符数
top_p=1,
frequency_penalty=conf().get('frequency_penalty', 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容
presence_penalty=conf().get('presence_penalty', 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容
)
if conf().get("rate_limit_chatgpt") and not self.tb4chatgpt.get_token():
raise openai.error.RateLimitError("RateLimitError: rate limit exceeded")
# if api_key == None, the default openai.api_key will be used
if args is None:
args = self.args
response = openai.ChatCompletion.create(api_key=api_key, messages=session.messages, **args)
# logger.debug("[CHATGPT] response={}".format(response))
# logger.info("[ChatGPT] reply={}, total_tokens={}".format(response.choices[0]['message']['content'], response["usage"]["total_tokens"]))
return {"total_tokens": response["usage"]["total_tokens"],
"completion_tokens": response["usage"]["completion_tokens"],
"content": response.choices[0]['message']['content']}
except openai.error.RateLimitError as e:
# rate limit exception
logger.warn(e)
if retry_count < 1:
time.sleep(5)
logger.warn("[OPEN_AI] RateLimit exceed, 第{}次重试".format(retry_count+1))
return self.reply_text(session, session_id, retry_count+1)
else:
return {"completion_tokens": 0, "content": "提问太快啦,请休息一下再问我吧"}
except openai.error.APIConnectionError as e:
# api connection exception
logger.warn(e)
logger.warn("[OPEN_AI] APIConnection failed")
return {"completion_tokens": 0, "content":"我连接不到你的网络"}
except openai.error.Timeout as e:
logger.warn(e)
logger.warn("[OPEN_AI] Timeout")
return {"completion_tokens": 0, "content":"我没有收到你的消息"}
return {
"total_tokens": response["usage"]["total_tokens"],
"completion_tokens": response["usage"]["completion_tokens"],
"content": response.choices[0]["message"]["content"],
}
except Exception as e:
# unknown exception
logger.exception(e)
Session.clear_session(session_id)
return {"completion_tokens": 0, "content": "请再问我一次吧"}
need_retry = retry_count < 2
result = {"completion_tokens": 0, "content": "我现在有点累了,等会再来吧"}
if isinstance(e, openai.error.RateLimitError):
logger.warn("[CHATGPT] RateLimitError: {}".format(e))
result["content"] = "提问太快啦,请休息一下再问我吧"
if need_retry:
time.sleep(20)
elif isinstance(e, openai.error.Timeout):
logger.warn("[CHATGPT] Timeout: {}".format(e))
result["content"] = "我没有收到你的消息"
if need_retry:
time.sleep(5)
elif isinstance(e, openai.error.APIError):
logger.warn("[CHATGPT] Bad Gateway: {}".format(e))
result["content"] = "请再问我一次"
if need_retry:
time.sleep(10)
elif isinstance(e, openai.error.APIConnectionError):
logger.warn("[CHATGPT] APIConnectionError: {}".format(e))
result["content"] = "我连接不到你的网络"
if need_retry:
time.sleep(5)
else:
logger.exception("[CHATGPT] Exception: {}".format(e))
need_retry = False
self.sessions.clear_session(session.session_id)
def create_img(self, query, retry_count=0):
if need_retry:
logger.warn("[CHATGPT] 第{}次重试".format(retry_count + 1))
return self.reply_text(session, api_key, args, retry_count + 1)
else:
return result
class AzureChatGPTBot(ChatGPTBot):
def __init__(self):
super().__init__()
openai.api_type = "azure"
openai.api_version = conf().get("azure_api_version", "2023-06-01-preview")
self.args["deployment_id"] = conf().get("azure_deployment_id")
def create_img(self, query, retry_count=0, api_key=None):
api_version = "2022-08-03-preview"
url = "{}dalle/text-to-image?api-version={}".format(openai.api_base, api_version)
api_key = api_key or openai.api_key
headers = {"api-key": api_key, "Content-Type": "application/json"}
try:
if conf().get('rate_limit_dalle') and not self.tb4dalle.get_token():
return "请求太快了,请休息一下再问我吧"
logger.info("[OPEN_AI] image_query={}".format(query))
response = openai.Image.create(
prompt=query, #图片描述
n=1, #每次生成图片的数量
size="256x256" #图片大小,可选有 256x256, 512x512, 1024x1024
)
image_url = response['data'][0]['url']
logger.info("[OPEN_AI] image_url={}".format(image_url))
return image_url
except openai.error.RateLimitError as e:
logger.warn(e)
if retry_count < 1:
time.sleep(5)
logger.warn("[OPEN_AI] ImgCreate RateLimit exceed, 第{}次重试".format(retry_count+1))
return self.create_img(query, retry_count+1)
else:
return "请求太快啦,请休息一下再问我吧"
body = {"caption": query, "resolution": conf().get("image_create_size", "256x256")}
submission = requests.post(url, headers=headers, json=body)
operation_location = submission.headers["Operation-Location"]
retry_after = submission.headers["Retry-after"]
status = ""
image_url = ""
while status != "Succeeded":
logger.info("waiting for image create..., " + status + ",retry after " + retry_after + " seconds")
time.sleep(int(retry_after))
response = requests.get(operation_location, headers=headers)
status = response.json()["status"]
image_url = response.json()["result"]["contentUrl"]
return True, image_url
except Exception as e:
logger.exception(e)
return None
class Session(object):
@staticmethod
def build_session_query(query, session_id):
'''
build query with conversation history
e.g. [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Who won the world series in 2020?"},
{"role": "assistant", "content": "The Los Angeles Dodgers won the World Series in 2020."},
{"role": "user", "content": "Where was it played?"}
]
:param query: query content
:param session_id: session id
:return: query content with conversaction
'''
session = all_sessions.get(session_id, [])
if len(session) == 0:
system_prompt = conf().get("character_desc", "")
system_item = {'role': 'system', 'content': system_prompt}
session.append(system_item)
all_sessions[session_id] = session
user_item = {'role': 'user', 'content': query}
session.append(user_item)
return session
@staticmethod
def save_session(answer, session_id, total_tokens):
max_tokens = conf().get("conversation_max_tokens")
if not max_tokens:
# default 3000
max_tokens = 1000
max_tokens=int(max_tokens)
session = all_sessions.get(session_id)
if session:
# append conversation
gpt_item = {'role': 'assistant', 'content': answer}
session.append(gpt_item)
# discard exceed limit conversation
Session.discard_exceed_conversation(session, max_tokens, total_tokens)
@staticmethod
def discard_exceed_conversation(session, max_tokens, total_tokens):
dec_tokens = int(total_tokens)
# logger.info("prompt tokens used={},max_tokens={}".format(used_tokens,max_tokens))
while dec_tokens > max_tokens:
# pop first conversation
if len(session) > 3:
session.pop(1)
session.pop(1)
else:
break
dec_tokens = dec_tokens - max_tokens
@staticmethod
def clear_session(session_id):
all_sessions[session_id] = []
@staticmethod
def clear_all_session():
all_sessions.clear()
logger.error("create image error: {}".format(e))
return False, "图片生成失败"

View File

@@ -0,0 +1,102 @@
from bot.session_manager import Session
from common.log import logger
from common import const
"""
e.g. [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Who won the world series in 2020?"},
{"role": "assistant", "content": "The Los Angeles Dodgers won the World Series in 2020."},
{"role": "user", "content": "Where was it played?"}
]
"""
class ChatGPTSession(Session):
def __init__(self, session_id, system_prompt=None, model="gpt-3.5-turbo"):
super().__init__(session_id, system_prompt)
self.model = model
self.reset()
def discard_exceeding(self, max_tokens, cur_tokens=None):
precise = True
try:
cur_tokens = self.calc_tokens()
except Exception as e:
precise = False
if cur_tokens is None:
raise e
logger.debug("Exception when counting tokens precisely for query: {}".format(e))
while cur_tokens > max_tokens:
if len(self.messages) > 2:
self.messages.pop(1)
elif len(self.messages) == 2 and self.messages[1]["role"] == "assistant":
self.messages.pop(1)
if precise:
cur_tokens = self.calc_tokens()
else:
cur_tokens = cur_tokens - max_tokens
break
elif len(self.messages) == 2 and self.messages[1]["role"] == "user":
logger.warn("user message exceed max_tokens. total_tokens={}".format(cur_tokens))
break
else:
logger.debug("max_tokens={}, total_tokens={}, len(messages)={}".format(max_tokens, cur_tokens, len(self.messages)))
break
if precise:
cur_tokens = self.calc_tokens()
else:
cur_tokens = cur_tokens - max_tokens
return cur_tokens
def calc_tokens(self):
return num_tokens_from_messages(self.messages, self.model)
# refer to https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb
def num_tokens_from_messages(messages, model):
"""Returns the number of tokens used by a list of messages."""
if model in ["wenxin", "xunfei", const.GEMINI]:
return num_tokens_by_character(messages)
import tiktoken
if model in ["gpt-3.5-turbo-0301", "gpt-35-turbo", "gpt-3.5-turbo-1106"]:
return num_tokens_from_messages(messages, model="gpt-3.5-turbo")
elif model in ["gpt-4-0314", "gpt-4-0613", "gpt-4-32k", "gpt-4-32k-0613", "gpt-3.5-turbo-0613",
"gpt-3.5-turbo-16k", "gpt-3.5-turbo-16k-0613", "gpt-35-turbo-16k", "gpt-4-turbo-preview",
"gpt-4-1106-preview", const.GPT4_TURBO_PREVIEW, const.GPT4_VISION_PREVIEW]:
return num_tokens_from_messages(messages, model="gpt-4")
try:
encoding = tiktoken.encoding_for_model(model)
except KeyError:
logger.debug("Warning: model not found. Using cl100k_base encoding.")
encoding = tiktoken.get_encoding("cl100k_base")
if model == "gpt-3.5-turbo":
tokens_per_message = 4 # every message follows <|start|>{role/name}\n{content}<|end|>\n
tokens_per_name = -1 # if there's a name, the role is omitted
elif model == "gpt-4":
tokens_per_message = 3
tokens_per_name = 1
else:
logger.warn(f"num_tokens_from_messages() is not implemented for model {model}. Returning num tokens assuming gpt-3.5-turbo.")
return num_tokens_from_messages(messages, model="gpt-3.5-turbo")
num_tokens = 0
for message in messages:
num_tokens += tokens_per_message
for key, value in message.items():
num_tokens += len(encoding.encode(value))
if key == "name":
num_tokens += tokens_per_name
num_tokens += 3 # every reply is primed with <|start|>assistant<|message|>
return num_tokens
def num_tokens_by_character(messages):
"""Returns the number of tokens used by a list of messages."""
tokens = 0
for msg in messages:
tokens += len(msg["content"])
return tokens

222
bot/claude/claude_ai_bot.py Normal file
View File

@@ -0,0 +1,222 @@
import re
import time
import json
import uuid
from curl_cffi import requests
from bot.bot import Bot
from bot.claude.claude_ai_session import ClaudeAiSession
from bot.openai.open_ai_image import OpenAIImage
from bot.session_manager import SessionManager
from bridge.context import Context, ContextType
from bridge.reply import Reply, ReplyType
from common.log import logger
from config import conf
class ClaudeAIBot(Bot, OpenAIImage):
def __init__(self):
super().__init__()
self.sessions = SessionManager(ClaudeAiSession, model=conf().get("model") or "gpt-3.5-turbo")
self.claude_api_cookie = conf().get("claude_api_cookie")
self.proxy = conf().get("proxy")
self.con_uuid_dic = {}
if self.proxy:
self.proxies = {
"http": self.proxy,
"https": self.proxy
}
else:
self.proxies = None
self.error = ""
self.org_uuid = self.get_organization_id()
def generate_uuid(self):
random_uuid = uuid.uuid4()
random_uuid_str = str(random_uuid)
formatted_uuid = f"{random_uuid_str[0:8]}-{random_uuid_str[9:13]}-{random_uuid_str[14:18]}-{random_uuid_str[19:23]}-{random_uuid_str[24:]}"
return formatted_uuid
def reply(self, query, context: Context = None) -> Reply:
if context.type == ContextType.TEXT:
return self._chat(query, context)
elif context.type == ContextType.IMAGE_CREATE:
ok, res = self.create_img(query, 0)
if ok:
reply = Reply(ReplyType.IMAGE_URL, res)
else:
reply = Reply(ReplyType.ERROR, res)
return reply
else:
reply = Reply(ReplyType.ERROR, "Bot不支持处理{}类型的消息".format(context.type))
return reply
def get_organization_id(self):
url = "https://claude.ai/api/organizations"
headers = {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
'Accept-Language': 'en-US,en;q=0.5',
'Referer': 'https://claude.ai/chats',
'Content-Type': 'application/json',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-origin',
'Connection': 'keep-alive',
'Cookie': f'{self.claude_api_cookie}'
}
try:
response = requests.get(url, headers=headers, impersonate="chrome110", proxies =self.proxies, timeout=400)
res = json.loads(response.text)
uuid = res[0]['uuid']
except:
if "App unavailable" in response.text:
logger.error("IP error: The IP is not allowed to be used on Claude")
self.error = "ip所在地区不被claude支持"
elif "Invalid authorization" in response.text:
logger.error("Cookie error: Invalid authorization of claude, check cookie please.")
self.error = "无法通过claude身份验证请检查cookie"
return None
return uuid
def conversation_share_check(self,session_id):
if conf().get("claude_uuid") is not None and conf().get("claude_uuid") != "":
con_uuid = conf().get("claude_uuid")
return con_uuid
if session_id not in self.con_uuid_dic:
self.con_uuid_dic[session_id] = self.generate_uuid()
self.create_new_chat(self.con_uuid_dic[session_id])
return self.con_uuid_dic[session_id]
def check_cookie(self):
flag = self.get_organization_id()
return flag
def create_new_chat(self, con_uuid):
"""
新建claude对话实体
:param con_uuid: 对话id
:return:
"""
url = f"https://claude.ai/api/organizations/{self.org_uuid}/chat_conversations"
payload = json.dumps({"uuid": con_uuid, "name": ""})
headers = {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
'Accept-Language': 'en-US,en;q=0.5',
'Referer': 'https://claude.ai/chats',
'Content-Type': 'application/json',
'Origin': 'https://claude.ai',
'DNT': '1',
'Connection': 'keep-alive',
'Cookie': self.claude_api_cookie,
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-origin',
'TE': 'trailers'
}
response = requests.post(url, headers=headers, data=payload, impersonate="chrome110", proxies=self.proxies, timeout=400)
# Returns JSON of the newly created conversation information
return response.json()
def _chat(self, query, context, retry_count=0) -> Reply:
"""
发起对话请求
:param query: 请求提示词
:param context: 对话上下文
:param retry_count: 当前递归重试次数
:return: 回复
"""
if retry_count >= 2:
# exit from retry 2 times
logger.warn("[CLAUDEAI] failed after maximum number of retry times")
return Reply(ReplyType.ERROR, "请再问我一次吧")
try:
session_id = context["session_id"]
if self.org_uuid is None:
return Reply(ReplyType.ERROR, self.error)
session = self.sessions.session_query(query, session_id)
con_uuid = self.conversation_share_check(session_id)
model = conf().get("model") or "gpt-3.5-turbo"
# remove system message
if session.messages[0].get("role") == "system":
if model == "wenxin" or model == "claude":
session.messages.pop(0)
logger.info(f"[CLAUDEAI] query={query}")
# do http request
base_url = "https://claude.ai"
payload = json.dumps({
"completion": {
"prompt": f"{query}",
"timezone": "Asia/Kolkata",
"model": "claude-2"
},
"organization_uuid": f"{self.org_uuid}",
"conversation_uuid": f"{con_uuid}",
"text": f"{query}",
"attachments": []
})
headers = {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
'Accept': 'text/event-stream, text/event-stream',
'Accept-Language': 'en-US,en;q=0.5',
'Referer': 'https://claude.ai/chats',
'Content-Type': 'application/json',
'Origin': 'https://claude.ai',
'DNT': '1',
'Connection': 'keep-alive',
'Cookie': f'{self.claude_api_cookie}',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-origin',
'TE': 'trailers'
}
res = requests.post(base_url + "/api/append_message", headers=headers, data=payload,impersonate="chrome110",proxies= self.proxies,timeout=400)
if res.status_code == 200 or "pemission" in res.text:
# execute success
decoded_data = res.content.decode("utf-8")
decoded_data = re.sub('\n+', '\n', decoded_data).strip()
data_strings = decoded_data.split('\n')
completions = []
for data_string in data_strings:
json_str = data_string[6:].strip()
data = json.loads(json_str)
if 'completion' in data:
completions.append(data['completion'])
reply_content = ''.join(completions)
if "rate limi" in reply_content:
logger.error("rate limit error: The conversation has reached the system speed limit and is synchronized with Cladue. Please go to the official website to check the lifting time")
return Reply(ReplyType.ERROR, "对话达到系统速率限制与cladue同步请进入官网查看解除限制时间")
logger.info(f"[CLAUDE] reply={reply_content}, total_tokens=invisible")
self.sessions.session_reply(reply_content, session_id, 100)
return Reply(ReplyType.TEXT, reply_content)
else:
flag = self.check_cookie()
if flag == None:
return Reply(ReplyType.ERROR, self.error)
response = res.json()
error = response.get("error")
logger.error(f"[CLAUDE] chat failed, status_code={res.status_code}, "
f"msg={error.get('message')}, type={error.get('type')}, detail: {res.text}, uuid: {con_uuid}")
if res.status_code >= 500:
# server error, need retry
time.sleep(2)
logger.warn(f"[CLAUDE] do retry, times={retry_count}")
return self._chat(query, context, retry_count + 1)
return Reply(ReplyType.ERROR, "提问太快啦,请休息一下再问我吧")
except Exception as e:
logger.exception(e)
# retry
time.sleep(2)
logger.warn(f"[CLAUDE] do retry, times={retry_count}")
return self._chat(query, context, retry_count + 1)

View File

@@ -0,0 +1,9 @@
from bot.session_manager import Session
class ClaudeAiSession(Session):
def __init__(self, session_id, system_prompt=None, model="claude"):
super().__init__(session_id, system_prompt)
self.model = model
# claude逆向不支持role prompt
# self.reset()

View File

@@ -0,0 +1,75 @@
"""
Google gemini bot
@author zhayujie
@Date 2023/12/15
"""
# encoding:utf-8
from bot.bot import Bot
import google.generativeai as genai
from bot.session_manager import SessionManager
from bridge.context import ContextType, Context
from bridge.reply import Reply, ReplyType
from common.log import logger
from config import conf
from bot.baidu.baidu_wenxin_session import BaiduWenxinSession
# OpenAI对话模型API (可用)
class GoogleGeminiBot(Bot):
def __init__(self):
super().__init__()
self.api_key = conf().get("gemini_api_key")
# 复用文心的token计算方式
self.sessions = SessionManager(BaiduWenxinSession, model=conf().get("model") or "gpt-3.5-turbo")
def reply(self, query, context: Context = None) -> Reply:
try:
if context.type != ContextType.TEXT:
logger.warn(f"[Gemini] Unsupported message type, type={context.type}")
return Reply(ReplyType.TEXT, None)
logger.info(f"[Gemini] query={query}")
session_id = context["session_id"]
session = self.sessions.session_query(query, session_id)
gemini_messages = self._convert_to_gemini_messages(self._filter_messages(session.messages))
genai.configure(api_key=self.api_key)
model = genai.GenerativeModel('gemini-pro')
response = model.generate_content(gemini_messages)
reply_text = response.text
self.sessions.session_reply(reply_text, session_id)
logger.info(f"[Gemini] reply={reply_text}")
return Reply(ReplyType.TEXT, reply_text)
except Exception as e:
logger.error("[Gemini] fetch reply error, may contain unsafe content")
logger.error(e)
def _convert_to_gemini_messages(self, messages: list):
res = []
for msg in messages:
if msg.get("role") == "user":
role = "user"
elif msg.get("role") == "assistant":
role = "model"
else:
continue
res.append({
"role": role,
"parts": [{"text": msg.get("content")}]
})
return res
def _filter_messages(self, messages: list):
res = []
turn = "user"
for i in range(len(messages) - 1, -1, -1):
message = messages[i]
if message.get("role") != turn:
continue
res.insert(0, message)
if turn == "user":
turn = "assistant"
elif turn == "assistant":
turn = "user"
return res

455
bot/linkai/link_ai_bot.py Normal file
View File

@@ -0,0 +1,455 @@
# access LinkAI knowledge base platform
# docs: https://link-ai.tech/platform/link-app/wechat
import re
import time
import requests
import config
from bot.bot import Bot
from bot.chatgpt.chat_gpt_session import ChatGPTSession
from bot.session_manager import SessionManager
from bridge.context import Context, ContextType
from bridge.reply import Reply, ReplyType
from common.log import logger
from config import conf, pconf
import threading
from common import memory, utils
import base64
import os
class LinkAIBot(Bot):
# authentication failed
AUTH_FAILED_CODE = 401
NO_QUOTA_CODE = 406
def __init__(self):
super().__init__()
self.sessions = LinkAISessionManager(LinkAISession, model=conf().get("model") or "gpt-3.5-turbo")
self.args = {}
def reply(self, query, context: Context = None) -> Reply:
if context.type == ContextType.TEXT:
return self._chat(query, context)
elif context.type == ContextType.IMAGE_CREATE:
if not conf().get("text_to_image"):
logger.warn("[LinkAI] text_to_image is not enabled, ignore the IMAGE_CREATE request")
return Reply(ReplyType.TEXT, "")
ok, res = self.create_img(query, 0)
if ok:
reply = Reply(ReplyType.IMAGE_URL, res)
else:
reply = Reply(ReplyType.ERROR, res)
return reply
else:
reply = Reply(ReplyType.ERROR, "Bot不支持处理{}类型的消息".format(context.type))
return reply
def _chat(self, query, context, retry_count=0) -> Reply:
"""
发起对话请求
:param query: 请求提示词
:param context: 对话上下文
:param retry_count: 当前递归重试次数
:return: 回复
"""
if retry_count > 2:
# exit from retry 2 times
logger.warn("[LINKAI] failed after maximum number of retry times")
return Reply(ReplyType.TEXT, "请再问我一次吧")
try:
# load config
if context.get("generate_breaked_by"):
logger.info(f"[LINKAI] won't set appcode because a plugin ({context['generate_breaked_by']}) affected the context")
app_code = None
else:
plugin_app_code = self._find_group_mapping_code(context)
app_code = context.kwargs.get("app_code") or plugin_app_code or conf().get("linkai_app_code")
linkai_api_key = conf().get("linkai_api_key")
session_id = context["session_id"]
session_message = self.sessions.session_msg_query(query, session_id)
logger.debug(f"[LinkAI] session={session_message}, session_id={session_id}")
# image process
img_cache = memory.USER_IMAGE_CACHE.get(session_id)
if img_cache:
messages = self._process_image_msg(app_code=app_code, session_id=session_id, query=query, img_cache=img_cache)
if messages:
session_message = messages
model = conf().get("model")
# remove system message
if session_message[0].get("role") == "system":
if app_code or model == "wenxin":
session_message.pop(0)
body = {
"app_code": app_code,
"messages": session_message,
"model": model, # 对话模型的名称, 支持 gpt-3.5-turbo, gpt-3.5-turbo-16k, gpt-4, wenxin, xunfei
"temperature": conf().get("temperature"),
"top_p": conf().get("top_p", 1),
"frequency_penalty": conf().get("frequency_penalty", 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容
"presence_penalty": conf().get("presence_penalty", 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容
"session_id": session_id,
"channel_type": conf().get("channel_type")
}
try:
from linkai import LinkAIClient
client_id = LinkAIClient.fetch_client_id()
if client_id:
body["client_id"] = client_id
# start: client info deliver
if context.kwargs.get("msg"):
body["session_id"] = context.kwargs.get("msg").from_user_id
if context.kwargs.get("msg").is_group:
body["is_group"] = True
body["group_name"] = context.kwargs.get("msg").from_user_nickname
body["sender_name"] = context.kwargs.get("msg").actual_user_nickname
else:
body["sender_name"] = context.kwargs.get("msg").from_user_nickname
except Exception as e:
pass
file_id = context.kwargs.get("file_id")
if file_id:
body["file_id"] = file_id
logger.info(f"[LINKAI] query={query}, app_code={app_code}, model={body.get('model')}, file_id={file_id}")
headers = {"Authorization": "Bearer " + linkai_api_key}
# do http request
base_url = conf().get("linkai_api_base", "https://api.link-ai.chat")
res = requests.post(url=base_url + "/v1/chat/completions", json=body, headers=headers,
timeout=conf().get("request_timeout", 180))
if res.status_code == 200:
# execute success
response = res.json()
reply_content = response["choices"][0]["message"]["content"]
total_tokens = response["usage"]["total_tokens"]
logger.info(f"[LINKAI] reply={reply_content}, total_tokens={total_tokens}")
self.sessions.session_reply(reply_content, session_id, total_tokens, query=query)
agent_suffix = self._fetch_agent_suffix(response)
if agent_suffix:
reply_content += agent_suffix
if not agent_suffix:
knowledge_suffix = self._fetch_knowledge_search_suffix(response)
if knowledge_suffix:
reply_content += knowledge_suffix
# image process
if response["choices"][0].get("img_urls"):
thread = threading.Thread(target=self._send_image, args=(context.get("channel"), context, response["choices"][0].get("img_urls")))
thread.start()
if response["choices"][0].get("text_content"):
reply_content = response["choices"][0].get("text_content")
reply_content = self._process_url(reply_content)
return Reply(ReplyType.TEXT, reply_content)
else:
response = res.json()
error = response.get("error")
logger.error(f"[LINKAI] chat failed, status_code={res.status_code}, "
f"msg={error.get('message')}, type={error.get('type')}")
if res.status_code >= 500:
# server error, need retry
time.sleep(2)
logger.warn(f"[LINKAI] do retry, times={retry_count}")
return self._chat(query, context, retry_count + 1)
return Reply(ReplyType.TEXT, "提问太快啦,请休息一下再问我吧")
except Exception as e:
logger.exception(e)
# retry
time.sleep(2)
logger.warn(f"[LINKAI] do retry, times={retry_count}")
return self._chat(query, context, retry_count + 1)
def _process_image_msg(self, app_code: str, session_id: str, query:str, img_cache: dict):
try:
enable_image_input = False
app_info = self._fetch_app_info(app_code)
if not app_info:
logger.debug(f"[LinkAI] not found app, can't process images, app_code={app_code}")
return None
plugins = app_info.get("data").get("plugins")
for plugin in plugins:
if plugin.get("input_type") and "IMAGE" in plugin.get("input_type"):
enable_image_input = True
if not enable_image_input:
return
msg = img_cache.get("msg")
path = img_cache.get("path")
msg.prepare()
logger.info(f"[LinkAI] query with images, path={path}")
messages = self._build_vision_msg(query, path)
memory.USER_IMAGE_CACHE[session_id] = None
return messages
except Exception as e:
logger.exception(e)
def _find_group_mapping_code(self, context):
try:
if context.kwargs.get("isgroup"):
group_name = context.kwargs.get("msg").from_user_nickname
if config.plugin_config and config.plugin_config.get("linkai"):
linkai_config = config.plugin_config.get("linkai")
group_mapping = linkai_config.get("group_app_map")
if group_mapping and group_name:
return group_mapping.get(group_name)
except Exception as e:
logger.exception(e)
return None
def _build_vision_msg(self, query: str, path: str):
try:
suffix = utils.get_path_suffix(path)
with open(path, "rb") as file:
base64_str = base64.b64encode(file.read()).decode('utf-8')
messages = [{
"role": "user",
"content": [
{
"type": "text",
"text": query
},
{
"type": "image_url",
"image_url": {
"url": f"data:image/{suffix};base64,{base64_str}"
}
}
]
}]
return messages
except Exception as e:
logger.exception(e)
def reply_text(self, session: ChatGPTSession, app_code="", retry_count=0) -> dict:
if retry_count >= 2:
# exit from retry 2 times
logger.warn("[LINKAI] failed after maximum number of retry times")
return {
"total_tokens": 0,
"completion_tokens": 0,
"content": "请再问我一次吧"
}
try:
body = {
"app_code": app_code,
"messages": session.messages,
"model": conf().get("model") or "gpt-3.5-turbo", # 对话模型的名称, 支持 gpt-3.5-turbo, gpt-3.5-turbo-16k, gpt-4, wenxin, xunfei
"temperature": conf().get("temperature"),
"top_p": conf().get("top_p", 1),
"frequency_penalty": conf().get("frequency_penalty", 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容
"presence_penalty": conf().get("presence_penalty", 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容
}
if self.args.get("max_tokens"):
body["max_tokens"] = self.args.get("max_tokens")
headers = {"Authorization": "Bearer " + conf().get("linkai_api_key")}
# do http request
base_url = conf().get("linkai_api_base", "https://api.link-ai.chat")
res = requests.post(url=base_url + "/v1/chat/completions", json=body, headers=headers,
timeout=conf().get("request_timeout", 180))
if res.status_code == 200:
# execute success
response = res.json()
reply_content = response["choices"][0]["message"]["content"]
total_tokens = response["usage"]["total_tokens"]
logger.info(f"[LINKAI] reply={reply_content}, total_tokens={total_tokens}")
return {
"total_tokens": total_tokens,
"completion_tokens": response["usage"]["completion_tokens"],
"content": reply_content,
}
else:
response = res.json()
error = response.get("error")
logger.error(f"[LINKAI] chat failed, status_code={res.status_code}, "
f"msg={error.get('message')}, type={error.get('type')}")
if res.status_code >= 500:
# server error, need retry
time.sleep(2)
logger.warn(f"[LINKAI] do retry, times={retry_count}")
return self.reply_text(session, app_code, retry_count + 1)
return {
"total_tokens": 0,
"completion_tokens": 0,
"content": "提问太快啦,请休息一下再问我吧"
}
except Exception as e:
logger.exception(e)
# retry
time.sleep(2)
logger.warn(f"[LINKAI] do retry, times={retry_count}")
return self.reply_text(session, app_code, retry_count + 1)
def _fetch_app_info(self, app_code: str):
headers = {"Authorization": "Bearer " + conf().get("linkai_api_key")}
# do http request
base_url = conf().get("linkai_api_base", "https://api.link-ai.chat")
params = {"app_code": app_code}
res = requests.get(url=base_url + "/v1/app/info", params=params, headers=headers, timeout=(5, 10))
if res.status_code == 200:
return res.json()
else:
logger.warning(f"[LinkAI] find app info exception, res={res}")
def create_img(self, query, retry_count=0, api_key=None):
try:
logger.info("[LinkImage] image_query={}".format(query))
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {conf().get('linkai_api_key')}"
}
data = {
"prompt": query,
"n": 1,
"model": conf().get("text_to_image") or "dall-e-2",
"response_format": "url",
"img_proxy": conf().get("image_proxy")
}
url = conf().get("linkai_api_base", "https://api.link-ai.chat") + "/v1/images/generations"
res = requests.post(url, headers=headers, json=data, timeout=(5, 90))
t2 = time.time()
image_url = res.json()["data"][0]["url"]
logger.info("[OPEN_AI] image_url={}".format(image_url))
return True, image_url
except Exception as e:
logger.error(format(e))
return False, "画图出现问题,请休息一下再问我吧"
def _fetch_knowledge_search_suffix(self, response) -> str:
try:
if response.get("knowledge_base"):
search_hit = response.get("knowledge_base").get("search_hit")
first_similarity = response.get("knowledge_base").get("first_similarity")
logger.info(f"[LINKAI] knowledge base, search_hit={search_hit}, first_similarity={first_similarity}")
plugin_config = pconf("linkai")
if plugin_config and plugin_config.get("knowledge_base") and plugin_config.get("knowledge_base").get("search_miss_text_enabled"):
search_miss_similarity = plugin_config.get("knowledge_base").get("search_miss_similarity")
search_miss_text = plugin_config.get("knowledge_base").get("search_miss_suffix")
if not search_hit:
return search_miss_text
if search_miss_similarity and float(search_miss_similarity) > first_similarity:
return search_miss_text
except Exception as e:
logger.exception(e)
def _fetch_agent_suffix(self, response):
try:
plugin_list = []
logger.debug(f"[LinkAgent] res={response}")
if response.get("agent") and response.get("agent").get("chain") and response.get("agent").get("need_show_plugin"):
chain = response.get("agent").get("chain")
suffix = "\n\n- - - - - - - - - - - -"
i = 0
for turn in chain:
plugin_name = turn.get('plugin_name')
suffix += "\n"
need_show_thought = response.get("agent").get("need_show_thought")
if turn.get("thought") and plugin_name and need_show_thought:
suffix += f"{turn.get('thought')}\n"
if plugin_name:
plugin_list.append(turn.get('plugin_name'))
if turn.get('plugin_icon'):
suffix += f"{turn.get('plugin_icon')} "
suffix += f"{turn.get('plugin_name')}"
if turn.get('plugin_input'):
suffix += f"{turn.get('plugin_input')}"
if i < len(chain) - 1:
suffix += "\n"
i += 1
logger.info(f"[LinkAgent] use plugins: {plugin_list}")
return suffix
except Exception as e:
logger.exception(e)
def _process_url(self, text):
try:
url_pattern = re.compile(r'\[(.*?)\]\((http[s]?://.*?)\)')
def replace_markdown_url(match):
return f"{match.group(2)}"
return url_pattern.sub(replace_markdown_url, text)
except Exception as e:
logger.error(e)
def _send_image(self, channel, context, image_urls):
if not image_urls:
return
try:
for url in image_urls:
if url.endswith(".mp4"):
reply_type = ReplyType.VIDEO_URL
elif url.endswith(".pdf") or url.endswith(".doc") or url.endswith(".docx"):
reply_type = ReplyType.FILE
url = _download_file(url)
if not url:
continue
else:
reply_type = ReplyType.IMAGE_URL
reply = Reply(reply_type, url)
channel.send(reply, context)
except Exception as e:
logger.error(e)
def _download_file(url: str):
try:
file_path = "tmp"
if not os.path.exists(file_path):
os.makedirs(file_path)
file_name = url.split("/")[-1] # 获取文件名
file_path = os.path.join(file_path, file_name)
response = requests.get(url)
with open(file_path, "wb") as f:
f.write(response.content)
return file_path
except Exception as e:
logger.warn(e)
class LinkAISessionManager(SessionManager):
def session_msg_query(self, query, session_id):
session = self.build_session(session_id)
messages = session.messages + [{"role": "user", "content": query}]
return messages
def session_reply(self, reply, session_id, total_tokens=None, query=None):
session = self.build_session(session_id)
if query:
session.add_query(query)
session.add_reply(reply)
try:
max_tokens = conf().get("conversation_max_tokens", 2500)
tokens_cnt = session.discard_exceeding(max_tokens, total_tokens)
logger.debug(f"[LinkAI] chat history, before tokens={total_tokens}, now tokens={tokens_cnt}")
except Exception as e:
logger.warning("Exception when counting tokens precisely for session: {}".format(str(e)))
return session
class LinkAISession(ChatGPTSession):
def calc_tokens(self):
if not self.messages:
return 0
return len(str(self.messages))
def discard_exceeding(self, max_tokens, cur_tokens=None):
cur_tokens = self.calc_tokens()
if cur_tokens > max_tokens:
for i in range(0, len(self.messages)):
if i > 0 and self.messages[i].get("role") == "assistant" and self.messages[i - 1].get("role") == "user":
self.messages.pop(i)
self.messages.pop(i - 1)
return self.calc_tokens()
return cur_tokens

View File

@@ -1,170 +1,122 @@
# encoding:utf-8
from bot.bot import Bot
from config import conf
from common.log import logger
import openai
import time
import openai
import openai.error
from bot.bot import Bot
from bot.openai.open_ai_image import OpenAIImage
from bot.openai.open_ai_session import OpenAISession
from bot.session_manager import SessionManager
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from common.log import logger
from config import conf
user_session = dict()
# OpenAI对话模型API (可用)
class OpenAIBot(Bot):
class OpenAIBot(Bot, OpenAIImage):
def __init__(self):
openai.api_key = conf().get('open_ai_api_key')
if conf().get('open_ai_api_base'):
openai.api_base = conf().get('open_ai_api_base')
proxy = conf().get('proxy')
super().__init__()
openai.api_key = conf().get("open_ai_api_key")
if conf().get("open_ai_api_base"):
openai.api_base = conf().get("open_ai_api_base")
proxy = conf().get("proxy")
if proxy:
openai.proxy = proxy
self.sessions = SessionManager(OpenAISession, model=conf().get("model") or "text-davinci-003")
self.args = {
"model": conf().get("model") or "text-davinci-003", # 对话模型的名称
"temperature": conf().get("temperature", 0.9), # 值在[0,1]之间,越大表示回复越具有不确定性
"max_tokens": 1200, # 回复最大的字符数
"top_p": 1,
"frequency_penalty": conf().get("frequency_penalty", 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容
"presence_penalty": conf().get("presence_penalty", 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容
"request_timeout": conf().get("request_timeout", None), # 请求超时时间openai接口默认设置为600对于难问题一般需要较长时间
"timeout": conf().get("request_timeout", None), # 重试超时时间,在这个时间内,将会自动重试
"stop": ["\n\n\n"],
}
def reply(self, query, context=None):
# acquire reply content
if not context or not context.get('type') or context.get('type') == 'TEXT':
logger.info("[OPEN_AI] query={}".format(query))
from_user_id = context.get('from_user_id') or context.get('session_id')
if query == '#清除记忆':
Session.clear_session(from_user_id)
return '记忆已清除'
elif query == '#清除所有':
Session.clear_all_session()
return '所有人记忆已清除'
if context and context.type:
if context.type == ContextType.TEXT:
logger.info("[OPEN_AI] query={}".format(query))
session_id = context["session_id"]
reply = None
if query == "#清除记忆":
self.sessions.clear_session(session_id)
reply = Reply(ReplyType.INFO, "记忆已清除")
elif query == "#清除所有":
self.sessions.clear_all_session()
reply = Reply(ReplyType.INFO, "所有人记忆已清除")
else:
session = self.sessions.session_query(query, session_id)
result = self.reply_text(session)
total_tokens, completion_tokens, reply_content = (
result["total_tokens"],
result["completion_tokens"],
result["content"],
)
logger.debug(
"[OPEN_AI] new_query={}, session_id={}, reply_cont={}, completion_tokens={}".format(str(session), session_id, reply_content, completion_tokens)
)
new_query = Session.build_session_query(query, from_user_id)
logger.debug("[OPEN_AI] session query={}".format(new_query))
if total_tokens == 0:
reply = Reply(ReplyType.ERROR, reply_content)
else:
self.sessions.session_reply(reply_content, session_id, total_tokens)
reply = Reply(ReplyType.TEXT, reply_content)
return reply
elif context.type == ContextType.IMAGE_CREATE:
ok, retstring = self.create_img(query, 0)
reply = None
if ok:
reply = Reply(ReplyType.IMAGE_URL, retstring)
else:
reply = Reply(ReplyType.ERROR, retstring)
return reply
reply_content = self.reply_text(new_query, from_user_id, 0)
logger.debug("[OPEN_AI] new_query={}, user={}, reply_cont={}".format(new_query, from_user_id, reply_content))
if reply_content and query:
Session.save_session(query, reply_content, from_user_id)
return reply_content
elif context.get('type', None) == 'IMAGE_CREATE':
return self.create_img(query, 0)
def reply_text(self, query, user_id, retry_count=0):
def reply_text(self, session: OpenAISession, retry_count=0):
try:
response = openai.Completion.create(
model= conf().get("model") or "text-davinci-003", # 对话模型的名称
prompt=query,
temperature=0.9, # 值在[0,1]之间,越大表示回复越具有不确定性
max_tokens=1200, # 回复最大的字符数
top_p=1,
frequency_penalty=0.0, # [-2,2]之间,该值越大则更倾向于产生不同的内容
presence_penalty=0.0, # [-2,2]之间,该值越大则更倾向于产生不同的内容
stop=["\n\n\n"]
)
res_content = response.choices[0]['text'].strip().replace('<|endoftext|>', '')
response = openai.Completion.create(prompt=str(session), **self.args)
res_content = response.choices[0]["text"].strip().replace("<|endoftext|>", "")
total_tokens = response["usage"]["total_tokens"]
completion_tokens = response["usage"]["completion_tokens"]
logger.info("[OPEN_AI] reply={}".format(res_content))
return res_content
except openai.error.RateLimitError as e:
# rate limit exception
logger.warn(e)
if retry_count < 1:
time.sleep(5)
logger.warn("[OPEN_AI] RateLimit exceed, 第{}次重试".format(retry_count+1))
return self.reply_text(query, user_id, retry_count+1)
else:
return "提问太快啦,请休息一下再问我吧"
return {
"total_tokens": total_tokens,
"completion_tokens": completion_tokens,
"content": res_content,
}
except Exception as e:
# unknown exception
logger.exception(e)
Session.clear_session(user_id)
return "请再问我一次吧"
def create_img(self, query, retry_count=0):
try:
logger.info("[OPEN_AI] image_query={}".format(query))
response = openai.Image.create(
prompt=query, #图片描述
n=1, #每次生成图片的数量
size="256x256" #图片大小,可选有 256x256, 512x512, 1024x1024
)
image_url = response['data'][0]['url']
logger.info("[OPEN_AI] image_url={}".format(image_url))
return image_url
except openai.error.RateLimitError as e:
logger.warn(e)
if retry_count < 1:
time.sleep(5)
logger.warn("[OPEN_AI] ImgCreate RateLimit exceed, 第{}次重试".format(retry_count+1))
return self.reply_text(query, retry_count+1)
need_retry = retry_count < 2
result = {"completion_tokens": 0, "content": "我现在有点累了,等会再来吧"}
if isinstance(e, openai.error.RateLimitError):
logger.warn("[OPEN_AI] RateLimitError: {}".format(e))
result["content"] = "提问太快啦,请休息一下再问我吧"
if need_retry:
time.sleep(20)
elif isinstance(e, openai.error.Timeout):
logger.warn("[OPEN_AI] Timeout: {}".format(e))
result["content"] = "我没有收到你的消息"
if need_retry:
time.sleep(5)
elif isinstance(e, openai.error.APIConnectionError):
logger.warn("[OPEN_AI] APIConnectionError: {}".format(e))
need_retry = False
result["content"] = "我连接不到你的网络"
else:
return "提问太快啦,请休息一下再问我吧"
except Exception as e:
logger.exception(e)
return None
logger.warn("[OPEN_AI] Exception: {}".format(e))
need_retry = False
self.sessions.clear_session(session.session_id)
class Session(object):
@staticmethod
def build_session_query(query, user_id):
'''
build query with conversation history
e.g. Q: xxx
A: xxx
Q: xxx
:param query: query content
:param user_id: from user id
:return: query content with conversaction
'''
prompt = conf().get("character_desc", "")
if prompt:
prompt += "<|endoftext|>\n\n\n"
session = user_session.get(user_id, None)
if session:
for conversation in session:
prompt += "Q: " + conversation["question"] + "\n\n\nA: " + conversation["answer"] + "<|endoftext|>\n"
prompt += "Q: " + query + "\nA: "
return prompt
else:
return prompt + "Q: " + query + "\nA: "
@staticmethod
def save_session(query, answer, user_id):
max_tokens = conf().get("conversation_max_tokens")
if not max_tokens:
# default 3000
max_tokens = 1000
conversation = dict()
conversation["question"] = query
conversation["answer"] = answer
session = user_session.get(user_id)
logger.debug(conversation)
logger.debug(session)
if session:
# append conversation
session.append(conversation)
else:
# create session
queue = list()
queue.append(conversation)
user_session[user_id] = queue
# discard exceed limit conversation
Session.discard_exceed_conversation(user_session[user_id], max_tokens)
@staticmethod
def discard_exceed_conversation(session, max_tokens):
count = 0
count_list = list()
for i in range(len(session)-1, -1, -1):
# count tokens of conversation list
history_conv = session[i]
count += len(history_conv["question"]) + len(history_conv["answer"])
count_list.append(count)
for c in count_list:
if c > max_tokens:
# pop first conversation
session.pop(0)
@staticmethod
def clear_session(user_id):
user_session[user_id] = []
@staticmethod
def clear_all_session():
user_session.clear()
if need_retry:
logger.warn("[OPEN_AI] 第{}次重试".format(retry_count + 1))
return self.reply_text(session, retry_count + 1)
else:
return result

View File

@@ -0,0 +1,43 @@
import time
import openai
import openai.error
from common.log import logger
from common.token_bucket import TokenBucket
from config import conf
# OPENAI提供的画图接口
class OpenAIImage(object):
def __init__(self):
openai.api_key = conf().get("open_ai_api_key")
if conf().get("rate_limit_dalle"):
self.tb4dalle = TokenBucket(conf().get("rate_limit_dalle", 50))
def create_img(self, query, retry_count=0, api_key=None, api_base=None):
try:
if conf().get("rate_limit_dalle") and not self.tb4dalle.get_token():
return False, "请求太快了,请休息一下再问我吧"
logger.info("[OPEN_AI] image_query={}".format(query))
response = openai.Image.create(
api_key=api_key,
prompt=query, # 图片描述
n=1, # 每次生成图片的数量
model=conf().get("text_to_image") or "dall-e-2",
# size=conf().get("image_create_size", "256x256"), # 图片大小,可选有 256x256, 512x512, 1024x1024
)
image_url = response["data"][0]["url"]
logger.info("[OPEN_AI] image_url={}".format(image_url))
return True, image_url
except openai.error.RateLimitError as e:
logger.warn(e)
if retry_count < 1:
time.sleep(5)
logger.warn("[OPEN_AI] ImgCreate RateLimit exceed, 第{}次重试".format(retry_count + 1))
return self.create_img(query, retry_count + 1)
else:
return False, "画图出现问题,请休息一下再问我吧"
except Exception as e:
logger.exception(e)
return False, "画图出现问题,请休息一下再问我吧"

View File

@@ -0,0 +1,73 @@
from bot.session_manager import Session
from common.log import logger
class OpenAISession(Session):
def __init__(self, session_id, system_prompt=None, model="text-davinci-003"):
super().__init__(session_id, system_prompt)
self.model = model
self.reset()
def __str__(self):
# 构造对话模型的输入
"""
e.g. Q: xxx
A: xxx
Q: xxx
"""
prompt = ""
for item in self.messages:
if item["role"] == "system":
prompt += item["content"] + "<|endoftext|>\n\n\n"
elif item["role"] == "user":
prompt += "Q: " + item["content"] + "\n"
elif item["role"] == "assistant":
prompt += "\n\nA: " + item["content"] + "<|endoftext|>\n"
if len(self.messages) > 0 and self.messages[-1]["role"] == "user":
prompt += "A: "
return prompt
def discard_exceeding(self, max_tokens, cur_tokens=None):
precise = True
try:
cur_tokens = self.calc_tokens()
except Exception as e:
precise = False
if cur_tokens is None:
raise e
logger.debug("Exception when counting tokens precisely for query: {}".format(e))
while cur_tokens > max_tokens:
if len(self.messages) > 1:
self.messages.pop(0)
elif len(self.messages) == 1 and self.messages[0]["role"] == "assistant":
self.messages.pop(0)
if precise:
cur_tokens = self.calc_tokens()
else:
cur_tokens = len(str(self))
break
elif len(self.messages) == 1 and self.messages[0]["role"] == "user":
logger.warn("user question exceed max_tokens. total_tokens={}".format(cur_tokens))
break
else:
logger.debug("max_tokens={}, total_tokens={}, len(conversation)={}".format(max_tokens, cur_tokens, len(self.messages)))
break
if precise:
cur_tokens = self.calc_tokens()
else:
cur_tokens = len(str(self))
return cur_tokens
def calc_tokens(self):
return num_tokens_from_string(str(self), self.model)
# refer to https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb
def num_tokens_from_string(string: str, model: str) -> int:
"""Returns the number of tokens in a text string."""
import tiktoken
encoding = tiktoken.encoding_for_model(model)
num_tokens = len(encoding.encode(string, disallowed_special=()))
return num_tokens

91
bot/session_manager.py Normal file
View File

@@ -0,0 +1,91 @@
from common.expired_dict import ExpiredDict
from common.log import logger
from config import conf
class Session(object):
def __init__(self, session_id, system_prompt=None):
self.session_id = session_id
self.messages = []
if system_prompt is None:
self.system_prompt = conf().get("character_desc", "")
else:
self.system_prompt = system_prompt
# 重置会话
def reset(self):
system_item = {"role": "system", "content": self.system_prompt}
self.messages = [system_item]
def set_system_prompt(self, system_prompt):
self.system_prompt = system_prompt
self.reset()
def add_query(self, query):
user_item = {"role": "user", "content": query}
self.messages.append(user_item)
def add_reply(self, reply):
assistant_item = {"role": "assistant", "content": reply}
self.messages.append(assistant_item)
def discard_exceeding(self, max_tokens=None, cur_tokens=None):
raise NotImplementedError
def calc_tokens(self):
raise NotImplementedError
class SessionManager(object):
def __init__(self, sessioncls, **session_args):
if conf().get("expires_in_seconds"):
sessions = ExpiredDict(conf().get("expires_in_seconds"))
else:
sessions = dict()
self.sessions = sessions
self.sessioncls = sessioncls
self.session_args = session_args
def build_session(self, session_id, system_prompt=None):
"""
如果session_id不在sessions中创建一个新的session并添加到sessions中
如果system_prompt不会空会更新session的system_prompt并重置session
"""
if session_id is None:
return self.sessioncls(session_id, system_prompt, **self.session_args)
if session_id not in self.sessions:
self.sessions[session_id] = self.sessioncls(session_id, system_prompt, **self.session_args)
elif system_prompt is not None: # 如果有新的system_prompt更新并重置session
self.sessions[session_id].set_system_prompt(system_prompt)
session = self.sessions[session_id]
return session
def session_query(self, query, session_id):
session = self.build_session(session_id)
session.add_query(query)
try:
max_tokens = conf().get("conversation_max_tokens", 1000)
total_tokens = session.discard_exceeding(max_tokens, None)
logger.debug("prompt tokens used={}".format(total_tokens))
except Exception as e:
logger.warning("Exception when counting tokens precisely for prompt: {}".format(str(e)))
return session
def session_reply(self, reply, session_id, total_tokens=None):
session = self.build_session(session_id)
session.add_reply(reply)
try:
max_tokens = conf().get("conversation_max_tokens", 1000)
tokens_cnt = session.discard_exceeding(max_tokens, total_tokens)
logger.debug("raw total_tokens={}, savesession tokens={}".format(total_tokens, tokens_cnt))
except Exception as e:
logger.warning("Exception when counting tokens precisely for session: {}".format(str(e)))
return session
def clear_session(self, session_id):
if session_id in self.sessions:
del self.sessions[session_id]
def clear_all_session(self):
self.sessions.clear()

View File

@@ -0,0 +1,267 @@
# encoding:utf-8
import requests, json
from bot.bot import Bot
from bot.session_manager import SessionManager
from bot.baidu.baidu_wenxin_session import BaiduWenxinSession
from bridge.context import ContextType, Context
from bridge.reply import Reply, ReplyType
from common.log import logger
from config import conf
from common import const
import time
import _thread as thread
import datetime
from datetime import datetime
from wsgiref.handlers import format_date_time
from urllib.parse import urlencode
import base64
import ssl
import hashlib
import hmac
import json
from time import mktime
from urllib.parse import urlparse
import websocket
import queue
import threading
import random
# 消息队列 map
queue_map = dict()
# 响应队列 map
reply_map = dict()
class XunFeiBot(Bot):
def __init__(self):
super().__init__()
self.app_id = conf().get("xunfei_app_id")
self.api_key = conf().get("xunfei_api_key")
self.api_secret = conf().get("xunfei_api_secret")
# 默认使用v2.0版本: "generalv2"
# v1.5版本为 "general"
# v3.0版本为: "generalv3"
self.domain = "generalv3"
# 默认使用v2.0版本: "ws://spark-api.xf-yun.com/v2.1/chat"
# v1.5版本为: "ws://spark-api.xf-yun.com/v1.1/chat"
# v3.0版本为: "ws://spark-api.xf-yun.com/v3.1/chat"
self.spark_url = "ws://spark-api.xf-yun.com/v3.1/chat"
self.host = urlparse(self.spark_url).netloc
self.path = urlparse(self.spark_url).path
# 和wenxin使用相同的session机制
self.sessions = SessionManager(BaiduWenxinSession, model=const.XUNFEI)
def reply(self, query, context: Context = None) -> Reply:
if context.type == ContextType.TEXT:
logger.info("[XunFei] query={}".format(query))
session_id = context["session_id"]
request_id = self.gen_request_id(session_id)
reply_map[request_id] = ""
session = self.sessions.session_query(query, session_id)
threading.Thread(target=self.create_web_socket,
args=(session.messages, request_id)).start()
depth = 0
time.sleep(0.1)
t1 = time.time()
usage = {}
while depth <= 300:
try:
data_queue = queue_map.get(request_id)
if not data_queue:
depth += 1
time.sleep(0.1)
continue
data_item = data_queue.get(block=True, timeout=0.1)
if data_item.is_end:
# 请求结束
del queue_map[request_id]
if data_item.reply:
reply_map[request_id] += data_item.reply
usage = data_item.usage
break
reply_map[request_id] += data_item.reply
depth += 1
except Exception as e:
depth += 1
continue
t2 = time.time()
logger.info(
f"[XunFei-API] response={reply_map[request_id]}, time={t2 - t1}s, usage={usage}"
)
self.sessions.session_reply(reply_map[request_id], session_id,
usage.get("total_tokens"))
reply = Reply(ReplyType.TEXT, reply_map[request_id])
del reply_map[request_id]
return reply
else:
reply = Reply(ReplyType.ERROR,
"Bot不支持处理{}类型的消息".format(context.type))
return reply
def create_web_socket(self, prompt, session_id, temperature=0.5):
logger.info(f"[XunFei] start connect, prompt={prompt}")
websocket.enableTrace(False)
wsUrl = self.create_url()
ws = websocket.WebSocketApp(wsUrl,
on_message=on_message,
on_error=on_error,
on_close=on_close,
on_open=on_open)
data_queue = queue.Queue(1000)
queue_map[session_id] = data_queue
ws.appid = self.app_id
ws.question = prompt
ws.domain = self.domain
ws.session_id = session_id
ws.temperature = temperature
ws.run_forever(sslopt={"cert_reqs": ssl.CERT_NONE})
def gen_request_id(self, session_id: str):
return session_id + "_" + str(int(time.time())) + "" + str(
random.randint(0, 100))
# 生成url
def create_url(self):
# 生成RFC1123格式的时间戳
now = datetime.now()
date = format_date_time(mktime(now.timetuple()))
# 拼接字符串
signature_origin = "host: " + self.host + "\n"
signature_origin += "date: " + date + "\n"
signature_origin += "GET " + self.path + " HTTP/1.1"
# 进行hmac-sha256进行加密
signature_sha = hmac.new(self.api_secret.encode('utf-8'),
signature_origin.encode('utf-8'),
digestmod=hashlib.sha256).digest()
signature_sha_base64 = base64.b64encode(signature_sha).decode(
encoding='utf-8')
authorization_origin = f'api_key="{self.api_key}", algorithm="hmac-sha256", headers="host date request-line", ' \
f'signature="{signature_sha_base64}"'
authorization = base64.b64encode(
authorization_origin.encode('utf-8')).decode(encoding='utf-8')
# 将请求的鉴权参数组合为字典
v = {"authorization": authorization, "date": date, "host": self.host}
# 拼接鉴权参数生成url
url = self.spark_url + '?' + urlencode(v)
# 此处打印出建立连接时候的url,参考本demo的时候可取消上方打印的注释比对相同参数时生成的url与自己代码生成的url是否一致
return url
def gen_params(self, appid, domain, question):
"""
通过appid和用户的提问来生成请参数
"""
data = {
"header": {
"app_id": appid,
"uid": "1234"
},
"parameter": {
"chat": {
"domain": domain,
"random_threshold": 0.5,
"max_tokens": 2048,
"auditing": "default"
}
},
"payload": {
"message": {
"text": question
}
}
}
return data
class ReplyItem:
def __init__(self, reply, usage=None, is_end=False):
self.is_end = is_end
self.reply = reply
self.usage = usage
# 收到websocket错误的处理
def on_error(ws, error):
logger.error(f"[XunFei] error: {str(error)}")
# 收到websocket关闭的处理
def on_close(ws, one, two):
data_queue = queue_map.get(ws.session_id)
data_queue.put("END")
# 收到websocket连接建立的处理
def on_open(ws):
logger.info(f"[XunFei] Start websocket, session_id={ws.session_id}")
thread.start_new_thread(run, (ws, ))
def run(ws, *args):
data = json.dumps(
gen_params(appid=ws.appid,
domain=ws.domain,
question=ws.question,
temperature=ws.temperature))
ws.send(data)
# Websocket 操作
# 收到websocket消息的处理
def on_message(ws, message):
data = json.loads(message)
code = data['header']['code']
if code != 0:
logger.error(f'请求错误: {code}, {data}')
ws.close()
else:
choices = data["payload"]["choices"]
status = choices["status"]
content = choices["text"][0]["content"]
data_queue = queue_map.get(ws.session_id)
if not data_queue:
logger.error(
f"[XunFei] can't find data queue, session_id={ws.session_id}")
return
reply_item = ReplyItem(content)
if status == 2:
usage = data["payload"].get("usage")
reply_item = ReplyItem(content, usage)
reply_item.is_end = True
ws.close()
data_queue.put(reply_item)
def gen_params(appid, domain, question, temperature=0.5):
"""
通过appid和用户的提问来生成请参数
"""
data = {
"header": {
"app_id": appid,
"uid": "1234"
},
"parameter": {
"chat": {
"domain": domain,
"temperature": temperature,
"random_threshold": 0.5,
"max_tokens": 2048,
"auditing": "default"
}
},
"payload": {
"message": {
"text": question
}
}
}
return data

View File

@@ -1,24 +1,84 @@
from bot import bot_factory
from voice import voice_factory
from config import conf
from bot.bot_factory import create_bot
from bridge.context import Context
from bridge.reply import Reply
from common import const
from common.log import logger
from common.singleton import singleton
from config import conf
from translate.factory import create_translator
from voice.factory import create_voice
@singleton
class Bridge(object):
def __init__(self):
pass
self.btype = {
"chat": const.CHATGPT,
"voice_to_text": conf().get("voice_to_text", "openai"),
"text_to_voice": conf().get("text_to_voice", "google"),
"translate": conf().get("translate", "baidu"),
}
model_type = conf().get("model") or const.GPT35
if model_type in ["text-davinci-003"]:
self.btype["chat"] = const.OPEN_AI
if conf().get("use_azure_chatgpt", False):
self.btype["chat"] = const.CHATGPTONAZURE
if model_type in ["wenxin", "wenxin-4"]:
self.btype["chat"] = const.BAIDU
if model_type in ["xunfei"]:
self.btype["chat"] = const.XUNFEI
if model_type in [const.QWEN]:
self.btype["chat"] = const.QWEN
if model_type in [const.GEMINI]:
self.btype["chat"] = const.GEMINI
def fetch_reply_content(self, query, context):
bot_type = const.CHATGPT
model_type = conf().get("model")
if model_type in ["gpt-3.5-turbo", "gpt-4", "gpt-4-32k"]:
bot_type = const.CHATGPT
elif model_type in ["text-davinci-003"]:
bot_type = const.OPEN_AI
return bot_factory.create_bot(bot_type).reply(query, context)
if conf().get("use_linkai") and conf().get("linkai_api_key"):
self.btype["chat"] = const.LINKAI
if not conf().get("voice_to_text") or conf().get("voice_to_text") in ["openai"]:
self.btype["voice_to_text"] = const.LINKAI
if not conf().get("text_to_voice") or conf().get("text_to_voice") in ["openai", const.TTS_1, const.TTS_1_HD]:
self.btype["text_to_voice"] = const.LINKAI
def fetch_voice_to_text(self, voiceFile):
return voice_factory.create_voice("openai").voiceToText(voiceFile)
if model_type in ["claude"]:
self.btype["chat"] = const.CLAUDEAI
self.bots = {}
self.chat_bots = {}
def fetch_text_to_voice(self, text):
return voice_factory.create_voice("baidu").textToVoice(text)
def get_bot(self, typename):
if self.bots.get(typename) is None:
logger.info("create bot {} for {}".format(self.btype[typename], typename))
if typename == "text_to_voice":
self.bots[typename] = create_voice(self.btype[typename])
elif typename == "voice_to_text":
self.bots[typename] = create_voice(self.btype[typename])
elif typename == "chat":
self.bots[typename] = create_bot(self.btype[typename])
elif typename == "translate":
self.bots[typename] = create_translator(self.btype[typename])
return self.bots[typename]
def get_bot_type(self, typename):
return self.btype[typename]
def fetch_reply_content(self, query, context: Context) -> Reply:
return self.get_bot("chat").reply(query, context)
def fetch_voice_to_text(self, voiceFile) -> Reply:
return self.get_bot("voice_to_text").voiceToText(voiceFile)
def fetch_text_to_voice(self, text) -> Reply:
return self.get_bot("text_to_voice").textToVoice(text)
def fetch_translate(self, text, from_lang="", to_lang="en") -> Reply:
return self.get_bot("translate").translate(text, from_lang, to_lang)
def find_chat_bot(self, bot_type: str):
if self.chat_bots.get(bot_type) is None:
self.chat_bots[bot_type] = create_bot(bot_type)
return self.chat_bots.get(bot_type)
def reset_bot(self):
"""
重置bot路由
"""
self.__init__()

71
bridge/context.py Normal file
View File

@@ -0,0 +1,71 @@
# encoding:utf-8
from enum import Enum
class ContextType(Enum):
TEXT = 1 # 文本消息
VOICE = 2 # 音频消息
IMAGE = 3 # 图片消息
FILE = 4 # 文件信息
VIDEO = 5 # 视频信息
SHARING = 6 # 分享信息
IMAGE_CREATE = 10 # 创建图片命令
ACCEPT_FRIEND = 19 # 同意好友请求
JOIN_GROUP = 20 # 加入群聊
PATPAT = 21 # 拍了拍
FUNCTION = 22 # 函数调用
EXIT_GROUP = 23 #退出
def __str__(self):
return self.name
class Context:
def __init__(self, type: ContextType = None, content=None, kwargs=dict()):
self.type = type
self.content = content
self.kwargs = kwargs
def __contains__(self, key):
if key == "type":
return self.type is not None
elif key == "content":
return self.content is not None
else:
return key in self.kwargs
def __getitem__(self, key):
if key == "type":
return self.type
elif key == "content":
return self.content
else:
return self.kwargs[key]
def get(self, key, default=None):
try:
return self[key]
except KeyError:
return default
def __setitem__(self, key, value):
if key == "type":
self.type = value
elif key == "content":
self.content = value
else:
self.kwargs[key] = value
def __delitem__(self, key):
if key == "type":
self.type = None
elif key == "content":
self.content = None
else:
del self.kwargs[key]
def __str__(self):
return "Context(type={}, content={}, kwargs={})".format(self.type, self.content, self.kwargs)

31
bridge/reply.py Normal file
View File

@@ -0,0 +1,31 @@
# encoding:utf-8
from enum import Enum
class ReplyType(Enum):
TEXT = 1 # 文本
VOICE = 2 # 音频文件
IMAGE = 3 # 图片文件
IMAGE_URL = 4 # 图片URL
VIDEO_URL = 5 # 视频URL
FILE = 6 # 文件
CARD = 7 # 微信名片仅支持ntchat
InviteRoom = 8 # 邀请好友进群
INFO = 9
ERROR = 10
TEXT_ = 11 # 强制文本
VIDEO = 12
MINIAPP = 13 # 小程序
def __str__(self):
return self.name
class Reply:
def __init__(self, type: ReplyType = None, content=None):
self.type = type
self.content = content
def __str__(self):
return "Reply(type={}, content={})".format(self.type, self.content)

View File

@@ -3,8 +3,14 @@ Message sending channel abstract class
"""
from bridge.bridge import Bridge
from bridge.context import Context
from bridge.reply import *
class Channel(object):
channel_type = ""
NOT_SUPPORT_REPLYTYPE = [ReplyType.VOICE, ReplyType.IMAGE]
def startup(self):
"""
init channel
@@ -18,20 +24,21 @@ class Channel(object):
"""
raise NotImplementedError
def send(self, msg, receiver):
# 统一的发送函数每个Channel自行实现根据reply的type字段发送不同类型的消息
def send(self, reply: Reply, context: Context):
"""
send message to user
:param msg: message content
:param receiver: receiver channel account
:return:
:return:
"""
raise NotImplementedError
def build_reply_content(self, query, context=None):
def build_reply_content(self, query, context: Context = None) -> Reply:
return Bridge().fetch_reply_content(query, context)
def build_voice_to_text(self, voice_file):
def build_voice_to_text(self, voice_file) -> Reply:
return Bridge().fetch_voice_to_text(voice_file)
def build_text_to_voice(self, text):
def build_text_to_voice(self, text) -> Reply:
return Bridge().fetch_text_to_voice(text)

View File

@@ -1,20 +1,45 @@
"""
channel factory
"""
from common import const
from .channel import Channel
def create_channel(channel_type):
def create_channel(channel_type) -> Channel:
"""
create a channel instance
:param channel_type: channel type code
:return: channel instance
"""
if channel_type == 'wx':
ch = Channel()
if channel_type == "wx":
from channel.wechat.wechat_channel import WechatChannel
return WechatChannel()
elif channel_type == 'wxy':
ch = WechatChannel()
elif channel_type == "wxy":
from channel.wechat.wechaty_channel import WechatyChannel
return WechatyChannel()
elif channel_type == 'terminal':
ch = WechatyChannel()
elif channel_type == "terminal":
from channel.terminal.terminal_channel import TerminalChannel
return TerminalChannel()
raise RuntimeError
ch = TerminalChannel()
elif channel_type == "wechatmp":
from channel.wechatmp.wechatmp_channel import WechatMPChannel
ch = WechatMPChannel(passive_reply=True)
elif channel_type == "wechatmp_service":
from channel.wechatmp.wechatmp_channel import WechatMPChannel
ch = WechatMPChannel(passive_reply=False)
elif channel_type == "wechatcom_app":
from channel.wechatcom.wechatcomapp_channel import WechatComAppChannel
ch = WechatComAppChannel()
elif channel_type == "wework":
from channel.wework.wework_channel import WeworkChannel
ch = WeworkChannel()
elif channel_type == const.FEISHU:
from channel.feishu.feishu_channel import FeiShuChanel
ch = FeiShuChanel()
elif channel_type == const.DINGTALK:
from channel.dingtalk.dingtalk_channel import DingTalkChanel
ch = DingTalkChanel()
else:
raise RuntimeError
ch.channel_type = channel_type
return ch

392
channel/chat_channel.py Normal file
View File

@@ -0,0 +1,392 @@
import os
import re
import threading
import time
from asyncio import CancelledError
from concurrent.futures import Future, ThreadPoolExecutor
from bridge.context import *
from bridge.reply import *
from channel.channel import Channel
from common.dequeue import Dequeue
from common import memory
from plugins import *
try:
from voice.audio_convert import any_to_wav
except Exception as e:
pass
# 抽象类, 它包含了与消息通道无关的通用处理逻辑
class ChatChannel(Channel):
name = None # 登录的用户名
user_id = None # 登录的用户id
futures = {} # 记录每个session_id提交到线程池的future对象, 用于重置会话时把没执行的future取消掉正在执行的不会被取消
sessions = {} # 用于控制并发每个session_id同时只能有一个context在处理
lock = threading.Lock() # 用于控制对sessions的访问
handler_pool = ThreadPoolExecutor(max_workers=8) # 处理消息的线程池
def __init__(self):
_thread = threading.Thread(target=self.consume)
_thread.setDaemon(True)
_thread.start()
# 根据消息构造context消息内容相关的触发项写在这里
def _compose_context(self, ctype: ContextType, content, **kwargs):
context = Context(ctype, content)
context.kwargs = kwargs
# context首次传入时origin_ctype是None,
# 引入的起因是当输入语音时会嵌套生成两个context第一步语音转文本第二步通过文本生成文字回复。
# origin_ctype用于第二步文本回复时判断是否需要匹配前缀如果是私聊的语音就不需要匹配前缀
if "origin_ctype" not in context:
context["origin_ctype"] = ctype
# context首次传入时receiver是None根据类型设置receiver
first_in = "receiver" not in context
# 群名匹配过程设置session_id和receiver
if first_in: # context首次传入时receiver是None根据类型设置receiver
config = conf()
cmsg = context["msg"]
user_data = conf().get_user_data(cmsg.from_user_id)
context["openai_api_key"] = user_data.get("openai_api_key")
context["gpt_model"] = user_data.get("gpt_model")
if context.get("isgroup", False):
group_name = cmsg.other_user_nickname
group_id = cmsg.other_user_id
group_name_white_list = config.get("group_name_white_list", [])
group_name_keyword_white_list = config.get("group_name_keyword_white_list", [])
if any(
[
group_name in group_name_white_list,
"ALL_GROUP" in group_name_white_list,
check_contain(group_name, group_name_keyword_white_list),
]
):
group_chat_in_one_session = conf().get("group_chat_in_one_session", [])
session_id = cmsg.actual_user_id
if any(
[
group_name in group_chat_in_one_session,
"ALL_GROUP" in group_chat_in_one_session,
]
):
session_id = group_id
else:
return None
context["session_id"] = session_id
context["receiver"] = group_id
else:
context["session_id"] = cmsg.other_user_id
context["receiver"] = cmsg.other_user_id
e_context = PluginManager().emit_event(EventContext(Event.ON_RECEIVE_MESSAGE, {"channel": self, "context": context}))
context = e_context["context"]
if e_context.is_pass() or context is None:
return context
if cmsg.from_user_id == self.user_id and not config.get("trigger_by_self", True):
logger.debug("[WX]self message skipped")
return None
# 消息内容匹配过程并处理content
if ctype == ContextType.TEXT:
if first_in and "\n- - - - - - -" in content: # 初次匹配 过滤引用消息
logger.debug(content)
logger.debug("[WX]reference query skipped")
return None
nick_name_black_list = conf().get("nick_name_black_list", [])
if context.get("isgroup", False): # 群聊
# 校验关键字
match_prefix = check_prefix(content, conf().get("group_chat_prefix"))
match_contain = check_contain(content, conf().get("group_chat_keyword"))
flag = False
if context["msg"].to_user_id != context["msg"].actual_user_id:
if match_prefix is not None or match_contain is not None:
flag = True
if match_prefix:
content = content.replace(match_prefix, "", 1).strip()
if context["msg"].is_at:
nick_name = context["msg"].actual_user_nickname
if nick_name and nick_name in nick_name_black_list:
# 黑名单过滤
logger.warning(f"[WX] Nickname {nick_name} in In BlackList, ignore")
return None
logger.info("[WX]receive group at")
if not conf().get("group_at_off", False):
flag = True
pattern = f"@{re.escape(self.name)}(\u2005|\u0020)"
subtract_res = re.sub(pattern, r"", content)
if isinstance(context["msg"].at_list, list):
for at in context["msg"].at_list:
pattern = f"@{re.escape(at)}(\u2005|\u0020)"
subtract_res = re.sub(pattern, r"", subtract_res)
if subtract_res == content and context["msg"].self_display_name:
# 前缀移除后没有变化,使用群昵称再次移除
pattern = f"@{re.escape(context['msg'].self_display_name)}(\u2005|\u0020)"
subtract_res = re.sub(pattern, r"", content)
content = subtract_res
if not flag:
if context["origin_ctype"] == ContextType.VOICE:
logger.info("[WX]receive group voice, but checkprefix didn't match")
return None
else: # 单聊
nick_name = context["msg"].from_user_nickname
if nick_name and nick_name in nick_name_black_list:
# 黑名单过滤
logger.warning(f"[WX] Nickname '{nick_name}' in In BlackList, ignore")
return None
match_prefix = check_prefix(content, conf().get("single_chat_prefix", [""]))
if match_prefix is not None: # 判断如果匹配到自定义前缀,则返回过滤掉前缀+空格后的内容
content = content.replace(match_prefix, "", 1).strip()
elif context["origin_ctype"] == ContextType.VOICE: # 如果源消息是私聊的语音消息,允许不匹配前缀,放宽条件
pass
else:
return None
content = content.strip()
img_match_prefix = check_prefix(content, conf().get("image_create_prefix"))
if img_match_prefix:
content = content.replace(img_match_prefix, "", 1)
context.type = ContextType.IMAGE_CREATE
else:
context.type = ContextType.TEXT
context.content = content.strip()
if "desire_rtype" not in context and conf().get("always_reply_voice") and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE:
context["desire_rtype"] = ReplyType.VOICE
elif context.type == ContextType.VOICE:
if "desire_rtype" not in context and conf().get("voice_reply_voice") and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE:
context["desire_rtype"] = ReplyType.VOICE
return context
def _handle(self, context: Context):
if context is None or not context.content:
return
logger.debug("[WX] ready to handle context: {}".format(context))
# reply的构建步骤
reply = self._generate_reply(context)
logger.debug("[WX] ready to decorate reply: {}".format(reply))
# reply的包装步骤
reply = self._decorate_reply(context, reply)
# reply的发送步骤
self._send_reply(context, reply)
def _generate_reply(self, context: Context, reply: Reply = Reply()) -> Reply:
e_context = PluginManager().emit_event(
EventContext(
Event.ON_HANDLE_CONTEXT,
{"channel": self, "context": context, "reply": reply},
)
)
reply = e_context["reply"]
if not e_context.is_pass():
logger.debug("[WX] ready to handle context: type={}, content={}".format(context.type, context.content))
if context.type == ContextType.TEXT or context.type == ContextType.IMAGE_CREATE: # 文字和图片消息
context["channel"] = e_context["channel"]
reply = super().build_reply_content(context.content, context)
elif context.type == ContextType.VOICE: # 语音消息
cmsg = context["msg"]
cmsg.prepare()
file_path = context.content
wav_path = os.path.splitext(file_path)[0] + ".wav"
try:
any_to_wav(file_path, wav_path)
except Exception as e: # 转换失败直接使用mp3对于某些apimp3也可以识别
logger.warning("[WX]any to wav error, use raw path. " + str(e))
wav_path = file_path
# 语音识别
reply = super().build_voice_to_text(wav_path)
# 删除临时文件
try:
os.remove(file_path)
if wav_path != file_path:
os.remove(wav_path)
except Exception as e:
pass
# logger.warning("[WX]delete temp file error: " + str(e))
if reply.type == ReplyType.TEXT:
new_context = self._compose_context(ContextType.TEXT, reply.content, **context.kwargs)
if new_context:
reply = self._generate_reply(new_context)
else:
return
elif context.type == ContextType.IMAGE: # 图片消息,当前仅做下载保存到本地的逻辑
memory.USER_IMAGE_CACHE[context["session_id"]] = {
"path": context.content,
"msg": context.get("msg")
}
elif context.type == ContextType.SHARING: # 分享信息,当前无默认逻辑
pass
elif context.type == ContextType.FUNCTION or context.type == ContextType.FILE: # 文件消息及函数调用等,当前无默认逻辑
pass
else:
logger.warning("[WX] unknown context type: {}".format(context.type))
return
return reply
def _decorate_reply(self, context: Context, reply: Reply) -> Reply:
if reply and reply.type:
e_context = PluginManager().emit_event(
EventContext(
Event.ON_DECORATE_REPLY,
{"channel": self, "context": context, "reply": reply},
)
)
reply = e_context["reply"]
desire_rtype = context.get("desire_rtype")
if not e_context.is_pass() and reply and reply.type:
if reply.type in self.NOT_SUPPORT_REPLYTYPE:
logger.error("[WX]reply type not support: " + str(reply.type))
reply.type = ReplyType.ERROR
reply.content = "不支持发送的消息类型: " + str(reply.type)
if reply.type == ReplyType.TEXT:
reply_text = reply.content
if desire_rtype == ReplyType.VOICE and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE:
reply = super().build_text_to_voice(reply.content)
return self._decorate_reply(context, reply)
if context.get("isgroup", False):
if not context.get("no_need_at", False):
reply_text = "@" + context["msg"].actual_user_nickname + "\n" + reply_text.strip()
reply_text = conf().get("group_chat_reply_prefix", "") + reply_text + conf().get("group_chat_reply_suffix", "")
else:
reply_text = conf().get("single_chat_reply_prefix", "") + reply_text + conf().get("single_chat_reply_suffix", "")
reply.content = reply_text
elif reply.type == ReplyType.ERROR or reply.type == ReplyType.INFO:
reply.content = "[" + str(reply.type) + "]\n" + reply.content
elif reply.type == ReplyType.IMAGE_URL or reply.type == ReplyType.VOICE or reply.type == ReplyType.IMAGE or reply.type == ReplyType.FILE or reply.type == ReplyType.VIDEO or reply.type == ReplyType.VIDEO_URL:
pass
else:
logger.error("[WX] unknown reply type: {}".format(reply.type))
return
if desire_rtype and desire_rtype != reply.type and reply.type not in [ReplyType.ERROR, ReplyType.INFO]:
logger.warning("[WX] desire_rtype: {}, but reply type: {}".format(context.get("desire_rtype"), reply.type))
return reply
def _send_reply(self, context: Context, reply: Reply):
if reply and reply.type:
e_context = PluginManager().emit_event(
EventContext(
Event.ON_SEND_REPLY,
{"channel": self, "context": context, "reply": reply},
)
)
reply = e_context["reply"]
if not e_context.is_pass() and reply and reply.type:
logger.debug("[WX] ready to send reply: {}, context: {}".format(reply, context))
self._send(reply, context)
def _send(self, reply: Reply, context: Context, retry_cnt=0):
try:
self.send(reply, context)
except Exception as e:
logger.error("[WX] sendMsg error: {}".format(str(e)))
if isinstance(e, NotImplementedError):
return
logger.exception(e)
if retry_cnt < 2:
time.sleep(3 + 3 * retry_cnt)
self._send(reply, context, retry_cnt + 1)
def _success_callback(self, session_id, **kwargs): # 线程正常结束时的回调函数
logger.debug("Worker return success, session_id = {}".format(session_id))
def _fail_callback(self, session_id, exception, **kwargs): # 线程异常结束时的回调函数
logger.exception("Worker return exception: {}".format(exception))
def _thread_pool_callback(self, session_id, **kwargs):
def func(worker: Future):
try:
worker_exception = worker.exception()
if worker_exception:
self._fail_callback(session_id, exception=worker_exception, **kwargs)
else:
self._success_callback(session_id, **kwargs)
except CancelledError as e:
logger.info("Worker cancelled, session_id = {}".format(session_id))
except Exception as e:
logger.exception("Worker raise exception: {}".format(e))
with self.lock:
self.sessions[session_id][1].release()
return func
def produce(self, context: Context):
session_id = context["session_id"]
with self.lock:
if session_id not in self.sessions:
self.sessions[session_id] = [
Dequeue(),
threading.BoundedSemaphore(conf().get("concurrency_in_session", 4)),
]
if context.type == ContextType.TEXT and context.content.startswith("#"):
self.sessions[session_id][0].putleft(context) # 优先处理管理命令
else:
self.sessions[session_id][0].put(context)
# 消费者函数,单独线程,用于从消息队列中取出消息并处理
def consume(self):
while True:
with self.lock:
session_ids = list(self.sessions.keys())
for session_id in session_ids:
context_queue, semaphore = self.sessions[session_id]
if semaphore.acquire(blocking=False): # 等线程处理完毕才能删除
if not context_queue.empty():
context = context_queue.get()
logger.debug("[WX] consume context: {}".format(context))
future: Future = self.handler_pool.submit(self._handle, context)
future.add_done_callback(self._thread_pool_callback(session_id, context=context))
if session_id not in self.futures:
self.futures[session_id] = []
self.futures[session_id].append(future)
elif semaphore._initial_value == semaphore._value + 1: # 除了当前,没有任务再申请到信号量,说明所有任务都处理完毕
self.futures[session_id] = [t for t in self.futures[session_id] if not t.done()]
assert len(self.futures[session_id]) == 0, "thread pool error"
del self.sessions[session_id]
else:
semaphore.release()
time.sleep(0.1)
# 取消session_id对应的所有任务只能取消排队的消息和已提交线程池但未执行的任务
def cancel_session(self, session_id):
with self.lock:
if session_id in self.sessions:
for future in self.futures[session_id]:
future.cancel()
cnt = self.sessions[session_id][0].qsize()
if cnt > 0:
logger.info("Cancel {} messages in session {}".format(cnt, session_id))
self.sessions[session_id][0] = Dequeue()
def cancel_all_session(self):
with self.lock:
for session_id in self.sessions:
for future in self.futures[session_id]:
future.cancel()
cnt = self.sessions[session_id][0].qsize()
if cnt > 0:
logger.info("Cancel {} messages in session {}".format(cnt, session_id))
self.sessions[session_id][0] = Dequeue()
def check_prefix(content, prefix_list):
if not prefix_list:
return None
for prefix in prefix_list:
if content.startswith(prefix):
return prefix
return None
def check_contain(content, keyword_list):
if not keyword_list:
return None
for ky in keyword_list:
if content.find(ky) != -1:
return True
return None

87
channel/chat_message.py Normal file
View File

@@ -0,0 +1,87 @@
"""
本类表示聊天消息用于对itchat和wechaty的消息进行统一的封装。
填好必填项(群聊6个非群聊8个)即可接入ChatChannel并支持插件参考TerminalChannel
ChatMessage
msg_id: 消息id (必填)
create_time: 消息创建时间
ctype: 消息类型 : ContextType (必填)
content: 消息内容, 如果是声音/图片,这里是文件路径 (必填)
from_user_id: 发送者id (必填)
from_user_nickname: 发送者昵称
to_user_id: 接收者id (必填)
to_user_nickname: 接收者昵称
other_user_id: 对方的id如果你是发送者那这个就是接收者id如果你是接收者那这个就是发送者id如果是群消息那这一直是群id (必填)
other_user_nickname: 同上
is_group: 是否是群消息 (群聊必填)
is_at: 是否被at
- (群消息时一般会存在实际发送者是群内某个成员的id和昵称下列项仅在群消息时存在)
actual_user_id: 实际发送者id (群聊必填)
actual_user_nickname实际发送者昵称
self_display_name: 自身的展示名,设置群昵称时,该字段表示群昵称
_prepare_fn: 准备函数,用于准备消息的内容,比如下载图片等,
_prepared: 是否已经调用过准备函数
_rawmsg: 原始消息对象
"""
class ChatMessage(object):
msg_id = None
create_time = None
ctype = None
content = None
from_user_id = None
from_user_nickname = None
to_user_id = None
to_user_nickname = None
other_user_id = None
other_user_nickname = None
my_msg = False
self_display_name = None
is_group = False
is_at = False
actual_user_id = None
actual_user_nickname = None
at_list = None
_prepare_fn = None
_prepared = False
_rawmsg = None
def __init__(self, _rawmsg):
self._rawmsg = _rawmsg
def prepare(self):
if self._prepare_fn and not self._prepared:
self._prepared = True
self._prepare_fn()
def __str__(self):
return "ChatMessage: id={}, create_time={}, ctype={}, content={}, from_user_id={}, from_user_nickname={}, to_user_id={}, to_user_nickname={}, other_user_id={}, other_user_nickname={}, is_group={}, is_at={}, actual_user_id={}, actual_user_nickname={}, at_list={}".format(
self.msg_id,
self.create_time,
self.ctype,
self.content,
self.from_user_id,
self.from_user_nickname,
self.to_user_id,
self.to_user_nickname,
self.other_user_id,
self.other_user_nickname,
self.is_group,
self.is_at,
self.actual_user_id,
self.actual_user_nickname,
self.at_list
)

View File

@@ -0,0 +1,100 @@
"""
钉钉通道接入
@author huiwen
@Date 2023/11/28
"""
# -*- coding=utf-8 -*-
from channel.dingtalk.dingtalk_message import DingTalkMessage
from bridge.context import Context
from bridge.reply import Reply
from common.log import logger
from common.singleton import singleton
from config import conf
from common.expired_dict import ExpiredDict
from bridge.context import ContextType
from channel.chat_channel import ChatChannel
import logging
from dingtalk_stream import AckMessage
import dingtalk_stream
@singleton
class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
dingtalk_client_id = conf().get('dingtalk_client_id')
dingtalk_client_secret = conf().get('dingtalk_client_secret')
def setup_logger(self):
logger = logging.getLogger()
handler = logging.StreamHandler()
handler.setFormatter(
logging.Formatter('%(asctime)s %(name)-8s %(levelname)-8s %(message)s [%(filename)s:%(lineno)d]'))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
return logger
def __init__(self):
super().__init__()
super(dingtalk_stream.ChatbotHandler, self).__init__()
self.logger = self.setup_logger()
# 历史消息id暂存用于幂等控制
self.receivedMsgs = ExpiredDict(60 * 60 * 7.1)
logger.info("[dingtalk] client_id={}, client_secret={} ".format(
self.dingtalk_client_id, self.dingtalk_client_secret))
# 无需群校验和前缀
conf()["group_name_white_list"] = ["ALL_GROUP"]
def startup(self):
credential = dingtalk_stream.Credential(self.dingtalk_client_id, self.dingtalk_client_secret)
client = dingtalk_stream.DingTalkStreamClient(credential)
client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, self)
client.start_forever()
def handle_single(self, cmsg: DingTalkMessage):
# 处理单聊消息
if cmsg.ctype == ContextType.VOICE:
logger.debug("[dingtalk]receive voice msg: {}".format(cmsg.content))
elif cmsg.ctype == ContextType.IMAGE:
logger.debug("[dingtalk]receive image msg: {}".format(cmsg.content))
elif cmsg.ctype == ContextType.PATPAT:
logger.debug("[dingtalk]receive patpat msg: {}".format(cmsg.content))
elif cmsg.ctype == ContextType.TEXT:
expression = cmsg.my_msg
cmsg.content = conf()["single_chat_prefix"][0] + cmsg.content
context = self._compose_context(cmsg.ctype, cmsg.content, isgroup=False, msg=cmsg)
if context:
self.produce(context)
def handle_group(self, cmsg: DingTalkMessage):
# 处理群聊消息
if cmsg.ctype == ContextType.VOICE:
logger.debug("[dingtalk]receive voice msg: {}".format(cmsg.content))
elif cmsg.ctype == ContextType.IMAGE:
logger.debug("[dingtalk]receive image msg: {}".format(cmsg.content))
elif cmsg.ctype == ContextType.PATPAT:
logger.debug("[dingtalk]receive patpat msg: {}".format(cmsg.content))
elif cmsg.ctype == ContextType.TEXT:
expression = cmsg.my_msg
cmsg.content = conf()["group_chat_prefix"][0] + cmsg.content
context = self._compose_context(cmsg.ctype, cmsg.content, isgroup=True, msg=cmsg)
context['no_need_at'] = True
if context:
self.produce(context)
async def process(self, callback: dingtalk_stream.CallbackMessage):
try:
incoming_message = dingtalk_stream.ChatbotMessage.from_dict(callback.data)
dingtalk_msg = DingTalkMessage(incoming_message)
if incoming_message.conversation_type == '1':
self.handle_single(dingtalk_msg)
else:
self.handle_group(dingtalk_msg)
return AckMessage.STATUS_OK, 'OK'
except Exception as e:
logger.error(e)
return self.FAILED_MSG
def send(self, reply: Reply, context: Context):
incoming_message = context.kwargs['msg'].incoming_message
self.reply_text(reply.content, incoming_message)

View File

@@ -0,0 +1,44 @@
from bridge.context import ContextType
from channel.chat_message import ChatMessage
import json
import requests
from common.log import logger
from common.tmp_dir import TmpDir
from common import utils
from dingtalk_stream import ChatbotMessage
class DingTalkMessage(ChatMessage):
def __init__(self, event: ChatbotMessage):
super().__init__(event)
self.msg_id = event.message_id
msg_type = event.message_type
self.incoming_message =event
self.sender_staff_id = event.sender_staff_id
self.other_user_id = event.conversation_id
self.create_time = event.create_at
if event.conversation_type=="1":
self.is_group = False
else:
self.is_group = True
if msg_type == "text":
self.ctype = ContextType.TEXT
self.content = event.text.content.strip()
elif msg_type == "audio":
# 钉钉支持直接识别语音,所以此处将直接提取文字,当文字处理
self.content = event.extensions['content']['recognition'].strip()
self.ctype = ContextType.TEXT
self.from_user_id = event.sender_id
self.to_user_id = event.chatbot_user_id
self.other_user_nickname = event.conversation_title
user_id = event.sender_id
nickname =event.sender_nick

View File

@@ -0,0 +1,254 @@
"""
飞书通道接入
@author Saboteur7
@Date 2023/11/19
"""
# -*- coding=utf-8 -*-
import uuid
import requests
import web
from channel.feishu.feishu_message import FeishuMessage
from bridge.context import Context
from bridge.reply import Reply, ReplyType
from common.log import logger
from common.singleton import singleton
from config import conf
from common.expired_dict import ExpiredDict
from bridge.context import ContextType
from channel.chat_channel import ChatChannel, check_prefix
from common import utils
import json
import os
URL_VERIFICATION = "url_verification"
@singleton
class FeiShuChanel(ChatChannel):
feishu_app_id = conf().get('feishu_app_id')
feishu_app_secret = conf().get('feishu_app_secret')
feishu_token = conf().get('feishu_token')
def __init__(self):
super().__init__()
# 历史消息id暂存用于幂等控制
self.receivedMsgs = ExpiredDict(60 * 60 * 7.1)
logger.info("[FeiShu] app_id={}, app_secret={} verification_token={}".format(
self.feishu_app_id, self.feishu_app_secret, self.feishu_token))
# 无需群校验和前缀
conf()["group_name_white_list"] = ["ALL_GROUP"]
conf()["single_chat_prefix"] = []
def startup(self):
urls = (
'/', 'channel.feishu.feishu_channel.FeishuController'
)
app = web.application(urls, globals(), autoreload=False)
port = conf().get("feishu_port", 9891)
web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port))
def send(self, reply: Reply, context: Context):
msg = context.get("msg")
is_group = context["isgroup"]
if msg:
access_token = msg.access_token
else:
access_token = self.fetch_access_token()
headers = {
"Authorization": "Bearer " + access_token,
"Content-Type": "application/json",
}
msg_type = "text"
logger.info(f"[FeiShu] start send reply message, type={context.type}, content={reply.content}")
reply_content = reply.content
content_key = "text"
if reply.type == ReplyType.IMAGE_URL:
# 图片上传
reply_content = self._upload_image_url(reply.content, access_token)
if not reply_content:
logger.warning("[FeiShu] upload file failed")
return
msg_type = "image"
content_key = "image_key"
if is_group:
# 群聊中直接回复
url = f"https://open.feishu.cn/open-apis/im/v1/messages/{msg.msg_id}/reply"
data = {
"msg_type": msg_type,
"content": json.dumps({content_key: reply_content})
}
res = requests.post(url=url, headers=headers, json=data, timeout=(5, 10))
else:
url = "https://open.feishu.cn/open-apis/im/v1/messages"
params = {"receive_id_type": context.get("receive_id_type") or "open_id"}
data = {
"receive_id": context.get("receiver"),
"msg_type": msg_type,
"content": json.dumps({content_key: reply_content})
}
res = requests.post(url=url, headers=headers, params=params, json=data, timeout=(5, 10))
res = res.json()
if res.get("code") == 0:
logger.info(f"[FeiShu] send message success")
else:
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
def fetch_access_token(self) -> str:
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal/"
headers = {
"Content-Type": "application/json"
}
req_body = {
"app_id": self.feishu_app_id,
"app_secret": self.feishu_app_secret
}
data = bytes(json.dumps(req_body), encoding='utf8')
response = requests.post(url=url, data=data, headers=headers)
if response.status_code == 200:
res = response.json()
if res.get("code") != 0:
logger.error(f"[FeiShu] get tenant_access_token error, code={res.get('code')}, msg={res.get('msg')}")
return ""
else:
return res.get("tenant_access_token")
else:
logger.error(f"[FeiShu] fetch token error, res={response}")
def _upload_image_url(self, img_url, access_token):
logger.debug(f"[WX] start download image, img_url={img_url}")
response = requests.get(img_url)
suffix = utils.get_path_suffix(img_url)
temp_name = str(uuid.uuid4()) + "." + suffix
if response.status_code == 200:
# 将图片内容保存为临时文件
with open(temp_name, "wb") as file:
file.write(response.content)
# upload
upload_url = "https://open.feishu.cn/open-apis/im/v1/images"
data = {
'image_type': 'message'
}
headers = {
'Authorization': f'Bearer {access_token}',
}
with open(temp_name, "rb") as file:
upload_response = requests.post(upload_url, files={"image": file}, data=data, headers=headers)
logger.info(f"[FeiShu] upload file, res={upload_response.content}")
os.remove(temp_name)
return upload_response.json().get("data").get("image_key")
class FeishuController:
# 类常量
FAILED_MSG = '{"success": false}'
SUCCESS_MSG = '{"success": true}'
MESSAGE_RECEIVE_TYPE = "im.message.receive_v1"
def GET(self):
return "Feishu service start success!"
def POST(self):
try:
channel = FeiShuChanel()
request = json.loads(web.data().decode("utf-8"))
logger.debug(f"[FeiShu] receive request: {request}")
# 1.事件订阅回调验证
if request.get("type") == URL_VERIFICATION:
varify_res = {"challenge": request.get("challenge")}
return json.dumps(varify_res)
# 2.消息接收处理
# token 校验
header = request.get("header")
if not header or header.get("token") != channel.feishu_token:
return self.FAILED_MSG
# 处理消息事件
event = request.get("event")
if header.get("event_type") == self.MESSAGE_RECEIVE_TYPE and event:
if not event.get("message") or not event.get("sender"):
logger.warning(f"[FeiShu] invalid message, msg={request}")
return self.FAILED_MSG
msg = event.get("message")
# 幂等判断
if channel.receivedMsgs.get(msg.get("message_id")):
logger.warning(f"[FeiShu] repeat msg filtered, event_id={header.get('event_id')}")
return self.SUCCESS_MSG
channel.receivedMsgs[msg.get("message_id")] = True
is_group = False
chat_type = msg.get("chat_type")
if chat_type == "group":
if not msg.get("mentions") and msg.get("message_type") == "text":
# 群聊中未@不响应
return self.SUCCESS_MSG
if msg.get("mentions")[0].get("name") != conf().get("feishu_bot_name") and msg.get("message_type") == "text":
# 不是@机器人,不响应
return self.SUCCESS_MSG
# 群聊
is_group = True
receive_id_type = "chat_id"
elif chat_type == "p2p":
receive_id_type = "open_id"
else:
logger.warning("[FeiShu] message ignore")
return self.SUCCESS_MSG
# 构造飞书消息对象
feishu_msg = FeishuMessage(event, is_group=is_group, access_token=channel.fetch_access_token())
if not feishu_msg:
return self.SUCCESS_MSG
context = self._compose_context(
feishu_msg.ctype,
feishu_msg.content,
isgroup=is_group,
msg=feishu_msg,
receive_id_type=receive_id_type,
no_need_at=True
)
if context:
channel.produce(context)
logger.info(f"[FeiShu] query={feishu_msg.content}, type={feishu_msg.ctype}")
return self.SUCCESS_MSG
except Exception as e:
logger.error(e)
return self.FAILED_MSG
def _compose_context(self, ctype: ContextType, content, **kwargs):
context = Context(ctype, content)
context.kwargs = kwargs
if "origin_ctype" not in context:
context["origin_ctype"] = ctype
cmsg = context["msg"]
context["session_id"] = cmsg.from_user_id
context["receiver"] = cmsg.other_user_id
if ctype == ContextType.TEXT:
# 1.文本请求
# 图片生成处理
img_match_prefix = check_prefix(content, conf().get("image_create_prefix"))
if img_match_prefix:
content = content.replace(img_match_prefix, "", 1)
context.type = ContextType.IMAGE_CREATE
else:
context.type = ContextType.TEXT
context.content = content.strip()
elif context.type == ContextType.VOICE:
# 2.语音请求
if "desire_rtype" not in context and conf().get("voice_reply_voice"):
context["desire_rtype"] = ReplyType.VOICE
return context

View File

@@ -0,0 +1,63 @@
from bridge.context import ContextType
from channel.chat_message import ChatMessage
import json
import requests
from common.log import logger
from common.tmp_dir import TmpDir
from common import utils
class FeishuMessage(ChatMessage):
def __init__(self, event: dict, is_group=False, access_token=None):
super().__init__(event)
msg = event.get("message")
sender = event.get("sender")
self.access_token = access_token
self.msg_id = msg.get("message_id")
self.create_time = msg.get("create_time")
self.is_group = is_group
msg_type = msg.get("message_type")
if msg_type == "text":
self.ctype = ContextType.TEXT
content = json.loads(msg.get('content'))
self.content = content.get("text").strip()
elif msg_type == "file":
self.ctype = ContextType.FILE
content = json.loads(msg.get("content"))
file_key = content.get("file_key")
file_name = content.get("file_name")
self.content = TmpDir().path() + file_key + "." + utils.get_path_suffix(file_name)
def _download_file():
# 如果响应状态码是200则将响应内容写入本地文件
url = f"https://open.feishu.cn/open-apis/im/v1/messages/{self.msg_id}/resources/{file_key}"
headers = {
"Authorization": "Bearer " + access_token,
}
params = {
"type": "file"
}
response = requests.get(url=url, headers=headers, params=params)
if response.status_code == 200:
with open(self.content, "wb") as f:
f.write(response.content)
else:
logger.info(f"[FeiShu] Failed to download file, key={file_key}, res={response.text}")
self._prepare_fn = _download_file
else:
raise NotImplementedError("Unsupported message type: Type:{} ".format(msg_type))
self.from_user_id = sender.get("sender_id").get("open_id")
self.to_user_id = event.get("app_id")
if is_group:
# 群聊
self.other_user_id = msg.get("chat_id")
self.actual_user_id = self.from_user_id
self.content = self.content.replace("@_user_1", "").strip()
self.actual_user_nickname = ""
else:
# 私聊
self.other_user_id = self.from_user_id
self.actual_user_id = self.from_user_id

View File

@@ -1,29 +1,92 @@
from channel.channel import Channel
import sys
class TerminalChannel(Channel):
from bridge.context import *
from bridge.reply import Reply, ReplyType
from channel.chat_channel import ChatChannel, check_prefix
from channel.chat_message import ChatMessage
from common.log import logger
from config import conf
class TerminalMessage(ChatMessage):
def __init__(
self,
msg_id,
content,
ctype=ContextType.TEXT,
from_user_id="User",
to_user_id="Chatgpt",
other_user_id="Chatgpt",
):
self.msg_id = msg_id
self.ctype = ctype
self.content = content
self.from_user_id = from_user_id
self.to_user_id = to_user_id
self.other_user_id = other_user_id
class TerminalChannel(ChatChannel):
NOT_SUPPORT_REPLYTYPE = [ReplyType.VOICE]
def send(self, reply: Reply, context: Context):
print("\nBot:")
if reply.type == ReplyType.IMAGE:
from PIL import Image
image_storage = reply.content
image_storage.seek(0)
img = Image.open(image_storage)
print("<IMAGE>")
img.show()
elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片
import io
import requests
from PIL import Image
img_url = reply.content
pic_res = requests.get(img_url, stream=True)
image_storage = io.BytesIO()
for block in pic_res.iter_content(1024):
image_storage.write(block)
image_storage.seek(0)
img = Image.open(image_storage)
print(img_url)
img.show()
else:
print(reply.content)
print("\nUser:", end="")
sys.stdout.flush()
return
def startup(self):
context = {"from_user_id": "User"}
print("\nPlease input your question")
context = Context()
logger.setLevel("WARN")
print("\nPlease input your question:\nUser:", end="")
sys.stdout.flush()
msg_id = 0
while True:
try:
prompt = self.get_input("User:\n")
prompt = self.get_input()
except KeyboardInterrupt:
print("\nExiting...")
sys.exit()
msg_id += 1
trigger_prefixs = conf().get("single_chat_prefix", [""])
if check_prefix(prompt, trigger_prefixs) is None:
prompt = trigger_prefixs[0] + prompt # 给没触发的消息加上触发前缀
print("Bot:")
sys.stdout.flush()
for res in super().build_reply_content(prompt, context):
print(res, end="")
sys.stdout.flush()
print("\n")
context = self._compose_context(ContextType.TEXT, prompt, msg=TerminalMessage(msg_id, prompt))
if context:
self.produce(context)
else:
raise Exception("context is None")
def get_input(self, prompt):
def get_input(self):
"""
Multi-line input function
"""
print(prompt, end="")
sys.stdout.flush()
line = input()
return line

View File

@@ -4,227 +4,267 @@
wechat channel
"""
import itchat
import json
from itchat.content import *
from channel.channel import Channel
from concurrent.futures import ThreadPoolExecutor
from common.log import logger
from common.tmp_dir import TmpDir
from config import conf
import requests
import io
import json
import os
import threading
import time
thread_pool = ThreadPoolExecutor(max_workers=8)
import requests
from bridge.context import *
from bridge.reply import *
from channel.chat_channel import ChatChannel
from channel.wechat.wechat_message import *
from common.expired_dict import ExpiredDict
from common.log import logger
from common.singleton import singleton
from common.time_check import time_checker
from config import conf, get_appdata_dir
from lib import itchat
from lib.itchat.content import *
@itchat.msg_register(TEXT)
@itchat.msg_register([TEXT, VOICE, PICTURE, NOTE, ATTACHMENT, SHARING])
def handler_single_msg(msg):
WechatChannel().handle_text(msg)
try:
cmsg = WechatMessage(msg, False)
except NotImplementedError as e:
logger.debug("[WX]single message {} skipped: {}".format(msg["MsgId"], e))
return None
WechatChannel().handle_single(cmsg)
return None
@itchat.msg_register(TEXT, isGroupChat=True)
@itchat.msg_register([TEXT, VOICE, PICTURE, NOTE, ATTACHMENT, SHARING], isGroupChat=True)
def handler_group_msg(msg):
WechatChannel().handle_group(msg)
try:
cmsg = WechatMessage(msg, True)
except NotImplementedError as e:
logger.debug("[WX]group message {} skipped: {}".format(msg["MsgId"], e))
return None
WechatChannel().handle_group(cmsg)
return None
@itchat.msg_register(VOICE)
def handler_single_voice(msg):
WechatChannel().handle_voice(msg)
return None
def _check(func):
def wrapper(self, cmsg: ChatMessage):
msgId = cmsg.msg_id
if msgId in self.receivedMsgs:
logger.info("Wechat message {} already received, ignore".format(msgId))
return
self.receivedMsgs[msgId] = True
create_time = cmsg.create_time # 消息时间戳
if conf().get("hot_reload") == True and int(create_time) < int(time.time()) - 60: # 跳过1分钟前的历史消息
logger.debug("[WX]history message {} skipped".format(msgId))
return
if cmsg.my_msg and not cmsg.is_group:
logger.debug("[WX]my message {} skipped".format(msgId))
return
return func(self, cmsg)
return wrapper
class WechatChannel(Channel):
# 可用的二维码生成接口
# https://api.qrserver.com/v1/create-qr-code/?size=400×400&data=https://www.abc.com
# https://api.isoyu.com/qr/?m=1&e=L&p=20&url=https://www.abc.com
def qrCallback(uuid, status, qrcode):
# logger.debug("qrCallback: {} {}".format(uuid,status))
if status == "0":
try:
from PIL import Image
img = Image.open(io.BytesIO(qrcode))
_thread = threading.Thread(target=img.show, args=("QRCode",))
_thread.setDaemon(True)
_thread.start()
except Exception as e:
pass
import qrcode
url = f"https://login.weixin.qq.com/l/{uuid}"
qr_api1 = "https://api.isoyu.com/qr/?m=1&e=L&p=20&url={}".format(url)
qr_api2 = "https://api.qrserver.com/v1/create-qr-code/?size=400×400&data={}".format(url)
qr_api3 = "https://api.pwmqr.com/qrcode/create/?url={}".format(url)
qr_api4 = "https://my.tv.sohu.com/user/a/wvideo/getQRCode.do?text={}".format(url)
print("You can also scan QRCode in any website below:")
print(qr_api3)
print(qr_api4)
print(qr_api2)
print(qr_api1)
_send_qr_code([qr_api1, qr_api2, qr_api3, qr_api4])
qr = qrcode.QRCode(border=1)
qr.add_data(url)
qr.make(fit=True)
qr.print_ascii(invert=True)
@singleton
class WechatChannel(ChatChannel):
NOT_SUPPORT_REPLYTYPE = []
def __init__(self):
pass
super().__init__()
self.receivedMsgs = ExpiredDict(60 * 60)
self.auto_login_times = 0
def startup(self):
itchat.instance.receivingRetryCount = 600 # 修改断线超时时间
# login by scan QRCode
itchat.auto_login(enableCmdQR=2, hotReload=conf().get('hot_reload', False))
hotReload = conf().get("hot_reload", False)
status_path = os.path.join(get_appdata_dir(), "itchat.pkl")
itchat.auto_login(
enableCmdQR=2,
hotReload=hotReload,
statusStorageDir=status_path,
qrCallback=qrCallback,
exitCallback=self.exitCallback,
loginCallback=self.loginCallback
)
self.user_id = itchat.instance.storageClass.userName
self.name = itchat.instance.storageClass.nickName
logger.info("Wechat login success, user_id: {}, nickname: {}".format(self.user_id, self.name))
# start message listener
itchat.run()
def handle_voice(self, msg):
if conf().get('speech_recognition') != True :
def exitCallback(self):
_send_logout()
time.sleep(3)
self.auto_login_times += 1
if self.auto_login_times < 100:
self.startup()
def loginCallback(self):
logger.debug("Login success")
_send_login_success()
# handle_* 系列函数处理收到的消息后构造Context然后传入produce函数中处理Context和发送回复
# Context包含了消息的所有信息包括以下属性
# type 消息类型, 包括TEXT、VOICE、IMAGE_CREATE
# content 消息内容如果是TEXT类型content就是文本内容如果是VOICE类型content就是语音文件名如果是IMAGE_CREATE类型content就是图片生成命令
# kwargs 附加参数字典包含以下的key
# session_id: 会话id
# isgroup: 是否是群聊
# receiver: 需要回复的对象
# msg: ChatMessage消息对象
# origin_ctype: 原始消息类型,语音转文字后,私聊时如果匹配前缀失败,会根据初始消息是否是语音来放宽触发规则
# desire_rtype: 希望回复类型默认是文本回复设置为ReplyType.VOICE是语音回复
@time_checker
@_check
def handle_single(self, cmsg: ChatMessage):
# filter system message
if cmsg.other_user_id in ["weixin"]:
return
logger.debug("[WX]receive voice msg: " + msg['FileName'])
thread_pool.submit(self._do_handle_voice, msg)
def _do_handle_voice(self, msg):
from_user_id = msg['FromUserName']
other_user_id = msg['User']['UserName']
if from_user_id == other_user_id:
file_name = TmpDir().path() + msg['FileName']
msg.download(file_name)
query = super().build_voice_to_text(file_name)
if conf().get('voice_reply_voice'):
self._do_send_voice(query, from_user_id)
else:
self._do_send_text(query, from_user_id)
def handle_text(self, msg):
logger.debug("[WX]receive text msg: " + json.dumps(msg, ensure_ascii=False))
content = msg['Text']
self._handle_single_msg(msg, content)
def _handle_single_msg(self, msg, content):
from_user_id = msg['FromUserName']
to_user_id = msg['ToUserName'] # 接收人id
other_user_id = msg['User']['UserName'] # 对手方id
create_time = msg['CreateTime'] # 消息时间
match_prefix = self.check_prefix(content, conf().get('single_chat_prefix'))
if conf().get('hot_reload') == True and int(create_time) < int(time.time()) - 60: #跳过1分钟前的历史消息
logger.debug("[WX]history message skipped")
return
if "\n- - - - - - - - - - - - - - -" in content:
logger.debug("[WX]reference query skipped")
return
if from_user_id == other_user_id and match_prefix is not None:
# 好友向自己发送消息
if match_prefix != '':
str_list = content.split(match_prefix, 1)
if len(str_list) == 2:
content = str_list[1].strip()
img_match_prefix = self.check_prefix(content, conf().get('image_create_prefix'))
if img_match_prefix:
content = content.split(img_match_prefix, 1)[1].strip()
thread_pool.submit(self._do_send_img, content, from_user_id)
else :
thread_pool.submit(self._do_send_text, content, from_user_id)
elif to_user_id == other_user_id and match_prefix:
# 自己给好友发送消息
str_list = content.split(match_prefix, 1)
if len(str_list) == 2:
content = str_list[1].strip()
img_match_prefix = self.check_prefix(content, conf().get('image_create_prefix'))
if img_match_prefix:
content = content.split(img_match_prefix, 1)[1].strip()
thread_pool.submit(self._do_send_img, content, to_user_id)
else:
thread_pool.submit(self._do_send_text, content, to_user_id)
def handle_group(self, msg):
logger.debug("[WX]receive group msg: " + json.dumps(msg, ensure_ascii=False))
group_name = msg['User'].get('NickName', None)
group_id = msg['User'].get('UserName', None)
create_time = msg['CreateTime'] # 消息时间
if conf().get('hot_reload') == True and int(create_time) < int(time.time()) - 60: #跳过1分钟前的历史消息
logger.debug("[WX]history group message skipped")
return
if not group_name:
return ""
origin_content = msg['Content']
content = msg['Content']
content_list = content.split(' ', 1)
context_special_list = content.split('\u2005', 1)
if len(context_special_list) == 2:
content = context_special_list[1]
elif len(content_list) == 2:
content = content_list[1]
if "\n- - - - - - - - - - - - - - -" in content:
logger.debug("[WX]reference query skipped")
return ""
config = conf()
match_prefix = (msg['IsAt'] and not config.get("group_at_off", False)) or self.check_prefix(origin_content, config.get('group_chat_prefix')) \
or self.check_contain(origin_content, config.get('group_chat_keyword'))
if ('ALL_GROUP' in config.get('group_name_white_list') or group_name in config.get('group_name_white_list') or self.check_contain(group_name, config.get('group_name_keyword_white_list'))) and match_prefix:
img_match_prefix = self.check_prefix(content, conf().get('image_create_prefix'))
if img_match_prefix:
content = content.split(img_match_prefix, 1)[1].strip()
thread_pool.submit(self._do_send_img, content, group_id)
else:
thread_pool.submit(self._do_send_group, content, msg)
def send(self, msg, receiver):
itchat.send(msg, toUserName=receiver)
logger.info('[WX] sendMsg={}, receiver={}'.format(msg, receiver))
def _do_send_voice(self, query, reply_user_id):
try:
if not query:
if cmsg.ctype == ContextType.VOICE:
if conf().get("speech_recognition") != True:
return
context = dict()
context['from_user_id'] = reply_user_id
reply_text = super().build_reply_content(query, context)
if reply_text:
replyFile = super().build_text_to_voice(reply_text)
itchat.send_file(replyFile, toUserName=reply_user_id)
logger.info('[WX] sendFile={}, receiver={}'.format(replyFile, reply_user_id))
except Exception as e:
logger.exception(e)
logger.debug("[WX]receive voice msg: {}".format(cmsg.content))
elif cmsg.ctype == ContextType.IMAGE:
logger.debug("[WX]receive image msg: {}".format(cmsg.content))
elif cmsg.ctype == ContextType.PATPAT:
logger.debug("[WX]receive patpat msg: {}".format(cmsg.content))
elif cmsg.ctype == ContextType.TEXT:
logger.debug("[WX]receive text msg: {}, cmsg={}".format(json.dumps(cmsg._rawmsg, ensure_ascii=False), cmsg))
else:
logger.debug("[WX]receive msg: {}, cmsg={}".format(cmsg.content, cmsg))
context = self._compose_context(cmsg.ctype, cmsg.content, isgroup=False, msg=cmsg)
if context:
self.produce(context)
def _do_send_text(self, query, reply_user_id):
try:
if not query:
@time_checker
@_check
def handle_group(self, cmsg: ChatMessage):
if cmsg.ctype == ContextType.VOICE:
if conf().get("group_speech_recognition") != True:
return
context = dict()
context['session_id'] = reply_user_id
reply_text = super().build_reply_content(query, context)
if reply_text:
self.send(conf().get("single_chat_reply_prefix") + reply_text, reply_user_id)
except Exception as e:
logger.exception(e)
logger.debug("[WX]receive voice for group msg: {}".format(cmsg.content))
elif cmsg.ctype == ContextType.IMAGE:
logger.debug("[WX]receive image for group msg: {}".format(cmsg.content))
elif cmsg.ctype in [ContextType.JOIN_GROUP, ContextType.PATPAT, ContextType.ACCEPT_FRIEND, ContextType.EXIT_GROUP]:
logger.debug("[WX]receive note msg: {}".format(cmsg.content))
elif cmsg.ctype == ContextType.TEXT:
# logger.debug("[WX]receive group msg: {}, cmsg={}".format(json.dumps(cmsg._rawmsg, ensure_ascii=False), cmsg))
pass
elif cmsg.ctype == ContextType.FILE:
logger.debug(f"[WX]receive attachment msg, file_name={cmsg.content}")
else:
logger.debug("[WX]receive group msg: {}".format(cmsg.content))
context = self._compose_context(cmsg.ctype, cmsg.content, isgroup=True, msg=cmsg)
if context:
self.produce(context)
def _do_send_img(self, query, reply_user_id):
try:
if not query:
return
context = dict()
context['type'] = 'IMAGE_CREATE'
img_url = super().build_reply_content(query, context)
if not img_url:
return
# 图片下载
# 统一的发送函数每个Channel自行实现根据reply的type字段发送不同类型的消息
def send(self, reply: Reply, context: Context):
receiver = context["receiver"]
if reply.type == ReplyType.TEXT:
itchat.send(reply.content, toUserName=receiver)
logger.info("[WX] sendMsg={}, receiver={}".format(reply, receiver))
elif reply.type == ReplyType.ERROR or reply.type == ReplyType.INFO:
itchat.send(reply.content, toUserName=receiver)
logger.info("[WX] sendMsg={}, receiver={}".format(reply, receiver))
elif reply.type == ReplyType.VOICE:
itchat.send_file(reply.content, toUserName=receiver)
logger.info("[WX] sendFile={}, receiver={}".format(reply.content, receiver))
elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片
img_url = reply.content
logger.debug(f"[WX] start download image, img_url={img_url}")
pic_res = requests.get(img_url, stream=True)
image_storage = io.BytesIO()
size = 0
for block in pic_res.iter_content(1024):
size += len(block)
image_storage.write(block)
logger.info(f"[WX] download image success, size={size}, img_url={img_url}")
image_storage.seek(0)
itchat.send_image(image_storage, toUserName=receiver)
logger.info("[WX] sendImage url={}, receiver={}".format(img_url, receiver))
elif reply.type == ReplyType.IMAGE: # 从文件读取图片
image_storage = reply.content
image_storage.seek(0)
itchat.send_image(image_storage, toUserName=receiver)
logger.info("[WX] sendImage, receiver={}".format(receiver))
elif reply.type == ReplyType.FILE: # 新增文件回复类型
file_storage = reply.content
itchat.send_file(file_storage, toUserName=receiver)
logger.info("[WX] sendFile, receiver={}".format(receiver))
elif reply.type == ReplyType.VIDEO: # 新增视频回复类型
video_storage = reply.content
itchat.send_video(video_storage, toUserName=receiver)
logger.info("[WX] sendFile, receiver={}".format(receiver))
elif reply.type == ReplyType.VIDEO_URL: # 新增视频URL回复类型
video_url = reply.content
logger.debug(f"[WX] start download video, video_url={video_url}")
video_res = requests.get(video_url, stream=True)
video_storage = io.BytesIO()
size = 0
for block in video_res.iter_content(1024):
size += len(block)
video_storage.write(block)
logger.info(f"[WX] download video success, size={size}, video_url={video_url}")
video_storage.seek(0)
itchat.send_video(video_storage, toUserName=receiver)
logger.info("[WX] sendVideo url={}, receiver={}".format(video_url, receiver))
# 图片发送
itchat.send_image(image_storage, reply_user_id)
logger.info('[WX] sendImage, receiver={}'.format(reply_user_id))
except Exception as e:
logger.exception(e)
def _send_login_success():
try:
from common.linkai_client import chat_client
chat_client.send_login_success()
except Exception as e:
pass
def _do_send_group(self, query, msg):
if not query:
return
context = dict()
group_name = msg['User']['NickName']
group_id = msg['User']['UserName']
group_chat_in_one_session = conf().get('group_chat_in_one_session', [])
if ('ALL_GROUP' in group_chat_in_one_session or \
group_name in group_chat_in_one_session or \
self.check_contain(group_name, group_chat_in_one_session)):
context['session_id'] = group_id
else:
context['session_id'] = msg['ActualUserName']
reply_text = super().build_reply_content(query, context)
if reply_text:
reply_text = '@' + msg['ActualNickName'] + ' ' + reply_text.strip()
self.send(conf().get("group_chat_reply_prefix", "") + reply_text, group_id)
def check_prefix(self, content, prefix_list):
for prefix in prefix_list:
if content.startswith(prefix):
return prefix
return None
def check_contain(self, content, keyword_list):
if not keyword_list:
return None
for ky in keyword_list:
if content.find(ky) != -1:
return True
return None
def _send_logout():
try:
from common.linkai_client import chat_client
chat_client.send_logout()
except Exception as e:
pass
def _send_qr_code(qrcode_list: list):
try:
from common.linkai_client import chat_client
chat_client.send_qrcode(qrcode_list)
except Exception as e:
pass

View File

@@ -0,0 +1,102 @@
import re
from bridge.context import ContextType
from channel.chat_message import ChatMessage
from common.log import logger
from common.tmp_dir import TmpDir
from lib import itchat
from lib.itchat.content import *
class WechatMessage(ChatMessage):
def __init__(self, itchat_msg, is_group=False):
super().__init__(itchat_msg)
self.msg_id = itchat_msg["MsgId"]
self.create_time = itchat_msg["CreateTime"]
self.is_group = is_group
if itchat_msg["Type"] == TEXT:
self.ctype = ContextType.TEXT
self.content = itchat_msg["Text"]
elif itchat_msg["Type"] == VOICE:
self.ctype = ContextType.VOICE
self.content = TmpDir().path() + itchat_msg["FileName"] # content直接存临时目录路径
self._prepare_fn = lambda: itchat_msg.download(self.content)
elif itchat_msg["Type"] == PICTURE and itchat_msg["MsgType"] == 3:
self.ctype = ContextType.IMAGE
self.content = TmpDir().path() + itchat_msg["FileName"] # content直接存临时目录路径
self._prepare_fn = lambda: itchat_msg.download(self.content)
elif itchat_msg["Type"] == NOTE and itchat_msg["MsgType"] == 10000:
if is_group and ("加入群聊" in itchat_msg["Content"] or "加入了群聊" in itchat_msg["Content"]):
# 这里只能得到nickname actual_user_id还是机器人的id
if "加入了群聊" in itchat_msg["Content"]:
self.ctype = ContextType.JOIN_GROUP
self.content = itchat_msg["Content"]
self.actual_user_nickname = re.findall(r"\"(.*?)\"", itchat_msg["Content"])[-1]
elif "加入群聊" in itchat_msg["Content"]:
self.ctype = ContextType.JOIN_GROUP
self.content = itchat_msg["Content"]
self.actual_user_nickname = re.findall(r"\"(.*?)\"", itchat_msg["Content"])[0]
elif is_group and ("移出了群聊" in itchat_msg["Content"]):
self.ctype = ContextType.EXIT_GROUP
self.content = itchat_msg["Content"]
self.actual_user_nickname = re.findall(r"\"(.*?)\"", itchat_msg["Content"])[0]
elif "你已添加了" in itchat_msg["Content"]: #通过好友请求
self.ctype = ContextType.ACCEPT_FRIEND
self.content = itchat_msg["Content"]
elif "拍了拍我" in itchat_msg["Content"]:
self.ctype = ContextType.PATPAT
self.content = itchat_msg["Content"]
if is_group:
self.actual_user_nickname = re.findall(r"\"(.*?)\"", itchat_msg["Content"])[0]
else:
raise NotImplementedError("Unsupported note message: " + itchat_msg["Content"])
elif itchat_msg["Type"] == ATTACHMENT:
self.ctype = ContextType.FILE
self.content = TmpDir().path() + itchat_msg["FileName"] # content直接存临时目录路径
self._prepare_fn = lambda: itchat_msg.download(self.content)
elif itchat_msg["Type"] == SHARING:
self.ctype = ContextType.SHARING
self.content = itchat_msg.get("Url")
else:
raise NotImplementedError("Unsupported message type: Type:{} MsgType:{}".format(itchat_msg["Type"], itchat_msg["MsgType"]))
self.from_user_id = itchat_msg["FromUserName"]
self.to_user_id = itchat_msg["ToUserName"]
user_id = itchat.instance.storageClass.userName
nickname = itchat.instance.storageClass.nickName
# 虽然from_user_id和to_user_id用的少但是为了保持一致性还是要填充一下
# 以下很繁琐,一句话总结:能填的都填了。
if self.from_user_id == user_id:
self.from_user_nickname = nickname
if self.to_user_id == user_id:
self.to_user_nickname = nickname
try: # 陌生人时候, User字段可能不存在
# my_msg 为True是表示是自己发送的消息
self.my_msg = itchat_msg["ToUserName"] == itchat_msg["User"]["UserName"] and \
itchat_msg["ToUserName"] != itchat_msg["FromUserName"]
self.other_user_id = itchat_msg["User"]["UserName"]
self.other_user_nickname = itchat_msg["User"]["NickName"]
if self.other_user_id == self.from_user_id:
self.from_user_nickname = self.other_user_nickname
if self.other_user_id == self.to_user_id:
self.to_user_nickname = self.other_user_nickname
if itchat_msg["User"].get("Self"):
# 自身的展示名,当设置了群昵称时,该字段表示群昵称
self.self_display_name = itchat_msg["User"].get("Self").get("DisplayName")
except KeyError as e: # 处理偶尔没有对方信息的情况
logger.warn("[WX]get other_user_id failed: " + str(e))
if self.from_user_id == user_id:
self.other_user_id = self.to_user_id
else:
self.other_user_id = self.from_user_id
if self.is_group:
self.is_at = itchat_msg["IsAt"]
self.actual_user_id = itchat_msg["ActualUserName"]
if self.ctype not in [ContextType.JOIN_GROUP, ContextType.PATPAT, ContextType.EXIT_GROUP]:
self.actual_user_nickname = itchat_msg["ActualNickName"]

View File

@@ -4,290 +4,126 @@
wechaty channel
Python Wechaty - https://github.com/wechaty/python-wechaty
"""
import io
import os
import json
import time
import asyncio
import requests
import pysilk
import wave
from pydub import AudioSegment
from typing import Optional, Union
from wechaty_puppet import MessageType, FileBox, ScanStatus # type: ignore
from wechaty import Wechaty, Contact
from wechaty.user import Message, Room, MiniProgram, UrlLink
from channel.channel import Channel
import base64
import os
import time
from wechaty import Contact, Wechaty
from wechaty.user import Message
from wechaty_puppet import FileBox
from bridge.context import *
from bridge.context import Context
from bridge.reply import *
from channel.chat_channel import ChatChannel
from channel.wechat.wechaty_message import WechatyMessage
from common.log import logger
from common.tmp_dir import TmpDir
from common.singleton import singleton
from config import conf
try:
from voice.audio_convert import any_to_sil
except Exception as e:
pass
class WechatyChannel(Channel):
@singleton
class WechatyChannel(ChatChannel):
NOT_SUPPORT_REPLYTYPE = []
def __init__(self):
pass
super().__init__()
def startup(self):
config = conf()
token = config.get("wechaty_puppet_service_token")
os.environ["WECHATY_PUPPET_SERVICE_TOKEN"] = token
asyncio.run(self.main())
async def main(self):
config = conf()
# 使用PadLocal协议 比较稳定(免费web协议 os.environ['WECHATY_PUPPET_SERVICE_ENDPOINT'] = '127.0.0.1:8080')
token = config.get('wechaty_puppet_service_token')
os.environ['WECHATY_PUPPET_SERVICE_TOKEN'] = token
global bot
bot = Wechaty()
bot.on('scan', self.on_scan)
bot.on('login', self.on_login)
bot.on('message', self.on_message)
await bot.start()
loop = asyncio.get_event_loop()
# 将asyncio的loop传入处理线程
self.handler_pool._initializer = lambda: asyncio.set_event_loop(loop)
self.bot = Wechaty()
self.bot.on("login", self.on_login)
self.bot.on("message", self.on_message)
await self.bot.start()
async def on_login(self, contact: Contact):
logger.info('[WX] login user={}'.format(contact))
self.user_id = contact.contact_id
self.name = contact.name
logger.info("[WX] login user={}".format(contact))
async def on_scan(self, status: ScanStatus, qr_code: Optional[str] = None,
data: Optional[str] = None):
contact = self.Contact.load(self.contact_id)
logger.info('[WX] scan user={}, scan status={}, scan qr_code={}'.format(contact, status.name, qr_code))
# print(f'user <{contact}> scan status: {status.name} , 'f'qr_code: {qr_code}')
# 统一的发送函数每个Channel自行实现根据reply的type字段发送不同类型的消息
def send(self, reply: Reply, context: Context):
receiver_id = context["receiver"]
loop = asyncio.get_event_loop()
if context["isgroup"]:
receiver = asyncio.run_coroutine_threadsafe(self.bot.Room.find(receiver_id), loop).result()
else:
receiver = asyncio.run_coroutine_threadsafe(self.bot.Contact.find(receiver_id), loop).result()
msg = None
if reply.type == ReplyType.TEXT:
msg = reply.content
asyncio.run_coroutine_threadsafe(receiver.say(msg), loop).result()
logger.info("[WX] sendMsg={}, receiver={}".format(reply, receiver))
elif reply.type == ReplyType.ERROR or reply.type == ReplyType.INFO:
msg = reply.content
asyncio.run_coroutine_threadsafe(receiver.say(msg), loop).result()
logger.info("[WX] sendMsg={}, receiver={}".format(reply, receiver))
elif reply.type == ReplyType.VOICE:
voiceLength = None
file_path = reply.content
sil_file = os.path.splitext(file_path)[0] + ".sil"
voiceLength = int(any_to_sil(file_path, sil_file))
if voiceLength >= 60000:
voiceLength = 60000
logger.info("[WX] voice too long, length={}, set to 60s".format(voiceLength))
# 发送语音
t = int(time.time())
msg = FileBox.from_file(sil_file, name=str(t) + ".sil")
if voiceLength is not None:
msg.metadata["voiceLength"] = voiceLength
asyncio.run_coroutine_threadsafe(receiver.say(msg), loop).result()
try:
os.remove(file_path)
if sil_file != file_path:
os.remove(sil_file)
except Exception as e:
pass
logger.info("[WX] sendVoice={}, receiver={}".format(reply.content, receiver))
elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片
img_url = reply.content
t = int(time.time())
msg = FileBox.from_url(url=img_url, name=str(t) + ".png")
asyncio.run_coroutine_threadsafe(receiver.say(msg), loop).result()
logger.info("[WX] sendImage url={}, receiver={}".format(img_url, receiver))
elif reply.type == ReplyType.IMAGE: # 从文件读取图片
image_storage = reply.content
image_storage.seek(0)
t = int(time.time())
msg = FileBox.from_base64(base64.b64encode(image_storage.read()), str(t) + ".png")
asyncio.run_coroutine_threadsafe(receiver.say(msg), loop).result()
logger.info("[WX] sendImage, receiver={}".format(receiver))
async def on_message(self, msg: Message):
"""
listen for message event
"""
from_contact = msg.talker() # 获取消息的发送者
to_contact = msg.to() # 接收人
room = msg.room() # 获取消息来自的群聊. 如果消息不是来自群聊, 则返回None
from_user_id = from_contact.contact_id
to_user_id = to_contact.contact_id # 接收人id
# other_user_id = msg['User']['UserName'] # 对手方id
content = msg.text()
mention_content = await msg.mention_text() # 返回过滤掉@name后的消息
match_prefix = self.check_prefix(content, conf().get('single_chat_prefix'))
conversation: Union[Room, Contact] = from_contact if room is None else room
if room is None and msg.type() == MessageType.MESSAGE_TYPE_TEXT:
if not msg.is_self() and match_prefix is not None:
# 好友向自己发送消息
if match_prefix != '':
str_list = content.split(match_prefix, 1)
if len(str_list) == 2:
content = str_list[1].strip()
img_match_prefix = self.check_prefix(content, conf().get('image_create_prefix'))
if img_match_prefix:
content = content.split(img_match_prefix, 1)[1].strip()
await self._do_send_img(content, from_user_id)
else:
await self._do_send(content, from_user_id)
elif msg.is_self() and match_prefix:
# 自己给好友发送消息
str_list = content.split(match_prefix, 1)
if len(str_list) == 2:
content = str_list[1].strip()
img_match_prefix = self.check_prefix(content, conf().get('image_create_prefix'))
if img_match_prefix:
content = content.split(img_match_prefix, 1)[1].strip()
await self._do_send_img(content, to_user_id)
else:
await self._do_send(content, to_user_id)
elif room is None and msg.type() == MessageType.MESSAGE_TYPE_AUDIO:
if not msg.is_self(): # 接收语音消息
# 下载语音文件
voice_file = await msg.to_file_box()
silk_file = TmpDir().path() + voice_file.name
await voice_file.to_file(silk_file)
logger.info("[WX]receive voice file: " + silk_file)
# 将文件转成wav格式音频
wav_file = silk_file.replace(".slk", ".wav")
with open(silk_file, 'rb') as f:
silk_data = f.read()
pcm_data = pysilk.decode(silk_data)
with wave.open(wav_file, 'wb') as wav_data:
wav_data.setnchannels(1)
wav_data.setsampwidth(2)
wav_data.setframerate(24000)
wav_data.writeframes(pcm_data)
if os.path.exists(wav_file):
converter_state = "true" # 转换wav成功
else:
converter_state = "false" # 转换wav失败
logger.info("[WX]receive voice converter: " + converter_state)
# 语音识别为文本
query = super().build_voice_to_text(wav_file)
# 交验关键字
match_prefix = self.check_prefix(query, conf().get('single_chat_prefix'))
if match_prefix is not None:
if match_prefix != '':
str_list = query.split(match_prefix, 1)
if len(str_list) == 2:
query = str_list[1].strip()
# 返回消息
if conf().get('voice_reply_voice'):
await self._do_send_voice(query, from_user_id)
else:
await self._do_send(query, from_user_id)
else:
logger.info("[WX]receive voice check prefix: " + 'False')
# 清除缓存文件
os.remove(wav_file)
os.remove(silk_file)
elif room and msg.type() == MessageType.MESSAGE_TYPE_TEXT:
# 群组&文本消息
room_id = room.room_id
room_name = await room.topic()
from_user_id = from_contact.contact_id
from_user_name = from_contact.name
is_at = await msg.mention_self()
content = mention_content
config = conf()
match_prefix = (is_at and not config.get("group_at_off", False)) \
or self.check_prefix(content, config.get('group_chat_prefix')) \
or self.check_contain(content, config.get('group_chat_keyword'))
# Wechaty判断is_at为True返回的内容是过滤掉@之后的内容而is_at为False则会返回完整的内容
# 故判断如果匹配到自定义前缀,则返回过滤掉前缀+空格后的内容,用于实现类似自定义+前缀触发生成AI图片的功能
prefixes = config.get('group_chat_prefix')
for prefix in prefixes:
if content.startswith(prefix):
content = content.replace(prefix, '', 1).strip()
break
if ('ALL_GROUP' in config.get('group_name_white_list') or room_name in config.get(
'group_name_white_list') or self.check_contain(room_name, config.get(
'group_name_keyword_white_list'))) and match_prefix:
img_match_prefix = self.check_prefix(content, conf().get('image_create_prefix'))
if img_match_prefix:
content = content.split(img_match_prefix, 1)[1].strip()
await self._do_send_group_img(content, room_id)
else:
await self._do_send_group(content, room_id, room_name, from_user_id, from_user_name)
async def send(self, message: Union[str, Message, FileBox, Contact, UrlLink, MiniProgram], receiver):
logger.info('[WX] sendMsg={}, receiver={}'.format(message, receiver))
if receiver:
contact = await bot.Contact.find(receiver)
await contact.say(message)
async def send_group(self, message: Union[str, Message, FileBox, Contact, UrlLink, MiniProgram], receiver):
logger.info('[WX] sendMsg={}, receiver={}'.format(message, receiver))
if receiver:
room = await bot.Room.find(receiver)
await room.say(message)
async def _do_send(self, query, reply_user_id):
try:
if not query:
return
context = dict()
context['session_id'] = reply_user_id
reply_text = super().build_reply_content(query, context)
if reply_text:
await self.send(conf().get("single_chat_reply_prefix") + reply_text, reply_user_id)
except Exception as e:
logger.exception(e)
async def _do_send_voice(self, query, reply_user_id):
try:
if not query:
return
context = dict()
context['session_id'] = reply_user_id
reply_text = super().build_reply_content(query, context)
if reply_text:
# 转换 mp3 文件为 silk 格式
mp3_file = super().build_text_to_voice(reply_text)
silk_file = mp3_file.replace(".mp3", ".silk")
# Load the MP3 file
audio = AudioSegment.from_file(mp3_file, format="mp3")
# Convert to WAV format
audio = audio.set_frame_rate(24000).set_channels(1)
wav_data = audio.raw_data
sample_width = audio.sample_width
# Encode to SILK format
silk_data = pysilk.encode(wav_data, 24000)
# Save the silk file
with open(silk_file, "wb") as f:
f.write(silk_data)
# 发送语音
t = int(time.time())
file_box = FileBox.from_file(silk_file, name=str(t) + '.silk')
await self.send(file_box, reply_user_id)
# 清除缓存文件
os.remove(mp3_file)
os.remove(silk_file)
except Exception as e:
logger.exception(e)
async def _do_send_img(self, query, reply_user_id):
try:
if not query:
return
context = dict()
context['type'] = 'IMAGE_CREATE'
img_url = super().build_reply_content(query, context)
if not img_url:
return
# 图片下载
# pic_res = requests.get(img_url, stream=True)
# image_storage = io.BytesIO()
# for block in pic_res.iter_content(1024):
# image_storage.write(block)
# image_storage.seek(0)
# 图片发送
logger.info('[WX] sendImage, receiver={}'.format(reply_user_id))
t = int(time.time())
file_box = FileBox.from_url(url=img_url, name=str(t) + '.png')
await self.send(file_box, reply_user_id)
except Exception as e:
logger.exception(e)
async def _do_send_group(self, query, group_id, group_name, group_user_id, group_user_name):
if not query:
cmsg = await WechatyMessage(msg)
except NotImplementedError as e:
logger.debug("[WX] {}".format(e))
return
context = dict()
group_chat_in_one_session = conf().get('group_chat_in_one_session', [])
if ('ALL_GROUP' in group_chat_in_one_session or \
group_name in group_chat_in_one_session or \
self.check_contain(group_name, group_chat_in_one_session)):
context['session_id'] = str(group_id)
else:
context['session_id'] = str(group_id) + '-' + str(group_user_id)
reply_text = super().build_reply_content(query, context)
if reply_text:
reply_text = '@' + group_user_name + ' ' + reply_text.strip()
await self.send_group(conf().get("group_chat_reply_prefix", "") + reply_text, group_id)
async def _do_send_group_img(self, query, reply_room_id):
try:
if not query:
return
context = dict()
context['type'] = 'IMAGE_CREATE'
img_url = super().build_reply_content(query, context)
if not img_url:
return
# 图片发送
logger.info('[WX] sendImage, receiver={}'.format(reply_room_id))
t = int(time.time())
file_box = FileBox.from_url(url=img_url, name=str(t) + '.png')
await self.send_group(file_box, reply_room_id)
except Exception as e:
logger.exception(e)
def check_prefix(self, content, prefix_list):
for prefix in prefix_list:
if content.startswith(prefix):
return prefix
return None
def check_contain(self, content, keyword_list):
if not keyword_list:
return None
for ky in keyword_list:
if content.find(ky) != -1:
return True
return None
logger.exception("[WX] {}".format(e))
return
logger.debug("[WX] message:{}".format(cmsg))
room = msg.room() # 获取消息来自的群聊. 如果消息不是来自群聊, 则返回None
isgroup = room is not None
ctype = cmsg.ctype
context = self._compose_context(ctype, cmsg.content, isgroup=isgroup, msg=cmsg)
if context:
logger.info("[WX] receiveMsg={}, context={}".format(cmsg, context))
self.produce(context)

View File

@@ -0,0 +1,89 @@
import asyncio
import re
from wechaty import MessageType
from wechaty.user import Message
from bridge.context import ContextType
from channel.chat_message import ChatMessage
from common.log import logger
from common.tmp_dir import TmpDir
class aobject(object):
"""Inheriting this class allows you to define an async __init__.
So you can create objects by doing something like `await MyClass(params)`
"""
async def __new__(cls, *a, **kw):
instance = super().__new__(cls)
await instance.__init__(*a, **kw)
return instance
async def __init__(self):
pass
class WechatyMessage(ChatMessage, aobject):
async def __init__(self, wechaty_msg: Message):
super().__init__(wechaty_msg)
room = wechaty_msg.room()
self.msg_id = wechaty_msg.message_id
self.create_time = wechaty_msg.payload.timestamp
self.is_group = room is not None
if wechaty_msg.type() == MessageType.MESSAGE_TYPE_TEXT:
self.ctype = ContextType.TEXT
self.content = wechaty_msg.text()
elif wechaty_msg.type() == MessageType.MESSAGE_TYPE_AUDIO:
self.ctype = ContextType.VOICE
voice_file = await wechaty_msg.to_file_box()
self.content = TmpDir().path() + voice_file.name # content直接存临时目录路径
def func():
loop = asyncio.get_event_loop()
asyncio.run_coroutine_threadsafe(voice_file.to_file(self.content), loop).result()
self._prepare_fn = func
else:
raise NotImplementedError("Unsupported message type: {}".format(wechaty_msg.type()))
from_contact = wechaty_msg.talker() # 获取消息的发送者
self.from_user_id = from_contact.contact_id
self.from_user_nickname = from_contact.name
# group中的from和towechaty跟itchat含义不一样
# wecahty: from是消息实际发送者, to:所在群
# itchat: 如果是你发送群消息from和to是你自己和所在群如果是别人发群消息from和to是所在群和你自己
# 但这个差别不影响逻辑group中只使用到1.用from来判断是否是自己发的2.actual_user_id来判断实际发送用户
if self.is_group:
self.to_user_id = room.room_id
self.to_user_nickname = await room.topic()
else:
to_contact = wechaty_msg.to()
self.to_user_id = to_contact.contact_id
self.to_user_nickname = to_contact.name
if self.is_group or wechaty_msg.is_self(): # 如果是群消息other_user设置为群如果是私聊消息而且自己发的就设置成对方。
self.other_user_id = self.to_user_id
self.other_user_nickname = self.to_user_nickname
else:
self.other_user_id = self.from_user_id
self.other_user_nickname = self.from_user_nickname
if self.is_group: # wechaty群聊中实际发送用户就是from_user
self.is_at = await wechaty_msg.mention_self()
if not self.is_at: # 有时候复制粘贴的消息,不算做@,但是内容里面会有@xxx这里做一下兼容
name = wechaty_msg.wechaty.user_self().name
pattern = f"@{re.escape(name)}(\u2005|\u0020)"
if re.search(pattern, self.content):
logger.debug(f"wechaty message {self.msg_id} include at")
self.is_at = True
self.actual_user_id = self.from_user_id
self.actual_user_nickname = self.from_user_nickname

View File

@@ -0,0 +1,85 @@
# 企业微信应用号channel
企业微信官方提供了客服、应用等API本channel使用的是企业微信的自建应用API的能力。
因为未来可能还会开发客服能力所以本channel的类型名叫作`wechatcom_app`
`wechatcom_app` channel支持插件系统和图片声音交互等能力除了无法加入群聊作为个人使用的私人助理已绰绰有余。
## 开始之前
- 在企业中确认自己拥有在企业内自建应用的权限。
- 如果没有权限或者是个人用户,也可创建未认证的企业。操作方式:登录手机企业微信,选择`创建/加入企业`来创建企业,类型请选择企业,企业名称可随意填写。
未认证的企业有100人的服务人数上限其他功能与认证企业没有差异。
本channel需安装的依赖与公众号一致需要安装`wechatpy``web.py`,它们包含在`requirements-optional.txt`中。
此外,如果你是`Linux`系统,除了`ffmpeg`还需要安装`amr`编码器,否则会出现找不到编码器的错误,无法正常使用语音功能。
- Ubuntu/Debian
```bash
apt-get install libavcodec-extra
```
- Alpine
需自行编译`ffmpeg`,在编译参数里加入`amr`编码器的支持
## 使用方法
1.查看企业ID
- 扫码登陆[企业微信后台](https://work.weixin.qq.com)
- 选择`我的企业`,点击`企业信息`,记住该`企业ID`
2.创建自建应用
- 选择应用管理, 在自建区选创建应用来创建企业自建应用
- 上传应用logo填写应用名称等项
- 创建应用后进入应用详情页面,记住`AgentId``Secert`
3.配置应用
- 在详情页点击`企业可信IP`的配置(没看到可以不管)填入你服务器的公网IP如果不知道可以先不填
- 点击`接收消息`下的启用API接收消息
- `URL`填写格式为`http://url:port/wxcomapp``port`是程序监听的端口默认是9898
如果是未认证的企业url可直接使用服务器的IP。如果是认证企业需要使用备案的域名可使用二级域名。
- `Token`可随意填写,停留在这个页面
- 在程序根目录`config.json`中增加配置(**去掉注释**`wechatcomapp_aes_key`是当前页面的`wechatcomapp_aes_key`
```python
"channel_type": "wechatcom_app",
"wechatcom_corp_id": "", # 企业微信公司的corpID
"wechatcomapp_token": "", # 企业微信app的token
"wechatcomapp_port": 9898, # 企业微信app的服务端口, 不需要端口转发
"wechatcomapp_secret": "", # 企业微信app的secret
"wechatcomapp_agent_id": "", # 企业微信app的agent_id
"wechatcomapp_aes_key": "", # 企业微信app的aes_key
```
- 运行程序,在页面中点击保存,保存成功说明验证成功
4.连接个人微信
选择`我的企业`,点击`微信插件`,下面有个邀请关注的二维码。微信扫码后,即可在微信中看到对应企业,在这里你便可以和机器人沟通。
向机器人发送消息,如果日志里出现报错:
```bash
Error code: 60020, message: "not allow to access from your ip, ...from ip: xx.xx.xx.xx"
```
意思是IP不可信需要参考上一步的`企业可信IP`配置把这里的IP加进去。
~~### Railway部署方式~~2023-06-08已失效
~~公众号不能在`Railway`上部署,但企业微信应用[可以](https://railway.app/template/-FHS--?referralCode=RC3znh)!~~
~~填写配置后,将部署完成后的网址```**.railway.app/wxcomapp```填写在上一步的URL中。发送信息后观察日志把报错的IP加入到可信IP。每次重启后都需要加入可信IP~~
## 测试体验
AIGC开放社区中已经部署了多个可免费使用的Bot扫描下方的二维码会自动邀请你来体验。
<img width="200" src="../../docs/images/aigcopen.png">

View File

@@ -0,0 +1,178 @@
# -*- coding=utf-8 -*-
import io
import os
import time
import requests
import web
from wechatpy.enterprise import create_reply, parse_message
from wechatpy.enterprise.crypto import WeChatCrypto
from wechatpy.enterprise.exceptions import InvalidCorpIdException
from wechatpy.exceptions import InvalidSignatureException, WeChatClientException
from bridge.context import Context
from bridge.reply import Reply, ReplyType
from channel.chat_channel import ChatChannel
from channel.wechatcom.wechatcomapp_client import WechatComAppClient
from channel.wechatcom.wechatcomapp_message import WechatComAppMessage
from common.log import logger
from common.singleton import singleton
from common.utils import compress_imgfile, fsize, split_string_by_utf8_length
from config import conf, subscribe_msg
from voice.audio_convert import any_to_amr, split_audio
MAX_UTF8_LEN = 2048
@singleton
class WechatComAppChannel(ChatChannel):
NOT_SUPPORT_REPLYTYPE = []
def __init__(self):
super().__init__()
self.corp_id = conf().get("wechatcom_corp_id")
self.secret = conf().get("wechatcomapp_secret")
self.agent_id = conf().get("wechatcomapp_agent_id")
self.token = conf().get("wechatcomapp_token")
self.aes_key = conf().get("wechatcomapp_aes_key")
print(self.corp_id, self.secret, self.agent_id, self.token, self.aes_key)
logger.info(
"[wechatcom] init: corp_id: {}, secret: {}, agent_id: {}, token: {}, aes_key: {}".format(self.corp_id, self.secret, self.agent_id, self.token, self.aes_key)
)
self.crypto = WeChatCrypto(self.token, self.aes_key, self.corp_id)
self.client = WechatComAppClient(self.corp_id, self.secret)
def startup(self):
# start message listener
urls = ("/wxcomapp", "channel.wechatcom.wechatcomapp_channel.Query")
app = web.application(urls, globals(), autoreload=False)
port = conf().get("wechatcomapp_port", 9898)
web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port))
def send(self, reply: Reply, context: Context):
receiver = context["receiver"]
if reply.type in [ReplyType.TEXT, ReplyType.ERROR, ReplyType.INFO]:
reply_text = reply.content
texts = split_string_by_utf8_length(reply_text, MAX_UTF8_LEN)
if len(texts) > 1:
logger.info("[wechatcom] text too long, split into {} parts".format(len(texts)))
for i, text in enumerate(texts):
self.client.message.send_text(self.agent_id, receiver, text)
if i != len(texts) - 1:
time.sleep(0.5) # 休眠0.5秒,防止发送过快乱序
logger.info("[wechatcom] Do send text to {}: {}".format(receiver, reply_text))
elif reply.type == ReplyType.VOICE:
try:
media_ids = []
file_path = reply.content
amr_file = os.path.splitext(file_path)[0] + ".amr"
any_to_amr(file_path, amr_file)
duration, files = split_audio(amr_file, 60 * 1000)
if len(files) > 1:
logger.info("[wechatcom] voice too long {}s > 60s , split into {} parts".format(duration / 1000.0, len(files)))
for path in files:
response = self.client.media.upload("voice", open(path, "rb"))
logger.debug("[wechatcom] upload voice response: {}".format(response))
media_ids.append(response["media_id"])
except WeChatClientException as e:
logger.error("[wechatcom] upload voice failed: {}".format(e))
return
try:
os.remove(file_path)
if amr_file != file_path:
os.remove(amr_file)
except Exception:
pass
for media_id in media_ids:
self.client.message.send_voice(self.agent_id, receiver, media_id)
time.sleep(1)
logger.info("[wechatcom] sendVoice={}, receiver={}".format(reply.content, receiver))
elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片
img_url = reply.content
pic_res = requests.get(img_url, stream=True)
image_storage = io.BytesIO()
for block in pic_res.iter_content(1024):
image_storage.write(block)
sz = fsize(image_storage)
if sz >= 10 * 1024 * 1024:
logger.info("[wechatcom] image too large, ready to compress, sz={}".format(sz))
image_storage = compress_imgfile(image_storage, 10 * 1024 * 1024 - 1)
logger.info("[wechatcom] image compressed, sz={}".format(fsize(image_storage)))
image_storage.seek(0)
try:
response = self.client.media.upload("image", image_storage)
logger.debug("[wechatcom] upload image response: {}".format(response))
except WeChatClientException as e:
logger.error("[wechatcom] upload image failed: {}".format(e))
return
self.client.message.send_image(self.agent_id, receiver, response["media_id"])
logger.info("[wechatcom] sendImage url={}, receiver={}".format(img_url, receiver))
elif reply.type == ReplyType.IMAGE: # 从文件读取图片
image_storage = reply.content
sz = fsize(image_storage)
if sz >= 10 * 1024 * 1024:
logger.info("[wechatcom] image too large, ready to compress, sz={}".format(sz))
image_storage = compress_imgfile(image_storage, 10 * 1024 * 1024 - 1)
logger.info("[wechatcom] image compressed, sz={}".format(fsize(image_storage)))
image_storage.seek(0)
try:
response = self.client.media.upload("image", image_storage)
logger.debug("[wechatcom] upload image response: {}".format(response))
except WeChatClientException as e:
logger.error("[wechatcom] upload image failed: {}".format(e))
return
self.client.message.send_image(self.agent_id, receiver, response["media_id"])
logger.info("[wechatcom] sendImage, receiver={}".format(receiver))
class Query:
def GET(self):
channel = WechatComAppChannel()
params = web.input()
logger.info("[wechatcom] receive params: {}".format(params))
try:
signature = params.msg_signature
timestamp = params.timestamp
nonce = params.nonce
echostr = params.echostr
echostr = channel.crypto.check_signature(signature, timestamp, nonce, echostr)
except InvalidSignatureException:
raise web.Forbidden()
return echostr
def POST(self):
channel = WechatComAppChannel()
params = web.input()
logger.info("[wechatcom] receive params: {}".format(params))
try:
signature = params.msg_signature
timestamp = params.timestamp
nonce = params.nonce
message = channel.crypto.decrypt_message(web.data(), signature, timestamp, nonce)
except (InvalidSignatureException, InvalidCorpIdException):
raise web.Forbidden()
msg = parse_message(message)
logger.debug("[wechatcom] receive message: {}, msg= {}".format(message, msg))
if msg.type == "event":
if msg.event == "subscribe":
reply_content = subscribe_msg()
if reply_content:
reply = create_reply(reply_content, msg).render()
res = channel.crypto.encrypt_message(reply, nonce, timestamp)
return res
else:
try:
wechatcom_msg = WechatComAppMessage(msg, client=channel.client)
except NotImplementedError as e:
logger.debug("[wechatcom] " + str(e))
return "success"
context = channel._compose_context(
wechatcom_msg.ctype,
wechatcom_msg.content,
isgroup=False,
msg=wechatcom_msg,
)
if context:
channel.produce(context)
return "success"

View File

@@ -0,0 +1,21 @@
import threading
import time
from wechatpy.enterprise import WeChatClient
class WechatComAppClient(WeChatClient):
def __init__(self, corp_id, secret, access_token=None, session=None, timeout=None, auto_retry=True):
super(WechatComAppClient, self).__init__(corp_id, secret, access_token, session, timeout, auto_retry)
self.fetch_access_token_lock = threading.Lock()
def fetch_access_token(self): # 重载父类方法加锁避免多线程重复获取access_token
with self.fetch_access_token_lock:
access_token = self.session.get(self.access_token_key)
if access_token:
if not self.expires_at:
return access_token
timestamp = time.time()
if self.expires_at - timestamp > 60:
return access_token
return super().fetch_access_token()

View File

@@ -0,0 +1,52 @@
from wechatpy.enterprise import WeChatClient
from bridge.context import ContextType
from channel.chat_message import ChatMessage
from common.log import logger
from common.tmp_dir import TmpDir
class WechatComAppMessage(ChatMessage):
def __init__(self, msg, client: WeChatClient, is_group=False):
super().__init__(msg)
self.msg_id = msg.id
self.create_time = msg.time
self.is_group = is_group
if msg.type == "text":
self.ctype = ContextType.TEXT
self.content = msg.content
elif msg.type == "voice":
self.ctype = ContextType.VOICE
self.content = TmpDir().path() + msg.media_id + "." + msg.format # content直接存临时目录路径
def download_voice():
# 如果响应状态码是200则将响应内容写入本地文件
response = client.media.download(msg.media_id)
if response.status_code == 200:
with open(self.content, "wb") as f:
f.write(response.content)
else:
logger.info(f"[wechatcom] Failed to download voice file, {response.content}")
self._prepare_fn = download_voice
elif msg.type == "image":
self.ctype = ContextType.IMAGE
self.content = TmpDir().path() + msg.media_id + ".png" # content直接存临时目录路径
def download_image():
# 如果响应状态码是200则将响应内容写入本地文件
response = client.media.download(msg.media_id)
if response.status_code == 200:
with open(self.content, "wb") as f:
f.write(response.content)
else:
logger.info(f"[wechatcom] Failed to download image file, {response.content}")
self._prepare_fn = download_image
else:
raise NotImplementedError("Unsupported message type: Type:{} ".format(msg.type))
self.from_user_id = msg.source
self.to_user_id = msg.target
self.other_user_id = msg.source

100
channel/wechatmp/README.md Normal file
View File

@@ -0,0 +1,100 @@
# 微信公众号channel
鉴于个人微信号在服务器上通过itchat登录有封号风险这里新增了微信公众号channel提供无风险的服务。
目前支持订阅号和服务号两种类型的公众号,它们都支持文本交互,语音和图片输入。其中个人主体的微信订阅号由于无法通过微信认证,存在回复时间限制,每天的图片和声音回复次数也有限制。
## 使用方法(订阅号,服务号类似)
在开始部署前你需要一个拥有公网IP的服务器以提供微信服务器和我们自己服务器的连接。或者你需要进行内网穿透否则微信服务器无法将消息发送给我们的服务器。
此外需要在我们的服务器上安装python的web框架web.py和wechatpy。
以ubuntu为例(在ubuntu 22.04上测试):
```
pip3 install web.py
pip3 install wechatpy
```
然后在[微信公众平台](https://mp.weixin.qq.com)注册一个自己的公众号,类型选择订阅号,主体为个人即可。
然后根据[接入指南](https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html)的说明,在[微信公众平台](https://mp.weixin.qq.com)的“设置与开发”-“基本配置”-“服务器配置”中填写服务器地址`URL`和令牌`Token``URL`填写格式为`http://url/wx`可使用IP成功几率看脸`Token`是你自己编的一个特定的令牌。消息加解密方式如果选择了需要加密的模式,需要在配置中填写`wechatmp_aes_key`
相关的服务器验证代码已经写好,你不需要再添加任何代码。你只需要在本项目根目录的`config.json`中添加
```
"channel_type": "wechatmp", # 如果通过了微信认证,将"wechatmp"替换为"wechatmp_service",可极大的优化使用体验
"wechatmp_token": "xxxx", # 微信公众平台的Token
"wechatmp_port": 8080, # 微信公众平台的端口,需要端口转发到80或443
"wechatmp_app_id": "xxxx", # 微信公众平台的appID
"wechatmp_app_secret": "xxxx", # 微信公众平台的appsecret
"wechatmp_aes_key": "", # 微信公众平台的EncodingAESKey加密模式需要
"single_chat_prefix": [""], # 推荐设置,任意对话都可以触发回复,不添加前缀
"single_chat_reply_prefix": "", # 推荐设置,回复不设置前缀
"plugin_trigger_prefix": "&", # 推荐设置,在手机微信客户端中,$%^等符号与中文连在一起时会自动显示一段较大的间隔,用户体验不好。请不要使用管理员指令前缀"#",这会造成未知问题。
```
然后运行`python3 app.py`启动web服务器。这里会默认监听8080端口但是微信公众号的服务器配置只支持80/443端口有两种方法来解决这个问题。第一个是推荐的方法使用端口转发命令将80端口转发到8080端口
```
sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8080
sudo iptables-save > /etc/iptables/rules.v4
```
第二个方法是让python程序直接监听80端口在配置文件中设置`"wechatmp_port": 80` 在linux上需要使用`sudo python3 app.py`启动程序。然而这会导致一系列环境和权限问题,因此不是推荐的方法。
443端口同理注意需要支持SSL也就是https的访问`wechatmp_channel.py`中需要修改相应的证书路径。
程序启动并监听端口后,在刚才的“服务器配置”中点击`提交`即可验证你的服务器。
随后在[微信公众平台](https://mp.weixin.qq.com)启用服务器关闭手动填写规则的自动回复即可实现ChatGPT的自动回复。
之后需要在公众号开发信息下将本机IP加入到IP白名单。
不然在启用后,发送语音、图片等消息可能会遇到如下报错:
```
'errcode': 40164, 'errmsg': 'invalid ip xx.xx.xx.xx not in whitelist rid
```
## 个人微信公众号的限制
由于人微信公众号不能通过微信认证所以没有客服接口因此公众号无法主动发出消息只能被动回复。而微信官方对被动回复有5秒的时间限制最多重试2次因此最多只有15秒的自动回复时间窗口。因此如果问题比较复杂或者我们的服务器比较忙ChatGPT的回答就没办法及时回复给用户。为了解决这个问题这里做了回答缓存它需要你在回复超时后再次主动发送任意文字例如1来尝试拿到回答缓存。为了优化使用体验目前设置了两分钟120秒的timeout用户在至多两分钟后即可得到查询到回复或者错误原因。
另外由于微信官方的限制自动回复有长度限制。因此这里将ChatGPT的回答进行了拆分以满足限制。
## 私有api_key
公共api有访问频率限制免费账号每分钟最多3次ChatGPT的API调用这在服务多人的时候会遇到问题。因此这里多加了一个设置私有api_key的功能。目前通过godcmd插件的命令来设置私有api_key。
## 语音输入
利用微信自带的语音识别功能,提供语音输入能力。需要在公众号管理页面的“设置与开发”->“接口权限”页面开启“接收语音识别结果”。
## 语音回复
请在配置文件中添加以下词条:
```
"voice_reply_voice": true,
```
这样公众号将会用语音回复语音消息,实现语音对话。
默认的语音合成引擎是`google`,它是免费使用的。
如果要选择其他的语音合成引擎,请添加以下配置项:
```
"text_to_voice": "pytts"
```
pytts是本地的语音合成引擎。还支持baidu,azure这些你需要自行配置相关的依赖和key。
如果使用pytts在ubuntu上需要安装如下依赖
```
sudo apt update
sudo apt install espeak
sudo apt install ffmpeg
python3 -m pip install pyttsx3
```
不是很建议开启pytts语音回复因为它是离线本地计算算的慢会拖垮服务器且声音不好听。
## 图片回复
现在认证公众号和非认证公众号都可以实现的图片和语音回复。但是非认证公众号使用了永久素材接口每天有1000次的调用上限每个月有10次重置机会程序中已设定遇到上限会自动重置且永久素材库存也有上限。因此对于非认证公众号我们会在回复图片或者语音消息后的10秒内从永久素材库存内删除该素材。
## 测试
目前在`RoboStyle`这个公众号上进行了测试(基于[wechatmp分支](https://github.com/JS00000/chatgpt-on-wechat/tree/wechatmp)感兴趣的可以关注并体验。开启了godcmd, Banwords, role, dungeon, finish这五个插件其他的插件还没有详尽测试。百度的接口暂未测试。[wechatmp-stable分支](https://github.com/JS00000/chatgpt-on-wechat/tree/wechatmp-stable)是较稳定的上个版本,但也缺少最新的功能支持。
## TODO
- [x] 语音输入
- [x] 图片输入
- [x] 使用临时素材接口提供认证公众号的图片和语音回复
- [x] 使用永久素材接口提供未认证公众号的图片和语音回复
- [ ] 高并发支持

View File

@@ -0,0 +1,75 @@
import time
import web
from wechatpy import parse_message
from wechatpy.replies import create_reply
from bridge.context import *
from bridge.reply import *
from channel.wechatmp.common import *
from channel.wechatmp.wechatmp_channel import WechatMPChannel
from channel.wechatmp.wechatmp_message import WeChatMPMessage
from common.log import logger
from config import conf, subscribe_msg
# This class is instantiated once per query
class Query:
def GET(self):
return verify_server(web.input())
def POST(self):
# Make sure to return the instance that first created, @singleton will do that.
try:
args = web.input()
verify_server(args)
channel = WechatMPChannel()
message = web.data()
encrypt_func = lambda x: x
if args.get("encrypt_type") == "aes":
logger.debug("[wechatmp] Receive encrypted post data:\n" + message.decode("utf-8"))
if not channel.crypto:
raise Exception("Crypto not initialized, Please set wechatmp_aes_key in config.json")
message = channel.crypto.decrypt_message(message, args.msg_signature, args.timestamp, args.nonce)
encrypt_func = lambda x: channel.crypto.encrypt_message(x, args.nonce, args.timestamp)
else:
logger.debug("[wechatmp] Receive post data:\n" + message.decode("utf-8"))
msg = parse_message(message)
if msg.type in ["text", "voice", "image"]:
wechatmp_msg = WeChatMPMessage(msg, client=channel.client)
from_user = wechatmp_msg.from_user_id
content = wechatmp_msg.content
message_id = wechatmp_msg.msg_id
logger.info(
"[wechatmp] {}:{} Receive post query {} {}: {}".format(
web.ctx.env.get("REMOTE_ADDR"),
web.ctx.env.get("REMOTE_PORT"),
from_user,
message_id,
content,
)
)
if msg.type == "voice" and wechatmp_msg.ctype == ContextType.TEXT and conf().get("voice_reply_voice", False):
context = channel._compose_context(wechatmp_msg.ctype, content, isgroup=False, desire_rtype=ReplyType.VOICE, msg=wechatmp_msg)
else:
context = channel._compose_context(wechatmp_msg.ctype, content, isgroup=False, msg=wechatmp_msg)
if context:
channel.produce(context)
# The reply will be sent by channel.send() in another thread
return "success"
elif msg.type == "event":
logger.info("[wechatmp] Event {} from {}".format(msg.event, msg.source))
if msg.event in ["subscribe", "subscribe_scan"]:
reply_text = subscribe_msg()
if reply_text:
replyPost = create_reply(reply_text, msg)
return encrypt_func(replyPost.render())
else:
return "success"
else:
logger.info("暂且不处理")
return "success"
except Exception as exc:
logger.exception(exc)
return exc

View File

@@ -0,0 +1,27 @@
import web
from wechatpy.crypto import WeChatCrypto
from wechatpy.exceptions import InvalidSignatureException
from wechatpy.utils import check_signature
from config import conf
MAX_UTF8_LEN = 2048
class WeChatAPIException(Exception):
pass
def verify_server(data):
try:
signature = data.signature
timestamp = data.timestamp
nonce = data.nonce
echostr = data.get("echostr", None)
token = conf().get("wechatmp_token") # 请按照公众平台官网\基本配置中信息填写
check_signature(token, signature, timestamp, nonce)
return echostr
except InvalidSignatureException:
raise web.Forbidden("Invalid signature")
except Exception as e:
raise web.Forbidden(str(e))

View File

@@ -0,0 +1,211 @@
import asyncio
import time
import web
from wechatpy import parse_message
from wechatpy.replies import ImageReply, VoiceReply, create_reply
import textwrap
from bridge.context import *
from bridge.reply import *
from channel.wechatmp.common import *
from channel.wechatmp.wechatmp_channel import WechatMPChannel
from channel.wechatmp.wechatmp_message import WeChatMPMessage
from common.log import logger
from common.utils import split_string_by_utf8_length
from config import conf, subscribe_msg
# This class is instantiated once per query
class Query:
def GET(self):
return verify_server(web.input())
def POST(self):
try:
args = web.input()
verify_server(args)
request_time = time.time()
channel = WechatMPChannel()
message = web.data()
encrypt_func = lambda x: x
if args.get("encrypt_type") == "aes":
logger.debug("[wechatmp] Receive encrypted post data:\n" + message.decode("utf-8"))
if not channel.crypto:
raise Exception("Crypto not initialized, Please set wechatmp_aes_key in config.json")
message = channel.crypto.decrypt_message(message, args.msg_signature, args.timestamp, args.nonce)
encrypt_func = lambda x: channel.crypto.encrypt_message(x, args.nonce, args.timestamp)
else:
logger.debug("[wechatmp] Receive post data:\n" + message.decode("utf-8"))
msg = parse_message(message)
if msg.type in ["text", "voice", "image"]:
wechatmp_msg = WeChatMPMessage(msg, client=channel.client)
from_user = wechatmp_msg.from_user_id
content = wechatmp_msg.content
message_id = wechatmp_msg.msg_id
supported = True
if "【收到不支持的消息类型,暂无法显示】" in content:
supported = False # not supported, used to refresh
# New request
if (
channel.cache_dict.get(from_user) is None
and from_user not in channel.running
or content.startswith("#")
and message_id not in channel.request_cnt # insert the godcmd
):
# The first query begin
if msg.type == "voice" and wechatmp_msg.ctype == ContextType.TEXT and conf().get("voice_reply_voice", False):
context = channel._compose_context(wechatmp_msg.ctype, content, isgroup=False, desire_rtype=ReplyType.VOICE, msg=wechatmp_msg)
else:
context = channel._compose_context(wechatmp_msg.ctype, content, isgroup=False, msg=wechatmp_msg)
logger.debug("[wechatmp] context: {} {} {}".format(context, wechatmp_msg, supported))
if supported and context:
channel.running.add(from_user)
channel.produce(context)
else:
trigger_prefix = conf().get("single_chat_prefix", [""])[0]
if trigger_prefix or not supported:
if trigger_prefix:
reply_text = textwrap.dedent(
f"""\
请输入'{trigger_prefix}'接你想说的话跟我说话。
例如:
{trigger_prefix}你好,很高兴见到你。"""
)
else:
reply_text = textwrap.dedent(
"""\
你好,很高兴见到你。
请跟我说话吧。"""
)
else:
logger.error(f"[wechatmp] unknown error")
reply_text = textwrap.dedent(
"""\
未知错误,请稍后再试"""
)
replyPost = create_reply(reply_text, msg)
return encrypt_func(replyPost.render())
# Wechat official server will request 3 times (5 seconds each), with the same message_id.
# Because the interval is 5 seconds, here assumed that do not have multithreading problems.
request_cnt = channel.request_cnt.get(message_id, 0) + 1
channel.request_cnt[message_id] = request_cnt
logger.info(
"[wechatmp] Request {} from {} {} {}:{}\n{}".format(
request_cnt, from_user, message_id, web.ctx.env.get("REMOTE_ADDR"), web.ctx.env.get("REMOTE_PORT"), content
)
)
task_running = True
waiting_until = request_time + 4
while time.time() < waiting_until:
if from_user in channel.running:
time.sleep(0.1)
else:
task_running = False
break
reply_text = ""
if task_running:
if request_cnt < 3:
# waiting for timeout (the POST request will be closed by Wechat official server)
time.sleep(2)
# and do nothing, waiting for the next request
return "success"
else: # request_cnt == 3:
# return timeout message
reply_text = "【正在思考中,回复任意文字尝试获取回复】"
replyPost = create_reply(reply_text, msg)
return encrypt_func(replyPost.render())
# reply is ready
channel.request_cnt.pop(message_id)
# no return because of bandwords or other reasons
if from_user not in channel.cache_dict and from_user not in channel.running:
return "success"
# Only one request can access to the cached data
try:
(reply_type, reply_content) = channel.cache_dict[from_user].pop(0)
if not channel.cache_dict[from_user]: # If popping the message makes the list empty, delete the user entry from cache
del channel.cache_dict[from_user]
except IndexError:
return "success"
if reply_type == "text":
if len(reply_content.encode("utf8")) <= MAX_UTF8_LEN:
reply_text = reply_content
else:
continue_text = "\n【未完待续,回复任意文字以继续】"
splits = split_string_by_utf8_length(
reply_content,
MAX_UTF8_LEN - len(continue_text.encode("utf-8")),
max_split=1,
)
reply_text = splits[0] + continue_text
channel.cache_dict[from_user].append(("text", splits[1]))
logger.info(
"[wechatmp] Request {} do send to {} {}: {}\n{}".format(
request_cnt,
from_user,
message_id,
content,
reply_text,
)
)
replyPost = create_reply(reply_text, msg)
return encrypt_func(replyPost.render())
elif reply_type == "voice":
media_id = reply_content
asyncio.run_coroutine_threadsafe(channel.delete_media(media_id), channel.delete_media_loop)
logger.info(
"[wechatmp] Request {} do send to {} {}: {} voice media_id {}".format(
request_cnt,
from_user,
message_id,
content,
media_id,
)
)
replyPost = VoiceReply(message=msg)
replyPost.media_id = media_id
return encrypt_func(replyPost.render())
elif reply_type == "image":
media_id = reply_content
asyncio.run_coroutine_threadsafe(channel.delete_media(media_id), channel.delete_media_loop)
logger.info(
"[wechatmp] Request {} do send to {} {}: {} image media_id {}".format(
request_cnt,
from_user,
message_id,
content,
media_id,
)
)
replyPost = ImageReply(message=msg)
replyPost.media_id = media_id
return encrypt_func(replyPost.render())
elif msg.type == "event":
logger.info("[wechatmp] Event {} from {}".format(msg.event, msg.source))
if msg.event in ["subscribe", "subscribe_scan"]:
reply_text = subscribe_msg()
if reply_text:
replyPost = create_reply(reply_text, msg)
return encrypt_func(replyPost.render())
else:
return "success"
else:
logger.info("暂且不处理")
return "success"
except Exception as exc:
logger.exception(exc)
return exc

View File

@@ -0,0 +1,236 @@
# -*- coding: utf-8 -*-
import asyncio
import imghdr
import io
import os
import threading
import time
import requests
import web
from wechatpy.crypto import WeChatCrypto
from wechatpy.exceptions import WeChatClientException
from collections import defaultdict
from bridge.context import *
from bridge.reply import *
from channel.chat_channel import ChatChannel
from channel.wechatmp.common import *
from channel.wechatmp.wechatmp_client import WechatMPClient
from common.log import logger
from common.singleton import singleton
from common.utils import split_string_by_utf8_length
from config import conf
from voice.audio_convert import any_to_mp3, split_audio
# If using SSL, uncomment the following lines, and modify the certificate path.
# from cheroot.server import HTTPServer
# from cheroot.ssl.builtin import BuiltinSSLAdapter
# HTTPServer.ssl_adapter = BuiltinSSLAdapter(
# certificate='/ssl/cert.pem',
# private_key='/ssl/cert.key')
@singleton
class WechatMPChannel(ChatChannel):
def __init__(self, passive_reply=True):
super().__init__()
self.passive_reply = passive_reply
self.NOT_SUPPORT_REPLYTYPE = []
appid = conf().get("wechatmp_app_id")
secret = conf().get("wechatmp_app_secret")
token = conf().get("wechatmp_token")
aes_key = conf().get("wechatmp_aes_key")
self.client = WechatMPClient(appid, secret)
self.crypto = None
if aes_key:
self.crypto = WeChatCrypto(token, aes_key, appid)
if self.passive_reply:
# Cache the reply to the user's first message
self.cache_dict = defaultdict(list)
# Record whether the current message is being processed
self.running = set()
# Count the request from wechat official server by message_id
self.request_cnt = dict()
# The permanent media need to be deleted to avoid media number limit
self.delete_media_loop = asyncio.new_event_loop()
t = threading.Thread(target=self.start_loop, args=(self.delete_media_loop,))
t.setDaemon(True)
t.start()
def startup(self):
if self.passive_reply:
urls = ("/wx", "channel.wechatmp.passive_reply.Query")
else:
urls = ("/wx", "channel.wechatmp.active_reply.Query")
app = web.application(urls, globals(), autoreload=False)
port = conf().get("wechatmp_port", 8080)
web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port))
def start_loop(self, loop):
asyncio.set_event_loop(loop)
loop.run_forever()
async def delete_media(self, media_id):
logger.debug("[wechatmp] permanent media {} will be deleted in 10s".format(media_id))
await asyncio.sleep(10)
self.client.material.delete(media_id)
logger.info("[wechatmp] permanent media {} has been deleted".format(media_id))
def send(self, reply: Reply, context: Context):
receiver = context["receiver"]
if self.passive_reply:
if reply.type == ReplyType.TEXT or reply.type == ReplyType.INFO or reply.type == ReplyType.ERROR:
reply_text = reply.content
logger.info("[wechatmp] text cached, receiver {}\n{}".format(receiver, reply_text))
self.cache_dict[receiver].append(("text", reply_text))
elif reply.type == ReplyType.VOICE:
voice_file_path = reply.content
duration, files = split_audio(voice_file_path, 60 * 1000)
if len(files) > 1:
logger.info("[wechatmp] voice too long {}s > 60s , split into {} parts".format(duration / 1000.0, len(files)))
for path in files:
# support: <2M, <60s, mp3/wma/wav/amr
try:
with open(path, "rb") as f:
response = self.client.material.add("voice", f)
logger.debug("[wechatmp] upload voice response: {}".format(response))
f_size = os.fstat(f.fileno()).st_size
time.sleep(1.0 + 2 * f_size / 1024 / 1024)
# todo check media_id
except WeChatClientException as e:
logger.error("[wechatmp] upload voice failed: {}".format(e))
return
media_id = response["media_id"]
logger.info("[wechatmp] voice uploaded, receiver {}, media_id {}".format(receiver, media_id))
self.cache_dict[receiver].append(("voice", media_id))
elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片
img_url = reply.content
pic_res = requests.get(img_url, stream=True)
image_storage = io.BytesIO()
for block in pic_res.iter_content(1024):
image_storage.write(block)
image_storage.seek(0)
image_type = imghdr.what(image_storage)
filename = receiver + "-" + str(context["msg"].msg_id) + "." + image_type
content_type = "image/" + image_type
try:
response = self.client.material.add("image", (filename, image_storage, content_type))
logger.debug("[wechatmp] upload image response: {}".format(response))
except WeChatClientException as e:
logger.error("[wechatmp] upload image failed: {}".format(e))
return
media_id = response["media_id"]
logger.info("[wechatmp] image uploaded, receiver {}, media_id {}".format(receiver, media_id))
self.cache_dict[receiver].append(("image", media_id))
elif reply.type == ReplyType.IMAGE: # 从文件读取图片
image_storage = reply.content
image_storage.seek(0)
image_type = imghdr.what(image_storage)
filename = receiver + "-" + str(context["msg"].msg_id) + "." + image_type
content_type = "image/" + image_type
try:
response = self.client.material.add("image", (filename, image_storage, content_type))
logger.debug("[wechatmp] upload image response: {}".format(response))
except WeChatClientException as e:
logger.error("[wechatmp] upload image failed: {}".format(e))
return
media_id = response["media_id"]
logger.info("[wechatmp] image uploaded, receiver {}, media_id {}".format(receiver, media_id))
self.cache_dict[receiver].append(("image", media_id))
else:
if reply.type == ReplyType.TEXT or reply.type == ReplyType.INFO or reply.type == ReplyType.ERROR:
reply_text = reply.content
texts = split_string_by_utf8_length(reply_text, MAX_UTF8_LEN)
if len(texts) > 1:
logger.info("[wechatmp] text too long, split into {} parts".format(len(texts)))
for i, text in enumerate(texts):
self.client.message.send_text(receiver, text)
if i != len(texts) - 1:
time.sleep(0.5) # 休眠0.5秒,防止发送过快乱序
logger.info("[wechatmp] Do send text to {}: {}".format(receiver, reply_text))
elif reply.type == ReplyType.VOICE:
try:
file_path = reply.content
file_name = os.path.basename(file_path)
file_type = os.path.splitext(file_name)[1]
if file_type == ".mp3":
file_type = "audio/mpeg"
elif file_type == ".amr":
file_type = "audio/amr"
else:
mp3_file = os.path.splitext(file_path)[0] + ".mp3"
any_to_mp3(file_path, mp3_file)
file_path = mp3_file
file_name = os.path.basename(file_path)
file_type = "audio/mpeg"
logger.info("[wechatmp] file_name: {}, file_type: {} ".format(file_name, file_type))
media_ids = []
duration, files = split_audio(file_path, 60 * 1000)
if len(files) > 1:
logger.info("[wechatmp] voice too long {}s > 60s , split into {} parts".format(duration / 1000.0, len(files)))
for path in files:
# support: <2M, <60s, AMR\MP3
response = self.client.media.upload("voice", (os.path.basename(path), open(path, "rb"), file_type))
logger.debug("[wechatcom] upload voice response: {}".format(response))
media_ids.append(response["media_id"])
os.remove(path)
except WeChatClientException as e:
logger.error("[wechatmp] upload voice failed: {}".format(e))
return
try:
os.remove(file_path)
except Exception:
pass
for media_id in media_ids:
self.client.message.send_voice(receiver, media_id)
time.sleep(1)
logger.info("[wechatmp] Do send voice to {}".format(receiver))
elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片
img_url = reply.content
pic_res = requests.get(img_url, stream=True)
image_storage = io.BytesIO()
for block in pic_res.iter_content(1024):
image_storage.write(block)
image_storage.seek(0)
image_type = imghdr.what(image_storage)
filename = receiver + "-" + str(context["msg"].msg_id) + "." + image_type
content_type = "image/" + image_type
try:
response = self.client.media.upload("image", (filename, image_storage, content_type))
logger.debug("[wechatmp] upload image response: {}".format(response))
except WeChatClientException as e:
logger.error("[wechatmp] upload image failed: {}".format(e))
return
self.client.message.send_image(receiver, response["media_id"])
logger.info("[wechatmp] Do send image to {}".format(receiver))
elif reply.type == ReplyType.IMAGE: # 从文件读取图片
image_storage = reply.content
image_storage.seek(0)
image_type = imghdr.what(image_storage)
filename = receiver + "-" + str(context["msg"].msg_id) + "." + image_type
content_type = "image/" + image_type
try:
response = self.client.media.upload("image", (filename, image_storage, content_type))
logger.debug("[wechatmp] upload image response: {}".format(response))
except WeChatClientException as e:
logger.error("[wechatmp] upload image failed: {}".format(e))
return
self.client.message.send_image(receiver, response["media_id"])
logger.info("[wechatmp] Do send image to {}".format(receiver))
return
def _success_callback(self, session_id, context, **kwargs): # 线程异常结束时的回调函数
logger.debug("[wechatmp] Success to generate reply, msgId={}".format(context["msg"].msg_id))
if self.passive_reply:
self.running.remove(session_id)
def _fail_callback(self, session_id, exception, context, **kwargs): # 线程异常结束时的回调函数
logger.exception("[wechatmp] Fail to generate reply to user, msgId={}, exception={}".format(context["msg"].msg_id, exception))
if self.passive_reply:
assert session_id not in self.cache_dict
self.running.remove(session_id)

View File

@@ -0,0 +1,49 @@
import threading
import time
from wechatpy.client import WeChatClient
from wechatpy.exceptions import APILimitedException
from channel.wechatmp.common import *
from common.log import logger
class WechatMPClient(WeChatClient):
def __init__(self, appid, secret, access_token=None, session=None, timeout=None, auto_retry=True):
super(WechatMPClient, self).__init__(appid, secret, access_token, session, timeout, auto_retry)
self.fetch_access_token_lock = threading.Lock()
self.clear_quota_lock = threading.Lock()
self.last_clear_quota_time = -1
def clear_quota(self):
return self.post("clear_quota", data={"appid": self.appid})
def clear_quota_v2(self):
return self.post("clear_quota/v2", params={"appid": self.appid, "appsecret": self.secret})
def fetch_access_token(self): # 重载父类方法加锁避免多线程重复获取access_token
with self.fetch_access_token_lock:
access_token = self.session.get(self.access_token_key)
if access_token:
if not self.expires_at:
return access_token
timestamp = time.time()
if self.expires_at - timestamp > 60:
return access_token
return super().fetch_access_token()
def _request(self, method, url_or_endpoint, **kwargs): # 重载父类方法遇到API限流时清除quota后重试
try:
return super()._request(method, url_or_endpoint, **kwargs)
except APILimitedException as e:
logger.error("[wechatmp] API quata has been used up. {}".format(e))
if self.last_clear_quota_time == -1 or time.time() - self.last_clear_quota_time > 60:
with self.clear_quota_lock:
if self.last_clear_quota_time == -1 or time.time() - self.last_clear_quota_time > 60:
self.last_clear_quota_time = time.time()
response = self.clear_quota_v2()
logger.debug("[wechatmp] API quata has been cleard, {}".format(response))
return super()._request(method, url_or_endpoint, **kwargs)
else:
logger.error("[wechatmp] last clear quota time is {}, less than 60s, skip clear quota")
raise e

View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-#
from bridge.context import ContextType
from channel.chat_message import ChatMessage
from common.log import logger
from common.tmp_dir import TmpDir
class WeChatMPMessage(ChatMessage):
def __init__(self, msg, client=None):
super().__init__(msg)
self.msg_id = msg.id
self.create_time = msg.time
self.is_group = False
if msg.type == "text":
self.ctype = ContextType.TEXT
self.content = msg.content
elif msg.type == "voice":
if msg.recognition == None:
self.ctype = ContextType.VOICE
self.content = TmpDir().path() + msg.media_id + "." + msg.format # content直接存临时目录路径
def download_voice():
# 如果响应状态码是200则将响应内容写入本地文件
response = client.media.download(msg.media_id)
if response.status_code == 200:
with open(self.content, "wb") as f:
f.write(response.content)
else:
logger.info(f"[wechatmp] Failed to download voice file, {response.content}")
self._prepare_fn = download_voice
else:
self.ctype = ContextType.TEXT
self.content = msg.recognition
elif msg.type == "image":
self.ctype = ContextType.IMAGE
self.content = TmpDir().path() + msg.media_id + ".png" # content直接存临时目录路径
def download_image():
# 如果响应状态码是200则将响应内容写入本地文件
response = client.media.download(msg.media_id)
if response.status_code == 200:
with open(self.content, "wb") as f:
f.write(response.content)
else:
logger.info(f"[wechatmp] Failed to download image file, {response.content}")
self._prepare_fn = download_image
else:
raise NotImplementedError("Unsupported message type: Type:{} ".format(msg.type))
self.from_user_id = msg.source
self.to_user_id = msg.target
self.other_user_id = msg.source

17
channel/wework/run.py Normal file
View File

@@ -0,0 +1,17 @@
import os
import time
os.environ['ntwork_LOG'] = "ERROR"
import ntwork
wework = ntwork.WeWork()
def forever():
try:
while True:
time.sleep(0.1)
except KeyboardInterrupt:
ntwork.exit_()
os._exit(0)

View File

@@ -0,0 +1,326 @@
import io
import os
import random
import tempfile
import threading
os.environ['ntwork_LOG'] = "ERROR"
import ntwork
import requests
import uuid
from bridge.context import *
from bridge.reply import *
from channel.chat_channel import ChatChannel
from channel.wework.wework_message import *
from channel.wework.wework_message import WeworkMessage
from common.singleton import singleton
from common.log import logger
from common.time_check import time_checker
from common.utils import compress_imgfile, fsize
from config import conf
from channel.wework.run import wework
from channel.wework import run
from PIL import Image
def get_wxid_by_name(room_members, group_wxid, name):
if group_wxid in room_members:
for member in room_members[group_wxid]['member_list']:
if member['room_nickname'] == name or member['username'] == name:
return member['user_id']
return None # 如果没有找到对应的group_wxid或name则返回None
def download_and_compress_image(url, filename, quality=30):
# 确定保存图片的目录
directory = os.path.join(os.getcwd(), "tmp")
# 如果目录不存在,则创建目录
if not os.path.exists(directory):
os.makedirs(directory)
# 下载图片
pic_res = requests.get(url, stream=True)
image_storage = io.BytesIO()
for block in pic_res.iter_content(1024):
image_storage.write(block)
# 检查图片大小并可能进行压缩
sz = fsize(image_storage)
if sz >= 10 * 1024 * 1024: # 如果图片大于 10 MB
logger.info("[wework] image too large, ready to compress, sz={}".format(sz))
image_storage = compress_imgfile(image_storage, 10 * 1024 * 1024 - 1)
logger.info("[wework] image compressed, sz={}".format(fsize(image_storage)))
# 将内存缓冲区的指针重置到起始位置
image_storage.seek(0)
# 读取并保存图片
image = Image.open(image_storage)
image_path = os.path.join(directory, f"{filename}.png")
image.save(image_path, "png")
return image_path
def download_video(url, filename):
# 确定保存视频的目录
directory = os.path.join(os.getcwd(), "tmp")
# 如果目录不存在,则创建目录
if not os.path.exists(directory):
os.makedirs(directory)
# 下载视频
response = requests.get(url, stream=True)
total_size = 0
video_path = os.path.join(directory, f"{filename}.mp4")
with open(video_path, 'wb') as f:
for block in response.iter_content(1024):
total_size += len(block)
# 如果视频的总大小超过30MB (30 * 1024 * 1024 bytes),则停止下载并返回
if total_size > 30 * 1024 * 1024:
logger.info("[WX] Video is larger than 30MB, skipping...")
return None
f.write(block)
return video_path
def create_message(wework_instance, message, is_group):
logger.debug(f"正在为{'群聊' if is_group else '单聊'}创建 WeworkMessage")
cmsg = WeworkMessage(message, wework=wework_instance, is_group=is_group)
logger.debug(f"cmsg:{cmsg}")
return cmsg
def handle_message(cmsg, is_group):
logger.debug(f"准备用 WeworkChannel 处理{'群聊' if is_group else '单聊'}消息")
if is_group:
WeworkChannel().handle_group(cmsg)
else:
WeworkChannel().handle_single(cmsg)
logger.debug(f"已用 WeworkChannel 处理完{'群聊' if is_group else '单聊'}消息")
def _check(func):
def wrapper(self, cmsg: ChatMessage):
msgId = cmsg.msg_id
create_time = cmsg.create_time # 消息时间戳
if create_time is None:
return func(self, cmsg)
if int(create_time) < int(time.time()) - 60: # 跳过1分钟前的历史消息
logger.debug("[WX]history message {} skipped".format(msgId))
return
return func(self, cmsg)
return wrapper
@wework.msg_register(
[ntwork.MT_RECV_TEXT_MSG, ntwork.MT_RECV_IMAGE_MSG, 11072, ntwork.MT_RECV_LINK_CARD_MSG,ntwork.MT_RECV_FILE_MSG, ntwork.MT_RECV_VOICE_MSG])
def all_msg_handler(wework_instance: ntwork.WeWork, message):
logger.debug(f"收到消息: {message}")
if 'data' in message:
# 首先查找conversation_id如果没有找到则查找room_conversation_id
conversation_id = message['data'].get('conversation_id', message['data'].get('room_conversation_id'))
if conversation_id is not None:
is_group = "R:" in conversation_id
try:
cmsg = create_message(wework_instance=wework_instance, message=message, is_group=is_group)
except NotImplementedError as e:
logger.error(f"[WX]{message.get('MsgId', 'unknown')} 跳过: {e}")
return None
delay = random.randint(1, 2)
timer = threading.Timer(delay, handle_message, args=(cmsg, is_group))
timer.start()
else:
logger.debug("消息数据中无 conversation_id")
return None
return None
def accept_friend_with_retries(wework_instance, user_id, corp_id):
result = wework_instance.accept_friend(user_id, corp_id)
logger.debug(f'result:{result}')
# @wework.msg_register(ntwork.MT_RECV_FRIEND_MSG)
# def friend(wework_instance: ntwork.WeWork, message):
# data = message["data"]
# user_id = data["user_id"]
# corp_id = data["corp_id"]
# logger.info(f"接收到好友请求,消息内容:{data}")
# delay = random.randint(1, 180)
# threading.Timer(delay, accept_friend_with_retries, args=(wework_instance, user_id, corp_id)).start()
#
# return None
def get_with_retry(get_func, max_retries=5, delay=5):
retries = 0
result = None
while retries < max_retries:
result = get_func()
if result:
break
logger.warning(f"获取数据失败,重试第{retries + 1}次······")
retries += 1
time.sleep(delay) # 等待一段时间后重试
return result
@singleton
class WeworkChannel(ChatChannel):
NOT_SUPPORT_REPLYTYPE = []
def __init__(self):
super().__init__()
def startup(self):
smart = conf().get("wework_smart", True)
wework.open(smart)
logger.info("等待登录······")
wework.wait_login()
login_info = wework.get_login_info()
self.user_id = login_info['user_id']
self.name = login_info['nickname']
logger.info(f"登录信息:>>>user_id:{self.user_id}>>>>>>>>name:{self.name}")
logger.info("静默延迟60s等待客户端刷新数据请勿进行任何操作······")
time.sleep(60)
contacts = get_with_retry(wework.get_external_contacts)
rooms = get_with_retry(wework.get_rooms)
directory = os.path.join(os.getcwd(), "tmp")
if not contacts or not rooms:
logger.error("获取contacts或rooms失败程序退出")
ntwork.exit_()
os.exit(0)
if not os.path.exists(directory):
os.makedirs(directory)
# 将contacts保存到json文件中
with open(os.path.join(directory, 'wework_contacts.json'), 'w', encoding='utf-8') as f:
json.dump(contacts, f, ensure_ascii=False, indent=4)
with open(os.path.join(directory, 'wework_rooms.json'), 'w', encoding='utf-8') as f:
json.dump(rooms, f, ensure_ascii=False, indent=4)
# 创建一个空字典来保存结果
result = {}
# 遍历列表中的每个字典
for room in rooms['room_list']:
# 获取聊天室ID
room_wxid = room['conversation_id']
# 获取聊天室成员
room_members = wework.get_room_members(room_wxid)
# 将聊天室成员保存到结果字典中
result[room_wxid] = room_members
# 将结果保存到json文件中
with open(os.path.join(directory, 'wework_room_members.json'), 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=4)
logger.info("wework程序初始化完成········")
run.forever()
@time_checker
@_check
def handle_single(self, cmsg: ChatMessage):
if cmsg.from_user_id == cmsg.to_user_id:
# ignore self reply
return
if cmsg.ctype == ContextType.VOICE:
if not conf().get("speech_recognition"):
return
logger.debug("[WX]receive voice msg: {}".format(cmsg.content))
elif cmsg.ctype == ContextType.IMAGE:
logger.debug("[WX]receive image msg: {}".format(cmsg.content))
elif cmsg.ctype == ContextType.PATPAT:
logger.debug("[WX]receive patpat msg: {}".format(cmsg.content))
elif cmsg.ctype == ContextType.TEXT:
logger.debug("[WX]receive text msg: {}, cmsg={}".format(json.dumps(cmsg._rawmsg, ensure_ascii=False), cmsg))
else:
logger.debug("[WX]receive msg: {}, cmsg={}".format(cmsg.content, cmsg))
context = self._compose_context(cmsg.ctype, cmsg.content, isgroup=False, msg=cmsg)
if context:
self.produce(context)
@time_checker
@_check
def handle_group(self, cmsg: ChatMessage):
if cmsg.ctype == ContextType.VOICE:
if not conf().get("speech_recognition"):
return
logger.debug("[WX]receive voice for group msg: {}".format(cmsg.content))
elif cmsg.ctype == ContextType.IMAGE:
logger.debug("[WX]receive image for group msg: {}".format(cmsg.content))
elif cmsg.ctype in [ContextType.JOIN_GROUP, ContextType.PATPAT]:
logger.debug("[WX]receive note msg: {}".format(cmsg.content))
elif cmsg.ctype == ContextType.TEXT:
pass
else:
logger.debug("[WX]receive group msg: {}".format(cmsg.content))
context = self._compose_context(cmsg.ctype, cmsg.content, isgroup=True, msg=cmsg)
if context:
self.produce(context)
# 统一的发送函数每个Channel自行实现根据reply的type字段发送不同类型的消息
def send(self, reply: Reply, context: Context):
logger.debug(f"context: {context}")
receiver = context["receiver"]
actual_user_id = context["msg"].actual_user_id
if reply.type == ReplyType.TEXT or reply.type == ReplyType.TEXT_:
match = re.search(r"^@(.*?)\n", reply.content)
logger.debug(f"match: {match}")
if match:
new_content = re.sub(r"^@(.*?)\n", "\n", reply.content)
at_list = [actual_user_id]
logger.debug(f"new_content: {new_content}")
wework.send_room_at_msg(receiver, new_content, at_list)
else:
wework.send_text(receiver, reply.content)
logger.info("[WX] sendMsg={}, receiver={}".format(reply, receiver))
elif reply.type == ReplyType.ERROR or reply.type == ReplyType.INFO:
wework.send_text(receiver, reply.content)
logger.info("[WX] sendMsg={}, receiver={}".format(reply, receiver))
elif reply.type == ReplyType.IMAGE: # 从文件读取图片
image_storage = reply.content
image_storage.seek(0)
# Read data from image_storage
data = image_storage.read()
# Create a temporary file
with tempfile.NamedTemporaryFile(delete=False) as temp:
temp_path = temp.name
temp.write(data)
# Send the image
wework.send_image(receiver, temp_path)
logger.info("[WX] sendImage, receiver={}".format(receiver))
# Remove the temporary file
os.remove(temp_path)
elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片
img_url = reply.content
filename = str(uuid.uuid4())
# 调用你的函数,下载图片并保存为本地文件
image_path = download_and_compress_image(img_url, filename)
wework.send_image(receiver, file_path=image_path)
logger.info("[WX] sendImage url={}, receiver={}".format(img_url, receiver))
elif reply.type == ReplyType.VIDEO_URL:
video_url = reply.content
filename = str(uuid.uuid4())
video_path = download_video(video_url, filename)
if video_path is None:
# 如果视频太大,下载可能会被跳过,此时 video_path 将为 None
wework.send_text(receiver, "抱歉,视频太大了!!!")
else:
wework.send_video(receiver, video_path)
logger.info("[WX] sendVideo, receiver={}".format(receiver))
elif reply.type == ReplyType.VOICE:
current_dir = os.getcwd()
voice_file = reply.content.split("/")[-1]
reply.content = os.path.join(current_dir, "tmp", voice_file)
wework.send_file(receiver, reply.content)
logger.info("[WX] sendFile={}, receiver={}".format(reply.content, receiver))

View File

@@ -0,0 +1,227 @@
import datetime
import json
import os
import re
import time
import pilk
from bridge.context import ContextType
from channel.chat_message import ChatMessage
from common.log import logger
from ntwork.const import send_type
def get_with_retry(get_func, max_retries=5, delay=5):
retries = 0
result = None
while retries < max_retries:
result = get_func()
if result:
break
logger.warning(f"获取数据失败,重试第{retries + 1}次······")
retries += 1
time.sleep(delay) # 等待一段时间后重试
return result
def get_room_info(wework, conversation_id):
logger.debug(f"传入的 conversation_id: {conversation_id}")
rooms = wework.get_rooms()
if not rooms or 'room_list' not in rooms:
logger.error(f"获取群聊信息失败: {rooms}")
return None
time.sleep(1)
logger.debug(f"获取到的群聊信息: {rooms}")
for room in rooms['room_list']:
if room['conversation_id'] == conversation_id:
return room
return None
def cdn_download(wework, message, file_name):
data = message["data"]
aes_key = data["cdn"]["aes_key"]
file_size = data["cdn"]["size"]
# 获取当前工作目录,然后与文件名拼接得到保存路径
current_dir = os.getcwd()
save_path = os.path.join(current_dir, "tmp", file_name)
# 下载保存图片到本地
if "url" in data["cdn"].keys() and "auth_key" in data["cdn"].keys():
url = data["cdn"]["url"]
auth_key = data["cdn"]["auth_key"]
# result = wework.wx_cdn_download(url, auth_key, aes_key, file_size, save_path) # ntwork库本身接口有问题缺失了aes_key这个参数
"""
下载wx类型的cdn文件以https开头
"""
data = {
'url': url,
'auth_key': auth_key,
'aes_key': aes_key,
'size': file_size,
'save_path': save_path
}
result = wework._WeWork__send_sync(send_type.MT_WXCDN_DOWNLOAD_MSG, data) # 直接用wx_cdn_download的接口内部实现来调用
elif "file_id" in data["cdn"].keys():
if message["type"] == 11042:
file_type = 2
elif message["type"] == 11045:
file_type = 5
file_id = data["cdn"]["file_id"]
result = wework.c2c_cdn_download(file_id, aes_key, file_size, file_type, save_path)
else:
logger.error(f"something is wrong, data: {data}")
return
# 输出下载结果
logger.debug(f"result: {result}")
def c2c_download_and_convert(wework, message, file_name):
data = message["data"]
aes_key = data["cdn"]["aes_key"]
file_size = data["cdn"]["size"]
file_type = 5
file_id = data["cdn"]["file_id"]
current_dir = os.getcwd()
save_path = os.path.join(current_dir, "tmp", file_name)
result = wework.c2c_cdn_download(file_id, aes_key, file_size, file_type, save_path)
logger.debug(result)
# 在下载完SILK文件之后立即将其转换为WAV文件
base_name, _ = os.path.splitext(save_path)
wav_file = base_name + ".wav"
pilk.silk_to_wav(save_path, wav_file, rate=24000)
# 删除SILK文件
try:
os.remove(save_path)
except Exception as e:
pass
class WeworkMessage(ChatMessage):
def __init__(self, wework_msg, wework, is_group=False):
try:
super().__init__(wework_msg)
self.msg_id = wework_msg['data'].get('conversation_id', wework_msg['data'].get('room_conversation_id'))
# 使用.get()防止 'send_time' 键不存在时抛出错误
self.create_time = wework_msg['data'].get("send_time")
self.is_group = is_group
self.wework = wework
if wework_msg["type"] == 11041: # 文本消息类型
if any(substring in wework_msg['data']['content'] for substring in ("该消息类型暂不能展示", "不支持的消息类型")):
return
self.ctype = ContextType.TEXT
self.content = wework_msg['data']['content']
elif wework_msg["type"] == 11044: # 语音消息类型,需要缓存文件
file_name = datetime.datetime.now().strftime('%Y%m%d%H%M%S') + ".silk"
base_name, _ = os.path.splitext(file_name)
file_name_2 = base_name + ".wav"
current_dir = os.getcwd()
self.ctype = ContextType.VOICE
self.content = os.path.join(current_dir, "tmp", file_name_2)
self._prepare_fn = lambda: c2c_download_and_convert(wework, wework_msg, file_name)
elif wework_msg["type"] == 11042: # 图片消息类型,需要下载文件
file_name = datetime.datetime.now().strftime('%Y%m%d%H%M%S') + ".jpg"
current_dir = os.getcwd()
self.ctype = ContextType.IMAGE
self.content = os.path.join(current_dir, "tmp", file_name)
self._prepare_fn = lambda: cdn_download(wework, wework_msg, file_name)
elif wework_msg["type"] == 11045: # 文件消息
print("文件消息")
print(wework_msg)
file_name = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
file_name = file_name + wework_msg['data']['cdn']['file_name']
current_dir = os.getcwd()
self.ctype = ContextType.FILE
self.content = os.path.join(current_dir, "tmp", file_name)
self._prepare_fn = lambda: cdn_download(wework, wework_msg, file_name)
elif wework_msg["type"] == 11047: # 链接消息
self.ctype = ContextType.SHARING
self.content = wework_msg['data']['url']
elif wework_msg["type"] == 11072: # 新成员入群通知
self.ctype = ContextType.JOIN_GROUP
member_list = wework_msg['data']['member_list']
self.actual_user_nickname = member_list[0]['name']
self.actual_user_id = member_list[0]['user_id']
self.content = f"{self.actual_user_nickname}加入了群聊!"
directory = os.path.join(os.getcwd(), "tmp")
rooms = get_with_retry(wework.get_rooms)
if not rooms:
logger.error("更新群信息失败···")
else:
result = {}
for room in rooms['room_list']:
# 获取聊天室ID
room_wxid = room['conversation_id']
# 获取聊天室成员
room_members = wework.get_room_members(room_wxid)
# 将聊天室成员保存到结果字典中
result[room_wxid] = room_members
with open(os.path.join(directory, 'wework_room_members.json'), 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=4)
logger.info("有新成员加入,已自动更新群成员列表缓存!")
else:
raise NotImplementedError(
"Unsupported message type: Type:{} MsgType:{}".format(wework_msg["type"], wework_msg["MsgType"]))
data = wework_msg['data']
login_info = self.wework.get_login_info()
logger.debug(f"login_info: {login_info}")
nickname = f"{login_info['username']}({login_info['nickname']})" if login_info['nickname'] else login_info['username']
user_id = login_info['user_id']
sender_id = data.get('sender')
conversation_id = data.get('conversation_id')
sender_name = data.get("sender_name")
self.from_user_id = user_id if sender_id == user_id else conversation_id
self.from_user_nickname = nickname if sender_id == user_id else sender_name
self.to_user_id = user_id
self.to_user_nickname = nickname
self.other_user_nickname = sender_name
self.other_user_id = conversation_id
if self.is_group:
conversation_id = data.get('conversation_id') or data.get('room_conversation_id')
self.other_user_id = conversation_id
if conversation_id:
room_info = get_room_info(wework=wework, conversation_id=conversation_id)
self.other_user_nickname = room_info.get('nickname', None) if room_info else None
self.from_user_nickname = room_info.get('nickname', None) if room_info else None
at_list = data.get('at_list', [])
tmp_list = []
for at in at_list:
tmp_list.append(at['nickname'])
at_list = tmp_list
logger.debug(f"at_list: {at_list}")
logger.debug(f"nickname: {nickname}")
self.is_at = False
if nickname in at_list or login_info['nickname'] in at_list or login_info['username'] in at_list:
self.is_at = True
self.at_list = at_list
# 检查消息内容是否包含@用户名。处理复制粘贴的消息,这类消息可能不会触发@通知,但内容中可能包含 "@用户名"。
content = data.get('content', '')
name = nickname
pattern = f"@{re.escape(name)}(\u2005|\u0020)"
if re.search(pattern, content):
logger.debug(f"Wechaty message {self.msg_id} includes at")
self.is_at = True
if not self.actual_user_id:
self.actual_user_id = data.get("sender")
self.actual_user_nickname = sender_name if self.ctype != ContextType.JOIN_GROUP else self.actual_user_nickname
else:
logger.error("群聊消息中没有找到 conversation_id 或 room_conversation_id")
logger.debug(f"WeworkMessage has been successfully instantiated with message id: {self.msg_id}")
except Exception as e:
logger.error(f"在 WeworkMessage 的初始化过程中出现错误:{e}")
raise e

View File

@@ -1,4 +1,26 @@
# bot_type
OPEN_AI = "openAI"
CHATGPT = "chatGPT"
BAIDU = "baidu"
BAIDU = "baidu"
XUNFEI = "xunfei"
CHATGPTONAZURE = "chatGPTOnAzure"
LINKAI = "linkai"
CLAUDEAI = "claude"
QWEN = "qwen"
GEMINI = "gemini"
# model
GPT35 = "gpt-3.5-turbo"
GPT4 = "gpt-4"
GPT4_TURBO_PREVIEW = "gpt-4-0125-preview"
GPT4_VISION_PREVIEW = "gpt-4-vision-preview"
WHISPER_1 = "whisper-1"
TTS_1 = "tts-1"
TTS_1_HD = "tts-1-hd"
MODEL_LIST = ["gpt-3.5-turbo", "gpt-3.5-turbo-16k", "gpt-4", "wenxin", "wenxin-4", "xunfei", "claude", "gpt-4-turbo",
"gpt-4-turbo-preview", "gpt-4-1106-preview", GPT4_TURBO_PREVIEW, QWEN, GEMINI]
# channel
FEISHU = "feishu"
DINGTALK = "dingtalk"

33
common/dequeue.py Normal file
View File

@@ -0,0 +1,33 @@
from queue import Full, Queue
from time import monotonic as time
# add implementation of putleft to Queue
class Dequeue(Queue):
def putleft(self, item, block=True, timeout=None):
with self.not_full:
if self.maxsize > 0:
if not block:
if self._qsize() >= self.maxsize:
raise Full
elif timeout is None:
while self._qsize() >= self.maxsize:
self.not_full.wait()
elif timeout < 0:
raise ValueError("'timeout' must be a non-negative number")
else:
endtime = time() + timeout
while self._qsize() >= self.maxsize:
remaining = endtime - time()
if remaining <= 0.0:
raise Full
self.not_full.wait(remaining)
self._putleft(item)
self.unfinished_tasks += 1
self.not_empty.notify()
def putleft_nowait(self, item):
return self.putleft(item, block=False)
def _putleft(self, item):
self.queue.appendleft(item)

View File

@@ -1,5 +1,6 @@
from datetime import datetime, timedelta
class ExpiredDict(dict):
def __init__(self, expires_in_seconds):
super().__init__()
@@ -16,8 +17,26 @@ class ExpiredDict(dict):
def __setitem__(self, key, value):
expiry_time = datetime.now() + timedelta(seconds=self.expires_in_seconds)
super().__setitem__(key, (value, expiry_time))
def get(self, key, default=None):
try:
return self[key]
except KeyError:
return default
return default
def __contains__(self, key):
try:
self[key]
return True
except KeyError:
return False
def keys(self):
keys = list(super().keys())
return [key for key in keys if key in self]
def items(self):
return [(key, self[key]) for key in self.keys()]
def __iter__(self):
return self.keys().__iter__()

30
common/linkai_client.py Normal file
View File

@@ -0,0 +1,30 @@
from bridge.context import Context, ContextType
from bridge.reply import Reply, ReplyType
from common.log import logger
from linkai import LinkAIClient, PushMsg
from config import conf
chat_client: LinkAIClient
class ChatClient(LinkAIClient):
def __init__(self, api_key, host, channel):
super().__init__(api_key, host)
self.channel = channel
self.client_type = channel.channel_type
def on_message(self, push_msg: PushMsg):
session_id = push_msg.session_id
msg_content = push_msg.msg_content
logger.info(f"receive msg push, session_id={session_id}, msg_content={msg_content}")
context = Context()
context.type = ContextType.TEXT
context["receiver"] = session_id
context["isgroup"] = push_msg.is_group
self.channel.send(Reply(ReplyType.TEXT, content=msg_content), context)
def start(channel):
global chat_client
chat_client = ChatClient(api_key=conf().get("linkai_api_key"),
host="link-ai.chat", channel=channel)
chat_client.start()

View File

@@ -2,15 +2,37 @@ import logging
import sys
def _get_logger():
log = logging.getLogger('log')
log.setLevel(logging.INFO)
def _reset_logger(log):
for handler in log.handlers:
handler.close()
log.removeHandler(handler)
del handler
log.handlers.clear()
log.propagate = False
console_handle = logging.StreamHandler(sys.stdout)
console_handle.setFormatter(logging.Formatter('[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'))
console_handle.setFormatter(
logging.Formatter(
"[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
file_handle = logging.FileHandler("run.log", encoding="utf-8")
file_handle.setFormatter(
logging.Formatter(
"[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
log.addHandler(file_handle)
log.addHandler(console_handle)
def _get_logger():
log = logging.getLogger("log")
_reset_logger(log)
log.setLevel(logging.INFO)
return log
# 日志句柄
logger = _get_logger()
logger = _get_logger()

3
common/memory.py Normal file
View File

@@ -0,0 +1,3 @@
from common.expired_dict import ExpiredDict
USER_IMAGE_CACHE = ExpiredDict(60 * 3)

36
common/package_manager.py Normal file
View File

@@ -0,0 +1,36 @@
import time
import pip
from pip._internal import main as pipmain
from common.log import _reset_logger, logger
def install(package):
pipmain(["install", package])
def install_requirements(file):
pipmain(["install", "-r", file, "--upgrade"])
_reset_logger(logger)
def check_dulwich():
needwait = False
for i in range(2):
if needwait:
time.sleep(3)
needwait = False
try:
import dulwich
return
except ImportError:
try:
install("dulwich")
except:
needwait = True
try:
import dulwich
except ImportError:
raise ImportError("Unable to import dulwich")

9
common/singleton.py Normal file
View File

@@ -0,0 +1,9 @@
def singleton(cls):
instances = {}
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance

65
common/sorted_dict.py Normal file
View File

@@ -0,0 +1,65 @@
import heapq
class SortedDict(dict):
def __init__(self, sort_func=lambda k, v: k, init_dict=None, reverse=False):
if init_dict is None:
init_dict = []
if isinstance(init_dict, dict):
init_dict = init_dict.items()
self.sort_func = sort_func
self.sorted_keys = None
self.reverse = reverse
self.heap = []
for k, v in init_dict:
self[k] = v
def __setitem__(self, key, value):
if key in self:
super().__setitem__(key, value)
for i, (priority, k) in enumerate(self.heap):
if k == key:
self.heap[i] = (self.sort_func(key, value), key)
heapq.heapify(self.heap)
break
self.sorted_keys = None
else:
super().__setitem__(key, value)
heapq.heappush(self.heap, (self.sort_func(key, value), key))
self.sorted_keys = None
def __delitem__(self, key):
super().__delitem__(key)
for i, (priority, k) in enumerate(self.heap):
if k == key:
del self.heap[i]
heapq.heapify(self.heap)
break
self.sorted_keys = None
def keys(self):
if self.sorted_keys is None:
self.sorted_keys = [k for _, k in sorted(self.heap, reverse=self.reverse)]
return self.sorted_keys
def items(self):
if self.sorted_keys is None:
self.sorted_keys = [k for _, k in sorted(self.heap, reverse=self.reverse)]
sorted_items = [(k, self[k]) for k in self.sorted_keys]
return sorted_items
def _update_heap(self, key):
for i, (priority, k) in enumerate(self.heap):
if k == key:
new_priority = self.sort_func(key, self[key])
if new_priority != priority:
self.heap[i] = (new_priority, key)
heapq.heapify(self.heap)
self.sorted_keys = None
break
def __iter__(self):
return iter(self.keys())
def __repr__(self):
return f"{type(self).__name__}({dict(self)}, sort_func={self.sort_func.__name__}, reverse={self.reverse})"

42
common/time_check.py Normal file
View File

@@ -0,0 +1,42 @@
import hashlib
import re
import time
import config
from common.log import logger
def time_checker(f):
def _time_checker(self, *args, **kwargs):
_config = config.conf()
chat_time_module = _config.get("chat_time_module", False)
if chat_time_module:
chat_start_time = _config.get("chat_start_time", "00:00")
chat_stopt_time = _config.get("chat_stop_time", "24:00")
time_regex = re.compile(r"^([01]?[0-9]|2[0-4])(:)([0-5][0-9])$") # 时间匹配包含24:00
starttime_format_check = time_regex.match(chat_start_time) # 检查停止时间格式
stoptime_format_check = time_regex.match(chat_stopt_time) # 检查停止时间格式
chat_time_check = chat_start_time < chat_stopt_time # 确定启动时间<停止时间
# 时间格式检查
if not (starttime_format_check and stoptime_format_check and chat_time_check):
logger.warn("时间格式不正确,请在config.json中修改您的CHAT_START_TIME/CHAT_STOP_TIME,否则可能会影响您正常使用,开始({})-结束({})".format(starttime_format_check, stoptime_format_check))
if chat_start_time > "23:59":
logger.error("启动时间可能存在问题,请修改!")
# 服务时间检查
now_time = time.strftime("%H:%M", time.localtime())
if chat_start_time <= now_time <= chat_stopt_time: # 服务时间内,正常返回回答
f(self, *args, **kwargs)
return None
else:
if args[0]["Content"] == "#更新配置": # 不在服务时间内也可以更新配置
f(self, *args, **kwargs)
else:
logger.info("非服务时间内,不接受访问")
return None
else:
f(self, *args, **kwargs) # 未开启时间模块则直接回答
return _time_checker

View File

@@ -1,20 +1,18 @@
import os
import pathlib
from config import conf
class TmpDir(object):
"""A temporary directory that is deleted when the object is destroyed.
"""
"""A temporary directory that is deleted when the object is destroyed."""
tmpFilePath = pathlib.Path("./tmp/")
tmpFilePath = pathlib.Path('./tmp/')
def __init__(self):
pathExists = os.path.exists(self.tmpFilePath)
if not pathExists and conf().get('speech_recognition') == True:
if not pathExists:
os.makedirs(self.tmpFilePath)
def path(self):
return str(self.tmpFilePath) + '/'
return str(self.tmpFilePath) + "/"

56
common/utils.py Normal file
View File

@@ -0,0 +1,56 @@
import io
import os
from urllib.parse import urlparse
from PIL import Image
def fsize(file):
if isinstance(file, io.BytesIO):
return file.getbuffer().nbytes
elif isinstance(file, str):
return os.path.getsize(file)
elif hasattr(file, "seek") and hasattr(file, "tell"):
pos = file.tell()
file.seek(0, os.SEEK_END)
size = file.tell()
file.seek(pos)
return size
else:
raise TypeError("Unsupported type")
def compress_imgfile(file, max_size):
if fsize(file) <= max_size:
return file
file.seek(0)
img = Image.open(file)
rgb_image = img.convert("RGB")
quality = 95
while True:
out_buf = io.BytesIO()
rgb_image.save(out_buf, "JPEG", quality=quality)
if fsize(out_buf) <= max_size:
return out_buf
quality -= 5
def split_string_by_utf8_length(string, max_length, max_split=0):
encoded = string.encode("utf-8")
start, end = 0, 0
result = []
while end < len(encoded):
if max_split > 0 and len(result) >= max_split:
result.append(encoded[start:].decode("utf-8"))
break
end = min(start + max_length, len(encoded))
# 如果当前字节不是 UTF-8 编码的开始字节,则向前查找直到找到开始字节为止
while end < len(encoded) and (encoded[end] & 0b11000000) == 0b10000000:
end -= 1
result.append(encoded[start:end].decode("utf-8"))
start = end
return result
def get_path_suffix(path):
path = urlparse(path).path
return os.path.splitext(path)[-1].lstrip('.')

View File

@@ -1,16 +1,36 @@
{
"open_ai_api_key": "YOUR API KEY",
"model": "gpt-3.5-turbo",
"proxy": "",
"single_chat_prefix": ["bot", "@bot"],
"single_chat_reply_prefix": "[bot] ",
"group_chat_prefix": ["@bot"],
"group_name_white_list": ["ChatGPT测试群", "ChatGPT测试群2"],
"image_create_prefix": ["画", "看", "找"],
"speech_recognition": false,
"voice_reply_voice": false,
"conversation_max_tokens": 1000,
"expires_in_seconds": 3600,
"character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。"
}
{
"channel_type": "wx",
"model": "",
"open_ai_api_key": "YOUR API KEY",
"text_to_image": "dall-e-2",
"voice_to_text": "openai",
"text_to_voice": "openai",
"proxy": "",
"hot_reload": false,
"single_chat_prefix": [
"bot",
"@bot"
],
"single_chat_reply_prefix": "[bot] ",
"group_chat_prefix": [
"@bot"
],
"group_name_white_list": [
"ChatGPT测试群",
"ChatGPT测试群2"
],
"image_create_prefix": [
"画"
],
"speech_recognition": true,
"group_speech_recognition": false,
"voice_reply_voice": false,
"conversation_max_tokens": 2500,
"expires_in_seconds": 3600,
"character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。",
"temperature": 0.7,
"subscribe_msg": "感谢您的关注!\n这里是AI智能助手可以自由对话。\n支持语音对话。\n支持图片输入。\n支持图片输出画字开头的消息将按要求创作图片。\n支持tool、角色扮演和文字冒险等丰富的插件。\n输入{trigger_prefix}#help 查看详细指令。",
"use_linkai": false,
"linkai_api_key": "",
"linkai_app_code": ""
}

284
config.py
View File

@@ -1,33 +1,307 @@
# encoding:utf-8
import json
import logging
import os
import pickle
from common.log import logger
config = {}
# 将所有可用的配置项写在字典里, 请使用小写字母
# 此处的配置值无实际意义程序不会读取此处的配置仅用于提示格式请将配置加入到config.json中
available_setting = {
# openai api配置
"open_ai_api_key": "", # openai api key
# openai apibase当use_azure_chatgpt为true时需要设置对应的api base
"open_ai_api_base": "https://api.openai.com/v1",
"proxy": "", # openai使用的代理
# chatgpt模型 当use_azure_chatgpt为true时其名称为Azure上model deployment名称
"model": "gpt-3.5-turbo", # 还支持 gpt-4, gpt-4-turbo, wenxin, xunfei, qwen
"use_azure_chatgpt": False, # 是否使用azure的chatgpt
"azure_deployment_id": "", # azure 模型部署名称
"azure_api_version": "", # azure api版本
# Bot触发配置
"single_chat_prefix": ["bot", "@bot"], # 私聊时文本需要包含该前缀才能触发机器人回复
"single_chat_reply_prefix": "[bot] ", # 私聊时自动回复的前缀,用于区分真人
"single_chat_reply_suffix": "", # 私聊时自动回复的后缀,\n 可以换行
"group_chat_prefix": ["@bot"], # 群聊时包含该前缀则会触发机器人回复
"group_chat_reply_prefix": "", # 群聊时自动回复的前缀
"group_chat_reply_suffix": "", # 群聊时自动回复的后缀,\n 可以换行
"group_chat_keyword": [], # 群聊时包含该关键词则会触发机器人回复
"group_at_off": False, # 是否关闭群聊时@bot的触发
"group_name_white_list": ["ChatGPT测试群", "ChatGPT测试群2"], # 开启自动回复的群名称列表
"group_name_keyword_white_list": [], # 开启自动回复的群名称关键词列表
"group_chat_in_one_session": ["ChatGPT测试群"], # 支持会话上下文共享的群名称
"nick_name_black_list": [], # 用户昵称黑名单
"group_welcome_msg": "", # 配置新人进群固定欢迎语,不配置则使用随机风格欢迎
"trigger_by_self": False, # 是否允许机器人触发
"text_to_image": "dall-e-2", # 图片生成模型,可选 dall-e-2, dall-e-3
"image_proxy": True, # 是否需要图片代理国内访问LinkAI时需要
"image_create_prefix": ["", "", ""], # 开启图片回复的前缀
"concurrency_in_session": 1, # 同一会话最多有多少条消息在处理中大于1可能乱序
"image_create_size": "256x256", # 图片大小,可选有 256x256, 512x512, 1024x1024 (dall-e-3默认为1024x1024)
"group_chat_exit_group": False,
# chatgpt会话参数
"expires_in_seconds": 3600, # 无操作会话的过期时间
# 人格描述
"character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。",
"conversation_max_tokens": 1000, # 支持上下文记忆的最多字符数
# chatgpt限流配置
"rate_limit_chatgpt": 20, # chatgpt的调用频率限制
"rate_limit_dalle": 50, # openai dalle的调用频率限制
# chatgpt api参数 参考https://platform.openai.com/docs/api-reference/chat/create
"temperature": 0.9,
"top_p": 1,
"frequency_penalty": 0,
"presence_penalty": 0,
"request_timeout": 180, # chatgpt请求超时时间openai接口默认设置为600对于难问题一般需要较长时间
"timeout": 120, # chatgpt重试超时时间在这个时间内将会自动重试
# Baidu 文心一言参数
"baidu_wenxin_model": "eb-instant", # 默认使用ERNIE-Bot-turbo模型
"baidu_wenxin_api_key": "", # Baidu api key
"baidu_wenxin_secret_key": "", # Baidu secret key
# 讯飞星火API
"xunfei_app_id": "", # 讯飞应用ID
"xunfei_api_key": "", # 讯飞 API key
"xunfei_api_secret": "", # 讯飞 API secret
# claude 配置
"claude_api_cookie": "",
"claude_uuid": "",
# 通义千问API, 获取方式查看文档 https://help.aliyun.com/document_detail/2587494.html
"qwen_access_key_id": "",
"qwen_access_key_secret": "",
"qwen_agent_key": "",
"qwen_app_id": "",
"qwen_node_id": "", # 流程编排模型用到的id如果没有用到qwen_node_id请务必保持为空字符串
# Google Gemini Api Key
"gemini_api_key": "",
# wework的通用配置
"wework_smart": True, # 配置wework是否使用已登录的企业微信False为多开
# 语音设置
"speech_recognition": True, # 是否开启语音识别
"group_speech_recognition": False, # 是否开启群组语音识别
"voice_reply_voice": False, # 是否使用语音回复语音需要设置对应语音合成引擎的api key
"always_reply_voice": False, # 是否一直使用语音回复
"voice_to_text": "openai", # 语音识别引擎支持openai,baidu,google,azure
"text_to_voice": "openai", # 语音合成引擎支持openai,baidu,google,pytts(offline),azure,elevenlabs
"text_to_voice_model": "tts-1",
"tts_voice_id": "alloy",
# baidu 语音api配置 使用百度语音识别和语音合成时需要
"baidu_app_id": "",
"baidu_api_key": "",
"baidu_secret_key": "",
# 1536普通话(支持简单的英文识别) 1737英语 1637粤语 1837四川话 1936普通话远场
"baidu_dev_pid": "1536",
# azure 语音api配置 使用azure语音识别和语音合成时需要
"azure_voice_api_key": "",
"azure_voice_region": "japaneast",
# elevenlabs 语音api配置
"xi_api_key": "", #获取ap的方法可以参考https://docs.elevenlabs.io/api-reference/quick-start/authentication
"xi_voice_id": "", #ElevenLabs提供了9种英式、美式等英语发音id分别是“Adam/Antoni/Arnold/Bella/Domi/Elli/Josh/Rachel/Sam”
# 服务时间限制目前支持itchat
"chat_time_module": False, # 是否开启服务时间限制
"chat_start_time": "00:00", # 服务开始时间
"chat_stop_time": "24:00", # 服务结束时间
# 翻译api
"translate": "baidu", # 翻译api支持baidu
# baidu翻译api的配置
"baidu_translate_app_id": "", # 百度翻译api的appid
"baidu_translate_app_key": "", # 百度翻译api的秘钥
# itchat的配置
"hot_reload": False, # 是否开启热重载
# wechaty的配置
"wechaty_puppet_service_token": "", # wechaty的token
# wechatmp的配置
"wechatmp_token": "", # 微信公众平台的Token
"wechatmp_port": 8080, # 微信公众平台的端口,需要端口转发到80或443
"wechatmp_app_id": "", # 微信公众平台的appID
"wechatmp_app_secret": "", # 微信公众平台的appsecret
"wechatmp_aes_key": "", # 微信公众平台的EncodingAESKey加密模式需要
# wechatcom的通用配置
"wechatcom_corp_id": "", # 企业微信公司的corpID
# wechatcomapp的配置
"wechatcomapp_token": "", # 企业微信app的token
"wechatcomapp_port": 9898, # 企业微信app的服务端口,不需要端口转发
"wechatcomapp_secret": "", # 企业微信app的secret
"wechatcomapp_agent_id": "", # 企业微信app的agent_id
"wechatcomapp_aes_key": "", # 企业微信app的aes_key
# 飞书配置
"feishu_port": 80, # 飞书bot监听端口
"feishu_app_id": "", # 飞书机器人应用APP Id
"feishu_app_secret": "", # 飞书机器人APP secret
"feishu_token": "", # 飞书 verification token
"feishu_bot_name": "", # 飞书机器人的名字
# 钉钉配置
"dingtalk_client_id": "", # 钉钉机器人Client ID
"dingtalk_client_secret": "", # 钉钉机器人Client Secret
# chatgpt指令自定义触发词
"clear_memory_commands": ["#清除记忆"], # 重置会话指令,必须以#开头
# channel配置
"channel_type": "wx", # 通道类型,支持:{wx,wxy,terminal,wechatmp,wechatmp_service,wechatcom_app}
"subscribe_msg": "", # 订阅消息, 支持: wechatmp, wechatmp_service, wechatcom_app
"debug": False, # 是否开启debug模式开启后会打印更多日志
"appdata_dir": "", # 数据目录
# 插件配置
"plugin_trigger_prefix": "$", # 规范插件提供聊天相关指令的前缀,建议不要和管理员指令前缀"#"冲突
# 是否使用全局插件配置
"use_global_plugin_config": False,
# 知识库平台配置
"use_linkai": False,
"linkai_api_key": "",
"linkai_app_code": "",
"linkai_api_base": "https://api.link-ai.chat", # linkAI服务地址若国内无法访问或延迟较高可改为 https://api.link-ai.tech
}
class Config(dict):
def __init__(self, d=None):
super().__init__()
if d is None:
d = {}
for k, v in d.items():
self[k] = v
# user_datas: 用户数据key为用户名value为用户数据也是dict
self.user_datas = {}
def __getitem__(self, key):
if key not in available_setting:
raise Exception("key {} not in available_setting".format(key))
return super().__getitem__(key)
def __setitem__(self, key, value):
if key not in available_setting:
raise Exception("key {} not in available_setting".format(key))
return super().__setitem__(key, value)
def get(self, key, default=None):
try:
return self[key]
except KeyError as e:
return default
except Exception as e:
raise e
# Make sure to return a dictionary to ensure atomic
def get_user_data(self, user) -> dict:
if self.user_datas.get(user) is None:
self.user_datas[user] = {}
return self.user_datas[user]
def load_user_datas(self):
try:
with open(os.path.join(get_appdata_dir(), "user_datas.pkl"), "rb") as f:
self.user_datas = pickle.load(f)
logger.info("[Config] User datas loaded.")
except FileNotFoundError as e:
logger.info("[Config] User datas file not found, ignore.")
except Exception as e:
logger.info("[Config] User datas error: {}".format(e))
self.user_datas = {}
def save_user_datas(self):
try:
with open(os.path.join(get_appdata_dir(), "user_datas.pkl"), "wb") as f:
pickle.dump(self.user_datas, f)
logger.info("[Config] User datas saved.")
except Exception as e:
logger.info("[Config] User datas error: {}".format(e))
config = Config()
def load_config():
global config
config_path = "./config.json"
if not os.path.exists(config_path):
raise Exception('配置文件不存在,请根据config-template.json模板创建config.json文件')
logger.info("配置文件不存在,将使用config-template.json模板")
config_path = "./config-template.json"
config_str = read_file(config_path)
logger.debug("[INIT] config str: {}".format(config_str))
# 将json字符串反序列化为dict类型
config = json.loads(config_str)
config = Config(json.loads(config_str))
# override config with environment variables.
# Some online deployment platforms (e.g. Railway) deploy project from github directly. So you shouldn't put your secrets like api key in a config file, instead use environment variables to override the default config.
for name, value in os.environ.items():
name = name.lower()
if name in available_setting:
logger.info("[INIT] override config by environ args: {}={}".format(name, value))
try:
config[name] = eval(value)
except:
if value == "false":
config[name] = False
elif value == "true":
config[name] = True
else:
config[name] = value
if config.get("debug", False):
logger.setLevel(logging.DEBUG)
logger.debug("[INIT] set log level to DEBUG")
logger.info("[INIT] load config: {}".format(config))
config.load_user_datas()
def get_root():
return os.path.dirname(os.path.abspath( __file__ ))
return os.path.dirname(os.path.abspath(__file__))
def read_file(path):
with open(path, mode='r', encoding='utf-8') as f:
with open(path, mode="r", encoding="utf-8") as f:
return f.read()
def conf():
return config
def get_appdata_dir():
data_path = os.path.join(get_root(), conf().get("appdata_dir", ""))
if not os.path.exists(data_path):
logger.info("[INIT] data path not exists, create it: {}".format(data_path))
os.makedirs(data_path)
return data_path
def subscribe_msg():
trigger_prefix = conf().get("single_chat_prefix", [""])[0]
msg = conf().get("subscribe_msg", "")
return msg.format(trigger_prefix=trigger_prefix)
# global plugin config
plugin_config = {}
def write_plugin_config(pconf: dict):
"""
写入插件全局配置
:param pconf: 全量插件配置
"""
global plugin_config
for k in pconf:
plugin_config[k.lower()] = pconf[k]
def pconf(plugin_name: str) -> dict:
"""
根据插件名称获取配置
:param plugin_name: 插件名称
:return: 该插件的配置项
"""
return plugin_config.get(plugin_name.lower())
# 全局配置,用于存放全局生效的状态
global_config = {
"admin_users": []
}

View File

@@ -1,42 +0,0 @@
FROM python:3.7.9-alpine
LABEL maintainer="foo@bar.com"
ARG TZ='Asia/Shanghai'
ARG CHATGPT_ON_WECHAT_VER
ENV BUILD_PREFIX=/app \
BUILD_OPEN_AI_API_KEY='YOUR OPEN AI KEY HERE'
RUN apk add --no-cache \
bash \
curl \
wget \
&& export BUILD_GITHUB_TAG=${CHATGPT_ON_WECHAT_VER:-`curl -sL "https://api.github.com/repos/zhayujie/chatgpt-on-wechat/releases/latest" | \
grep '"tag_name":' | \
sed -E 's/.*"([^"]+)".*/\1/'`} \
&& wget -t 3 -T 30 -nv -O chatgpt-on-wechat-${BUILD_GITHUB_TAG}.tar.gz \
https://github.com/zhayujie/chatgpt-on-wechat/archive/refs/tags/${BUILD_GITHUB_TAG}.tar.gz \
&& tar -xzf chatgpt-on-wechat-${BUILD_GITHUB_TAG}.tar.gz \
&& mv chatgpt-on-wechat-${BUILD_GITHUB_TAG} ${BUILD_PREFIX} \
&& rm chatgpt-on-wechat-${BUILD_GITHUB_TAG}.tar.gz \
&& cd ${BUILD_PREFIX} \
&& cp config-template.json ${BUILD_PREFIX}/config.json \
&& sed -i "2s/YOUR API KEY/${BUILD_OPEN_AI_API_KEY}/" ${BUILD_PREFIX}/config.json \
&& /usr/local/bin/python -m pip install --no-cache --upgrade pip \
&& pip install --no-cache \
itchat-uos==1.5.0.dev0 \
openai \
&& apk del curl wget
WORKDIR ${BUILD_PREFIX}
ADD ./entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh \
&& adduser -D -h /home/noroot -u 1000 -s /bin/bash noroot \
&& chown noroot:noroot ${BUILD_PREFIX}
USER noroot
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -1,43 +0,0 @@
FROM python:3.7.9
LABEL maintainer="foo@bar.com"
ARG TZ='Asia/Shanghai'
ARG CHATGPT_ON_WECHAT_VER
ENV BUILD_PREFIX=/app \
BUILD_OPEN_AI_API_KEY='YOUR OPEN AI KEY HERE'
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
wget \
curl \
&& rm -rf /var/lib/apt/lists/* \
&& export BUILD_GITHUB_TAG=${CHATGPT_ON_WECHAT_VER:-`curl -sL "https://api.github.com/repos/zhayujie/chatgpt-on-wechat/releases/latest" | \
grep '"tag_name":' | \
sed -E 's/.*"([^"]+)".*/\1/'`} \
&& wget -t 3 -T 30 -nv -O chatgpt-on-wechat-${BUILD_GITHUB_TAG}.tar.gz \
https://github.com/zhayujie/chatgpt-on-wechat/archive/refs/tags/${BUILD_GITHUB_TAG}.tar.gz \
&& tar -xzf chatgpt-on-wechat-${BUILD_GITHUB_TAG}.tar.gz \
&& mv chatgpt-on-wechat-${BUILD_GITHUB_TAG} ${BUILD_PREFIX} \
&& rm chatgpt-on-wechat-${BUILD_GITHUB_TAG}.tar.gz \
&& cd ${BUILD_PREFIX} \
&& cp config-template.json ${BUILD_PREFIX}/config.json \
&& sed -i "2s/YOUR API KEY/${BUILD_OPEN_AI_API_KEY}/" ${BUILD_PREFIX}/config.json \
&& /usr/local/bin/python -m pip install --no-cache --upgrade pip \
&& pip install --no-cache \
itchat-uos==1.5.0.dev0 \
openai
WORKDIR ${BUILD_PREFIX}
ADD ./entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh \
&& groupadd -r noroot \
&& useradd -r -g noroot -s /bin/bash -d /home/noroot noroot \
&& chown -R noroot:noroot ${BUILD_PREFIX}
USER noroot
ENTRYPOINT ["/entrypoint.sh"]

35
docker/Dockerfile.latest Normal file
View File

@@ -0,0 +1,35 @@
FROM python:3.10-slim-bullseye
LABEL maintainer="foo@bar.com"
ARG TZ='Asia/Shanghai'
ARG CHATGPT_ON_WECHAT_VER
RUN echo /etc/apt/sources.list
# RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list
ENV BUILD_PREFIX=/app
ADD . ${BUILD_PREFIX}
RUN apt-get update \
&&apt-get install -y --no-install-recommends bash ffmpeg espeak libavcodec-extra\
&& cd ${BUILD_PREFIX} \
&& cp config-template.json config.json \
&& /usr/local/bin/python -m pip install --no-cache --upgrade pip \
&& pip install --no-cache -r requirements.txt \
&& pip install --no-cache -r requirements-optional.txt \
&& pip install azure-cognitiveservices-speech
WORKDIR ${BUILD_PREFIX}
ADD docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh \
&& mkdir -p /home/noroot \
&& groupadd -r noroot \
&& useradd -r -g noroot -s /bin/bash -d /home/noroot noroot \
&& chown -R noroot:noroot /home/noroot ${BUILD_PREFIX} /usr/local/lib
USER noroot
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -1,16 +0,0 @@
#!/bin/bash
# fetch latest release tag
CHATGPT_ON_WECHAT_TAG=`curl -sL "https://api.github.com/repos/zhayujie/chatgpt-on-wechat/releases/latest" | \
grep '"tag_name":' | \
sed -E 's/.*"([^"]+)".*/\1/'`
# build image
docker build -f Dockerfile.alpine \
--build-arg CHATGPT_ON_WECHAT_VER=$CHATGPT_ON_WECHAT_TAG \
-t zhayujie/chatgpt-on-wechat .
# tag image
docker tag zhayujie/chatgpt-on-wechat zhayujie/chatgpt-on-wechat:alpine
docker tag zhayujie/chatgpt-on-wechat zhayujie/chatgpt-on-wechat:$CHATGPT_ON_WECHAT_TAG-alpine

View File

@@ -1,15 +0,0 @@
#!/bin/bash
# fetch latest release tag
CHATGPT_ON_WECHAT_TAG=`curl -sL "https://api.github.com/repos/zhayujie/chatgpt-on-wechat/releases/latest" | \
grep '"tag_name":' | \
sed -E 's/.*"([^"]+)".*/\1/'`
# build image
docker build -f Dockerfile.debian \
--build-arg CHATGPT_ON_WECHAT_VER=$CHATGPT_ON_WECHAT_TAG \
-t zhayujie/chatgpt-on-wechat .
# tag image
docker tag zhayujie/chatgpt-on-wechat zhayujie/chatgpt-on-wechat:debian
docker tag zhayujie/chatgpt-on-wechat zhayujie/chatgpt-on-wechat:$CHATGPT_ON_WECHAT_TAG-debian

8
docker/build.latest.sh Normal file
View File

@@ -0,0 +1,8 @@
#!/bin/bash
unset KUBECONFIG
cd .. && docker build -f docker/Dockerfile.latest \
-t zhayujie/chatgpt-on-wechat .
docker tag zhayujie/chatgpt-on-wechat zhayujie/chatgpt-on-wechat:$(date +%y%m%d)

View File

@@ -1,23 +0,0 @@
FROM zhayujie/chatgpt-on-wechat:alpine
LABEL maintainer="foo@bar.com"
ARG TZ='Asia/Shanghai'
USER root
RUN apk add --no-cache \
ffmpeg \
espeak \
&& pip install --no-cache \
baidu-aip \
chardet \
SpeechRecognition
# replace entrypoint
ADD ./entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
USER noroot
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -1,24 +0,0 @@
FROM zhayujie/chatgpt-on-wechat:debian
LABEL maintainer="foo@bar.com"
ARG TZ='Asia/Shanghai'
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ffmpeg \
espeak \
&& pip install --no-cache \
baidu-aip \
chardet \
SpeechRecognition
# replace entrypoint
ADD ./entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
USER noroot
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -1,24 +0,0 @@
version: '2.0'
services:
chatgpt-on-wechat:
build:
context: ./
dockerfile: Dockerfile.alpine
image: zhayujie/chatgpt-on-wechat-voice-reply
container_name: chatgpt-on-wechat-voice-reply
environment:
OPEN_AI_API_KEY: 'YOUR API KEY'
OPEN_AI_PROXY: ''
SINGLE_CHAT_PREFIX: '["bot", "@bot"]'
SINGLE_CHAT_REPLY_PREFIX: '"[bot] "'
GROUP_CHAT_PREFIX: '["@bot"]'
GROUP_NAME_WHITE_LIST: '["ChatGPT测试群", "ChatGPT测试群2"]'
IMAGE_CREATE_PREFIX: '["画", "看", "找"]'
CONVERSATION_MAX_TOKENS: 1000
SPEECH_RECOGNITION: 'true'
CHARACTER_DESC: '你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。'
EXPIRES_IN_SECONDS: 3600
VOICE_REPLY_VOICE: 'true'
BAIDU_APP_ID: 'YOUR BAIDU APP ID'
BAIDU_API_KEY: 'YOUR BAIDU API KEY'
BAIDU_SECRET_KEY: 'YOUR BAIDU SERVICE KEY'

View File

@@ -1,117 +0,0 @@
#!/bin/bash
set -e
# build prefix
CHATGPT_ON_WECHAT_PREFIX=${CHATGPT_ON_WECHAT_PREFIX:-""}
# path to config.json
CHATGPT_ON_WECHAT_CONFIG_PATH=${CHATGPT_ON_WECHAT_CONFIG_PATH:-""}
# execution command line
CHATGPT_ON_WECHAT_EXEC=${CHATGPT_ON_WECHAT_EXEC:-""}
OPEN_AI_API_KEY=${OPEN_AI_API_KEY:-""}
OPEN_AI_PROXY=${OPEN_AI_PROXY:-""}
SINGLE_CHAT_PREFIX=${SINGLE_CHAT_PREFIX:-""}
SINGLE_CHAT_REPLY_PREFIX=${SINGLE_CHAT_REPLY_PREFIX:-""}
GROUP_CHAT_PREFIX=${GROUP_CHAT_PREFIX:-""}
GROUP_NAME_WHITE_LIST=${GROUP_NAME_WHITE_LIST:-""}
IMAGE_CREATE_PREFIX=${IMAGE_CREATE_PREFIX:-""}
CONVERSATION_MAX_TOKENS=${CONVERSATION_MAX_TOKENS:-""}
SPEECH_RECOGNITION=${SPEECH_RECOGNITION:-""}
CHARACTER_DESC=${CHARACTER_DESC:-""}
EXPIRES_IN_SECONDS=${EXPIRES_IN_SECONDS:-""}
VOICE_REPLY_VOICE=${VOICE_REPLY_VOICE:-""}
BAIDU_APP_ID=${BAIDU_APP_ID:-""}
BAIDU_API_KEY=${BAIDU_API_KEY:-""}
BAIDU_SECRET_KEY=${BAIDU_SECRET_KEY:-""}
# CHATGPT_ON_WECHAT_PREFIX is empty, use /app
if [ "$CHATGPT_ON_WECHAT_PREFIX" == "" ] ; then
CHATGPT_ON_WECHAT_PREFIX=/app
fi
# CHATGPT_ON_WECHAT_CONFIG_PATH is empty, use '/app/config.json'
if [ "$CHATGPT_ON_WECHAT_CONFIG_PATH" == "" ] ; then
CHATGPT_ON_WECHAT_CONFIG_PATH=$CHATGPT_ON_WECHAT_PREFIX/config.json
fi
# CHATGPT_ON_WECHAT_EXEC is empty, use python app.py
if [ "$CHATGPT_ON_WECHAT_EXEC" == "" ] ; then
CHATGPT_ON_WECHAT_EXEC="python app.py"
fi
# modify content in config.json
if [ "$OPEN_AI_API_KEY" != "" ] ; then
sed -i "s/\"open_ai_api_key\".*,$/\"open_ai_api_key\": \"$OPEN_AI_API_KEY\",/" $CHATGPT_ON_WECHAT_CONFIG_PATH
else
echo -e "\033[31m[Warning] You need to set OPEN_AI_API_KEY before running!\033[0m"
fi
# use http_proxy as default
if [ "$HTTP_PROXY" != "" ] ; then
sed -i "s/\"proxy\".*,$/\"proxy\": \"$HTTP_PROXY\",/" $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$OPEN_AI_PROXY" != "" ] ; then
sed -i "s/\"proxy\".*,$/\"proxy\": \"$OPEN_AI_PROXY\",/" $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$SINGLE_CHAT_PREFIX" != "" ] ; then
sed -i "s/\"single_chat_prefix\".*,$/\"single_chat_prefix\": $SINGLE_CHAT_PREFIX,/" $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$SINGLE_CHAT_REPLY_PREFIX" != "" ] ; then
sed -i "s/\"single_chat_reply_prefix\".*,$/\"single_chat_reply_prefix\": $SINGLE_CHAT_REPLY_PREFIX,/" $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$GROUP_CHAT_PREFIX" != "" ] ; then
sed -i "s/\"group_chat_prefix\".*,$/\"group_chat_prefix\": $GROUP_CHAT_PREFIX,/" $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$GROUP_NAME_WHITE_LIST" != "" ] ; then
sed -i "s/\"group_name_white_list\".*,$/\"group_name_white_list\": $GROUP_NAME_WHITE_LIST,/" $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$IMAGE_CREATE_PREFIX" != "" ] ; then
sed -i "s/\"image_create_prefix\".*,$/\"image_create_prefix\": $IMAGE_CREATE_PREFIX,/" $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$CONVERSATION_MAX_TOKENS" != "" ] ; then
sed -i "s/\"conversation_max_tokens\".*,$/\"conversation_max_tokens\": $CONVERSATION_MAX_TOKENS,/" $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$SPEECH_RECOGNITION" != "" ] ; then
sed -i "s/\"speech_recognition\".*,$/\"speech_recognition\": $SPEECH_RECOGNITION,/" $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$CHARACTER_DESC" != "" ] ; then
sed -i "s/\"character_desc\".*,$/\"character_desc\": \"$CHARACTER_DESC\",/" $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$EXPIRES_IN_SECONDS" != "" ] ; then
sed -i "s/\"expires_in_seconds\".*$/\"expires_in_seconds\": $EXPIRES_IN_SECONDS/" $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
# append
if [ "$BAIDU_SECRET_KEY" != "" ] ; then
sed -i "1a \ \ \"baidu_secret_key\": \"$BAIDU_SECRET_KEY\"," $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$BAIDU_API_KEY" != "" ] ; then
sed -i "1a \ \ \"baidu_api_key\": \"$BAIDU_API_KEY\"," $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$BAIDU_APP_ID" != "" ] ; then
sed -i "1a \ \ \"baidu_app_id\": \"$BAIDU_APP_ID\"," $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$VOICE_REPLY_VOICE" != "" ] ; then
sed -i "1a \ \ \"voice_reply_voice\": $VOICE_REPLY_VOICE," $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
# go to prefix dir
cd $CHATGPT_ON_WECHAT_PREFIX
# excute
$CHATGPT_ON_WECHAT_EXEC

View File

@@ -1,20 +1,24 @@
version: '2.0'
services:
chatgpt-on-wechat:
build:
context: ./
dockerfile: Dockerfile.alpine
image: zhayujie/chatgpt-on-wechat
container_name: sample-chatgpt-on-wechat
container_name: chatgpt-on-wechat
security_opt:
- seccomp:unconfined
environment:
OPEN_AI_API_KEY: 'YOUR API KEY'
OPEN_AI_PROXY: ''
MODEL: 'gpt-3.5-turbo'
PROXY: ''
SINGLE_CHAT_PREFIX: '["bot", "@bot"]'
SINGLE_CHAT_REPLY_PREFIX: '"[bot] "'
GROUP_CHAT_PREFIX: '["@bot"]'
GROUP_NAME_WHITE_LIST: '["ChatGPT测试群", "ChatGPT测试群2"]'
IMAGE_CREATE_PREFIX: '["画", "看", "找"]'
CONVERSATION_MAX_TOKENS: 1000
SPEECH_RECOGNITION: 'false'
SPEECH_RECOGNITION: 'False'
CHARACTER_DESC: '你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。'
EXPIRES_IN_SECONDS: 3600
EXPIRES_IN_SECONDS: 3600
USE_GLOBAL_PLUGIN_CONFIG: 'True'
USE_LINKAI: 'False'
LINKAI_API_KEY: ''
LINKAI_APP_CODE: ''

View File

@@ -8,17 +8,19 @@ CHATGPT_ON_WECHAT_CONFIG_PATH=${CHATGPT_ON_WECHAT_CONFIG_PATH:-""}
# execution command line
CHATGPT_ON_WECHAT_EXEC=${CHATGPT_ON_WECHAT_EXEC:-""}
OPEN_AI_API_KEY=${OPEN_AI_API_KEY:-""}
OPEN_AI_PROXY=${OPEN_AI_PROXY:-""}
SINGLE_CHAT_PREFIX=${SINGLE_CHAT_PREFIX:-""}
SINGLE_CHAT_REPLY_PREFIX=${SINGLE_CHAT_REPLY_PREFIX:-""}
GROUP_CHAT_PREFIX=${GROUP_CHAT_PREFIX:-""}
GROUP_NAME_WHITE_LIST=${GROUP_NAME_WHITE_LIST:-""}
IMAGE_CREATE_PREFIX=${IMAGE_CREATE_PREFIX:-""}
CONVERSATION_MAX_TOKENS=${CONVERSATION_MAX_TOKENS:-""}
SPEECH_RECOGNITION=${SPEECH_RECOGNITION:-""}
CHARACTER_DESC=${CHARACTER_DESC:-""}
EXPIRES_IN_SECONDS=${EXPIRES_IN_SECONDS:-""}
# use environment variables to pass parameters
# if you have not defined environment variables, set them below
# export OPEN_AI_API_KEY=${OPEN_AI_API_KEY:-'YOUR API KEY'}
# export OPEN_AI_PROXY=${OPEN_AI_PROXY:-""}
# export SINGLE_CHAT_PREFIX=${SINGLE_CHAT_PREFIX:-'["bot", "@bot"]'}
# export SINGLE_CHAT_REPLY_PREFIX=${SINGLE_CHAT_REPLY_PREFIX:-'"[bot] "'}
# export GROUP_CHAT_PREFIX=${GROUP_CHAT_PREFIX:-'["@bot"]'}
# export GROUP_NAME_WHITE_LIST=${GROUP_NAME_WHITE_LIST:-'["ChatGPT测试群", "ChatGPT测试群2"]'}
# export IMAGE_CREATE_PREFIX=${IMAGE_CREATE_PREFIX:-'["画", "看", "找"]'}
# export CONVERSATION_MAX_TOKENS=${CONVERSATION_MAX_TOKENS:-"1000"}
# export SPEECH_RECOGNITION=${SPEECH_RECOGNITION:-"False"}
# export CHARACTER_DESC=${CHARACTER_DESC:-"你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。"}
# export EXPIRES_IN_SECONDS=${EXPIRES_IN_SECONDS:-"3600"}
# CHATGPT_ON_WECHAT_PREFIX is empty, use /app
if [ "$CHATGPT_ON_WECHAT_PREFIX" == "" ] ; then
@@ -36,56 +38,10 @@ if [ "$CHATGPT_ON_WECHAT_EXEC" == "" ] ; then
fi
# modify content in config.json
if [ "$OPEN_AI_API_KEY" != "" ] ; then
sed -i "s/\"open_ai_api_key\".*,$/\"open_ai_api_key\": \"$OPEN_AI_API_KEY\",/" $CHATGPT_ON_WECHAT_CONFIG_PATH
else
echo -e "\033[31m[Warning] You need to set OPEN_AI_API_KEY before running!\033[0m"
fi
# if [ "$OPEN_AI_API_KEY" == "YOUR API KEY" ] || [ "$OPEN_AI_API_KEY" == "" ]; then
# echo -e "\033[31m[Warning] You need to set OPEN_AI_API_KEY before running!\033[0m"
# fi
# use http_proxy as default
if [ "$HTTP_PROXY" != "" ] ; then
sed -i "s/\"proxy\".*,$/\"proxy\": \"$HTTP_PROXY\",/" $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$OPEN_AI_PROXY" != "" ] ; then
sed -i "s/\"proxy\".*,$/\"proxy\": \"$OPEN_AI_PROXY\",/" $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$SINGLE_CHAT_PREFIX" != "" ] ; then
sed -i "s/\"single_chat_prefix\".*,$/\"single_chat_prefix\": $SINGLE_CHAT_PREFIX,/" $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$SINGLE_CHAT_REPLY_PREFIX" != "" ] ; then
sed -i "s/\"single_chat_reply_prefix\".*,$/\"single_chat_reply_prefix\": $SINGLE_CHAT_REPLY_PREFIX,/" $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$GROUP_CHAT_PREFIX" != "" ] ; then
sed -i "s/\"group_chat_prefix\".*,$/\"group_chat_prefix\": $GROUP_CHAT_PREFIX,/" $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$GROUP_NAME_WHITE_LIST" != "" ] ; then
sed -i "s/\"group_name_white_list\".*,$/\"group_name_white_list\": $GROUP_NAME_WHITE_LIST,/" $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$IMAGE_CREATE_PREFIX" != "" ] ; then
sed -i "s/\"image_create_prefix\".*,$/\"image_create_prefix\": $IMAGE_CREATE_PREFIX,/" $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$CONVERSATION_MAX_TOKENS" != "" ] ; then
sed -i "s/\"conversation_max_tokens\".*,$/\"conversation_max_tokens\": $CONVERSATION_MAX_TOKENS,/" $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$SPEECH_RECOGNITION" != "" ] ; then
sed -i "s/\"speech_recognition\".*,$/\"speech_recognition\": $SPEECH_RECOGNITION,/" $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$CHARACTER_DESC" != "" ] ; then
sed -i "s/\"character_desc\".*,$/\"character_desc\": \"$CHARACTER_DESC\",/" $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
if [ "$EXPIRES_IN_SECONDS" != "" ] ; then
sed -i "s/\"expires_in_seconds\".*$/\"expires_in_seconds\": $EXPIRES_IN_SECONDS/" $CHATGPT_ON_WECHAT_CONFIG_PATH
fi
# go to prefix dir
cd $CHATGPT_ON_WECHAT_PREFIX

View File

@@ -1,16 +0,0 @@
OPEN_AI_API_KEY=YOUR API KEY
OPEN_AI_PROXY=
SINGLE_CHAT_PREFIX=["bot", "@bot"]
SINGLE_CHAT_REPLY_PREFIX="[bot] "
GROUP_CHAT_PREFIX=["@bot"]
GROUP_NAME_WHITE_LIST=["ChatGPT测试群", "ChatGPT测试群2"]
IMAGE_CREATE_PREFIX=["画", "看", "找"]
CONVERSATION_MAX_TOKENS=1000
SPEECH_RECOGNITION=false
CHARACTER_DESC=你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。
EXPIRES_IN_SECONDS=3600
# Optional
#CHATGPT_ON_WECHAT_PREFIX=/app
#CHATGPT_ON_WECHAT_CONFIG_PATH=/app/config.json
#CHATGPT_ON_WECHAT_EXEC=python app.py

View File

@@ -1,26 +0,0 @@
IMG:=`cat Name`
MOUNT:=
PORT_MAP:=
DOTENV:=.env
CONTAINER_NAME:=sample-chatgpt-on-wechat
echo:
echo $(IMG)
run_d:
docker rm $(CONTAINER_NAME) || echo
docker run -dt --name $(CONTAINER_NAME) $(PORT_MAP) \
--env-file=$(DOTENV) \
$(MOUNT) $(IMG)
run_i:
docker rm $(CONTAINER_NAME) || echo
docker run -it --name $(CONTAINER_NAME) $(PORT_MAP) \
--env-file=$(DOTENV) \
$(MOUNT) $(IMG)
stop:
docker stop $(CONTAINER_NAME)
rm: stop
docker rm $(CONTAINER_NAME)

View File

@@ -1 +0,0 @@
zhayujie/chatgpt-on-wechat

BIN
docs/images/aigcopen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
docs/images/contact.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

BIN
docs/images/planet.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

9
lib/itchat/LICENSE Normal file
View File

@@ -0,0 +1,9 @@
**The MIT License (MIT)**
Copyright (c) 2017 LittleCoder ([littlecodersh@Github](https://github.com/littlecodersh))
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

96
lib/itchat/__init__.py Normal file
View File

@@ -0,0 +1,96 @@
from .core import Core
from .config import VERSION, ASYNC_COMPONENTS
from .log import set_logging
if ASYNC_COMPONENTS:
from .async_components import load_components
else:
from .components import load_components
__version__ = VERSION
instanceList = []
def load_async_itchat() -> Core:
"""load async-based itchat instance
Returns:
Core: the abstract interface of itchat
"""
from .async_components import load_components
load_components(Core)
return Core()
def load_sync_itchat() -> Core:
"""load sync-based itchat instance
Returns:
Core: the abstract interface of itchat
"""
from .components import load_components
load_components(Core)
return Core()
if ASYNC_COMPONENTS:
instance = load_async_itchat()
else:
instance = load_sync_itchat()
instanceList = [instance]
# I really want to use sys.modules[__name__] = originInstance
# but it makes auto-fill a real mess, so forgive me for my following **
# actually it toke me less than 30 seconds, god bless Uganda
# components.login
login = instance.login
get_QRuuid = instance.get_QRuuid
get_QR = instance.get_QR
check_login = instance.check_login
web_init = instance.web_init
show_mobile_login = instance.show_mobile_login
start_receiving = instance.start_receiving
get_msg = instance.get_msg
logout = instance.logout
# components.contact
update_chatroom = instance.update_chatroom
update_friend = instance.update_friend
get_contact = instance.get_contact
get_friends = instance.get_friends
get_chatrooms = instance.get_chatrooms
get_mps = instance.get_mps
set_alias = instance.set_alias
set_pinned = instance.set_pinned
accept_friend = instance.accept_friend
get_head_img = instance.get_head_img
create_chatroom = instance.create_chatroom
set_chatroom_name = instance.set_chatroom_name
delete_member_from_chatroom = instance.delete_member_from_chatroom
add_member_into_chatroom = instance.add_member_into_chatroom
# components.messages
send_raw_msg = instance.send_raw_msg
send_msg = instance.send_msg
upload_file = instance.upload_file
send_file = instance.send_file
send_image = instance.send_image
send_video = instance.send_video
send = instance.send
revoke = instance.revoke
# components.hotreload
dump_login_status = instance.dump_login_status
load_login_status = instance.load_login_status
# components.register
auto_login = instance.auto_login
configured_reply = instance.configured_reply
msg_register = instance.msg_register
run = instance.run
# other functions
search_friends = instance.search_friends
search_chatrooms = instance.search_chatrooms
search_mps = instance.search_mps
set_logging = set_logging

View File

@@ -0,0 +1,12 @@
from .contact import load_contact
from .hotreload import load_hotreload
from .login import load_login
from .messages import load_messages
from .register import load_register
def load_components(core):
load_contact(core)
load_hotreload(core)
load_login(core)
load_messages(core)
load_register(core)

View File

@@ -0,0 +1,488 @@
import time, re, io
import json, copy
import logging
from .. import config, utils
from ..components.contact import accept_friend
from ..returnvalues import ReturnValue
from ..storage import contact_change
from ..utils import update_info_dict
logger = logging.getLogger('itchat')
def load_contact(core):
core.update_chatroom = update_chatroom
core.update_friend = update_friend
core.get_contact = get_contact
core.get_friends = get_friends
core.get_chatrooms = get_chatrooms
core.get_mps = get_mps
core.set_alias = set_alias
core.set_pinned = set_pinned
core.accept_friend = accept_friend
core.get_head_img = get_head_img
core.create_chatroom = create_chatroom
core.set_chatroom_name = set_chatroom_name
core.delete_member_from_chatroom = delete_member_from_chatroom
core.add_member_into_chatroom = add_member_into_chatroom
def update_chatroom(self, userName, detailedMember=False):
if not isinstance(userName, list):
userName = [userName]
url = '%s/webwxbatchgetcontact?type=ex&r=%s' % (
self.loginInfo['url'], int(time.time()))
headers = {
'ContentType': 'application/json; charset=UTF-8',
'User-Agent' : config.USER_AGENT }
data = {
'BaseRequest': self.loginInfo['BaseRequest'],
'Count': len(userName),
'List': [{
'UserName': u,
'ChatRoomId': '', } for u in userName], }
chatroomList = json.loads(self.s.post(url, data=json.dumps(data), headers=headers
).content.decode('utf8', 'replace')).get('ContactList')
if not chatroomList:
return ReturnValue({'BaseResponse': {
'ErrMsg': 'No chatroom found',
'Ret': -1001, }})
if detailedMember:
def get_detailed_member_info(encryChatroomId, memberList):
url = '%s/webwxbatchgetcontact?type=ex&r=%s' % (
self.loginInfo['url'], int(time.time()))
headers = {
'ContentType': 'application/json; charset=UTF-8',
'User-Agent' : config.USER_AGENT, }
data = {
'BaseRequest': self.loginInfo['BaseRequest'],
'Count': len(memberList),
'List': [{
'UserName': member['UserName'],
'EncryChatRoomId': encryChatroomId} \
for member in memberList], }
return json.loads(self.s.post(url, data=json.dumps(data), headers=headers
).content.decode('utf8', 'replace'))['ContactList']
MAX_GET_NUMBER = 50
for chatroom in chatroomList:
totalMemberList = []
for i in range(int(len(chatroom['MemberList']) / MAX_GET_NUMBER + 1)):
memberList = chatroom['MemberList'][i*MAX_GET_NUMBER: (i+1)*MAX_GET_NUMBER]
totalMemberList += get_detailed_member_info(chatroom['EncryChatRoomId'], memberList)
chatroom['MemberList'] = totalMemberList
update_local_chatrooms(self, chatroomList)
r = [self.storageClass.search_chatrooms(userName=c['UserName'])
for c in chatroomList]
return r if 1 < len(r) else r[0]
def update_friend(self, userName):
if not isinstance(userName, list):
userName = [userName]
url = '%s/webwxbatchgetcontact?type=ex&r=%s' % (
self.loginInfo['url'], int(time.time()))
headers = {
'ContentType': 'application/json; charset=UTF-8',
'User-Agent' : config.USER_AGENT }
data = {
'BaseRequest': self.loginInfo['BaseRequest'],
'Count': len(userName),
'List': [{
'UserName': u,
'EncryChatRoomId': '', } for u in userName], }
friendList = json.loads(self.s.post(url, data=json.dumps(data), headers=headers
).content.decode('utf8', 'replace')).get('ContactList')
update_local_friends(self, friendList)
r = [self.storageClass.search_friends(userName=f['UserName'])
for f in friendList]
return r if len(r) != 1 else r[0]
@contact_change
def update_local_chatrooms(core, l):
'''
get a list of chatrooms for updating local chatrooms
return a list of given chatrooms with updated info
'''
for chatroom in l:
# format new chatrooms
utils.emoji_formatter(chatroom, 'NickName')
for member in chatroom['MemberList']:
if 'NickName' in member:
utils.emoji_formatter(member, 'NickName')
if 'DisplayName' in member:
utils.emoji_formatter(member, 'DisplayName')
if 'RemarkName' in member:
utils.emoji_formatter(member, 'RemarkName')
# update it to old chatrooms
oldChatroom = utils.search_dict_list(
core.chatroomList, 'UserName', chatroom['UserName'])
if oldChatroom:
update_info_dict(oldChatroom, chatroom)
# - update other values
memberList = chatroom.get('MemberList', [])
oldMemberList = oldChatroom['MemberList']
if memberList:
for member in memberList:
oldMember = utils.search_dict_list(
oldMemberList, 'UserName', member['UserName'])
if oldMember:
update_info_dict(oldMember, member)
else:
oldMemberList.append(member)
else:
core.chatroomList.append(chatroom)
oldChatroom = utils.search_dict_list(
core.chatroomList, 'UserName', chatroom['UserName'])
# delete useless members
if len(chatroom['MemberList']) != len(oldChatroom['MemberList']) and \
chatroom['MemberList']:
existsUserNames = [member['UserName'] for member in chatroom['MemberList']]
delList = []
for i, member in enumerate(oldChatroom['MemberList']):
if member['UserName'] not in existsUserNames:
delList.append(i)
delList.sort(reverse=True)
for i in delList:
del oldChatroom['MemberList'][i]
# - update OwnerUin
if oldChatroom.get('ChatRoomOwner') and oldChatroom.get('MemberList'):
owner = utils.search_dict_list(oldChatroom['MemberList'],
'UserName', oldChatroom['ChatRoomOwner'])
oldChatroom['OwnerUin'] = (owner or {}).get('Uin', 0)
# - update IsAdmin
if 'OwnerUin' in oldChatroom and oldChatroom['OwnerUin'] != 0:
oldChatroom['IsAdmin'] = \
oldChatroom['OwnerUin'] == int(core.loginInfo['wxuin'])
else:
oldChatroom['IsAdmin'] = None
# - update Self
newSelf = utils.search_dict_list(oldChatroom['MemberList'],
'UserName', core.storageClass.userName)
oldChatroom['Self'] = newSelf or copy.deepcopy(core.loginInfo['User'])
return {
'Type' : 'System',
'Text' : [chatroom['UserName'] for chatroom in l],
'SystemInfo' : 'chatrooms',
'FromUserName' : core.storageClass.userName,
'ToUserName' : core.storageClass.userName, }
@contact_change
def update_local_friends(core, l):
'''
get a list of friends or mps for updating local contact
'''
fullList = core.memberList + core.mpList
for friend in l:
if 'NickName' in friend:
utils.emoji_formatter(friend, 'NickName')
if 'DisplayName' in friend:
utils.emoji_formatter(friend, 'DisplayName')
if 'RemarkName' in friend:
utils.emoji_formatter(friend, 'RemarkName')
oldInfoDict = utils.search_dict_list(
fullList, 'UserName', friend['UserName'])
if oldInfoDict is None:
oldInfoDict = copy.deepcopy(friend)
if oldInfoDict['VerifyFlag'] & 8 == 0:
core.memberList.append(oldInfoDict)
else:
core.mpList.append(oldInfoDict)
else:
update_info_dict(oldInfoDict, friend)
@contact_change
def update_local_uin(core, msg):
'''
content contains uins and StatusNotifyUserName contains username
they are in same order, so what I do is to pair them together
I caught an exception in this method while not knowing why
but don't worry, it won't cause any problem
'''
uins = re.search('<username>([^<]*?)<', msg['Content'])
usernameChangedList = []
r = {
'Type': 'System',
'Text': usernameChangedList,
'SystemInfo': 'uins', }
if uins:
uins = uins.group(1).split(',')
usernames = msg['StatusNotifyUserName'].split(',')
if 0 < len(uins) == len(usernames):
for uin, username in zip(uins, usernames):
if not '@' in username: continue
fullContact = core.memberList + core.chatroomList + core.mpList
userDicts = utils.search_dict_list(fullContact,
'UserName', username)
if userDicts:
if userDicts.get('Uin', 0) == 0:
userDicts['Uin'] = uin
usernameChangedList.append(username)
logger.debug('Uin fetched: %s, %s' % (username, uin))
else:
if userDicts['Uin'] != uin:
logger.debug('Uin changed: %s, %s' % (
userDicts['Uin'], uin))
else:
if '@@' in username:
core.storageClass.updateLock.release()
update_chatroom(core, username)
core.storageClass.updateLock.acquire()
newChatroomDict = utils.search_dict_list(
core.chatroomList, 'UserName', username)
if newChatroomDict is None:
newChatroomDict = utils.struct_friend_info({
'UserName': username,
'Uin': uin,
'Self': copy.deepcopy(core.loginInfo['User'])})
core.chatroomList.append(newChatroomDict)
else:
newChatroomDict['Uin'] = uin
elif '@' in username:
core.storageClass.updateLock.release()
update_friend(core, username)
core.storageClass.updateLock.acquire()
newFriendDict = utils.search_dict_list(
core.memberList, 'UserName', username)
if newFriendDict is None:
newFriendDict = utils.struct_friend_info({
'UserName': username,
'Uin': uin, })
core.memberList.append(newFriendDict)
else:
newFriendDict['Uin'] = uin
usernameChangedList.append(username)
logger.debug('Uin fetched: %s, %s' % (username, uin))
else:
logger.debug('Wrong length of uins & usernames: %s, %s' % (
len(uins), len(usernames)))
else:
logger.debug('No uins in 51 message')
logger.debug(msg['Content'])
return r
def get_contact(self, update=False):
if not update:
return utils.contact_deep_copy(self, self.chatroomList)
def _get_contact(seq=0):
url = '%s/webwxgetcontact?r=%s&seq=%s&skey=%s' % (self.loginInfo['url'],
int(time.time()), seq, self.loginInfo['skey'])
headers = {
'ContentType': 'application/json; charset=UTF-8',
'User-Agent' : config.USER_AGENT, }
try:
r = self.s.get(url, headers=headers)
except:
logger.info('Failed to fetch contact, that may because of the amount of your chatrooms')
for chatroom in self.get_chatrooms():
self.update_chatroom(chatroom['UserName'], detailedMember=True)
return 0, []
j = json.loads(r.content.decode('utf-8', 'replace'))
return j.get('Seq', 0), j.get('MemberList')
seq, memberList = 0, []
while 1:
seq, batchMemberList = _get_contact(seq)
memberList.extend(batchMemberList)
if seq == 0:
break
chatroomList, otherList = [], []
for m in memberList:
if m['Sex'] != 0:
otherList.append(m)
elif '@@' in m['UserName']:
chatroomList.append(m)
elif '@' in m['UserName']:
# mp will be dealt in update_local_friends as well
otherList.append(m)
if chatroomList:
update_local_chatrooms(self, chatroomList)
if otherList:
update_local_friends(self, otherList)
return utils.contact_deep_copy(self, chatroomList)
def get_friends(self, update=False):
if update:
self.get_contact(update=True)
return utils.contact_deep_copy(self, self.memberList)
def get_chatrooms(self, update=False, contactOnly=False):
if contactOnly:
return self.get_contact(update=True)
else:
if update:
self.get_contact(True)
return utils.contact_deep_copy(self, self.chatroomList)
def get_mps(self, update=False):
if update: self.get_contact(update=True)
return utils.contact_deep_copy(self, self.mpList)
def set_alias(self, userName, alias):
oldFriendInfo = utils.search_dict_list(
self.memberList, 'UserName', userName)
if oldFriendInfo is None:
return ReturnValue({'BaseResponse': {
'Ret': -1001, }})
url = '%s/webwxoplog?lang=%s&pass_ticket=%s' % (
self.loginInfo['url'], 'zh_CN', self.loginInfo['pass_ticket'])
data = {
'UserName' : userName,
'CmdId' : 2,
'RemarkName' : alias,
'BaseRequest' : self.loginInfo['BaseRequest'], }
headers = { 'User-Agent' : config.USER_AGENT}
r = self.s.post(url, json.dumps(data, ensure_ascii=False).encode('utf8'),
headers=headers)
r = ReturnValue(rawResponse=r)
if r:
oldFriendInfo['RemarkName'] = alias
return r
def set_pinned(self, userName, isPinned=True):
url = '%s/webwxoplog?pass_ticket=%s' % (
self.loginInfo['url'], self.loginInfo['pass_ticket'])
data = {
'UserName' : userName,
'CmdId' : 3,
'OP' : int(isPinned),
'BaseRequest' : self.loginInfo['BaseRequest'], }
headers = { 'User-Agent' : config.USER_AGENT}
r = self.s.post(url, json=data, headers=headers)
return ReturnValue(rawResponse=r)
def accept_friend(self, userName, v4= '', autoUpdate=True):
url = f"{self.loginInfo['url']}/webwxverifyuser?r={int(time.time())}&pass_ticket={self.loginInfo['pass_ticket']}"
data = {
'BaseRequest': self.loginInfo['BaseRequest'],
'Opcode': 3, # 3
'VerifyUserListSize': 1,
'VerifyUserList': [{
'Value': userName,
'VerifyUserTicket': v4, }],
'VerifyContent': '',
'SceneListCount': 1,
'SceneList': [33],
'skey': self.loginInfo['skey'], }
headers = {
'ContentType': 'application/json; charset=UTF-8',
'User-Agent' : config.USER_AGENT }
r = self.s.post(url, headers=headers,
data=json.dumps(data, ensure_ascii=False).encode('utf8', 'replace'))
if autoUpdate:
self.update_friend(userName)
return ReturnValue(rawResponse=r)
def get_head_img(self, userName=None, chatroomUserName=None, picDir=None):
''' get head image
* if you want to get chatroom header: only set chatroomUserName
* if you want to get friend header: only set userName
* if you want to get chatroom member header: set both
'''
params = {
'userName': userName or chatroomUserName or self.storageClass.userName,
'skey': self.loginInfo['skey'],
'type': 'big', }
url = '%s/webwxgeticon' % self.loginInfo['url']
if chatroomUserName is None:
infoDict = self.storageClass.search_friends(userName=userName)
if infoDict is None:
return ReturnValue({'BaseResponse': {
'ErrMsg': 'No friend found',
'Ret': -1001, }})
else:
if userName is None:
url = '%s/webwxgetheadimg' % self.loginInfo['url']
else:
chatroom = self.storageClass.search_chatrooms(userName=chatroomUserName)
if chatroomUserName is None:
return ReturnValue({'BaseResponse': {
'ErrMsg': 'No chatroom found',
'Ret': -1001, }})
if 'EncryChatRoomId' in chatroom:
params['chatroomid'] = chatroom['EncryChatRoomId']
params['chatroomid'] = params.get('chatroomid') or chatroom['UserName']
headers = { 'User-Agent' : config.USER_AGENT}
r = self.s.get(url, params=params, stream=True, headers=headers)
tempStorage = io.BytesIO()
for block in r.iter_content(1024):
tempStorage.write(block)
if picDir is None:
return tempStorage.getvalue()
with open(picDir, 'wb') as f:
f.write(tempStorage.getvalue())
tempStorage.seek(0)
return ReturnValue({'BaseResponse': {
'ErrMsg': 'Successfully downloaded',
'Ret': 0, },
'PostFix': utils.get_image_postfix(tempStorage.read(20)), })
def create_chatroom(self, memberList, topic=''):
url = '%s/webwxcreatechatroom?pass_ticket=%s&r=%s' % (
self.loginInfo['url'], self.loginInfo['pass_ticket'], int(time.time()))
data = {
'BaseRequest': self.loginInfo['BaseRequest'],
'MemberCount': len(memberList.split(',')),
'MemberList': [{'UserName': member} for member in memberList.split(',')],
'Topic': topic, }
headers = {
'content-type': 'application/json; charset=UTF-8',
'User-Agent' : config.USER_AGENT }
r = self.s.post(url, headers=headers,
data=json.dumps(data, ensure_ascii=False).encode('utf8', 'ignore'))
return ReturnValue(rawResponse=r)
def set_chatroom_name(self, chatroomUserName, name):
url = '%s/webwxupdatechatroom?fun=modtopic&pass_ticket=%s' % (
self.loginInfo['url'], self.loginInfo['pass_ticket'])
data = {
'BaseRequest': self.loginInfo['BaseRequest'],
'ChatRoomName': chatroomUserName,
'NewTopic': name, }
headers = {
'content-type': 'application/json; charset=UTF-8',
'User-Agent' : config.USER_AGENT }
r = self.s.post(url, headers=headers,
data=json.dumps(data, ensure_ascii=False).encode('utf8', 'ignore'))
return ReturnValue(rawResponse=r)
def delete_member_from_chatroom(self, chatroomUserName, memberList):
url = '%s/webwxupdatechatroom?fun=delmember&pass_ticket=%s' % (
self.loginInfo['url'], self.loginInfo['pass_ticket'])
data = {
'BaseRequest': self.loginInfo['BaseRequest'],
'ChatRoomName': chatroomUserName,
'DelMemberList': ','.join([member['UserName'] for member in memberList]), }
headers = {
'content-type': 'application/json; charset=UTF-8',
'User-Agent' : config.USER_AGENT}
r = self.s.post(url, data=json.dumps(data),headers=headers)
return ReturnValue(rawResponse=r)
def add_member_into_chatroom(self, chatroomUserName, memberList,
useInvitation=False):
''' add or invite member into chatroom
* there are two ways to get members into chatroom: invite or directly add
* but for chatrooms with more than 40 users, you can only use invite
* but don't worry we will auto-force userInvitation for you when necessary
'''
if not useInvitation:
chatroom = self.storageClass.search_chatrooms(userName=chatroomUserName)
if not chatroom: chatroom = self.update_chatroom(chatroomUserName)
if len(chatroom['MemberList']) > self.loginInfo['InviteStartCount']:
useInvitation = True
if useInvitation:
fun, memberKeyName = 'invitemember', 'InviteMemberList'
else:
fun, memberKeyName = 'addmember', 'AddMemberList'
url = '%s/webwxupdatechatroom?fun=%s&pass_ticket=%s' % (
self.loginInfo['url'], fun, self.loginInfo['pass_ticket'])
params = {
'BaseRequest' : self.loginInfo['BaseRequest'],
'ChatRoomName' : chatroomUserName,
memberKeyName : memberList, }
headers = {
'content-type': 'application/json; charset=UTF-8',
'User-Agent' : config.USER_AGENT}
r = self.s.post(url, data=json.dumps(params),headers=headers)
return ReturnValue(rawResponse=r)

View File

@@ -0,0 +1,102 @@
import pickle, os
import logging
import requests # type: ignore
from ..config import VERSION
from ..returnvalues import ReturnValue
from ..storage import templates
from .contact import update_local_chatrooms, update_local_friends
from .messages import produce_msg
logger = logging.getLogger('itchat')
def load_hotreload(core):
core.dump_login_status = dump_login_status
core.load_login_status = load_login_status
async def dump_login_status(self, fileDir=None):
fileDir = fileDir or self.hotReloadDir
try:
with open(fileDir, 'w') as f:
f.write('itchat - DELETE THIS')
os.remove(fileDir)
except:
raise Exception('Incorrect fileDir')
status = {
'version' : VERSION,
'loginInfo' : self.loginInfo,
'cookies' : self.s.cookies.get_dict(),
'storage' : self.storageClass.dumps()}
with open(fileDir, 'wb') as f:
pickle.dump(status, f)
logger.debug('Dump login status for hot reload successfully.')
async def load_login_status(self, fileDir,
loginCallback=None, exitCallback=None):
try:
with open(fileDir, 'rb') as f:
j = pickle.load(f)
except Exception as e:
logger.debug('No such file, loading login status failed.')
return ReturnValue({'BaseResponse': {
'ErrMsg': 'No such file, loading login status failed.',
'Ret': -1002, }})
if j.get('version', '') != VERSION:
logger.debug(('you have updated itchat from %s to %s, ' +
'so cached status is ignored') % (
j.get('version', 'old version'), VERSION))
return ReturnValue({'BaseResponse': {
'ErrMsg': 'cached status ignored because of version',
'Ret': -1005, }})
self.loginInfo = j['loginInfo']
self.loginInfo['User'] = templates.User(self.loginInfo['User'])
self.loginInfo['User'].core = self
self.s.cookies = requests.utils.cookiejar_from_dict(j['cookies'])
self.storageClass.loads(j['storage'])
try:
msgList, contactList = self.get_msg()
except:
msgList = contactList = None
if (msgList or contactList) is None:
self.logout()
await load_last_login_status(self.s, j['cookies'])
logger.debug('server refused, loading login status failed.')
return ReturnValue({'BaseResponse': {
'ErrMsg': 'server refused, loading login status failed.',
'Ret': -1003, }})
else:
if contactList:
for contact in contactList:
if '@@' in contact['UserName']:
update_local_chatrooms(self, [contact])
else:
update_local_friends(self, [contact])
if msgList:
msgList = produce_msg(self, msgList)
for msg in msgList: self.msgList.put(msg)
await self.start_receiving(exitCallback)
logger.debug('loading login status succeeded.')
if hasattr(loginCallback, '__call__'):
await loginCallback(self.storageClass.userName)
return ReturnValue({'BaseResponse': {
'ErrMsg': 'loading login status succeeded.',
'Ret': 0, }})
async def load_last_login_status(session, cookiesDict):
try:
session.cookies = requests.utils.cookiejar_from_dict({
'webwxuvid': cookiesDict['webwxuvid'],
'webwx_auth_ticket': cookiesDict['webwx_auth_ticket'],
'login_frequency': '2',
'last_wxuin': cookiesDict['wxuin'],
'wxloadtime': cookiesDict['wxloadtime'] + '_expired',
'wxpluginkey': cookiesDict['wxloadtime'],
'wxuin': cookiesDict['wxuin'],
'mm_lang': 'zh_CN',
'MM_WX_NOTIFY_STATE': '1',
'MM_WX_SOUND_STATE': '1', })
except:
logger.info('Load status for push login failed, we may have experienced a cookies change.')
logger.info('If you are using the newest version of itchat, you may report a bug.')

View File

@@ -0,0 +1,422 @@
import asyncio
import os, time, re, io
import threading
import json
import random
import traceback
import logging
try:
from httplib import BadStatusLine
except ImportError:
from http.client import BadStatusLine
import requests # type: ignore
from pyqrcode import QRCode
from .. import config, utils
from ..returnvalues import ReturnValue
from ..storage.templates import wrap_user_dict
from .contact import update_local_chatrooms, update_local_friends
from .messages import produce_msg
logger = logging.getLogger('itchat')
def load_login(core):
core.login = login
core.get_QRuuid = get_QRuuid
core.get_QR = get_QR
core.check_login = check_login
core.web_init = web_init
core.show_mobile_login = show_mobile_login
core.start_receiving = start_receiving
core.get_msg = get_msg
core.logout = logout
async def login(self, enableCmdQR=False, picDir=None, qrCallback=None, EventScanPayload=None,ScanStatus=None,event_stream=None,
loginCallback=None, exitCallback=None):
if self.alive or self.isLogging:
logger.warning('itchat has already logged in.')
return
self.isLogging = True
while self.isLogging:
uuid = await push_login(self)
if uuid:
payload = EventScanPayload(
status=ScanStatus.Waiting,
qrcode=f"qrcode/https://login.weixin.qq.com/l/{uuid}"
)
event_stream.emit('scan', payload)
await asyncio.sleep(0.1)
else:
logger.info('Getting uuid of QR code.')
self.get_QRuuid()
payload = EventScanPayload(
status=ScanStatus.Waiting,
qrcode=f"https://login.weixin.qq.com/l/{self.uuid}"
)
print(f"https://wechaty.js.org/qrcode/https://login.weixin.qq.com/l/{self.uuid}")
event_stream.emit('scan', payload)
await asyncio.sleep(0.1)
# logger.info('Please scan the QR code to log in.')
isLoggedIn = False
while not isLoggedIn:
status = await self.check_login()
# if hasattr(qrCallback, '__call__'):
# await qrCallback(uuid=self.uuid, status=status, qrcode=self.qrStorage.getvalue())
if status == '200':
isLoggedIn = True
payload = EventScanPayload(
status=ScanStatus.Scanned,
qrcode=f"https://login.weixin.qq.com/l/{self.uuid}"
)
event_stream.emit('scan', payload)
await asyncio.sleep(0.1)
elif status == '201':
if isLoggedIn is not None:
logger.info('Please press confirm on your phone.')
isLoggedIn = None
payload = EventScanPayload(
status=ScanStatus.Waiting,
qrcode=f"https://login.weixin.qq.com/l/{self.uuid}"
)
event_stream.emit('scan', payload)
await asyncio.sleep(0.1)
elif status != '408':
payload = EventScanPayload(
status=ScanStatus.Cancel,
qrcode=f"https://login.weixin.qq.com/l/{self.uuid}"
)
event_stream.emit('scan', payload)
await asyncio.sleep(0.1)
break
if isLoggedIn:
payload = EventScanPayload(
status=ScanStatus.Confirmed,
qrcode=f"https://login.weixin.qq.com/l/{self.uuid}"
)
event_stream.emit('scan', payload)
await asyncio.sleep(0.1)
break
elif self.isLogging:
logger.info('Log in time out, reloading QR code.')
payload = EventScanPayload(
status=ScanStatus.Timeout,
qrcode=f"https://login.weixin.qq.com/l/{self.uuid}"
)
event_stream.emit('scan', payload)
await asyncio.sleep(0.1)
else:
return
logger.info('Loading the contact, this may take a little while.')
await self.web_init()
await self.show_mobile_login()
self.get_contact(True)
if hasattr(loginCallback, '__call__'):
r = await loginCallback(self.storageClass.userName)
else:
utils.clear_screen()
if os.path.exists(picDir or config.DEFAULT_QR):
os.remove(picDir or config.DEFAULT_QR)
logger.info('Login successfully as %s' % self.storageClass.nickName)
await self.start_receiving(exitCallback)
self.isLogging = False
async def push_login(core):
cookiesDict = core.s.cookies.get_dict()
if 'wxuin' in cookiesDict:
url = '%s/cgi-bin/mmwebwx-bin/webwxpushloginurl?uin=%s' % (
config.BASE_URL, cookiesDict['wxuin'])
headers = { 'User-Agent' : config.USER_AGENT}
r = core.s.get(url, headers=headers).json()
if 'uuid' in r and r.get('ret') in (0, '0'):
core.uuid = r['uuid']
return r['uuid']
return False
def get_QRuuid(self):
url = '%s/jslogin' % config.BASE_URL
params = {
'appid' : 'wx782c26e4c19acffb',
'fun' : 'new',
'redirect_uri' : 'https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxnewloginpage?mod=desktop',
'lang' : 'zh_CN' }
headers = { 'User-Agent' : config.USER_AGENT}
r = self.s.get(url, params=params, headers=headers)
regx = r'window.QRLogin.code = (\d+); window.QRLogin.uuid = "(\S+?)";'
data = re.search(regx, r.text)
if data and data.group(1) == '200':
self.uuid = data.group(2)
return self.uuid
async def get_QR(self, uuid=None, enableCmdQR=False, picDir=None, qrCallback=None):
uuid = uuid or self.uuid
picDir = picDir or config.DEFAULT_QR
qrStorage = io.BytesIO()
qrCode = QRCode('https://login.weixin.qq.com/l/' + uuid)
qrCode.png(qrStorage, scale=10)
if hasattr(qrCallback, '__call__'):
await qrCallback(uuid=uuid, status='0', qrcode=qrStorage.getvalue())
else:
with open(picDir, 'wb') as f:
f.write(qrStorage.getvalue())
if enableCmdQR:
utils.print_cmd_qr(qrCode.text(1), enableCmdQR=enableCmdQR)
else:
utils.print_qr(picDir)
return qrStorage
async def check_login(self, uuid=None):
uuid = uuid or self.uuid
url = '%s/cgi-bin/mmwebwx-bin/login' % config.BASE_URL
localTime = int(time.time())
params = 'loginicon=true&uuid=%s&tip=1&r=%s&_=%s' % (
uuid, int(-localTime / 1579), localTime)
headers = { 'User-Agent' : config.USER_AGENT}
r = self.s.get(url, params=params, headers=headers)
regx = r'window.code=(\d+)'
data = re.search(regx, r.text)
if data and data.group(1) == '200':
if await process_login_info(self, r.text):
return '200'
else:
return '400'
elif data:
return data.group(1)
else:
return '400'
async def process_login_info(core, loginContent):
''' when finish login (scanning qrcode)
* syncUrl and fileUploadingUrl will be fetched
* deviceid and msgid will be generated
* skey, wxsid, wxuin, pass_ticket will be fetched
'''
regx = r'window.redirect_uri="(\S+)";'
core.loginInfo['url'] = re.search(regx, loginContent).group(1)
headers = { 'User-Agent' : config.USER_AGENT,
'client-version' : config.UOS_PATCH_CLIENT_VERSION,
'extspam' : config.UOS_PATCH_EXTSPAM,
'referer' : 'https://wx.qq.com/?&lang=zh_CN&target=t'
}
r = core.s.get(core.loginInfo['url'], headers=headers, allow_redirects=False)
core.loginInfo['url'] = core.loginInfo['url'][:core.loginInfo['url'].rfind('/')]
for indexUrl, detailedUrl in (
("wx2.qq.com" , ("file.wx2.qq.com", "webpush.wx2.qq.com")),
("wx8.qq.com" , ("file.wx8.qq.com", "webpush.wx8.qq.com")),
("qq.com" , ("file.wx.qq.com", "webpush.wx.qq.com")),
("web2.wechat.com" , ("file.web2.wechat.com", "webpush.web2.wechat.com")),
("wechat.com" , ("file.web.wechat.com", "webpush.web.wechat.com"))):
fileUrl, syncUrl = ['https://%s/cgi-bin/mmwebwx-bin' % url for url in detailedUrl]
if indexUrl in core.loginInfo['url']:
core.loginInfo['fileUrl'], core.loginInfo['syncUrl'] = \
fileUrl, syncUrl
break
else:
core.loginInfo['fileUrl'] = core.loginInfo['syncUrl'] = core.loginInfo['url']
core.loginInfo['deviceid'] = 'e' + repr(random.random())[2:17]
core.loginInfo['logintime'] = int(time.time() * 1e3)
core.loginInfo['BaseRequest'] = {}
cookies = core.s.cookies.get_dict()
skey = re.findall('<skey>(.*?)</skey>', r.text, re.S)[0]
pass_ticket = re.findall('<pass_ticket>(.*?)</pass_ticket>', r.text, re.S)[0]
core.loginInfo['skey'] = core.loginInfo['BaseRequest']['Skey'] = skey
core.loginInfo['wxsid'] = core.loginInfo['BaseRequest']['Sid'] = cookies["wxsid"]
core.loginInfo['wxuin'] = core.loginInfo['BaseRequest']['Uin'] = cookies["wxuin"]
core.loginInfo['pass_ticket'] = pass_ticket
# A question : why pass_ticket == DeviceID ?
# deviceID is only a randomly generated number
# UOS PATCH By luvletter2333, Sun Feb 28 10:00 PM
# for node in xml.dom.minidom.parseString(r.text).documentElement.childNodes:
# if node.nodeName == 'skey':
# core.loginInfo['skey'] = core.loginInfo['BaseRequest']['Skey'] = node.childNodes[0].data
# elif node.nodeName == 'wxsid':
# core.loginInfo['wxsid'] = core.loginInfo['BaseRequest']['Sid'] = node.childNodes[0].data
# elif node.nodeName == 'wxuin':
# core.loginInfo['wxuin'] = core.loginInfo['BaseRequest']['Uin'] = node.childNodes[0].data
# elif node.nodeName == 'pass_ticket':
# core.loginInfo['pass_ticket'] = core.loginInfo['BaseRequest']['DeviceID'] = node.childNodes[0].data
if not all([key in core.loginInfo for key in ('skey', 'wxsid', 'wxuin', 'pass_ticket')]):
logger.error('Your wechat account may be LIMITED to log in WEB wechat, error info:\n%s' % r.text)
core.isLogging = False
return False
return True
async def web_init(self):
url = '%s/webwxinit' % self.loginInfo['url']
params = {
'r': int(-time.time() / 1579),
'pass_ticket': self.loginInfo['pass_ticket'], }
data = { 'BaseRequest': self.loginInfo['BaseRequest'], }
headers = {
'ContentType': 'application/json; charset=UTF-8',
'User-Agent' : config.USER_AGENT, }
r = self.s.post(url, params=params, data=json.dumps(data), headers=headers)
dic = json.loads(r.content.decode('utf-8', 'replace'))
# deal with login info
utils.emoji_formatter(dic['User'], 'NickName')
self.loginInfo['InviteStartCount'] = int(dic['InviteStartCount'])
self.loginInfo['User'] = wrap_user_dict(utils.struct_friend_info(dic['User']))
self.memberList.append(self.loginInfo['User'])
self.loginInfo['SyncKey'] = dic['SyncKey']
self.loginInfo['synckey'] = '|'.join(['%s_%s' % (item['Key'], item['Val'])
for item in dic['SyncKey']['List']])
self.storageClass.userName = dic['User']['UserName']
self.storageClass.nickName = dic['User']['NickName']
# deal with contact list returned when init
contactList = dic.get('ContactList', [])
chatroomList, otherList = [], []
for m in contactList:
if m['Sex'] != 0:
otherList.append(m)
elif '@@' in m['UserName']:
m['MemberList'] = [] # don't let dirty info pollute the list
chatroomList.append(m)
elif '@' in m['UserName']:
# mp will be dealt in update_local_friends as well
otherList.append(m)
if chatroomList:
update_local_chatrooms(self, chatroomList)
if otherList:
update_local_friends(self, otherList)
return dic
async def show_mobile_login(self):
url = '%s/webwxstatusnotify?lang=zh_CN&pass_ticket=%s' % (
self.loginInfo['url'], self.loginInfo['pass_ticket'])
data = {
'BaseRequest' : self.loginInfo['BaseRequest'],
'Code' : 3,
'FromUserName' : self.storageClass.userName,
'ToUserName' : self.storageClass.userName,
'ClientMsgId' : int(time.time()), }
headers = {
'ContentType': 'application/json; charset=UTF-8',
'User-Agent' : config.USER_AGENT, }
r = self.s.post(url, data=json.dumps(data), headers=headers)
return ReturnValue(rawResponse=r)
async def start_receiving(self, exitCallback=None, getReceivingFnOnly=False):
self.alive = True
def maintain_loop():
retryCount = 0
while self.alive:
try:
i = sync_check(self)
if i is None:
self.alive = False
elif i == '0':
pass
else:
msgList, contactList = self.get_msg()
if msgList:
msgList = produce_msg(self, msgList)
for msg in msgList:
self.msgList.put(msg)
if contactList:
chatroomList, otherList = [], []
for contact in contactList:
if '@@' in contact['UserName']:
chatroomList.append(contact)
else:
otherList.append(contact)
chatroomMsg = update_local_chatrooms(self, chatroomList)
chatroomMsg['User'] = self.loginInfo['User']
self.msgList.put(chatroomMsg)
update_local_friends(self, otherList)
retryCount = 0
except requests.exceptions.ReadTimeout:
pass
except:
retryCount += 1
logger.error(traceback.format_exc())
if self.receivingRetryCount < retryCount:
self.alive = False
else:
time.sleep(1)
self.logout()
if hasattr(exitCallback, '__call__'):
exitCallback(self.storageClass.userName)
else:
logger.info('LOG OUT!')
if getReceivingFnOnly:
return maintain_loop
else:
maintainThread = threading.Thread(target=maintain_loop)
maintainThread.setDaemon(True)
maintainThread.start()
def sync_check(self):
url = '%s/synccheck' % self.loginInfo.get('syncUrl', self.loginInfo['url'])
params = {
'r' : int(time.time() * 1000),
'skey' : self.loginInfo['skey'],
'sid' : self.loginInfo['wxsid'],
'uin' : self.loginInfo['wxuin'],
'deviceid' : self.loginInfo['deviceid'],
'synckey' : self.loginInfo['synckey'],
'_' : self.loginInfo['logintime'], }
headers = { 'User-Agent' : config.USER_AGENT}
self.loginInfo['logintime'] += 1
try:
r = self.s.get(url, params=params, headers=headers, timeout=config.TIMEOUT)
except requests.exceptions.ConnectionError as e:
try:
if not isinstance(e.args[0].args[1], BadStatusLine):
raise
# will return a package with status '0 -'
# and value like:
# 6f:00:8a:9c:09:74:e4:d8:e0:14:bf:96:3a:56:a0:64:1b:a4:25:5d:12:f4:31:a5:30:f1:c6:48:5f:c3:75:6a:99:93
# seems like status of typing, but before I make further achievement code will remain like this
return '2'
except:
raise
r.raise_for_status()
regx = r'window.synccheck={retcode:"(\d+)",selector:"(\d+)"}'
pm = re.search(regx, r.text)
if pm is None or pm.group(1) != '0':
logger.debug('Unexpected sync check result: %s' % r.text)
return None
return pm.group(2)
def get_msg(self):
self.loginInfo['deviceid'] = 'e' + repr(random.random())[2:17]
url = '%s/webwxsync?sid=%s&skey=%s&pass_ticket=%s' % (
self.loginInfo['url'], self.loginInfo['wxsid'],
self.loginInfo['skey'],self.loginInfo['pass_ticket'])
data = {
'BaseRequest' : self.loginInfo['BaseRequest'],
'SyncKey' : self.loginInfo['SyncKey'],
'rr' : ~int(time.time()), }
headers = {
'ContentType': 'application/json; charset=UTF-8',
'User-Agent' : config.USER_AGENT }
r = self.s.post(url, data=json.dumps(data), headers=headers, timeout=config.TIMEOUT)
dic = json.loads(r.content.decode('utf-8', 'replace'))
if dic['BaseResponse']['Ret'] != 0: return None, None
self.loginInfo['SyncKey'] = dic['SyncKey']
self.loginInfo['synckey'] = '|'.join(['%s_%s' % (item['Key'], item['Val'])
for item in dic['SyncCheckKey']['List']])
return dic['AddMsgList'], dic['ModContactList']
def logout(self):
if self.alive:
url = '%s/webwxlogout' % self.loginInfo['url']
params = {
'redirect' : 1,
'type' : 1,
'skey' : self.loginInfo['skey'], }
headers = { 'User-Agent' : config.USER_AGENT}
self.s.get(url, params=params, headers=headers)
self.alive = False
self.isLogging = False
self.s.cookies.clear()
del self.chatroomList[:]
del self.memberList[:]
del self.mpList[:]
return ReturnValue({'BaseResponse': {
'ErrMsg': 'logout successfully.',
'Ret': 0, }})

View File

@@ -0,0 +1,527 @@
import os, time, re, io
import json
import mimetypes, hashlib
import logging
from collections import OrderedDict
from .. import config, utils
from ..returnvalues import ReturnValue
from ..storage import templates
from .contact import update_local_uin
logger = logging.getLogger('itchat')
def load_messages(core):
core.send_raw_msg = send_raw_msg
core.send_msg = send_msg
core.upload_file = upload_file
core.send_file = send_file
core.send_image = send_image
core.send_video = send_video
core.send = send
core.revoke = revoke
async def get_download_fn(core, url, msgId):
async def download_fn(downloadDir=None):
params = {
'msgid': msgId,
'skey': core.loginInfo['skey'],}
headers = { 'User-Agent' : config.USER_AGENT}
r = core.s.get(url, params=params, stream=True, headers = headers)
tempStorage = io.BytesIO()
for block in r.iter_content(1024):
tempStorage.write(block)
if downloadDir is None:
return tempStorage.getvalue()
with open(downloadDir, 'wb') as f:
f.write(tempStorage.getvalue())
tempStorage.seek(0)
return ReturnValue({'BaseResponse': {
'ErrMsg': 'Successfully downloaded',
'Ret': 0, },
'PostFix': utils.get_image_postfix(tempStorage.read(20)), })
return download_fn
def produce_msg(core, msgList):
''' for messages types
* 40 msg, 43 videochat, 50 VOIPMSG, 52 voipnotifymsg
* 53 webwxvoipnotifymsg, 9999 sysnotice
'''
rl = []
srl = [40, 43, 50, 52, 53, 9999]
for m in msgList:
# get actual opposite
if m['FromUserName'] == core.storageClass.userName:
actualOpposite = m['ToUserName']
else:
actualOpposite = m['FromUserName']
# produce basic message
if '@@' in m['FromUserName'] or '@@' in m['ToUserName']:
produce_group_chat(core, m)
else:
utils.msg_formatter(m, 'Content')
# set user of msg
if '@@' in actualOpposite:
m['User'] = core.search_chatrooms(userName=actualOpposite) or \
templates.Chatroom({'UserName': actualOpposite})
# we don't need to update chatroom here because we have
# updated once when producing basic message
elif actualOpposite in ('filehelper', 'fmessage'):
m['User'] = templates.User({'UserName': actualOpposite})
else:
m['User'] = core.search_mps(userName=actualOpposite) or \
core.search_friends(userName=actualOpposite) or \
templates.User(userName=actualOpposite)
# by default we think there may be a user missing not a mp
m['User'].core = core
if m['MsgType'] == 1: # words
if m['Url']:
regx = r'(.+?\(.+?\))'
data = re.search(regx, m['Content'])
data = 'Map' if data is None else data.group(1)
msg = {
'Type': 'Map',
'Text': data,}
else:
msg = {
'Type': 'Text',
'Text': m['Content'],}
elif m['MsgType'] == 3 or m['MsgType'] == 47: # picture
download_fn = get_download_fn(core,
'%s/webwxgetmsgimg' % core.loginInfo['url'], m['NewMsgId'])
msg = {
'Type' : 'Picture',
'FileName' : '%s.%s' % (time.strftime('%y%m%d-%H%M%S', time.localtime()),
'png' if m['MsgType'] == 3 else 'gif'),
'Text' : download_fn, }
elif m['MsgType'] == 34: # voice
download_fn = get_download_fn(core,
'%s/webwxgetvoice' % core.loginInfo['url'], m['NewMsgId'])
msg = {
'Type': 'Recording',
'FileName' : '%s.mp3' % time.strftime('%y%m%d-%H%M%S', time.localtime()),
'Text': download_fn,}
elif m['MsgType'] == 37: # friends
m['User']['UserName'] = m['RecommendInfo']['UserName']
msg = {
'Type': 'Friends',
'Text': {
'status' : m['Status'],
'userName' : m['RecommendInfo']['UserName'],
'verifyContent' : m['Ticket'],
'autoUpdate' : m['RecommendInfo'], }, }
m['User'].verifyDict = msg['Text']
elif m['MsgType'] == 42: # name card
msg = {
'Type': 'Card',
'Text': m['RecommendInfo'], }
elif m['MsgType'] in (43, 62): # tiny video
msgId = m['MsgId']
async def download_video(videoDir=None):
url = '%s/webwxgetvideo' % core.loginInfo['url']
params = {
'msgid': msgId,
'skey': core.loginInfo['skey'],}
headers = {'Range': 'bytes=0-', 'User-Agent' : config.USER_AGENT}
r = core.s.get(url, params=params, headers=headers, stream=True)
tempStorage = io.BytesIO()
for block in r.iter_content(1024):
tempStorage.write(block)
if videoDir is None:
return tempStorage.getvalue()
with open(videoDir, 'wb') as f:
f.write(tempStorage.getvalue())
return ReturnValue({'BaseResponse': {
'ErrMsg': 'Successfully downloaded',
'Ret': 0, }})
msg = {
'Type': 'Video',
'FileName' : '%s.mp4' % time.strftime('%y%m%d-%H%M%S', time.localtime()),
'Text': download_video, }
elif m['MsgType'] == 49: # sharing
if m['AppMsgType'] == 0: # chat history
msg = {
'Type': 'Note',
'Text': m['Content'], }
elif m['AppMsgType'] == 6:
rawMsg = m
cookiesList = {name:data for name,data in core.s.cookies.items()}
async def download_atta(attaDir=None):
url = core.loginInfo['fileUrl'] + '/webwxgetmedia'
params = {
'sender': rawMsg['FromUserName'],
'mediaid': rawMsg['MediaId'],
'filename': rawMsg['FileName'],
'fromuser': core.loginInfo['wxuin'],
'pass_ticket': 'undefined',
'webwx_data_ticket': cookiesList['webwx_data_ticket'],}
headers = { 'User-Agent' : config.USER_AGENT}
r = core.s.get(url, params=params, stream=True, headers=headers)
tempStorage = io.BytesIO()
for block in r.iter_content(1024):
tempStorage.write(block)
if attaDir is None:
return tempStorage.getvalue()
with open(attaDir, 'wb') as f:
f.write(tempStorage.getvalue())
return ReturnValue({'BaseResponse': {
'ErrMsg': 'Successfully downloaded',
'Ret': 0, }})
msg = {
'Type': 'Attachment',
'Text': download_atta, }
elif m['AppMsgType'] == 8:
download_fn = get_download_fn(core,
'%s/webwxgetmsgimg' % core.loginInfo['url'], m['NewMsgId'])
msg = {
'Type' : 'Picture',
'FileName' : '%s.gif' % (
time.strftime('%y%m%d-%H%M%S', time.localtime())),
'Text' : download_fn, }
elif m['AppMsgType'] == 17:
msg = {
'Type': 'Note',
'Text': m['FileName'], }
elif m['AppMsgType'] == 2000:
regx = r'\[CDATA\[(.+?)\][\s\S]+?\[CDATA\[(.+?)\]'
data = re.search(regx, m['Content'])
if data:
data = data.group(2).split(u'\u3002')[0]
else:
data = 'You may found detailed info in Content key.'
msg = {
'Type': 'Note',
'Text': data, }
else:
msg = {
'Type': 'Sharing',
'Text': m['FileName'], }
elif m['MsgType'] == 51: # phone init
msg = update_local_uin(core, m)
elif m['MsgType'] == 10000:
msg = {
'Type': 'Note',
'Text': m['Content'],}
elif m['MsgType'] == 10002:
regx = r'\[CDATA\[(.+?)\]\]'
data = re.search(regx, m['Content'])
data = 'System message' if data is None else data.group(1).replace('\\', '')
msg = {
'Type': 'Note',
'Text': data, }
elif m['MsgType'] in srl:
msg = {
'Type': 'Useless',
'Text': 'UselessMsg', }
else:
logger.debug('Useless message received: %s\n%s' % (m['MsgType'], str(m)))
msg = {
'Type': 'Useless',
'Text': 'UselessMsg', }
m = dict(m, **msg)
rl.append(m)
return rl
def produce_group_chat(core, msg):
r = re.match('(@[0-9a-z]*?):<br/>(.*)$', msg['Content'])
if r:
actualUserName, content = r.groups()
chatroomUserName = msg['FromUserName']
elif msg['FromUserName'] == core.storageClass.userName:
actualUserName = core.storageClass.userName
content = msg['Content']
chatroomUserName = msg['ToUserName']
else:
msg['ActualUserName'] = core.storageClass.userName
msg['ActualNickName'] = core.storageClass.nickName
msg['IsAt'] = False
utils.msg_formatter(msg, 'Content')
return
chatroom = core.storageClass.search_chatrooms(userName=chatroomUserName)
member = utils.search_dict_list((chatroom or {}).get(
'MemberList') or [], 'UserName', actualUserName)
if member is None:
chatroom = core.update_chatroom(chatroomUserName)
member = utils.search_dict_list((chatroom or {}).get(
'MemberList') or [], 'UserName', actualUserName)
if member is None:
logger.debug('chatroom member fetch failed with %s' % actualUserName)
msg['ActualNickName'] = ''
msg['IsAt'] = False
else:
msg['ActualNickName'] = member.get('DisplayName', '') or member['NickName']
atFlag = '@' + (chatroom['Self'].get('DisplayName', '') or core.storageClass.nickName)
msg['IsAt'] = (
(atFlag + (u'\u2005' if u'\u2005' in msg['Content'] else ' '))
in msg['Content'] or msg['Content'].endswith(atFlag))
msg['ActualUserName'] = actualUserName
msg['Content'] = content
utils.msg_formatter(msg, 'Content')
async def send_raw_msg(self, msgType, content, toUserName):
url = '%s/webwxsendmsg' % self.loginInfo['url']
data = {
'BaseRequest': self.loginInfo['BaseRequest'],
'Msg': {
'Type': msgType,
'Content': content,
'FromUserName': self.storageClass.userName,
'ToUserName': (toUserName if toUserName else self.storageClass.userName),
'LocalID': int(time.time() * 1e4),
'ClientMsgId': int(time.time() * 1e4),
},
'Scene': 0, }
headers = { 'ContentType': 'application/json; charset=UTF-8', 'User-Agent' : config.USER_AGENT}
r = self.s.post(url, headers=headers,
data=json.dumps(data, ensure_ascii=False).encode('utf8'))
return ReturnValue(rawResponse=r)
async def send_msg(self, msg='Test Message', toUserName=None):
logger.debug('Request to send a text message to %s: %s' % (toUserName, msg))
r = await self.send_raw_msg(1, msg, toUserName)
return r
def _prepare_file(fileDir, file_=None):
fileDict = {}
if file_:
if hasattr(file_, 'read'):
file_ = file_.read()
else:
return ReturnValue({'BaseResponse': {
'ErrMsg': 'file_ param should be opened file',
'Ret': -1005, }})
else:
if not utils.check_file(fileDir):
return ReturnValue({'BaseResponse': {
'ErrMsg': 'No file found in specific dir',
'Ret': -1002, }})
with open(fileDir, 'rb') as f:
file_ = f.read()
fileDict['fileSize'] = len(file_)
fileDict['fileMd5'] = hashlib.md5(file_).hexdigest()
fileDict['file_'] = io.BytesIO(file_)
return fileDict
def upload_file(self, fileDir, isPicture=False, isVideo=False,
toUserName='filehelper', file_=None, preparedFile=None):
logger.debug('Request to upload a %s: %s' % (
'picture' if isPicture else 'video' if isVideo else 'file', fileDir))
if not preparedFile:
preparedFile = _prepare_file(fileDir, file_)
if not preparedFile:
return preparedFile
fileSize, fileMd5, file_ = \
preparedFile['fileSize'], preparedFile['fileMd5'], preparedFile['file_']
fileSymbol = 'pic' if isPicture else 'video' if isVideo else'doc'
chunks = int((fileSize - 1) / 524288) + 1
clientMediaId = int(time.time() * 1e4)
uploadMediaRequest = json.dumps(OrderedDict([
('UploadType', 2),
('BaseRequest', self.loginInfo['BaseRequest']),
('ClientMediaId', clientMediaId),
('TotalLen', fileSize),
('StartPos', 0),
('DataLen', fileSize),
('MediaType', 4),
('FromUserName', self.storageClass.userName),
('ToUserName', toUserName),
('FileMd5', fileMd5)]
), separators = (',', ':'))
r = {'BaseResponse': {'Ret': -1005, 'ErrMsg': 'Empty file detected'}}
for chunk in range(chunks):
r = upload_chunk_file(self, fileDir, fileSymbol, fileSize,
file_, chunk, chunks, uploadMediaRequest)
file_.close()
if isinstance(r, dict):
return ReturnValue(r)
return ReturnValue(rawResponse=r)
def upload_chunk_file(core, fileDir, fileSymbol, fileSize,
file_, chunk, chunks, uploadMediaRequest):
url = core.loginInfo.get('fileUrl', core.loginInfo['url']) + \
'/webwxuploadmedia?f=json'
# save it on server
cookiesList = {name:data for name,data in core.s.cookies.items()}
fileType = mimetypes.guess_type(fileDir)[0] or 'application/octet-stream'
fileName = utils.quote(os.path.basename(fileDir))
files = OrderedDict([
('id', (None, 'WU_FILE_0')),
('name', (None, fileName)),
('type', (None, fileType)),
('lastModifiedDate', (None, time.strftime('%a %b %d %Y %H:%M:%S GMT+0800 (CST)'))),
('size', (None, str(fileSize))),
('chunks', (None, None)),
('chunk', (None, None)),
('mediatype', (None, fileSymbol)),
('uploadmediarequest', (None, uploadMediaRequest)),
('webwx_data_ticket', (None, cookiesList['webwx_data_ticket'])),
('pass_ticket', (None, core.loginInfo['pass_ticket'])),
('filename' , (fileName, file_.read(524288), 'application/octet-stream'))])
if chunks == 1:
del files['chunk']; del files['chunks']
else:
files['chunk'], files['chunks'] = (None, str(chunk)), (None, str(chunks))
headers = { 'User-Agent' : config.USER_AGENT}
return core.s.post(url, files=files, headers=headers, timeout=config.TIMEOUT)
async def send_file(self, fileDir, toUserName=None, mediaId=None, file_=None):
logger.debug('Request to send a file(mediaId: %s) to %s: %s' % (
mediaId, toUserName, fileDir))
if hasattr(fileDir, 'read'):
return ReturnValue({'BaseResponse': {
'ErrMsg': 'fileDir param should not be an opened file in send_file',
'Ret': -1005, }})
if toUserName is None:
toUserName = self.storageClass.userName
preparedFile = _prepare_file(fileDir, file_)
if not preparedFile:
return preparedFile
fileSize = preparedFile['fileSize']
if mediaId is None:
r = self.upload_file(fileDir, preparedFile=preparedFile)
if r:
mediaId = r['MediaId']
else:
return r
url = '%s/webwxsendappmsg?fun=async&f=json' % self.loginInfo['url']
data = {
'BaseRequest': self.loginInfo['BaseRequest'],
'Msg': {
'Type': 6,
'Content': ("<appmsg appid='wxeb7ec651dd0aefa9' sdkver=''><title>%s</title>" % os.path.basename(fileDir) +
"<des></des><action></action><type>6</type><content></content><url></url><lowurl></lowurl>" +
"<appattach><totallen>%s</totallen><attachid>%s</attachid>" % (str(fileSize), mediaId) +
"<fileext>%s</fileext></appattach><extinfo></extinfo></appmsg>" % os.path.splitext(fileDir)[1].replace('.','')),
'FromUserName': self.storageClass.userName,
'ToUserName': toUserName,
'LocalID': int(time.time() * 1e4),
'ClientMsgId': int(time.time() * 1e4), },
'Scene': 0, }
headers = {
'User-Agent': config.USER_AGENT,
'Content-Type': 'application/json;charset=UTF-8', }
r = self.s.post(url, headers=headers,
data=json.dumps(data, ensure_ascii=False).encode('utf8'))
return ReturnValue(rawResponse=r)
async def send_image(self, fileDir=None, toUserName=None, mediaId=None, file_=None):
logger.debug('Request to send a image(mediaId: %s) to %s: %s' % (
mediaId, toUserName, fileDir))
if fileDir or file_:
if hasattr(fileDir, 'read'):
file_, fileDir = fileDir, None
if fileDir is None:
fileDir = 'tmp.jpg' # specific fileDir to send gifs
else:
return ReturnValue({'BaseResponse': {
'ErrMsg': 'Either fileDir or file_ should be specific',
'Ret': -1005, }})
if toUserName is None:
toUserName = self.storageClass.userName
if mediaId is None:
r = self.upload_file(fileDir, isPicture=not fileDir[-4:] == '.gif', file_=file_)
if r:
mediaId = r['MediaId']
else:
return r
url = '%s/webwxsendmsgimg?fun=async&f=json' % self.loginInfo['url']
data = {
'BaseRequest': self.loginInfo['BaseRequest'],
'Msg': {
'Type': 3,
'MediaId': mediaId,
'FromUserName': self.storageClass.userName,
'ToUserName': toUserName,
'LocalID': int(time.time() * 1e4),
'ClientMsgId': int(time.time() * 1e4), },
'Scene': 0, }
if fileDir[-4:] == '.gif':
url = '%s/webwxsendemoticon?fun=sys' % self.loginInfo['url']
data['Msg']['Type'] = 47
data['Msg']['EmojiFlag'] = 2
headers = {
'User-Agent': config.USER_AGENT,
'Content-Type': 'application/json;charset=UTF-8', }
r = self.s.post(url, headers=headers,
data=json.dumps(data, ensure_ascii=False).encode('utf8'))
return ReturnValue(rawResponse=r)
async def send_video(self, fileDir=None, toUserName=None, mediaId=None, file_=None):
logger.debug('Request to send a video(mediaId: %s) to %s: %s' % (
mediaId, toUserName, fileDir))
if fileDir or file_:
if hasattr(fileDir, 'read'):
file_, fileDir = fileDir, None
if fileDir is None:
fileDir = 'tmp.mp4' # specific fileDir to send other formats
else:
return ReturnValue({'BaseResponse': {
'ErrMsg': 'Either fileDir or file_ should be specific',
'Ret': -1005, }})
if toUserName is None:
toUserName = self.storageClass.userName
if mediaId is None:
r = self.upload_file(fileDir, isVideo=True, file_=file_)
if r:
mediaId = r['MediaId']
else:
return r
url = '%s/webwxsendvideomsg?fun=async&f=json&pass_ticket=%s' % (
self.loginInfo['url'], self.loginInfo['pass_ticket'])
data = {
'BaseRequest': self.loginInfo['BaseRequest'],
'Msg': {
'Type' : 43,
'MediaId' : mediaId,
'FromUserName' : self.storageClass.userName,
'ToUserName' : toUserName,
'LocalID' : int(time.time() * 1e4),
'ClientMsgId' : int(time.time() * 1e4), },
'Scene': 0, }
headers = {
'User-Agent' : config.USER_AGENT,
'Content-Type': 'application/json;charset=UTF-8', }
r = self.s.post(url, headers=headers,
data=json.dumps(data, ensure_ascii=False).encode('utf8'))
return ReturnValue(rawResponse=r)
async def send(self, msg, toUserName=None, mediaId=None):
if not msg:
r = ReturnValue({'BaseResponse': {
'ErrMsg': 'No message.',
'Ret': -1005, }})
elif msg[:5] == '@fil@':
if mediaId is None:
r = await self.send_file(msg[5:], toUserName)
else:
r = await self.send_file(msg[5:], toUserName, mediaId)
elif msg[:5] == '@img@':
if mediaId is None:
r = await self.send_image(msg[5:], toUserName)
else:
r = await self.send_image(msg[5:], toUserName, mediaId)
elif msg[:5] == '@msg@':
r = await self.send_msg(msg[5:], toUserName)
elif msg[:5] == '@vid@':
if mediaId is None:
r = await self.send_video(msg[5:], toUserName)
else:
r = await self.send_video(msg[5:], toUserName, mediaId)
else:
r = await self.send_msg(msg, toUserName)
return r
async def revoke(self, msgId, toUserName, localId=None):
url = '%s/webwxrevokemsg' % self.loginInfo['url']
data = {
'BaseRequest': self.loginInfo['BaseRequest'],
"ClientMsgId": localId or str(time.time() * 1e3),
"SvrMsgId": msgId,
"ToUserName": toUserName}
headers = {
'ContentType': 'application/json; charset=UTF-8',
'User-Agent' : config.USER_AGENT }
r = self.s.post(url, headers=headers,
data=json.dumps(data, ensure_ascii=False).encode('utf8'))
return ReturnValue(rawResponse=r)

View File

@@ -0,0 +1,106 @@
import logging, traceback, sys, threading
try:
import Queue
except ImportError:
import queue as Queue # type: ignore
from ..log import set_logging
from ..utils import test_connect
from ..storage import templates
logger = logging.getLogger('itchat')
def load_register(core):
core.auto_login = auto_login
core.configured_reply = configured_reply
core.msg_register = msg_register
core.run = run
async def auto_login(self, EventScanPayload=None,ScanStatus=None,event_stream=None,
hotReload=True, statusStorageDir='itchat.pkl',
enableCmdQR=False, picDir=None, qrCallback=None,
loginCallback=None, exitCallback=None):
if not test_connect():
logger.info("You can't get access to internet or wechat domain, so exit.")
sys.exit()
self.useHotReload = hotReload
self.hotReloadDir = statusStorageDir
if hotReload:
if await self.load_login_status(statusStorageDir,
loginCallback=loginCallback, exitCallback=exitCallback):
return
await self.login(enableCmdQR=enableCmdQR, picDir=picDir, qrCallback=qrCallback, EventScanPayload=EventScanPayload, ScanStatus=ScanStatus, event_stream=event_stream,
loginCallback=loginCallback, exitCallback=exitCallback)
await self.dump_login_status(statusStorageDir)
else:
await self.login(enableCmdQR=enableCmdQR, picDir=picDir, qrCallback=qrCallback, EventScanPayload=EventScanPayload, ScanStatus=ScanStatus, event_stream=event_stream,
loginCallback=loginCallback, exitCallback=exitCallback)
async def configured_reply(self, event_stream, payload, message_container):
''' determine the type of message and reply if its method is defined
however, I use a strange way to determine whether a msg is from massive platform
I haven't found a better solution here
The main problem I'm worrying about is the mismatching of new friends added on phone
If you have any good idea, pleeeease report an issue. I will be more than grateful.
'''
try:
msg = self.msgList.get(timeout=1)
if 'MsgId' in msg.keys():
message_container[msg['MsgId']] = msg
except Queue.Empty:
pass
else:
if isinstance(msg['User'], templates.User):
replyFn = self.functionDict['FriendChat'].get(msg['Type'])
elif isinstance(msg['User'], templates.MassivePlatform):
replyFn = self.functionDict['MpChat'].get(msg['Type'])
elif isinstance(msg['User'], templates.Chatroom):
replyFn = self.functionDict['GroupChat'].get(msg['Type'])
if replyFn is None:
r = None
else:
try:
r = await replyFn(msg)
if r is not None:
await self.send(r, msg.get('FromUserName'))
except:
logger.warning(traceback.format_exc())
def msg_register(self, msgType, isFriendChat=False, isGroupChat=False, isMpChat=False):
''' a decorator constructor
return a specific decorator based on information given '''
if not (isinstance(msgType, list) or isinstance(msgType, tuple)):
msgType = [msgType]
def _msg_register(fn):
for _msgType in msgType:
if isFriendChat:
self.functionDict['FriendChat'][_msgType] = fn
if isGroupChat:
self.functionDict['GroupChat'][_msgType] = fn
if isMpChat:
self.functionDict['MpChat'][_msgType] = fn
if not any((isFriendChat, isGroupChat, isMpChat)):
self.functionDict['FriendChat'][_msgType] = fn
return fn
return _msg_register
async def run(self, debug=False, blockThread=True):
logger.info('Start auto replying.')
if debug:
set_logging(loggingLevel=logging.DEBUG)
async def reply_fn():
try:
while self.alive:
await self.configured_reply()
except KeyboardInterrupt:
if self.useHotReload:
await self.dump_login_status()
self.alive = False
logger.debug('itchat received an ^C and exit.')
logger.info('Bye~')
if blockThread:
await reply_fn()
else:
replyThread = threading.Thread(target=reply_fn)
replyThread.setDaemon(True)
replyThread.start()

View File

@@ -0,0 +1,12 @@
from .contact import load_contact
from .hotreload import load_hotreload
from .login import load_login
from .messages import load_messages
from .register import load_register
def load_components(core):
load_contact(core)
load_hotreload(core)
load_login(core)
load_messages(core)
load_register(core)

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