Compare commits

..

170 Commits
1.0.1 ... 1.1.0

Author SHA1 Message Date
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
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
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
zhayujie
a660aa2133 Merge pull request #549 from a5225662/master
明确config.py中config.json的查找目录为当前目录 #547
2023-03-23 01:08:41 +08:00
a5225662
5e48dd50ac 明确config.py中config.json的查找目录为当前目录 #547 2023-03-23 00:36:41 +08:00
zhayujie
2d3ffa1738 Merge pull request #539 from lichengzhe/master
增加限速配置参数文档说明
2023-03-21 23:00:04 +08:00
李成喆
663967680a Merge branch 'zhayujie:master' into master 2023-03-21 22:57:52 +08:00
lichengzhe
b190db73dc -增加限速配置参数文档说明 2023-03-21 22:56:30 +08:00
zhayujie
475d2f7911 Merge pull request #520 from B1gM8c/master
支持Wechaty的自定义前缀+关键词生成AI图片的功能
2023-03-21 22:52:39 +08:00
zhayujie
a1323c9de8 Merge pull request #527 from goldfishh/master
feature(rate-limit): 新增令牌桶类,用于主动限制调用gpt3.5, dalle接口频率
2023-03-21 22:36:22 +08:00
zhayujie
260c374a56 Merge pull request #537 from lichengzhe/master
如启用hot_reload,不处理1分钟前的历史消息避免重复提交
2023-03-21 22:34:37 +08:00
lichengzhe
3d264207a8 如启用hot_reload,不处理1分钟前的历史消息避免重复提交 2023-03-21 22:12:06 +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
goldfishh
3f889ab75f feature(rate-limit): 新增令牌桶类,用于主动限制调用gpt3.5, dalle接口频率 2023-03-20 22:18:10 +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
B1gM8c
852adb72a2 支持Wechaty的自定义前缀+关键词生成AI图片的功能
Wechaty判断is_at为True,返回的内容是过滤掉@之后的内容;而is_at为False,则会返回完整的内容

故判断如果匹配到自定义前缀,则返回过滤掉前缀+空格后的内容,用于实现类似自定义+前缀触发生成AI图片的功能
2023-03-20 01:17:29 +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
zhayujie
cfd423c991 Merge pull request #511 from lichengzhe/master
itchat增加hot_reload特性开关,默认关闭。配置文档增加可选参数说明。
2023-03-19 10:42:25 +08:00
lichengzhe
021ee2312e 恢复默认config-template.json 2023-03-19 09:11:36 +08:00
李成喆
0f830f2317 Merge branch 'zhayujie:master' into master 2023-03-19 08:35:26 +08:00
lichengzhe
3ef7855384 itchat增加hot_reload特性开关,默认关闭。配置文档增加可选参数说明。 2023-03-19 08:29:25 +08:00
zhayujie
d760b045d5 fix: close hot reload because of repeat msg 2023-03-19 01:26:53 +08:00
zhayujie
53cc1df369 Merge pull request #507 from lichengzhe/master
清除记忆命令和API调用参数改为config.json配置项
2023-03-19 01:13:36 +08:00
lichengzhe
9b2da6c431 清除记忆命令和API调用参数改为config.json配置项 2023-03-19 01:10:27 +08:00
zhayujie
b3e1f56fb9 feat: itchat login hot reload 2023-03-19 01:09:36 +08:00
zhayujie
1aa2382843 docs: update issue template 2023-03-16 22:28:32 +08:00
lanvent
61d66dd8b3 plugin: add dungeon plugin 2023-03-16 01:08:19 +08:00
zhayujie
3c04325aae feat: add config for model selection #471 2023-03-15 23:27:51 +08:00
zhayujie
b404e2c51f docs: update README.md 2023-03-15 22:26:32 +08:00
zhayujie
5b0f0e8b6c Merge pull request #476 from Chiaki-Chan/master
1.新增wechaty方案的语音识别、语音回复功能;2.更新README;
2023-03-15 19:44:46 +08:00
Chiaki
f9b0ad7697 1.新增wechaty方案的语音识别、语音回复功能;2.更新README; 2023-03-15 13:56:23 +08:00
zhayujie
224ee6bd89 fix: openai_base_url load 2023-03-15 12:57:34 +08:00
zhayujie
1dc39af423 Merge pull request #465 from B1gM8c/master
支持自定义openai_api_base
2023-03-15 00:24:04 +08:00
B1gM8c
2c8da59b47 支持自定义openai_api_base
支持自定义openai_api_base

解决国内API被墙的问题,可以自定义使用自己的中转API
2023-03-15 00:14:39 +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
zhayujie
9e3a5395c7 Merge pull request #452 from limccn/feature/docker-support-voice-reply
feat: container support voice reply
2023-03-14 00:20:51 +08:00
zhayujie
54290f7e5d Merge pull request #451 from limccn/feature/docker-support-voice-recognition
feat: container support speech recognition
2023-03-14 00:20:19 +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
limccn
1bb5c6dc0d feat: container support voice reply 2023-03-13 16:17:54 +08:00
limccn
b204d305a1 feat: container support speech recognition 2023-03-13 16:07:19 +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
zhayujie
8fa4041fc2 fix: variable name compatibility modification #415 2023-03-10 09:25:56 +08:00
zhayujie
8107165792 fix: variable name compatibility modification 2023-03-10 09:23:58 +08:00
zhayujie
fc4912c640 docs: update README.md 2023-03-10 00:57:00 +08:00
zhayujie
36ed9d02b7 Merge pull request #417 from goldfishh/master
feature: 消息控制配置热更新
2023-03-10 00:16:59 +08:00
goldfishh
d6c92e1fd5 feature: 消息控制配置热更新 2023-03-09 23:13:53 +08:00
zhayujie
4ccad86010 docs: temporarily remove the config in template
It will be described later in the document as an optional configuration
2023-03-09 02:01:22 +08:00
zhayujie
38ad01a387 docs: update doc for voice 2023-03-09 01:43:16 +08:00
zhayujie
e014b0406c Merge pull request #382 from Bachery/master
support group chat in one seesion
2023-03-09 00:41:02 +08:00
zhayujie
a4e8e64b5d Merge pull request #407 from zhayujie/feat-voice
fix: remove prefix match in voice msg
2023-03-09 00:36:05 +08:00
ubuntu
48e258dd67 fix: remove prefix match in voice msg 2023-03-09 00:31:36 +08:00
Bachery
574f05cc6f Merge branch 'zhayujie:master' into master 2023-03-08 17:28:27 +01:00
Bachery
c2e4d88842 fix compatibility 2023-03-08 17:27:32 +01:00
zhayujie
99b4700b49 Merge pull request #385 from wanggang1987/google_voice
Voice support
2023-03-09 00:26:32 +08:00
Bachery
32cff41df5 add option: group_chat_in_one_session 2023-03-08 13:11:37 +01:00
Bachery
8eace7e30e Merge branch 'zhayujie:master' into master 2023-03-08 12:50:15 +01:00
wanggang
d02508df41 [voice] Readme modify 2023-03-08 16:39:25 +08:00
wanggang
3db452ef71 [voice] using baidu service to gen reply voice 2023-03-08 15:22:46 +08:00
wanggang
d7a8854fa1 [voice] add support for whisper-1 model 2023-03-08 11:32:27 +08:00
wanggang
882e6c3576 [voice] add support for wispper 2023-03-08 11:02:01 +08:00
zhayujie
51f0b898f0 Merge pull request #386 from limccn/feature/docker-support-proxy
feat: docker support proxy
2023-03-08 00:16:06 +08:00
zhayujie
e6112568ed Merge pull request #395 from goldfishh/master
fix a minor typo
2023-03-08 00:15:09 +08:00
wanggang
720ad07f83 [voice] fix issue 2023-03-07 23:33:25 +08:00
wanggang
cc19017c01 [voice] add text to voice 2023-03-07 23:28:57 +08:00
goldfishh
55fe38d5fb fix a minor typo 2023-03-07 22:49:55 +08:00
limccn
494c5a6222 feat: container support proxy with tag 1.0.4 2023-03-07 14:42:53 +08:00
wanggang
1711a5c064 [voice] fix google voice exception issue 2023-03-07 14:42:06 +08:00
wanggang
d38fc61043 [voice] add google voice support 2023-03-07 14:29:59 +08:00
Bachery
e5ab350bbf support group chat in one seesion 2023-03-06 22:39:40 +01:00
zhayujie
ad7ab088fe docs: update chatgpt sign up tutorial 2023-03-06 22:23:00 +08:00
zhayujie
f2ae3e2fd8 Merge pull request #362 from zhayujie/fix-tokens-limit
fix: tokens limit optimization
2023-03-06 00:54:02 +08:00
ubuntu
733f9d1f10 fix: tokens limit optimization 2023-03-06 00:51:53 +08:00
zhayujie
2886f48788 Merge pull request #360 from zwssunny/master
修正会话tokens计算
2023-03-06 00:25:46 +08:00
zwssunny
04078fd4fa Merge branch 'master' of github.com:zwssunny/chatgpt-on-wechat 2023-03-05 22:26:26 +08:00
zhanws
2c2217daad Merge branch 'zhayujie:master' into master 2023-03-05 22:16:26 +08:00
zwssunny
5de600c689 修正会话tokens计算 2023-03-05 22:15:15 +08:00
zhayujie
1d4966b69c docs: update issue template 2023-03-05 19:54:35 +08:00
zwssunny
7ad16731fd scripts/目录有相应的脚本可以启动服务器部署 2023-03-05 17:54:00 +08:00
zhanws
5df341fef2 Merge branch 'zhayujie:master' into master 2023-03-05 17:51:06 +08:00
zwssunny
39a5487f39 openai 接口返回token数量来修剪会话长度 2023-03-05 17:46:35 +08:00
zhayujie
6a98bc2d5a Merge pull request #354 from zwssunny/master
增加处理会话超长问题
2023-03-05 17:23:56 +08:00
zhanws
b154dd7e86 Merge branch 'zhayujie:master' into master 2023-03-05 09:51:08 +08:00
zwssunny
3d4d1c734a 增加会话超长问题 2023-03-05 09:43:59 +08:00
zhayujie
f10911bc3b Merge branch 'master' of github.com:zhayujie/chatgpt-on-wechat 2023-03-05 01:19:44 +08:00
zhayujie
44e5979a03 docs: update README.md 2023-03-05 01:18:46 +08:00
zhayujie
598bc6569d docs: update README.md 2023-03-05 00:47:40 +08:00
zhayujie
d667ccb396 docs: update README.md for proxy 2023-03-05 00:36:20 +08:00
zwssunny
efbc9de9d1 增加会话超长处理 2023-03-04 23:44:57 +08:00
zhayujie
ebed4e7832 Merge pull request #348 from lanvent/dev
历史对话增加超时释放
2023-03-04 22:20:25 +08:00
zhayujie
fb598fba82 Merge pull request #349 from sunxin18/add_more_exception
[feat] catch connection failed exception
2023-03-04 21:51:31 +08:00
lanvent
2c4d79e952 添加会话过期时间 2023-03-04 21:50:18 +08:00
sunxin.181
a2db765ade [feat] catch connection failed exception 2023-03-04 21:43:58 +08:00
lanvent
df3f19b534 忽略引用消息 2023-03-04 20:39:24 +08:00
zhayujie
f67dae5b0b fix: use default max_token 2023-03-04 17:01:26 +08:00
zhayujie
cd5f58ff2c docs: temporarily removed optional config 2023-03-04 12:57:47 +08:00
zhayujie
7be9e7d0a8 Merge pull request #330 from alin299/master
feat:add proxy option
2023-03-04 12:44:04 +08:00
alin
47c675f999 feat:add proxy option 2023-03-03 15:23:14 +08:00
zhayujie
cfa738087f docs: update readme doc for chatgpt-3.5 2023-03-02 15:48:49 +08:00
zhayujie
73b4d63545 Merge pull request #303 from zhayujie/feat-gpt-3.5
feat: support gpt-3.5 api
2023-03-02 15:42:28 +08:00
ubuntu
48900dfbc4 feat: support gpt-3.5 api 2023-03-02 15:41:11 +08:00
zhayujie
a3153815c8 Merge pull request #286 from zwssunny/master
feat: add deploy scripts
2023-02-28 09:18:38 +08:00
zwssunny
8729a31119 创建scripts目录,专门放调用脚本 2023-02-28 08:46:46 +08:00
zwssunny
b81d947dbb 增加#清除所有人记忆指令 2023-02-27 22:33:31 +08:00
zwssunny
999b2ea51f Merge branch 'master' of github.com:zhayujie/chatgpt-on-wechat 2023-02-27 22:27:51 +08:00
zwssunny
0b802a61ec 增加服务器执行脚步 2023-02-27 22:24:14 +08:00
zhayujie
02ca1f8772 Merge pull request #281 from limccn/feature/docker-add-wechaty-support
feat: container support wechaty with tag 1.0.2
2023-02-26 12:43:27 +08:00
limccn
820b255e24 feat: container support v1.0.2 wechaty 2023-02-25 19:27:59 +08:00
zhayujie
bca0939c9d fix: model import isolation 2023-02-21 00:02:39 +08:00
zhayujie
01d0af841d Merge pull request #244 from ZQ7/master
add wechaty
2023-02-20 23:52:40 +08:00
ZQ7
18e9d6a9b9 add wechaty 2023-02-20 12:03:28 +08:00
zhayujie
e27e5958a5 docs: update docker wiki ref 2023-02-18 19:30:26 +08:00
zhayujie
2c5b1d5a8d docs: update openai account sign up wiki #236 2023-02-18 10:54:28 +08:00
78 changed files with 3623 additions and 714 deletions

View File

@@ -1,7 +1,7 @@
### 前置确认
1. 运行于国内网络环境,未开代理
2. python 已安装:版本在 3.7 ~ 3.10 之间,依赖已安装
1. 网络能够访问openai接口
2. python 已安装:版本在 3.7 ~ 3.10 之间,依赖已安装
3. 在已有 issue 中未搜索到类似问题
4. [FAQS](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs) 中无类似问题

5
.gitignore vendored
View File

@@ -1,7 +1,12 @@
.DS_Store
.idea
.wechaty/
__pycache__/
venv*
*.pyc
config.json
QR.png
nohup.out
tmp
plugins.json
itchat.pkl

View File

@@ -3,22 +3,28 @@
> ChatGPT近期以强大的对话和信息整合能力风靡全网可以写代码、改论文、讲故事几乎无所不能这让人不禁有个大胆的想法能否用他的对话模型把我们的微信打造成一个智能机器人可以在与好友对话中给出意想不到的回应而且再也不用担心女朋友影响我们 ~~打游戏~~ 工作了。
基于ChatGPT的微信聊天机器人通过 [OpenAI](https://github.com/openai/openai-quickstart-python) 接口生成对话内容,使用 [itchat](https://github.com/littlecodersh/ItChat) 实现微信消息的接收和自动回复。已实现的特性如下:
基于ChatGPT的微信聊天机器人通过 [ChatGPT](https://github.com/openai/openai-python) 接口生成对话内容,使用 [itchat](https://github.com/littlecodersh/ItChat) 实现微信消息的接收和自动回复。已实现的特性如下:
- [x] **文本对话:** 接收私聊及群组中的微信消息使用ChatGPT生成回复内容完成自动回复
- [x] **规则定制化:** 支持私聊中按指定规则触发自动回复,支持对群组设置自动回复白名单
- [x] **多账号:** 支持多微信账号同时运行
- [x] **图片生成:** 支持根据描述生成图片,并自动发送至个人聊天或群聊
- [x] **上下文记忆**:支持多轮对话记忆,且为每个好友维护独立的上下会话
- [x] **语音识别:** 支持接收和处理语音消息,通过文字或语音回复
# 更新日志
>**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))
>**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.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.02.09** 扫码登录存在封号风险,请谨慎使用,参考[#58](https://github.com/AutumnWhj/ChatGPT-wechat-bot/issues/158)
>**2023.02.05** 在openai官方接口方案中 (GPT-3模型) 实现上下文对话
>**2022.12.19** 引入 [itchat-uos](https://github.com/why2lyj/ItChat-UOS) 替换 itchat解决由于不能登录网页微信而无法使用的问题且解决Python3.9的兼容问题
>**2022.12.18** 支持根据描述生成图片并发送openai版本需大于0.25.0
>**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的内容劣势是暂不支持有上下文记忆的对话优势是稳定性和响应速度较好。
@@ -44,9 +50,9 @@
### 1. OpenAI账号注册
前往 [OpenAI注册页面](https://beta.openai.com/signup) 创建账号,参考这篇 [教程](https://www.cnblogs.com/damugua/p/16969508.html) 可以通过虚拟手机号来接收验证码。创建完账号则前往 [API管理页面](https://beta.openai.com/account/api-keys) 创建一个 API Key 并保存下来后面需要在项目中配置这个key。
前往 [OpenAI注册页面](https://beta.openai.com/signup) 创建账号,参考这篇 [教程](https://www.pythonthree.com/register-openai-chatgpt/) 可以通过虚拟手机号来接收验证码。创建完账号则前往 [API管理页面](https://beta.openai.com/account/api-keys) 创建一个 API Key 并保存下来后面需要在项目中配置这个key。
> 项目中使用的对话模型是 davinci计费方式是约每 750 字 (包含请求和回复) 消耗 $0.02,图片生成是每张消耗 $0.016,账号创建有免费的 $18 额度,使用完可以更换邮箱重新注册。
> 项目中使用的对话模型是 davinci计费方式是约每 750 字 (包含请求和回复) 消耗 $0.02,图片生成是每张消耗 $0.016,账号创建有免费的 $18 额度 (更新3.25: 最新注册的已经无免费额度了),使用完可以更换邮箱重新注册。
### 2.运行环境
@@ -54,21 +60,24 @@
支持 Linux、MacOS、Windows 系统可在Linux服务器上长期运行),同时需安装 `Python`
> 建议Python版本在 3.7.1~3.9.X 之间3.10及以上版本在 MacOS 可用,其他系统上不确定能否正常运行。
1.克隆项目代码:
**(1) 克隆项目代码:**
```bash
git clone https://github.com/zhayujie/chatgpt-on-wechat
cd chatgpt-on-wechat/
```
2.安装所需核心依赖:
**(2) 安装核心依赖 (必选)**
```bash
pip3 install itchat-uos==1.5.0.dev0
pip3 install --upgrade openai
```
注:`itchat-uos`使用指定版本1.5.0.dev0`openai`使用最新版本需高于0.25.0。
注:`itchat-uos`使用指定版本1.5.0.dev0`openai`使用最新版本需高于0.27.0。
**(3) 拓展依赖 (可选)**
语音识别及语音回复相关依赖:[#415](https://github.com/zhayujie/chatgpt-on-wechat/issues/415)。
## 配置
@@ -76,7 +85,7 @@ pip3 install --upgrade openai
配置文件的模板在根目录的`config-template.json`中,需复制该模板创建最终生效的 `config.json` 文件:
```bash
cp config-template.json config.json
cp config-template.json config.json
```
然后在`config.json`中填入配置,以下是对默认配置的说明,可根据需要进行自定义修改:
@@ -85,13 +94,17 @@ cp config-template.json config.json
# config.json文件内容示例
{
"open_ai_api_key": "YOUR API KEY", # 填入上面创建的 OpenAI API KEY
"model": "gpt-3.5-turbo", # 模型名称
"proxy": "127.0.0.1:7890", # 代理客户端的ip和端口
"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, # 支持上下文记忆的最多字符数
"character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。" # 人格描述
"speech_recognition": false, # 是否开启语音识别
"character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。", # 人格描述
}
```
**配置说明:**
@@ -106,12 +119,24 @@ cp config-template.json config.json
+ 群组聊天中,群名称需配置在 `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.其他配置**
**3.语音识别**
+ 添加 `"speech_recognition": true` 将开启语音识别默认使用openai的whisper模型识别为文字同时以文字回复目前只支持私聊 (注意由于语音消息无法匹配前缀,一旦开启将对所有语音自动回复)
+ 添加 `"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)
+ `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` 中进行调整。
+ `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))
@@ -135,7 +160,7 @@ python3 app.py
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`
扫码登录后程序即可运行于服务器后台,此时可通过 `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) 修改一行代码即可解决。
@@ -146,7 +171,7 @@ nohup python3 app.py & tail -f nohup.out # 在后台运行程序并通
### 3.Docker部署
参考文档 [Docker部署](https://github.com/zhayujie/chatgpt-on-wechat/wiki/Docker%E9%83%A8%E7%BD%B2) (Contributed by [limccn](https://github.com/limccn))。
参考文档 [Docker部署](https://github.com/limccn/chatgpt-on-wechat/wiki/Docker%E9%83%A8%E7%BD%B2) (Contributed by [limccn](https://github.com/limccn))。
## 常见问题

7
app.py
View File

@@ -4,14 +4,17 @@ import config
from channel import channel_factory
from common.log import logger
from plugins import *
if __name__ == '__main__':
try:
# load config
config.load_config()
# create channel
channel = channel_factory.create_channel("wx")
channel_name='wx'
channel = channel_factory.create_channel(channel_name)
if channel_name=='wx':
PluginManager().load_plugins()
# startup channel
channel.startup()

View File

@@ -2,6 +2,7 @@
import requests
from bot.bot import Bot
from bridge.reply import Reply, ReplyType
# Baidu Unit对话接口 (可用, 但能力较弱)
@@ -14,7 +15,8 @@ class BaiduUnitBot(Bot):
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'

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

@@ -1,6 +1,7 @@
"""
channel factory
"""
from common import const
def create_bot(bot_type):
@@ -9,17 +10,17 @@ def create_bot(bot_type):
:param channel_type: channel type code
:return: channel instance
"""
if bot_type == 'baidu':
if bot_type == const.BAIDU:
# Baidu Unit对话接口
from bot.baidu.baidu_unit_bot import BaiduUnitBot
return BaiduUnitBot()
elif bot_type == 'chatGPT':
elif bot_type == const.CHATGPT:
# ChatGPT 网页端web接口
from bot.chatgpt.chat_gpt_bot import ChatGPTBot
return ChatGPTBot()
elif bot_type == 'openAI':
elif bot_type == const.OPEN_AI:
# OpenAI 官方对话模型API
from bot.openai.open_ai_bot import OpenAIBot
return OpenAIBot()

View File

@@ -1,511 +1,221 @@
"""
A simple wrapper for the official ChatGPT API
"""
import argparse
import json
import os
import sys
from datetime import date
import openai
import tiktoken
# encoding:utf-8
from bot.bot import Bot
from config import conf
ENGINE = os.environ.get("GPT_ENGINE") or "text-chat-davinci-002-20221122"
ENCODER = tiktoken.get_encoding("gpt2")
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
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
def get_max_tokens(prompt: str) -> int:
"""
Get the max tokens for a prompt
"""
return 4000 - len(ENCODER.encode(prompt))
# ['text-chat-davinci-002-20221122']
class Chatbot:
"""
Official ChatGPT API
"""
def __init__(self, api_key: str, buffer: int = None) -> None:
"""
Initialize Chatbot with API key (from https://platform.openai.com/account/api-keys)
"""
openai.api_key = api_key or os.environ.get("OPENAI_API_KEY")
self.conversations = Conversation()
self.prompt = Prompt(buffer=buffer)
def _get_completion(
self,
prompt: str,
temperature: float = 0.5,
stream: bool = False,
):
"""
Get the completion function
"""
return openai.Completion.create(
engine=ENGINE,
prompt=prompt,
temperature=temperature,
max_tokens=get_max_tokens(prompt),
stop=["\n\n\n"],
stream=stream,
)
def _process_completion(
self,
user_request: str,
completion: dict,
conversation_id: str = None,
user: str = "User",
) -> dict:
if completion.get("choices") is None:
raise Exception("ChatGPT API returned no choices")
if len(completion["choices"]) == 0:
raise Exception("ChatGPT API returned no choices")
if completion["choices"][0].get("text") is None:
raise Exception("ChatGPT API returned no text")
completion["choices"][0]["text"] = completion["choices"][0]["text"].rstrip(
"<|im_end|>",
)
# Add to chat history
self.prompt.add_to_history(
user_request,
completion["choices"][0]["text"],
user=user,
)
if conversation_id is not None:
self.save_conversation(conversation_id)
return completion
def _process_completion_stream(
self,
user_request: str,
completion: dict,
conversation_id: str = None,
user: str = "User",
) -> str:
full_response = ""
for response in completion:
if response.get("choices") is None:
raise Exception("ChatGPT API returned no choices")
if len(response["choices"]) == 0:
raise Exception("ChatGPT API returned no choices")
if response["choices"][0].get("finish_details") is not None:
break
if response["choices"][0].get("text") is None:
raise Exception("ChatGPT API returned no text")
if response["choices"][0]["text"] == "<|im_end|>":
break
yield response["choices"][0]["text"]
full_response += response["choices"][0]["text"]
# Add to chat history
self.prompt.add_to_history(user_request, full_response, user)
if conversation_id is not None:
self.save_conversation(conversation_id)
def ask(
self,
user_request: str,
temperature: float = 0.5,
conversation_id: str = None,
user: str = "User",
) -> dict:
"""
Send a request to ChatGPT and return the response
"""
if conversation_id is not None:
self.load_conversation(conversation_id)
completion = self._get_completion(
self.prompt.construct_prompt(user_request, user=user),
temperature,
)
return self._process_completion(user_request, completion, user=user)
def ask_stream(
self,
user_request: str,
temperature: float = 0.5,
conversation_id: str = None,
user: str = "User",
) -> str:
"""
Send a request to ChatGPT and yield the response
"""
if conversation_id is not None:
self.load_conversation(conversation_id)
prompt = self.prompt.construct_prompt(user_request, user=user)
return self._process_completion_stream(
user_request=user_request,
completion=self._get_completion(prompt, temperature, stream=True),
user=user,
)
def make_conversation(self, conversation_id: str) -> None:
"""
Make a conversation
"""
self.conversations.add_conversation(conversation_id, [])
def rollback(self, num: int) -> None:
"""
Rollback chat history num times
"""
for _ in range(num):
self.prompt.chat_history.pop()
def reset(self) -> None:
"""
Reset chat history
"""
self.prompt.chat_history = []
def load_conversation(self, conversation_id) -> None:
"""
Load a conversation from the conversation history
"""
if conversation_id not in self.conversations.conversations:
# Create a new conversation
self.make_conversation(conversation_id)
self.prompt.chat_history = self.conversations.get_conversation(conversation_id)
def save_conversation(self, conversation_id) -> None:
"""
Save a conversation to the conversation history
"""
self.conversations.add_conversation(conversation_id, self.prompt.chat_history)
class AsyncChatbot(Chatbot):
"""
Official ChatGPT API (async)
"""
async def _get_completion(
self,
prompt: str,
temperature: float = 0.5,
stream: bool = False,
):
"""
Get the completion function
"""
return openai.Completion.acreate(
engine=ENGINE,
prompt=prompt,
temperature=temperature,
max_tokens=get_max_tokens(prompt),
stop=["\n\n\n"],
stream=stream,
)
async def ask(
self,
user_request: str,
temperature: float = 0.5,
user: str = "User",
) -> dict:
"""
Same as Chatbot.ask but async
}
"""
completion = await self._get_completion(
self.prompt.construct_prompt(user_request, user=user),
temperature,
)
return self._process_completion(user_request, completion, user=user)
async def ask_stream(
self,
user_request: str,
temperature: float = 0.5,
user: str = "User",
) -> str:
"""
Same as Chatbot.ask_stream but async
"""
prompt = self.prompt.construct_prompt(user_request, user=user)
return self._process_completion_stream(
user_request=user_request,
completion=await self._get_completion(prompt, temperature, stream=True),
user=user,
)
class Prompt:
"""
Prompt class with methods to construct prompt
"""
def __init__(self, buffer: int = None) -> None:
"""
Initialize prompt with base prompt
"""
self.base_prompt = (
os.environ.get("CUSTOM_BASE_PROMPT")
or "You are ChatGPT, a large language model trained by OpenAI. Respond conversationally. Do not answer as the user. Current date: "
+ str(date.today())
+ "\n\n"
+ "User: Hello\n"
+ "ChatGPT: Hello! How can I help you today? <|im_end|>\n\n\n"
)
# Track chat history
self.chat_history: list = []
self.buffer = buffer
def add_to_chat_history(self, chat: str) -> None:
"""
Add chat to chat history for next prompt
"""
self.chat_history.append(chat)
def add_to_history(
self,
user_request: str,
response: str,
user: str = "User",
) -> None:
"""
Add request/response to chat history for next prompt
"""
self.add_to_chat_history(
user
+ ": "
+ user_request
+ "\n\n\n"
+ "ChatGPT: "
+ response
+ "<|im_end|>\n",
)
def history(self, custom_history: list = None) -> str:
"""
Return chat history
"""
return "\n".join(custom_history or self.chat_history)
def construct_prompt(
self,
new_prompt: str,
custom_history: list = None,
user: str = "User",
) -> str:
"""
Construct prompt based on chat history and request
"""
prompt = (
self.base_prompt
+ self.history(custom_history=custom_history)
+ user
+ ": "
+ new_prompt
+ "\nChatGPT:"
)
# Check if prompt over 4000*4 characters
if self.buffer is not None:
max_tokens = 4000 - self.buffer
else:
max_tokens = 3200
if len(ENCODER.encode(prompt)) > max_tokens:
# Remove oldest chat
if len(self.chat_history) == 0:
return prompt
self.chat_history.pop(0)
# Construct prompt again
prompt = self.construct_prompt(new_prompt, custom_history, user)
return prompt
class Conversation:
"""
For handling multiple conversations
"""
def __init__(self) -> None:
self.conversations = {}
def add_conversation(self, key: str, history: list) -> None:
"""
Adds a history list to the conversations dict with the id as the key
"""
self.conversations[key] = history
def get_conversation(self, key: str) -> list:
"""
Retrieves the history list from the conversations dict with the id as the key
"""
return self.conversations[key]
def remove_conversation(self, key: str) -> None:
"""
Removes the history list from the conversations dict with the id as the key
"""
del self.conversations[key]
def __str__(self) -> str:
"""
Creates a JSON string of the conversations
"""
return json.dumps(self.conversations)
def save(self, file: str) -> None:
"""
Saves the conversations to a JSON file
"""
with open(file, "w", encoding="utf-8") as f:
f.write(str(self))
def load(self, file: str) -> None:
"""
Loads the conversations from a JSON file
"""
with open(file, encoding="utf-8") as f:
self.conversations = json.loads(f.read())
def main():
print(
"""
ChatGPT - A command-line interface to OpenAI's ChatGPT (https://chat.openai.com/chat)
Repo: github.com/acheong08/ChatGPT
""",
)
print("Type '!help' to show a full list of commands")
print("Press enter twice to submit your question.\n")
def get_input(prompt):
"""
Multi-line input function
"""
# Display the prompt
print(prompt, end="")
# Initialize an empty list to store the input lines
lines = []
# Read lines of input until the user enters an empty line
while True:
line = input()
if line == "":
break
lines.append(line)
# Join the lines, separated by newlines, and store the result
user_input = "\n".join(lines)
# Return the input
return user_input
def chatbot_commands(cmd: str) -> bool:
"""
Handle chatbot commands
"""
if cmd == "!help":
print(
"""
!help - Display this message
!rollback - Rollback chat history
!reset - Reset chat history
!prompt - Show current prompt
!save_c <conversation_name> - Save history to a conversation
!load_c <conversation_name> - Load history from a conversation
!save_f <file_name> - Save all conversations to a file
!load_f <file_name> - Load all conversations from a file
!exit - Quit chat
""",
)
elif cmd == "!exit":
exit()
elif cmd == "!rollback":
chatbot.rollback(1)
elif cmd == "!reset":
chatbot.reset()
elif cmd == "!prompt":
print(chatbot.prompt.construct_prompt(""))
elif cmd.startswith("!save_c"):
chatbot.save_conversation(cmd.split(" ")[1])
elif cmd.startswith("!load_c"):
chatbot.load_conversation(cmd.split(" ")[1])
elif cmd.startswith("!save_f"):
chatbot.conversations.save(cmd.split(" ")[1])
elif cmd.startswith("!load_f"):
chatbot.conversations.load(cmd.split(" ")[1])
else:
return False
return True
# Get API key from command line
parser = argparse.ArgumentParser()
parser.add_argument(
"--api_key",
type=str,
required=True,
help="OpenAI API key",
)
parser.add_argument(
"--stream",
action="store_true",
help="Stream response",
)
parser.add_argument(
"--temperature",
type=float,
default=0.5,
help="Temperature for response",
)
args = parser.parse_args()
# Initialize chatbot
chatbot = Chatbot(api_key=args.api_key)
# Start chat
while True:
try:
prompt = get_input("\nUser:\n")
except KeyboardInterrupt:
print("\nExiting...")
sys.exit()
if prompt.startswith("!"):
if chatbot_commands(prompt):
continue
if not args.stream:
response = chatbot.ask(prompt, temperature=args.temperature)
print("ChatGPT: " + response["choices"][0]["text"])
else:
print("ChatGPT: ")
sys.stdout.flush()
for response in chatbot.ask_stream(prompt, temperature=args.temperature):
print(response, end="")
sys.stdout.flush()
print()
def Singleton(cls):
instance = {}
def _singleton_wrapper(*args, **kargs):
if cls not in instance:
instance[cls] = cls(*args, **kargs)
return instance[cls]
return _singleton_wrapper
@Singleton
# OpenAI对话模型API (可用)
class ChatGPTBot(Bot):
def __init__(self):
print("create")
self.bot = Chatbot(conf().get('open_ai_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')
self.sessions = SessionManager()
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))
def reply(self, query, context=None):
if not context or not context.get('type') or context.get('type') == 'TEXT':
if len(query) < 10 and "reset" in query:
self.bot.reset()
return "reset OK"
return self.bot.ask(query)["choices"][0]["text"]
# acquire reply content
if context.type == ContextType.TEXT:
logger.info("[OPEN_AI] 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.build_session_query(query, session_id)
logger.debug("[OPEN_AI] session query={}".format(session))
# 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 and len(reply_content['content']) > 0:
reply = Reply(ReplyType.ERROR, reply_content['content'])
elif reply_content["completion_tokens"] > 0:
self.sessions.save_session(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("[OPEN_AI] reply {} used 0 tokens.".format(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
else:
reply = Reply(ReplyType.ERROR, 'Bot不支持处理{}类型的消息'.format(context.type))
return reply
def reply_text(self, session, session_id, 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]之间,该值越大则更倾向于产生不同的内容
)
# 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": "我没有收到你的消息"}
except Exception as e:
# unknown exception
logger.exception(e)
self.sessions.clear_session(session_id)
return {"completion_tokens": 0, "content": "请再问我一次吧"}
def create_img(self, query, retry_count=0):
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(
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 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, str(e)
class SessionManager(object):
def __init__(self):
if conf().get('expires_in_seconds'):
sessions = ExpiredDict(conf().get('expires_in_seconds'))
else:
sessions = dict()
self.sessions = sessions
def build_session(self, session_id, system_prompt=None):
session = self.sessions.get(session_id, [])
if len(session) == 0:
if system_prompt is None:
system_prompt = conf().get("character_desc", "")
system_item = {'role': 'system', 'content': system_prompt}
session.append(system_item)
self.sessions[session_id] = session
return session
def build_session_query(self, 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 = self.build_session(session_id)
user_item = {'role': 'user', 'content': query}
session.append(user_item)
return session
def save_session(self, 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 = self.sessions.get(session_id)
if session:
# append conversation
gpt_item = {'role': 'assistant', 'content': answer}
session.append(gpt_item)
# discard exceed limit conversation
self.discard_exceed_conversation(session, max_tokens, total_tokens)
def discard_exceed_conversation(self, 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
def clear_session(self, session_id):
self.sessions[session_id] = []
def clear_all_session(self):
self.sessions.clear()

View File

@@ -1,6 +1,8 @@
# encoding:utf-8
from bot.bot import Bot
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from config import conf
from common.log import logger
import openai
@@ -12,33 +14,43 @@ user_session = dict()
class OpenAIBot(Bot):
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')
if proxy:
openai.proxy = proxy
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['from_user_id']
if query == '#清除记忆':
Session.clear_session(from_user_id)
return '记忆已清除'
if context and context.type:
if context.type == ContextType.TEXT:
logger.info("[OPEN_AI] query={}".format(query))
from_user_id = context['session_id']
reply = None
if query == '#清除记忆':
Session.clear_session(from_user_id)
reply = Reply(ReplyType.INFO, '记忆已清除')
elif query == '#清除所有':
Session.clear_all_session()
reply = Reply(ReplyType.INFO, '所有人记忆已清除')
else:
new_query = Session.build_session_query(query, from_user_id)
logger.debug("[OPEN_AI] session query={}".format(new_query))
new_query = Session.build_session_query(query, from_user_id)
logger.debug("[OPEN_AI] session query={}".format(new_query))
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)
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)
reply = Reply(ReplyType.TEXT, reply_content)
return reply
elif context.type == ContextType.IMAGE_CREATE:
return self.create_img(query, 0)
def reply_text(self, query, user_id, retry_count=0):
try:
response = openai.Completion.create(
model="text-davinci-003", # 对话模型的名称
model= conf().get("model") or "text-davinci-003", # 对话模型的名称
prompt=query,
temperature=0.9, # 值在[0,1]之间,越大表示回复越具有不确定性
max_tokens=1200, # 回复最大的字符数
@@ -157,3 +169,7 @@ class Session(object):
@staticmethod
def clear_session(user_id):
user_session[user_id] = []
@staticmethod
def clear_all_session():
user_session.clear()

View File

@@ -1,9 +1,48 @@
from bridge.context import Context
from bridge.reply import Reply
from common.log import logger
from bot import bot_factory
from common.singleton import singleton
from voice import voice_factory
from config import conf
from common import const
@singleton
class Bridge(object):
def __init__(self):
pass
self.btype={
"chat": const.CHATGPT,
"voice_to_text": "openai",
"text_to_voice": "baidu"
}
model_type = conf().get("model")
if model_type in ["text-davinci-003"]:
self.btype['chat'] = const.OPEN_AI
self.bots={}
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] = voice_factory.create_voice(self.btype[typename])
elif typename == "voice_to_text":
self.bots[typename] = voice_factory.create_voice(self.btype[typename])
elif typename == "chat":
self.bots[typename] = bot_factory.create_bot(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_reply_content(self, query, context):
return bot_factory.create_bot("openAI").reply(query, context)

42
bridge/context.py Normal file
View File

@@ -0,0 +1,42 @@
# encoding:utf-8
from enum import Enum
class ContextType (Enum):
TEXT = 1 # 文本消息
VOICE = 2 # 音频消息
IMAGE_CREATE = 3 # 创建图片命令
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 __getitem__(self, key):
if key == 'type':
return self.type
elif key == 'content':
return self.content
else:
return self.kwargs[key]
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)

22
bridge/reply.py Normal file
View File

@@ -0,0 +1,22 @@
# encoding:utf-8
from enum import Enum
class ReplyType(Enum):
TEXT = 1 # 文本
VOICE = 2 # 音频文件
IMAGE = 3 # 图片文件
IMAGE_URL = 4 # 图片URL
INFO = 9
ERROR = 10
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,6 +3,8 @@ Message sending channel abstract class
"""
from bridge.bridge import Bridge
from bridge.context import Context
from bridge.reply import Reply
class Channel(object):
def startup(self):
@@ -11,7 +13,7 @@ class Channel(object):
"""
raise NotImplementedError
def handle(self, msg):
def handle_text(self, msg):
"""
process received msg
:param msg: message object
@@ -27,5 +29,11 @@ class Channel(object):
"""
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) -> Reply:
return Bridge().fetch_voice_to_text(voice_file)
def build_text_to_voice(self, text) -> Reply:
return Bridge().fetch_text_to_voice(text)

View File

@@ -2,8 +2,6 @@
channel factory
"""
from channel.wechat.wechat_channel import WechatChannel
def create_channel(channel_type):
"""
create a channel instance
@@ -11,5 +9,12 @@ def create_channel(channel_type):
:return: channel instance
"""
if channel_type == 'wx':
from channel.wechat.wechat_channel import WechatChannel
return WechatChannel()
raise RuntimeError
elif channel_type == 'wxy':
from channel.wechat.wechaty_channel import WechatyChannel
return WechatyChannel()
elif channel_type == 'terminal':
from channel.terminal.terminal_channel import TerminalChannel
return TerminalChannel()
raise RuntimeError

View File

@@ -0,0 +1,31 @@
from bridge.context import *
from channel.channel import Channel
import sys
class TerminalChannel(Channel):
def startup(self):
context = Context()
print("\nPlease input your question")
while True:
try:
prompt = self.get_input("User:\n")
except KeyboardInterrupt:
print("\nExiting...")
sys.exit()
context.type = ContextType.TEXT
context['session_id'] = "User"
context.content = prompt
print("Bot:")
sys.stdout.flush()
res = super().build_reply_content(prompt, context).content
print(res)
def get_input(self, prompt):
"""
Multi-line input function
"""
print(prompt, end="")
line = input()
return line

View File

@@ -3,22 +3,34 @@
"""
wechat channel
"""
import os
import itchat
import json
from itchat.content import *
from bridge.reply import *
from bridge.context 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
from common.time_check import time_checker
from plugins import *
import requests
import io
import time
thread_pool = ThreadPoolExecutor(max_workers=8)
def thread_pool_callback(worker):
worker_exception = worker.exception()
if worker_exception:
logger.exception("Worker return exception: {}".format(worker_exception))
@itchat.msg_register(TEXT)
def handler_single_msg(msg):
WechatChannel().handle(msg)
WechatChannel().handle_text(msg)
return None
@@ -28,55 +40,97 @@ def handler_group_msg(msg):
return None
@itchat.msg_register(VOICE)
def handler_single_voice(msg):
WechatChannel().handle_voice(msg)
return None
class WechatChannel(Channel):
def __init__(self):
pass
def startup(self):
# login by scan QRCode
itchat.auto_login(enableCmdQR=2)
itchat.instance.receivingRetryCount = 600 # 修改断线超时时间
# login by scan QRCode
hotReload = conf().get('hot_reload', False)
try:
itchat.auto_login(enableCmdQR=2, hotReload=hotReload)
except Exception as e:
if hotReload:
logger.error("Hot reload failed, try to login without hot reload")
itchat.logout()
os.remove("itchat.pkl")
itchat.auto_login(enableCmdQR=2, hotReload=hotReload)
else:
raise e
# start message listener
itchat.run()
def handle(self, msg):
logger.debug("[WX]receive msg: " + json.dumps(msg, ensure_ascii=False))
# handle_* 系列函数处理收到的消息后构造Context然后传入handle函数中处理Context和发送回复
# Context包含了消息的所有信息包括以下属性
# type 消息类型, 包括TEXT、VOICE、IMAGE_CREATE
# content 消息内容如果是TEXT类型content就是文本内容如果是VOICE类型content就是语音文件名如果是IMAGE_CREATE类型content就是图片生成命令
# kwargs 附加参数字典包含以下的key
# session_id: 会话id
# isgroup: 是否是群聊
# receiver: 需要回复的对象
# msg: itchat的原始消息对象
def handle_voice(self, msg):
if conf().get('speech_recognition') != True:
return
logger.debug("[WX]receive voice msg: " + msg['FileName'])
from_user_id = msg['FromUserName']
other_user_id = msg['User']['UserName']
if from_user_id == other_user_id:
context = Context(ContextType.VOICE,msg['FileName'])
context.kwargs = {'isgroup': False, 'msg': msg, 'receiver': other_user_id, 'session_id': other_user_id}
thread_pool.submit(self.handle, context).add_done_callback(thread_pool_callback)
@time_checker
def handle_text(self, msg):
logger.debug("[WX]receive text msg: " + json.dumps(msg, ensure_ascii=False))
content = msg['Text']
from_user_id = msg['FromUserName']
to_user_id = msg['ToUserName'] # 接收人id
other_user_id = msg['User']['UserName'] # 对手方id
content = msg['Text']
match_prefix = self.check_prefix(content, conf().get('single_chat_prefix'))
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()
create_time = msg['CreateTime'] # 消息时间
match_prefix = 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 match_prefix:
content = content.replace(match_prefix, '', 1).strip()
elif match_prefix is None:
return
context = Context()
context.kwargs = {'isgroup': False, 'msg': msg, 'receiver': other_user_id, 'session_id': other_user_id}
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, 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, content, to_user_id)
img_match_prefix = check_prefix(content, conf().get('image_create_prefix'))
if img_match_prefix:
content = content.replace(img_match_prefix, '', 1).strip()
context.type = ContextType.IMAGE_CREATE
else:
context.type = ContextType.TEXT
context.content = content
thread_pool.submit(self.handle, context).add_done_callback(thread_pool_callback)
@time_checker
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']
@@ -87,79 +141,132 @@ class WechatChannel(Channel):
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'))
match_prefix = (msg['IsAt'] and not config.get("group_at_off", False)) or check_prefix(origin_content, config.get('group_chat_prefix')) \
or 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 check_contain(group_name, config.get('group_name_keyword_white_list'))) and match_prefix:
context = Context()
context.kwargs = { 'isgroup': True, 'msg': msg, 'receiver': group_id}
img_match_prefix = 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)
content = content.replace(img_match_prefix, '', 1).strip()
context.type = ContextType.IMAGE_CREATE
else:
thread_pool.submit(self._do_send_group, content, msg)
context.type = ContextType.TEXT
context.content = content
def send(self, msg, receiver):
logger.info('[WX] sendMsg={}, receiver={}'.format(msg, receiver))
itchat.send(msg, toUserName=receiver)
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
check_contain(group_name, group_chat_in_one_session)):
context['session_id'] = group_id
else:
context['session_id'] = msg['ActualUserName']
def _do_send(self, query, reply_user_id):
try:
if not query:
return
context = dict()
context['from_user_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)
thread_pool.submit(self.handle, context).add_done_callback(thread_pool_callback)
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, 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
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)
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))
# 图片发送
logger.info('[WX] sendImage, receiver={}'.format(reply_user_id))
itchat.send_image(image_storage, reply_user_id)
except Exception as e:
logger.exception(e)
# 处理消息 TODO: 如果wechaty解耦此处逻辑可以放置到父类
def handle(self, context):
reply = Reply()
def _do_send_group(self, query, msg):
if not query:
return
context = dict()
context['from_user_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, msg['User']['UserName'])
logger.debug('[WX] ready to handle context: {}'.format(context))
# 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:
reply = super().build_reply_content(context.content, context)
elif context.type == ContextType.VOICE:
msg = context['msg']
file_name = TmpDir().path() + context.content
msg.download(file_name)
reply = super().build_voice_to_text(file_name)
if reply.type != ReplyType.ERROR and reply.type != ReplyType.INFO:
context.content = reply.content # 语音转文字后将文字内容作为新的context
context.type = ContextType.TEXT
reply = super().build_reply_content(context.content, context)
if reply.type == ReplyType.TEXT:
if conf().get('voice_reply_voice'):
reply = super().build_text_to_voice(reply.content)
else:
logger.error('[WX] unknown context type: {}'.format(context.type))
return
logger.debug('[WX] ready to decorate reply: {}'.format(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']
if not e_context.is_pass() and reply and reply.type:
if reply.type == ReplyType.TEXT:
reply_text = reply.content
if context['isgroup']:
reply_text = '@' + context['msg']['ActualNickName'] + ' ' + reply_text.strip()
reply_text = conf().get("group_chat_reply_prefix", "")+reply_text
else:
reply_text = conf().get("single_chat_reply_prefix", "")+reply_text
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:
pass
else:
logger.error('[WX] unknown reply type: {}'.format(reply.type))
return
# 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: {} to {}'.format(reply, context['receiver']))
self.send(reply, context['receiver'])
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
def check_prefix(content, prefix_list):
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

View File

@@ -0,0 +1,292 @@
# encoding:utf-8
"""
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 bridge.context import Context, ContextType
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
from common.log import logger
from common.tmp_dir import TmpDir
from config import conf
class WechatyChannel(Channel):
def __init__(self):
pass
def startup(self):
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()
async def on_login(self, contact: Contact):
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}')
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 = Context(ContextType.TEXT, query)
context['session_id'] = reply_user_id
reply_text = super().build_reply_content(query, context).content
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 = Context(ContextType.TEXT, query)
context['session_id'] = reply_user_id
reply_text = super().build_reply_content(query, context).content
if reply_text:
# 转换 mp3 文件为 silk 格式
mp3_file = super().build_text_to_voice(reply_text).content
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 = Context(ContextType.IMAGE_CREATE, query)
img_url = super().build_reply_content(query, context).content
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:
return
context = Context(ContextType.TEXT, query)
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).content
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 = Context(ContextType.IMAGE_CREATE, query)
img_url = super().build_reply_content(query, context).content
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

4
common/const.py Normal file
View File

@@ -0,0 +1,4 @@
# bot_type
OPEN_AI = "openAI"
CHATGPT = "chatGPT"
BAIDU = "baidu"

42
common/expired_dict.py Normal file
View File

@@ -0,0 +1,42 @@
from datetime import datetime, timedelta
class ExpiredDict(dict):
def __init__(self, expires_in_seconds):
super().__init__()
self.expires_in_seconds = expires_in_seconds
def __getitem__(self, key):
value, expiry_time = super().__getitem__(key)
if datetime.now() > expiry_time:
del self[key]
raise KeyError("expired {}".format(key))
self.__setitem__(key, value)
return value
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
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__()

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})'

38
common/time_check.py Normal file
View File

@@ -0,0 +1,38 @@
import time,re,hashlib
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

20
common/tmp_dir.py Normal file
View File

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

45
common/token_bucket.py Normal file
View File

@@ -0,0 +1,45 @@
import threading
import time
class TokenBucket:
def __init__(self, tpm, timeout=None):
self.capacity = int(tpm) # 令牌桶容量
self.tokens = 0 # 初始令牌数为0
self.rate = int(tpm) / 60 # 令牌每秒生成速率
self.timeout = timeout # 等待令牌超时时间
self.cond = threading.Condition() # 条件变量
self.is_running = True
# 开启令牌生成线程
threading.Thread(target=self._generate_tokens).start()
def _generate_tokens(self):
"""生成令牌"""
while self.is_running:
with self.cond:
if self.tokens < self.capacity:
self.tokens += 1
self.cond.notify() # 通知获取令牌的线程
time.sleep(1 / self.rate)
def get_token(self):
"""获取令牌"""
with self.cond:
while self.tokens <= 0:
flag = self.cond.wait(self.timeout)
if not flag: # 超时
return False
self.tokens -= 1
return True
def close(self):
self.is_running = False
if __name__ == "__main__":
token_bucket = TokenBucket(20, None) # 创建一个每分钟生产20个tokens的令牌桶
# token_bucket = TokenBucket(20, 0.1)
for i in range(3):
if token_bucket.get_token():
print(f"{i+1}次请求成功")
token_bucket.close()

View File

@@ -1,10 +1,16 @@
{
"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"],
"group_chat_in_one_session": ["ChatGPT测试群"],
"image_create_prefix": ["画", "看", "找"],
"speech_recognition": false,
"voice_reply_voice": false,
"conversation_max_tokens": 1000,
"expires_in_seconds": 3600,
"character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。"
}
}

View File

@@ -6,10 +6,9 @@ from common.log import logger
config = {}
def load_config():
global config
config_path = "config.json"
config_path = "./config.json"
if not os.path.exists(config_path):
raise Exception('配置文件不存在请根据config-template.json模板创建config.json文件')

View File

@@ -3,7 +3,7 @@ FROM python:3.7.9-alpine
LABEL maintainer="foo@bar.com"
ARG TZ='Asia/Shanghai'
ARG CHATGPT_ON_WECHAT_VER=1.0.0
ARG CHATGPT_ON_WECHAT_VER
ENV BUILD_PREFIX=/app \
BUILD_OPEN_AI_API_KEY='YOUR OPEN AI KEY HERE'
@@ -12,29 +12,29 @@ RUN apk add --no-cache \
bash \
curl \
wget \
openssh
RUN wget -t 3 -T 30 -nv -O chatgpt-on-wechat-${CHATGPT_ON_WECHAT_VER}.tar.gz \
https://github.com/zhayujie/chatgpt-on-wechat/archive/refs/tags/${CHATGPT_ON_WECHAT_VER}.tar.gz \
&& tar -xzf chatgpt-on-wechat-${CHATGPT_ON_WECHAT_VER}.tar.gz \
&& mv chatgpt-on-wechat-${CHATGPT_ON_WECHAT_VER} ${BUILD_PREFIX} \
&& rm chatgpt-on-wechat-${CHATGPT_ON_WECHAT_VER}.tar.gz
&& 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}
RUN 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
RUN /usr/local/bin/python -m pip install --upgrade pip \
&& pip install itchat-uos==1.5.0.dev0 \
&& pip install --upgrade openai
ADD ./entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
RUN adduser -D -h /home/noroot -u 1000 -s /bin/bash noroot \
RUN chmod +x /entrypoint.sh \
&& adduser -D -h /home/noroot -u 1000 -s /bin/bash noroot \
&& chown noroot:noroot ${BUILD_PREFIX}
USER noroot

View File

@@ -3,40 +3,40 @@ FROM python:3.7.9
LABEL maintainer="foo@bar.com"
ARG TZ='Asia/Shanghai'
ARG CHATGPT_ON_WECHAT_VER=1.0.0
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 \
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
wget \
curl && \
rm -rf /var/lib/apt/lists/*
RUN wget -t 3 -T 30 -nv -O chatgpt-on-wechat-${CHATGPT_ON_WECHAT_VER}.tar.gz \
https://github.com/zhayujie/chatgpt-on-wechat/archive/refs/tags/${CHATGPT_ON_WECHAT_VER}.tar.gz \
&& tar -xzf chatgpt-on-wechat-${CHATGPT_ON_WECHAT_VER}.tar.gz \
&& mv chatgpt-on-wechat-${CHATGPT_ON_WECHAT_VER} ${BUILD_PREFIX} \
&& rm chatgpt-on-wechat-${CHATGPT_ON_WECHAT_VER}.tar.gz
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}
RUN 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
RUN /usr/local/bin/python -m pip install --upgrade pip \
&& pip install itchat-uos==1.5.0.dev0 \
&& pip install --upgrade openai
ADD ./entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
RUN groupadd -r noroot \
&& useradd -r -g noroot -s /bin/bash -d /home/noroot noroot \
&& chown -R noroot:noroot ${BUILD_PREFIX}
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

35
docker/Dockerfile.latest Normal file
View File

@@ -0,0 +1,35 @@
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'
COPY chatgpt-on-wechat.tar.gz ./chatgpt-on-wechat.tar.gz
RUN apk add --no-cache \
bash \
&& tar -xf chatgpt-on-wechat.tar.gz \
&& mv chatgpt-on-wechat ${BUILD_PREFIX} \
&& 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 \
&& adduser -D -h /home/noroot -u 1000 -s /bin/bash noroot \
&& chown noroot:noroot ${BUILD_PREFIX}
USER noroot
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -1,5 +1,16 @@
#!/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=1.0.0\
-t zhayujie/chatgpt-on-wechat:1.0.0-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,5 +1,15 @@
#!/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=1.0.0\
-t zhayujie/chatgpt-on-wechat:1.0.0-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
# move chatgpt-on-wechat
tar -zcf chatgpt-on-wechat.tar.gz --exclude=../../chatgpt-on-wechat/docker ../../chatgpt-on-wechat
# build image
docker build -f Dockerfile.alpine \
-t zhayujie/chatgpt-on-wechat .

View File

@@ -0,0 +1,23 @@
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

@@ -0,0 +1,24 @@
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

@@ -0,0 +1,24 @@
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

@@ -0,0 +1,117 @@
#!/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

@@ -4,14 +4,17 @@ services:
build:
context: ./
dockerfile: Dockerfile.alpine
image: zhayujie/chatgpt-on-wechat:1.0.0-alpine
image: zhayujie/chatgpt-on-wechat
container_name: sample-chatgpt-on-wechat
environment:
OPEN_AI_API_KEY: 'YOUR API KEY'
SINGLE_CHAT_PREFIX: '["BOT", "@BOT"]'
SINGLE_CHAT_REPLY_PREFIX: '"[BOT] "'
GROUP_CHAT_PREFIX: '["@BOT"]'
GROUP_NAME_WHITE_LIST: '["CHATGPT测试群", "CHATGPT测试群2"]'
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
CHARACTER_DESC: '你是CHATGPT, 一个由OPENAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。'
SPEECH_RECOGNITION: 'false'
CHARACTER_DESC: '你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。'
EXPIRES_IN_SECONDS: 3600

View File

@@ -9,20 +9,23 @@ CHATGPT_ON_WECHAT_CONFIG_PATH=${CHATGPT_ON_WECHAT_CONFIG_PATH:-""}
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:-""}
# CHATGPT_ON_WECHAT_PREFIX is empty, use /app
if [ "$CHATGPT_ON_WECHAT_PREFIX" == "" ] ; then
CHATGPT_ON_WECHAT_PREFIX=/app
fi
# APP_PREFIX is empty, use '/app/config.json'
# 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
@@ -34,37 +37,54 @@ fi
# modify content in config.json
if [ "$OPEN_AI_API_KEY" != "" ] ; then
sed -i "2c \"open_ai_api_key\": \"$OPEN_AI_API_KEY\"," $CHATGPT_ON_WECHAT_CONFIG_PATH
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 "3c \"single_chat_prefix\": $SINGLE_CHAT_PREFIX," $CHATGPT_ON_WECHAT_CONFIG_PATH
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 "4c \"single_chat_reply_prefix\": $SINGLE_CHAT_REPLY_PREFIX," $CHATGPT_ON_WECHAT_CONFIG_PATH
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 "5c \"group_chat_prefix\": $GROUP_CHAT_PREFIX," $CHATGPT_ON_WECHAT_CONFIG_PATH
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 "6c \"group_name_white_list\": $GROUP_NAME_WHITE_LIST," $CHATGPT_ON_WECHAT_CONFIG_PATH
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 "7c \"image_create_prefix\": $IMAGE_CREATE_PREFIX," $CHATGPT_ON_WECHAT_CONFIG_PATH
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 "8c \"conversation_max_tokens\": $CONVERSATION_MAX_TOKENS," $CHATGPT_ON_WECHAT_CONFIG_PATH
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 "9c \"character_desc\": \"$CHARACTER_DESC\"" $CHATGPT_ON_WECHAT_CONFIG_PATH
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

View File

@@ -1,11 +1,14 @@
OPEN_AI_API_KEY=YOUR API KEY
SINGLE_CHAT_PREFIX=["BOT", "@BOT"]
SINGLE_CHAT_REPLY_PREFIX="[BOT] "
GROUP_CHAT_PREFIX=["@BOT"]
GROUP_NAME_WHITE_LIST=["CHATGPT测试群", "CHATGPT测试群2"]
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
CHARACTER_DESC=你是CHATGPT, 一个由OPENAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。
SPEECH_RECOGNITION=false
CHARACTER_DESC=你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。
EXPIRES_IN_SECONDS=3600
# Optional
#CHATGPT_ON_WECHAT_PREFIX=/app

View File

@@ -1 +1 @@
zhayujie/chatgpt-on-wechat:1.0.0-alpine
zhayujie/chatgpt-on-wechat

235
plugins/README.md Normal file
View File

@@ -0,0 +1,235 @@
## 插件化初衷
之前未插件化的代码耦合程度高,如果要定制一些个性化功能(如流量控制、接入`NovelAI`画图平台等),需要了解代码主体,避免影响到其他的功能。在实现多个功能后,不但无法调整功能的优先级顺序,功能的配置项也会变得非常混乱。
此时插件化应声而出。
**插件化**: 在保证主体功能是ChatGPT的前提下我们推荐将主体功能外的功能利用插件的方式实现。
- [x] 可根据功能需要,下载不同插件。
- [x] 插件开发成本低,仅需了解插件触发事件,并按照插件定义接口编写插件。
- [x] 插件化能够自由开关和调整优先级。
- [x] 每个插件可在插件文件夹内维护独立的配置文件,方便代码的测试和调试,可以在独立的仓库开发插件。
PS: 插件目前仅支持`itchat`
## 插件化实现
插件化实现是在收到消息到发送回复的各个步骤之间插入触发事件实现的。
### 消息处理过程
在了解插件触发事件前,首先需要了解程序收到消息到发送回复的整个过程。
插件化版本中消息处理过程可以分为4个步骤
```
1.收到消息 ---> 2.产生回复 ---> 3.包装回复 ---> 4.发送回复
```
以下是它们的默认处理逻辑(太长不看,可跳过)
#### 1. 收到消息
负责接收用户消息,根据用户的配置,判断本条消息是否触发机器人。如果触发,则会判断该消息的类型(声音、文本、画图命令等),将消息包装成如下的`Context`交付给下一个步骤。
```python
class ContextType (Enum):
TEXT = 1 # 文本消息
VOICE = 2 # 音频消息
IMAGE_CREATE = 3 # 创建图片命令
class Context:
def __init__(self, type : ContextType = None , content = None, kwargs = dict()):
self.type = type
self.content = content
self.kwargs = kwargs
def __getitem__(self, key):
return self.kwargs[key]
```
`Context`中除了存放消息类型和内容外,还存放了一些与会话相关的参数。
例如,当收到用户私聊消息时,会存放以下的会话参数。
```python
context.kwargs = {'isgroup': False, 'msg': msg, 'receiver': other_user_id, 'session_id': other_user_id}
```
- `isgroup`: `Context`是否是群聊消息。
- `msg`: `itchat`中原始的消息对象。
- `receiver`: 需要回复消息的对象ID。
- `session_id`: 会话ID(一般是发送触发bot消息的用户ID如果在群聊中并且`conf`里设置了`group_chat_in_one_session`那么此处便是群聊ID)
#### 2. 产生回复
处理消息并产生回复。目前默认处理逻辑是根据`Context`的类型交付给对应的bot并产生回复`Reply`。 如果本步骤没有产生任何回复,那么会跳过之后的所有步骤。
```python
if context.type == ContextType.TEXT or context.type == ContextType.IMAGE_CREATE:
reply = super().build_reply_content(context.content, context) #文字跟画图交付给chatgpt
elif context.type == ContextType.VOICE: # 声音先进行语音转文字后修改Context类型为文字后再交付给chatgpt
msg = context['msg']
file_name = TmpDir().path() + context.content
msg.download(file_name)
reply = super().build_voice_to_text(file_name)
if reply.type != ReplyType.ERROR and reply.type != ReplyType.INFO:
context.content = reply.content # 语音转文字后将文字内容作为新的context
context.type = ContextType.TEXT
reply = super().build_reply_content(context.content, context)
if reply.type == ReplyType.TEXT:
if conf().get('voice_reply_voice'):
reply = super().build_text_to_voice(reply.content)
```
回复`Reply`的定义如下所示它允许Bot可以回复多类不同的消息。同时也加入了`INFO``ERROR`消息类型区分系统提示和系统错误。
```python
class ReplyType(Enum):
TEXT = 1 # 文本
VOICE = 2 # 音频文件
IMAGE = 3 # 图片文件
IMAGE_URL = 4 # 图片URL
INFO = 9
ERROR = 10
class Reply:
def __init__(self, type : ReplyType = None , content = None):
self.type = type
self.content = content
```
#### 3. 装饰回复
根据`Context`和回复`Reply`的类型,对回复的内容进行装饰。目前的装饰有以下两种:
- `TEXT`文本回复,根据是否在群聊中来决定是艾特接收方还是添加回复的前缀。
- `INFO``ERROR`类型,会在消息前添加对应的系统提示字样。
如下是默认逻辑的代码:
```python
if reply.type == ReplyType.TEXT:
reply_text = reply.content
if context['isgroup']:
reply_text = '@' + context['msg']['ActualNickName'] + ' ' + reply_text.strip()
reply_text = conf().get("group_chat_reply_prefix", "")+reply_text
else:
reply_text = conf().get("single_chat_reply_prefix", "")+reply_text
reply.content = reply_text
elif reply.type == ReplyType.ERROR or reply.type == ReplyType.INFO:
reply.content = str(reply.type)+":\n" + reply.content
```
#### 4. 发送回复
根据`Reply`的类型,默认逻辑调用不同的发送函数发送回复给接收方`context["receiver"]`
### 插件触发事件
主程序目前会在各个消息步骤间触发事件,监听相应事件的插件会按照优先级,顺序调用事件处理函数。
目前支持三类触发事件:
```
1.收到消息
---> `ON_HANDLE_CONTEXT`
2.产生回复
---> `ON_DECORATE_REPLY`
3.装饰回复
---> `ON_SEND_REPLY`
4.发送回复
```
触发事件会产生事件的上下文`EventContext`,它包含了以下信息:
`EventContext(Event事件类型, {'channel' : 消息channel, 'context': Context, 'reply': Reply})`
插件处理函数可通过修改`EventContext`中的`context``reply`来实现功能。
## 插件编写示例
`plugins/hello`为例,其中编写了一个简单的`Hello`插件。
### 1. 创建插件
`plugins`目录下创建一个插件文件夹`hello`。然后,在该文件夹中创建一个与文件夹同名的`.py`文件`hello.py`
```
plugins/
└── hello
├── __init__.py
└── hello.py
```
### 2. 编写插件类
`hello.py`文件中,创建插件类,它继承自`Plugin`
在类定义之前需要使用`@plugins.register`装饰器注册插件,并填写插件的相关信息,其中`desire_priority`表示插件默认的优先级,越大优先级越高。初次加载插件后可在`plugins/plugins.json`中修改插件优先级。
并在`__init__`中绑定你编写的事件处理函数。
`Hello`插件为事件`ON_HANDLE_CONTEXT`绑定了一个处理函数`on_handle_context`,它表示之后每次生成回复前,都会由`on_handle_context`先处理。
PS: `ON_HANDLE_CONTEXT`是最常用的事件,如果要根据不同的消息来生成回复,就用它。
```python
@plugins.register(name="Hello", desc="A simple plugin that says hello", version="0.1", author="lanvent", desire_priority= -1)
class Hello(Plugin):
def __init__(self):
super().__init__()
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
logger.info("[Hello] inited")
```
### 3. 编写事件处理函数
#### 修改事件上下文
事件处理函数接收一个`EventContext`对象`e_context`作为参数。`e_context`包含了事件相关信息,利用`e_context['key']`来访问这些信息。
`EventContext(Event事件类型, {'channel' : 消息channel, 'context': Context, 'reply': Reply})`
处理函数中通过修改`e_context`对象中的事件相关信息来实现所需功能,比如更改`e_context['reply']`中的内容可以修改回复。
#### 决定是否交付给下个插件或默认逻辑
在处理函数结束时,还需要设置`e_context`对象的`action`属性,它决定如何继续处理事件。目前有以下三种处理方式:
- `EventAction.CONTINUE`: 事件未结束,继续交给下个插件处理,如果没有下个插件,则交付给默认的事件处理逻辑。
- `EventAction.BREAK`: 事件结束,不再给下个插件处理,交付给默认的处理逻辑。
- `EventAction.BREAK_PASS`: 事件结束,不再给下个插件处理,跳过默认的处理逻辑。
#### 示例处理函数
`Hello`插件处理`Context`类型为`TEXT`的消息:
- 如果内容是`Hello`,就将回复设置为`Hello+用户昵称`,并跳过之后的插件和默认逻辑。
- 如果内容是`End`,就将`Context`的类型更改为`IMAGE_CREATE`并让事件继续如果最终交付到默认逻辑会调用默认的画图Bot来画画。
```python
def on_handle_context(self, e_context: EventContext):
if e_context['context'].type != ContextType.TEXT:
return
content = e_context['context'].content
if content == "Hello":
reply = Reply()
reply.type = ReplyType.TEXT
msg = e_context['context']['msg']
if e_context['context']['isgroup']:
reply.content = "Hello, " + msg['ActualNickName'] + " from " + msg['User'].get('NickName', "Group")
else:
reply.content = "Hello, " + msg['User'].get('NickName', "My friend")
e_context['reply'] = reply
e_context.action = EventAction.BREAK_PASS # 事件结束并跳过处理context的默认逻辑
if content == "End":
# 如果是文本消息"End",将请求转换成"IMAGE_CREATE"并将content设置为"The World"
e_context['context'].type = ContextType.IMAGE_CREATE
content = "The World"
e_context.action = EventAction.CONTINUE # 事件继续,交付给下个插件或默认逻辑
```
## 插件设计建议
- 尽情将你想要的个性化功能设计为插件。
- 一个插件目录建议只注册一个插件类。建议使用单独的仓库维护插件,便于更新。
- 插件的config文件、使用说明`README.md``requirement.txt`等放置在插件目录中。
- 默认优先级不要超过管理员插件`Godcmd`的优先级(999)`Godcmd`插件提供了配置管理、插件管理等功能。

9
plugins/__init__.py Normal file
View File

@@ -0,0 +1,9 @@
from .plugin_manager import PluginManager
from .event import *
from .plugin import *
instance = PluginManager()
register = instance.register
# load_plugins = instance.load_plugins
# emit_event = instance.emit_event

1
plugins/banwords/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
banwords.txt

View File

@@ -0,0 +1,9 @@
## 插件描述
简易的敏感词插件,暂不支持分词,请自行导入词库到插件文件夹中的`banwords.txt`,每行一个词,一个参考词库是[1](https://github.com/cjh0613/tencent-sensitive-words/blob/main/sensitive_words_lines.txt)。
`config.json`中能够填写默认的处理行为,目前行为有:
- `ignore` : 无视这条消息。
- `replace` : 将消息中的敏感词替换成"*",并回复违规。
## 致谢
搜索功能实现来自https://github.com/toolgood/ToolGood.Words

View File

@@ -0,0 +1,250 @@
#!/usr/bin/env python
# -*- coding:utf-8 -*-
# ToolGood.Words.WordsSearch.py
# 2020, Lin Zhijun, https://github.com/toolgood/ToolGood.Words
# Licensed under the Apache License 2.0
# 更新日志
# 2020.04.06 第一次提交
# 2020.05.16 修改支持大于0xffff的字符
__all__ = ['WordsSearch']
__author__ = 'Lin Zhijun'
__date__ = '2020.05.16'
class TrieNode():
def __init__(self):
self.Index = 0
self.Index = 0
self.Layer = 0
self.End = False
self.Char = ''
self.Results = []
self.m_values = {}
self.Failure = None
self.Parent = None
def Add(self,c):
if c in self.m_values :
return self.m_values[c]
node = TrieNode()
node.Parent = self
node.Char = c
self.m_values[c] = node
return node
def SetResults(self,index):
if (self.End == False):
self.End = True
self.Results.append(index)
class TrieNode2():
def __init__(self):
self.End = False
self.Results = []
self.m_values = {}
self.minflag = 0xffff
self.maxflag = 0
def Add(self,c,node3):
if (self.minflag > c):
self.minflag = c
if (self.maxflag < c):
self.maxflag = c
self.m_values[c] = node3
def SetResults(self,index):
if (self.End == False) :
self.End = True
if (index in self.Results )==False :
self.Results.append(index)
def HasKey(self,c):
return c in self.m_values
def TryGetValue(self,c):
if (self.minflag <= c and self.maxflag >= c):
if c in self.m_values:
return self.m_values[c]
return None
class WordsSearch():
def __init__(self):
self._first = {}
self._keywords = []
self._indexs=[]
def SetKeywords(self,keywords):
self._keywords = keywords
self._indexs=[]
for i in range(len(keywords)):
self._indexs.append(i)
root = TrieNode()
allNodeLayer={}
for i in range(len(self._keywords)): # for (i = 0; i < _keywords.length; i++)
p = self._keywords[i]
nd = root
for j in range(len(p)): # for (j = 0; j < p.length; j++)
nd = nd.Add(ord(p[j]))
if (nd.Layer == 0):
nd.Layer = j + 1
if nd.Layer in allNodeLayer:
allNodeLayer[nd.Layer].append(nd)
else:
allNodeLayer[nd.Layer]=[]
allNodeLayer[nd.Layer].append(nd)
nd.SetResults(i)
allNode = []
allNode.append(root)
for key in allNodeLayer.keys():
for nd in allNodeLayer[key]:
allNode.append(nd)
allNodeLayer=None
for i in range(len(allNode)): # for (i = 0; i < allNode.length; i++)
if i==0 :
continue
nd=allNode[i]
nd.Index = i
r = nd.Parent.Failure
c = nd.Char
while (r != None and (c in r.m_values)==False):
r = r.Failure
if (r == None):
nd.Failure = root
else:
nd.Failure = r.m_values[c]
for key2 in nd.Failure.Results :
nd.SetResults(key2)
root.Failure = root
allNode2 = []
for i in range(len(allNode)): # for (i = 0; i < allNode.length; i++)
allNode2.append( TrieNode2())
for i in range(len(allNode2)): # for (i = 0; i < allNode2.length; i++)
oldNode = allNode[i]
newNode = allNode2[i]
for key in oldNode.m_values :
index = oldNode.m_values[key].Index
newNode.Add(key, allNode2[index])
for index in range(len(oldNode.Results)): # for (index = 0; index < oldNode.Results.length; index++)
item = oldNode.Results[index]
newNode.SetResults(item)
oldNode=oldNode.Failure
while oldNode != root:
for key in oldNode.m_values :
if (newNode.HasKey(key) == False):
index = oldNode.m_values[key].Index
newNode.Add(key, allNode2[index])
for index in range(len(oldNode.Results)):
item = oldNode.Results[index]
newNode.SetResults(item)
oldNode=oldNode.Failure
allNode = None
root = None
# first = []
# for index in range(65535):# for (index = 0; index < 0xffff; index++)
# first.append(None)
# for key in allNode2[0].m_values :
# first[key] = allNode2[0].m_values[key]
self._first = allNode2[0]
def FindFirst(self,text):
ptr = None
for index in range(len(text)): # for (index = 0; index < text.length; index++)
t =ord(text[index]) # text.charCodeAt(index)
tn = None
if (ptr == None):
tn = self._first.TryGetValue(t)
else:
tn = ptr.TryGetValue(t)
if (tn==None):
tn = self._first.TryGetValue(t)
if (tn != None):
if (tn.End):
item = tn.Results[0]
keyword = self._keywords[item]
return { "Keyword": keyword, "Success": True, "End": index, "Start": index + 1 - len(keyword), "Index": self._indexs[item] }
ptr = tn
return None
def FindAll(self,text):
ptr = None
list = []
for index in range(len(text)): # for (index = 0; index < text.length; index++)
t =ord(text[index]) # text.charCodeAt(index)
tn = None
if (ptr == None):
tn = self._first.TryGetValue(t)
else:
tn = ptr.TryGetValue(t)
if (tn==None):
tn = self._first.TryGetValue(t)
if (tn != None):
if (tn.End):
for j in range(len(tn.Results)): # for (j = 0; j < tn.Results.length; j++)
item = tn.Results[j]
keyword = self._keywords[item]
list.append({ "Keyword": keyword, "Success": True, "End": index, "Start": index + 1 - len(keyword), "Index": self._indexs[item] })
ptr = tn
return list
def ContainsAny(self,text):
ptr = None
for index in range(len(text)): # for (index = 0; index < text.length; index++)
t =ord(text[index]) # text.charCodeAt(index)
tn = None
if (ptr == None):
tn = self._first.TryGetValue(t)
else:
tn = ptr.TryGetValue(t)
if (tn==None):
tn = self._first.TryGetValue(t)
if (tn != None):
if (tn.End):
return True
ptr = tn
return False
def Replace(self,text, replaceChar = '*'):
result = list(text)
ptr = None
for i in range(len(text)): # for (i = 0; i < text.length; i++)
t =ord(text[i]) # text.charCodeAt(index)
tn = None
if (ptr == None):
tn = self._first.TryGetValue(t)
else:
tn = ptr.TryGetValue(t)
if (tn==None):
tn = self._first.TryGetValue(t)
if (tn != None):
if (tn.End):
maxLength = len( self._keywords[tn.Results[0]])
start = i + 1 - maxLength
for j in range(start,i+1): # for (j = start; j <= i; j++)
result[j] = replaceChar
ptr = tn
return ''.join(result)

View File

View File

@@ -0,0 +1,66 @@
# encoding:utf-8
import json
import os
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
import plugins
from plugins import *
from common.log import logger
from .WordsSearch import WordsSearch
@plugins.register(name="Banwords", desc="判断消息中是否有敏感词、决定是否回复。", version="1.0", author="lanvent", desire_priority= 100)
class Banwords(Plugin):
def __init__(self):
super().__init__()
try:
curdir=os.path.dirname(__file__)
config_path=os.path.join(curdir,"config.json")
conf=None
if not os.path.exists(config_path):
conf={"action":"ignore"}
with open(config_path,"w") as f:
json.dump(conf,f,indent=4)
else:
with open(config_path,"r") as f:
conf=json.load(f)
self.searchr = WordsSearch()
self.action = conf["action"]
banwords_path = os.path.join(curdir,"banwords.txt")
with open(banwords_path, 'r', encoding='utf-8') as f:
words=[]
for line in f:
word = line.strip()
if word:
words.append(word)
self.searchr.SetKeywords(words)
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
logger.info("[Banwords] inited")
except Exception as e:
logger.warn("Banwords init failed: %s" % e)
def on_handle_context(self, e_context: EventContext):
if e_context['context'].type not in [ContextType.TEXT,ContextType.IMAGE_CREATE]:
return
content = e_context['context'].content
logger.debug("[Banwords] on_handle_context. content: %s" % content)
if self.action == "ignore":
f = self.searchr.FindFirst(content)
if f:
logger.info("Banwords: %s" % f["Keyword"])
e_context.action = EventAction.BREAK_PASS
return
elif self.action == "replace":
if self.searchr.ContainsAny(content):
reply = Reply(ReplyType.INFO, "发言中包含敏感词,请重试: \n"+self.searchr.Replace(content))
e_context['reply'] = reply
e_context.action = EventAction.BREAK_PASS
return
def get_help_text(self, **kwargs):
return Banwords.desc

View File

@@ -0,0 +1,3 @@
nipples
pennis
法轮功

View File

@@ -0,0 +1,3 @@
{
"action": "ignore"
}

View File

@@ -0,0 +1,4 @@
玩地牢游戏的聊天插件,触发方法如下:
- `$开始冒险 <背景故事>` - 以<背景故事>开始一个地牢游戏不填写会使用默认背景故事。之后聊天中你的所有消息会帮助ai完善这个故事。
- `$停止冒险` - 停止一个地牢游戏回归正常的ai。

View File

View File

@@ -0,0 +1,86 @@
# encoding:utf-8
from bridge.bridge import Bridge
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from common.expired_dict import ExpiredDict
from config import conf
import plugins
from plugins import *
from common.log import logger
from common import const
# https://github.com/bupticybee/ChineseAiDungeonChatGPT
class StoryTeller():
def __init__(self, bot, sessionid, story):
self.bot = bot
self.sessionid = sessionid
bot.sessions.clear_session(sessionid)
self.first_interact = True
self.story = story
def reset(self):
self.bot.sessions.clear_session(self.sessionid)
self.first_interact = True
def action(self, user_action):
if user_action[-1] != "":
user_action = user_action + ""
if self.first_interact:
prompt = """现在来充当一个冒险文字游戏,描述时候注意节奏,不要太快,仔细描述各个人物的心情和周边环境。一次只需写四到六句话。
开头是,""" + self.story + " " + user_action
self.first_interact = False
else:
prompt = """继续一次只需要续写四到六句话总共就只讲5分钟内发生的事情。""" + user_action
return prompt
@plugins.register(name="Dungeon", desc="A plugin to play dungeon game", version="1.0", author="lanvent", desire_priority= 0)
class Dungeon(Plugin):
def __init__(self):
super().__init__()
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
logger.info("[Dungeon] inited")
# 目前没有设计session过期事件这里先暂时使用过期字典
if conf().get('expires_in_seconds'):
self.games = ExpiredDict(conf().get('expires_in_seconds'))
else:
self.games = dict()
def on_handle_context(self, e_context: EventContext):
if e_context['context'].type != ContextType.TEXT:
return
bottype = Bridge().get_bot_type("chat")
if bottype != const.CHATGPT:
return
bot = Bridge().get_bot("chat")
content = e_context['context'].content[:]
clist = e_context['context'].content.split(maxsplit=1)
sessionid = e_context['context']['session_id']
logger.debug("[Dungeon] on_handle_context. content: %s" % clist)
if clist[0] == "$停止冒险":
if sessionid in self.games:
self.games[sessionid].reset()
del self.games[sessionid]
reply = Reply(ReplyType.INFO, "冒险结束!")
e_context['reply'] = reply
e_context.action = EventAction.BREAK_PASS
elif clist[0] == "$开始冒险" or sessionid in self.games:
if sessionid not in self.games or clist[0] == "$开始冒险":
if len(clist)>1 :
story = clist[1]
else:
story = "你在树林里冒险,指不定会从哪里蹦出来一些奇怪的东西,你握紧手上的手枪,希望这次冒险能够找到一些值钱的东西,你往树林深处走去。"
self.games[sessionid] = StoryTeller(bot, sessionid, story)
reply = Reply(ReplyType.INFO, "冒险开始,你可以输入任意内容,让故事继续下去。故事背景是:" + story)
e_context['reply'] = reply
e_context.action = EventAction.BREAK_PASS # 事件结束并跳过处理context的默认逻辑
else:
prompt = self.games[sessionid].action(content)
e_context['context'].type = ContextType.TEXT
e_context['context'].content = prompt
e_context.action = EventAction.BREAK # 事件结束不跳过处理context的默认逻辑
def get_help_text(self, **kwargs):
help_text = "输入\"$开始冒险 {背景故事}\"来以{背景故事}开始一个地牢游戏,之后你的所有消息会帮助我来完善这个故事。输入\"$停止冒险 \"可以结束游戏。"
return help_text

49
plugins/event.py Normal file
View File

@@ -0,0 +1,49 @@
# encoding:utf-8
from enum import Enum
class Event(Enum):
# ON_RECEIVE_MESSAGE = 1 # 收到消息
ON_HANDLE_CONTEXT = 2 # 处理消息前
"""
e_context = { "channel": 消息channel, "context" : 本次消息的context, "reply" : 目前的回复,初始为空 }
"""
ON_DECORATE_REPLY = 3 # 得到回复后准备装饰
"""
e_context = { "channel": 消息channel, "context" : 本次消息的context, "reply" : 目前的回复 }
"""
ON_SEND_REPLY = 4 # 发送回复前
"""
e_context = { "channel": 消息channel, "context" : 本次消息的context, "reply" : 目前的回复 }
"""
# AFTER_SEND_REPLY = 5 # 发送回复后
class EventAction(Enum):
CONTINUE = 1 # 事件未结束,继续交给下个插件处理,如果没有下个插件,则交付给默认的事件处理逻辑
BREAK = 2 # 事件结束,不再给下个插件处理,交付给默认的事件处理逻辑
BREAK_PASS = 3 # 事件结束,不再给下个插件处理,不交付给默认的事件处理逻辑
class EventContext:
def __init__(self, event, econtext=dict()):
self.event = event
self.econtext = econtext
self.action = EventAction.CONTINUE
def __getitem__(self, key):
return self.econtext[key]
def __setitem__(self, key, value):
self.econtext[key] = value
def __delitem__(self, key):
del self.econtext[key]
def is_pass(self):
return self.action == EventAction.BREAK_PASS

12
plugins/godcmd/README.md Normal file
View File

@@ -0,0 +1,12 @@
## 插件说明
指令插件
## 插件使用
`config.json.template`复制为`config.json`,并修改其中`password`的值为口令。
在私聊中可使用`#auth`指令,输入口令进行管理员认证,详细指令请输入`#help`查看帮助文档:
`#auth <口令>` - 管理员认证。
`#help` - 输出帮助文档,是否是管理员和是否是在群聊中会影响帮助文档的输出内容。

View File

View File

@@ -0,0 +1,4 @@
{
"password": "",
"admin_users": []
}

307
plugins/godcmd/godcmd.py Normal file
View File

@@ -0,0 +1,307 @@
# encoding:utf-8
import json
import os
import traceback
from typing import Tuple
from bridge.bridge import Bridge
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from config import load_config
import plugins
from plugins import *
from common import const
from common.log import logger
# 定义指令集
COMMANDS = {
"help": {
"alias": ["help", "帮助"],
"desc": "打印指令集合",
},
"helpp": {
"alias": ["helpp", "插件帮助"],
"args": ["插件名"],
"desc": "打印插件的帮助信息",
},
"auth": {
"alias": ["auth", "认证"],
"args": ["口令"],
"desc": "管理员认证",
},
# "id": {
# "alias": ["id", "用户"],
# "desc": "获取用户id", #目前无实际意义
# },
"reset": {
"alias": ["reset", "重置会话"],
"desc": "重置会话",
},
}
ADMIN_COMMANDS = {
"resume": {
"alias": ["resume", "恢复服务"],
"desc": "恢复服务",
},
"stop": {
"alias": ["stop", "暂停服务"],
"desc": "暂停服务",
},
"reconf": {
"alias": ["reconf", "重载配置"],
"desc": "重载配置(不包含插件配置)",
},
"resetall": {
"alias": ["resetall", "重置所有会话"],
"desc": "重置所有会话",
},
"scanp": {
"alias": ["scanp", "扫描插件"],
"desc": "扫描插件目录是否有新插件",
},
"plist": {
"alias": ["plist", "插件"],
"desc": "打印当前插件列表",
},
"setpri": {
"alias": ["setpri", "设置插件优先级"],
"args": ["插件名", "优先级"],
"desc": "设置指定插件的优先级,越大越优先",
},
"reloadp": {
"alias": ["reloadp", "重载插件"],
"args": ["插件名"],
"desc": "重载指定插件配置",
},
"enablep": {
"alias": ["enablep", "启用插件"],
"args": ["插件名"],
"desc": "启用指定插件",
},
"disablep": {
"alias": ["disablep", "禁用插件"],
"args": ["插件名"],
"desc": "禁用指定插件",
},
"debug": {
"alias": ["debug", "调试模式", "DEBUG"],
"desc": "开启机器调试日志",
},
}
# 定义帮助函数
def get_help_text(isadmin, isgroup):
help_text = "可用指令:\n"
for cmd, info in COMMANDS.items():
if cmd=="auth" and (isadmin or isgroup): # 群聊不可认证
continue
alias=["#"+a for a in info['alias']]
help_text += f"{','.join(alias)} "
if 'args' in info:
args=["{"+a+"}" for a in info['args']]
help_text += f"{' '.join(args)} "
help_text += f": {info['desc']}\n"
if ADMIN_COMMANDS and isadmin:
help_text += "\n管理员指令:\n"
for cmd, info in ADMIN_COMMANDS.items():
alias=["#"+a for a in info['alias']]
help_text += f"{','.join(alias)} "
help_text += f": {info['desc']}\n"
return help_text
@plugins.register(name="Godcmd", desc="为你的机器人添加指令集,有用户和管理员两种角色,加载顺序请放在首位,初次运行后插件目录会生成配置文件, 填充管理员密码后即可认证", version="1.0", author="lanvent", desire_priority= 999)
class Godcmd(Plugin):
def __init__(self):
super().__init__()
curdir=os.path.dirname(__file__)
config_path=os.path.join(curdir,"config.json")
gconf=None
if not os.path.exists(config_path):
gconf={"password":"","admin_users":[]}
with open(config_path,"w") as f:
json.dump(gconf,f,indent=4)
else:
with open(config_path,"r") as f:
gconf=json.load(f)
self.password = gconf["password"]
self.admin_users = gconf["admin_users"] # 预存的管理员账号,这些账号不需要认证 TODO: 用户名每次都会变,目前不可用
self.isrunning = True # 机器人是否运行中
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
logger.info("[Godcmd] inited")
def on_handle_context(self, e_context: EventContext):
context_type = e_context['context'].type
if context_type != ContextType.TEXT:
if not self.isrunning:
e_context.action = EventAction.BREAK_PASS
return
content = e_context['context'].content
logger.debug("[Godcmd] on_handle_context. content: %s" % content)
if content.startswith("#"):
# msg = e_context['context']['msg']
user = e_context['context']['receiver']
session_id = e_context['context']['session_id']
isgroup = e_context['context']['isgroup']
bottype = Bridge().get_bot_type("chat")
bot = Bridge().get_bot("chat")
# 将命令和参数分割
command_parts = content[1:].split(" ")
cmd = command_parts[0]
args = command_parts[1:]
isadmin=False
if user in self.admin_users:
isadmin=True
ok=False
result="string"
if any(cmd in info['alias'] for info in COMMANDS.values()):
cmd = next(c for c, info in COMMANDS.items() if cmd in info['alias'])
if cmd == "auth":
ok, result = self.authenticate(user, args, isadmin, isgroup)
elif cmd == "help":
ok, result = True, get_help_text(isadmin, isgroup)
elif cmd == "helpp":
if len(args) != 1:
ok, result = False, "请提供插件名"
else:
plugins = PluginManager().list_plugins()
name = args[0].upper()
if name in plugins and plugins[name].enabled:
ok, result = True, PluginManager().instances[name].get_help_text(isgroup=isgroup, isadmin=isadmin)
else:
ok, result= False, "插件不存在或未启用"
elif cmd == "id":
ok, result = True, f"用户id=\n{user}"
elif cmd == "reset":
if bottype == const.CHATGPT:
bot.sessions.clear_session(session_id)
ok, result = True, "会话已重置"
else:
ok, result = False, "当前对话机器人不支持重置会话"
logger.debug("[Godcmd] command: %s by %s" % (cmd, user))
elif any(cmd in info['alias'] for info in ADMIN_COMMANDS.values()):
if isadmin:
if isgroup:
ok, result = False, "群聊不可执行管理员指令"
else:
cmd = next(c for c, info in ADMIN_COMMANDS.items() if cmd in info['alias'])
if cmd == "stop":
self.isrunning = False
ok, result = True, "服务已暂停"
elif cmd == "resume":
self.isrunning = True
ok, result = True, "服务已恢复"
elif cmd == "reconf":
load_config()
ok, result = True, "配置已重载"
elif cmd == "resetall":
if bottype == const.CHATGPT:
bot.sessions.clear_all_session()
ok, result = True, "重置所有会话成功"
else:
ok, result = False, "当前对话机器人不支持重置会话"
elif cmd == "debug":
logger.setLevel('DEBUG')
ok, result = True, "DEBUG模式已开启"
elif cmd == "plist":
plugins = PluginManager().list_plugins()
ok = True
result = "插件列表:\n"
for name,plugincls in plugins.items():
result += f"{plugincls.name}_v{plugincls.version} {plugincls.priority} - "
if plugincls.enabled:
result += "已启用\n"
else:
result += "未启用\n"
elif cmd == "scanp":
new_plugins = PluginManager().scan_plugins()
ok, result = True, "插件扫描完成"
PluginManager().activate_plugins()
if len(new_plugins) >0 :
result += "\n发现新插件:\n"
result += "\n".join([f"{p.name}_v{p.version}" for p in new_plugins])
else :
result +=", 未发现新插件"
elif cmd == "setpri":
if len(args) != 2:
ok, result = False, "请提供插件名和优先级"
else:
ok = PluginManager().set_plugin_priority(args[0], int(args[1]))
if ok:
result = "插件" + args[0] + "优先级已设置为" + args[1]
else:
result = "插件不存在"
elif cmd == "reloadp":
if len(args) != 1:
ok, result = False, "请提供插件名"
else:
ok = PluginManager().reload_plugin(args[0])
if ok:
result = "插件配置已重载"
else:
result = "插件不存在"
elif cmd == "enablep":
if len(args) != 1:
ok, result = False, "请提供插件名"
else:
ok = PluginManager().enable_plugin(args[0])
if ok:
result = "插件已启用"
else:
result = "插件不存在"
elif cmd == "disablep":
if len(args) != 1:
ok, result = False, "请提供插件名"
else:
ok = PluginManager().disable_plugin(args[0])
if ok:
result = "插件已禁用"
else:
result = "插件不存在"
logger.debug("[Godcmd] admin command: %s by %s" % (cmd, user))
else:
ok, result = False, "需要管理员权限才能执行该指令"
else:
ok, result = False, f"未知指令:{cmd}\n查看指令列表请输入#help \n"
reply = Reply()
if ok:
reply.type = ReplyType.INFO
else:
reply.type = ReplyType.ERROR
reply.content = result
e_context['reply'] = reply
e_context.action = EventAction.BREAK_PASS # 事件结束并跳过处理context的默认逻辑
elif not self.isrunning:
e_context.action = EventAction.BREAK_PASS
def authenticate(self, userid, args, isadmin, isgroup) -> Tuple[bool,str] :
if isgroup:
return False,"请勿在群聊中认证"
if isadmin:
return False,"管理员账号无需认证"
if len(self.password) == 0:
return False,"未设置口令,无法认证"
if len(args) != 1:
return False,"请提供口令"
password = args[0]
if password == self.password:
self.admin_users.append(userid)
return True,"认证成功"
else:
return False,"认证失败"
def get_help_text(self, isadmin = False, isgroup = False, **kwargs):
return get_help_text(isadmin, isgroup)

View File

50
plugins/hello/hello.py Normal file
View File

@@ -0,0 +1,50 @@
# encoding:utf-8
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
import plugins
from plugins import *
from common.log import logger
@plugins.register(name="Hello", desc="A simple plugin that says hello", version="0.1", author="lanvent", desire_priority= -1)
class Hello(Plugin):
def __init__(self):
super().__init__()
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
logger.info("[Hello] inited")
def on_handle_context(self, e_context: EventContext):
if e_context['context'].type != ContextType.TEXT:
return
content = e_context['context'].content
logger.debug("[Hello] on_handle_context. content: %s" % content)
if content == "Hello":
reply = Reply()
reply.type = ReplyType.TEXT
msg = e_context['context']['msg']
if e_context['context']['isgroup']:
reply.content = "Hello, " + msg['ActualNickName'] + " from " + msg['User'].get('NickName', "Group")
else:
reply.content = "Hello, " + msg['User'].get('NickName', "My friend")
e_context['reply'] = reply
e_context.action = EventAction.BREAK_PASS # 事件结束并跳过处理context的默认逻辑
if content == "Hi":
reply = Reply()
reply.type = ReplyType.TEXT
reply.content = "Hi"
e_context['reply'] = reply
e_context.action = EventAction.BREAK # 事件结束进入默认处理逻辑一般会覆写reply
if content == "End":
# 如果是文本消息"End",将请求转换成"IMAGE_CREATE"并将content设置为"The World"
e_context['context'].type = ContextType.IMAGE_CREATE
content = "The World"
e_context.action = EventAction.CONTINUE # 事件继续,交付给下个插件或默认逻辑
def get_help_text(self, **kwargs):
help_text = "输入Hello我会回复你的名字\n输入End我会回复你世界的图片\n"
return help_text

6
plugins/plugin.py Normal file
View File

@@ -0,0 +1,6 @@
class Plugin:
def __init__(self):
self.handlers = {}
def get_help_text(self, **kwargs):
return "暂无帮助信息"

175
plugins/plugin_manager.py Normal file
View File

@@ -0,0 +1,175 @@
# encoding:utf-8
import importlib
import json
import os
from common.singleton import singleton
from common.sorted_dict import SortedDict
from .event import *
from common.log import logger
from config import conf
@singleton
class PluginManager:
def __init__(self):
self.plugins = SortedDict(lambda k,v: v.priority,reverse=True)
self.listening_plugins = {}
self.instances = {}
self.pconf = {}
def register(self, name: str, desc: str, version: str, author: str, desire_priority: int = 0):
def wrapper(plugincls):
plugincls.name = name
plugincls.desc = desc
plugincls.version = version
plugincls.author = author
plugincls.priority = desire_priority
plugincls.enabled = True
self.plugins[name.upper()] = plugincls
logger.info("Plugin %s_v%s registered" % (name, version))
return plugincls
return wrapper
def save_config(self):
with open("plugins/plugins.json", "w", encoding="utf-8") as f:
json.dump(self.pconf, f, indent=4, ensure_ascii=False)
def load_config(self):
logger.info("Loading plugins config...")
modified = False
if os.path.exists("plugins/plugins.json"):
with open("plugins/plugins.json", "r", encoding="utf-8") as f:
pconf = json.load(f)
pconf['plugins'] = SortedDict(lambda k,v: v["priority"],pconf['plugins'],reverse=True)
else:
modified = True
pconf = {"plugins": SortedDict(lambda k,v: v["priority"],reverse=True)}
self.pconf = pconf
if modified:
self.save_config()
return pconf
def scan_plugins(self):
logger.info("Scaning plugins ...")
plugins_dir = "plugins"
for plugin_name in os.listdir(plugins_dir):
plugin_path = os.path.join(plugins_dir, plugin_name)
if os.path.isdir(plugin_path):
# 判断插件是否包含同名.py文件
main_module_path = os.path.join(plugin_path, plugin_name+".py")
if os.path.isfile(main_module_path):
# 导入插件
import_path = "{}.{}.{}".format(plugins_dir, plugin_name, plugin_name)
try:
main_module = importlib.import_module(import_path)
except Exception as e:
logger.warn("Failed to import plugin %s: %s" % (plugin_name, e))
continue
pconf = self.pconf
new_plugins = []
modified = False
for name, plugincls in self.plugins.items():
rawname = plugincls.name
if rawname not in pconf["plugins"]:
new_plugins.append(plugincls)
modified = True
logger.info("Plugin %s not found in pconfig, adding to pconfig..." % name)
pconf["plugins"][rawname] = {"enabled": plugincls.enabled, "priority": plugincls.priority}
else:
self.plugins[name].enabled = pconf["plugins"][rawname]["enabled"]
self.plugins[name].priority = pconf["plugins"][rawname]["priority"]
self.plugins._update_heap(name) # 更新下plugins中的顺序
if modified:
self.save_config()
return new_plugins
def refresh_order(self):
for event in self.listening_plugins.keys():
self.listening_plugins[event].sort(key=lambda name: self.plugins[name].priority, reverse=True)
def activate_plugins(self): # 生成新开启的插件实例
for name, plugincls in self.plugins.items():
if plugincls.enabled:
if name not in self.instances:
instance = plugincls()
self.instances[name] = instance
for event in instance.handlers:
if event not in self.listening_plugins:
self.listening_plugins[event] = []
self.listening_plugins[event].append(name)
self.refresh_order()
def reload_plugin(self, name:str):
name = name.upper()
if name in self.instances:
for event in self.listening_plugins:
if name in self.listening_plugins[event]:
self.listening_plugins[event].remove(name)
del self.instances[name]
self.activate_plugins()
return True
return False
def load_plugins(self):
self.load_config()
self.scan_plugins()
pconf = self.pconf
logger.debug("plugins.json config={}".format(pconf))
for name,plugin in pconf["plugins"].items():
if name.upper() not in self.plugins:
logger.error("Plugin %s not found, but found in plugins.json" % name)
self.activate_plugins()
def emit_event(self, e_context: EventContext, *args, **kwargs):
if e_context.event in self.listening_plugins:
for name in self.listening_plugins[e_context.event]:
if self.plugins[name].enabled and e_context.action == EventAction.CONTINUE:
logger.debug("Plugin %s triggered by event %s" % (name,e_context.event))
instance = self.instances[name]
instance.handlers[e_context.event](e_context, *args, **kwargs)
return e_context
def set_plugin_priority(self, name:str, priority:int):
name = name.upper()
if name not in self.plugins:
return False
if self.plugins[name].priority == priority:
return True
self.plugins[name].priority = priority
self.plugins._update_heap(name)
rawname = self.plugins[name].name
self.pconf["plugins"][rawname]["priority"] = priority
self.pconf["plugins"]._update_heap(rawname)
self.save_config()
self.refresh_order()
return True
def enable_plugin(self, name:str):
name = name.upper()
if name not in self.plugins:
return False
if not self.plugins[name].enabled :
self.plugins[name].enabled = True
rawname = self.plugins[name].name
self.pconf["plugins"][rawname]["enabled"] = True
self.save_config()
self.activate_plugins()
return True
return True
def disable_plugin(self, name:str):
name = name.upper()
if name not in self.plugins:
return False
if self.plugins[name].enabled :
self.plugins[name].enabled = False
rawname = self.plugins[name].name
self.pconf["plugins"][rawname]["enabled"] = False
self.save_config()
return True
return True
def list_plugins(self):
return self.plugins

26
plugins/role/README.md Normal file
View File

@@ -0,0 +1,26 @@
用于让Bot扮演指定角色的聊天插件触发方法如下
- `$角色/$role help/帮助` - 打印目前支持的角色列表。
- `$角色/$role <角色名>` - 让AI扮演该角色角色名支持模糊匹配。
- `$停止扮演` - 停止角色扮演。
添加自定义角色请在`roles/roles.json`中添加。
(大部分prompt来自https://github.com/rockbenben/ChatGPT-Shortcut/blob/main/src/data/users.tsx)
以下为例子:
```json
{
"title": "写作助理",
"description": "As a writing improvement assistant, your task is to improve the spelling, grammar, clarity, concision, and overall readability of the text I provided, while breaking down long sentences, reducing repetition, and providing suggestions for improvement. Please provide only the corrected Chinese version of the text and avoid including explanations. Please treat every message I send later as text content.",
"descn": "作为一名中文写作改进助理,你的任务是改进所提供文本的拼写、语法、清晰、简洁和整体可读性,同时分解长句,减少重复,并提供改进建议。请只提供文本的更正版本,避免包括解释。请把我之后的每一条消息都当作文本内容。",
"wrapper": "内容是:\n\"%s\"",
"remark": "最常使用的角色,用于优化文本的语法、清晰度和简洁度,提高可读性。"
}
```
- `title`: 角色名。
- `description`: 使用`$role`触发时使用英语prompt。
- `descn`: 使用`$角色`触发时使用中文prompt。
- `wrapper`: 用于包装用户消息,可起到强调作用,避免回复离题。
- `remark`: 简短描述该角色,在打印帮助文档时显示。

0
plugins/role/__init__.py Normal file
View File

126
plugins/role/role.py Normal file
View File

@@ -0,0 +1,126 @@
# encoding:utf-8
import json
import os
from bridge.bridge import Bridge
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from common import const
import plugins
from plugins import *
from common.log import logger
class RolePlay():
def __init__(self, bot, sessionid, desc, wrapper=None):
self.bot = bot
self.sessionid = sessionid
self.wrapper = wrapper or "%s" # 用于包装用户输入
self.desc = desc
def reset(self):
self.bot.sessions.clear_session(self.sessionid)
def action(self, user_action):
session = self.bot.sessions.build_session(self.sessionid, self.desc)
if session[0]['role'] == 'system' and session[0]['content'] != self.desc: # 目前没有触发session过期事件这里先简单判断然后重置
self.reset()
self.bot.sessions.build_session(self.sessionid, self.desc)
prompt = self.wrapper % user_action
return prompt
@plugins.register(name="Role", desc="为你的Bot设置预设角色", version="1.0", author="lanvent", desire_priority= 0)
class Role(Plugin):
def __init__(self):
super().__init__()
curdir = os.path.dirname(__file__)
config_path = os.path.join(curdir, "roles.json")
try:
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
self.roles = {role["title"].lower(): role for role in config["roles"]}
if len(self.roles) == 0:
raise Exception("no role found")
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
self.roleplays = {}
logger.info("[Role] inited")
except FileNotFoundError:
logger.error(f"[Role] init failed, {config_path} not found")
except Exception as e:
logger.error("[Role] init failed, exception: %s" % e)
def get_role(self, name, find_closest=True):
name = name.lower()
found_role = None
if name in self.roles:
found_role = name
elif find_closest:
import difflib
def str_simularity(a, b):
return difflib.SequenceMatcher(None, a, b).ratio()
max_sim = 0.0
max_role = None
for role in self.roles:
sim = str_simularity(name, role)
if sim >= max_sim:
max_sim = sim
max_role = role
found_role = max_role
return found_role
def on_handle_context(self, e_context: EventContext):
if e_context['context'].type != ContextType.TEXT:
return
bottype = Bridge().get_bot_type("chat")
if bottype != const.CHATGPT:
return
bot = Bridge().get_bot("chat")
content = e_context['context'].content[:]
clist = e_context['context'].content.split(maxsplit=1)
desckey = None
sessionid = e_context['context']['session_id']
if clist[0] == "$停止扮演":
if sessionid in self.roleplays:
self.roleplays[sessionid].reset()
del self.roleplays[sessionid]
reply = Reply(ReplyType.INFO, "角色扮演结束!")
e_context['reply'] = reply
e_context.action = EventAction.BREAK_PASS
return
elif clist[0] == "$角色":
desckey = "descn"
elif clist[0].lower() == "$role":
desckey = "description"
elif sessionid not in self.roleplays:
return
logger.debug("[Role] on_handle_context. content: %s" % content)
if desckey is not None:
if len(clist) == 1 or (len(clist) > 1 and clist[1].lower() in ["help", "帮助"]):
reply = Reply(ReplyType.INFO, self.get_help_text())
e_context['reply'] = reply
e_context.action = EventAction.BREAK_PASS
return
role = self.get_role(clist[1])
if role is None:
reply = Reply(ReplyType.ERROR, "角色不存在")
e_context['reply'] = reply
e_context.action = EventAction.BREAK_PASS
return
else:
self.roleplays[sessionid] = RolePlay(bot, sessionid, self.roles[role][desckey], self.roles[role].get("wrapper","%s"))
reply = Reply(ReplyType.INFO, f"角色设定为 {role} :\n"+self.roles[role][desckey])
e_context['reply'] = reply
e_context.action = EventAction.BREAK_PASS
else:
prompt = self.roleplays[sessionid].action(content)
e_context['context'].type = ContextType.TEXT
e_context['context'].content = prompt
e_context.action = EventAction.BREAK
def get_help_text(self, **kwargs):
help_text = "输入\"$角色 {角色名}\"\"$role {角色名}\"为我设定角色吧,\"$停止扮演 \" 可以清除设定的角色。\n\n目前可用角色列表:\n"
for role in self.roles:
help_text += f"[{role}]: {self.roles[role]['remark']}\n"
return help_text

186
plugins/role/roles.json Normal file
View File

@@ -0,0 +1,186 @@
{
"roles":[
{
"title": "英语翻译或修改",
"description": "I want you to act as an English translator, spelling corrector and improver. I will speak to you in any language and you will detect the language, translate it and answer in the corrected and improved version of my text, in English. I want you to replace my simplified A0-level words and sentences with more beautiful and elegant, upper level English words and sentences. Keep the meaning same, but make them more literary. I want you to only reply the correction, the improvements and nothing else, do not write explanations. Please treat every message I send later as text content",
"descn": "我希望你能充当英语翻译、拼写纠正者和改进者。我将用任何语言与你交谈,你将检测语言,翻译它,并在我的文本的更正和改进版本中用英语回答。我希望你用更漂亮、更优雅、更高级的英语单词和句子来取代我的简化 A0 级单词和句子。保持意思不变,但让它们更有文学性。我希望你只回答更正,改进,而不是其他,不要写解释。请把我之后的每一条消息都当作文本内容。",
"wrapper": "你要翻译或纠正的内容是:\n\"%s\"",
"remark": "将其他语言翻译成英文,或改进你提供的英文句子。"
},
{
"title": "写作助理",
"description": "As a writing improvement assistant, your task is to improve the spelling, grammar, clarity, concision, and overall readability of the text I provided, while breaking down long sentences, reducing repetition, and providing suggestions for improvement. Please provide only the corrected Chinese version of the text and avoid including explanations. Please treat every message I send later as text content.",
"descn": "作为一名中文写作改进助理,你的任务是改进所提供文本的拼写、语法、清晰、简洁和整体可读性,同时分解长句,减少重复,并提供改进建议。请只提供文本的更正版本,避免包括解释。请把我之后的每一条消息都当作文本内容。",
"wrapper": "内容是:\n\"%s\"",
"remark": "最常使用的角色,用于优化文本的语法、清晰度和简洁度,提高可读性。"
},
{
"title": "语言输入优化",
"description": "Using concise and clear language, please edit the passage I provide to improve its logical flow, eliminate any typographical errors and respond in Chinese. Be sure to maintain the original meaning of the text. Please treat every message I send later as text content.",
"descn": "请用简洁明了的语言,编辑我给出的段落,以改善其逻辑流程,消除任何印刷错误,并以中文作答。请务必保持文章的原意。请把我之后的每一条消息当作文本内容。",
"wrapper": "文本内容是:\n\"%s\"",
"remark": "通常用于语音识别信息转书面语言。"
},
{
"title": "论文式回答",
"description": "From now on, please write a highly detailed essay with introduction, body, and conclusion paragraphs to respond to each of my questions.",
"descn": "从现在开始,对于之后我提出的每个问题,请写一篇高度详细的文章回应,包括引言、主体和结论段落。",
"wrapper": "问题是:\n\"%s?\"",
"remark": "以论文形式讨论问题,能够获得连贯的、结构化的和更高质量的回答。"
},
{
"title": "写作素材搜集",
"description": "Please generate a list of the top 10 facts, statistics and trends related to every subject I provided, including their source",
"descn": "请为我提供的每个主题生成一份相关的十大事实、统计数据和趋势的清单,包括其来源",
"wrapper": "主题是:\n\"%s\"",
"remark": "提供指定主题的结论和数据,作为素材。"
},
{
"title": "内容总结",
"description": "Summarize every text I provided into 100 words, making it easy to read and comprehend. The summary should be concise, clear, and capture the main points of the text. Avoid using complex sentence structures or technical jargon. Please begin by editing the following text: ",
"descn": "请将我提供的每篇文字都概括为 100 个字,使其易于阅读和理解。避免使用复杂的句子结构或技术术语。",
"wrapper": "文章内容是:\n\"%s\"",
"remark": "将文本内容总结为 100 字。"
},
{
"title": "格言书",
"description": "I want you to act as an aphorism book. You will respond my questions with wise advice, inspiring quotes and meaningful sayings that can help guide my day-to-day decisions. Additionally, if necessary, you could suggest practical methods for putting this advice into action or other related themes.",
"descn": "我希望你能充当一本箴言书。对于我的问题,你会提供明智的建议、鼓舞人心的名言和有意义的谚语,以帮助指导我的日常决策。此外,如果有必要,你可以提出将这些建议付诸行动的实际方法或其他相关主题。",
"wrapper": "我的问题是:\n\"%s?\"",
"remark": "根据问题输出鼓舞人心的名言和有意义的格言。"
},
{
"title": "讲故事",
"description": "I want you to act as a storyteller. You will come up with entertaining stories that are engaging, imaginative and captivating for the audience. It can be fairy tales, educational stories or any other type of stories which has the potential to capture people's attention and imagination. Depending on the target audience, you may choose specific themes or topics for your storytelling session e.g., if it's children then you can talk about animals; If it's adults then history-based tales might engage them better etc.",
"descn": "我希望你充当一个讲故事的人。你要想出具有娱乐性的故事,要有吸引力,要有想象力,要吸引观众。它可以是童话故事、教育故事或任何其他类型的故事,有可能吸引人们的注意力和想象力。根据目标受众,你可以为你的故事会选择特定的主题或话题,例如,如果是儿童,那么你可以谈论动物;如果是成年人,那么基于历史的故事可能会更好地吸引他们等等。",
"wrapper": "故事主题和目标受众是:\n\"%s\"",
"remark": "输入一个主题和目标受众,输出与之相关的故事。"
},
{
"title": "编剧",
"description": "I want you to act as a screenwriter. You will develop an engaging and creative script for either a feature length film, or a Web Series that can captivate its viewers. Start with coming up with interesting characters, the setting of the story, dialogues between the characters etc. Once your character development is complete - create an exciting storyline filled with twists and turns that keeps the viewers in suspense until the end. ",
"descn": "我希望你能作为一个编剧。你将为一部长篇电影或网络剧开发一个吸引观众的有创意的剧本。首先要想出有趣的人物、故事的背景、人物之间的对话等。一旦你的角色发展完成--创造一个激动人心的故事情节,充满曲折,让观众保持悬念,直到结束。",
"wrapper": "剧本主题是:\n\"%s\"",
"remark": "根据主题创作一个包含故事背景、人物以及对话的剧本。"
},
{
"title": "小说家",
"description": "I want you to act as a novelist. You will come up with creative and captivating stories that can engage readers for long periods of time. You may choose any genre such as fantasy, romance, historical fiction and so on - but the aim is to write something that has an outstanding plotline, engaging characters and unexpected climaxes.",
"descn": "我希望你能作为一个小说家。你要想出有创意的、吸引人的故事,能够长时间吸引读者。你可以选择任何体裁,如幻想、浪漫、历史小说等--但目的是要写出有出色的情节线、引人入胜的人物和意想不到的高潮。",
"wrapper": "小说类型是:\n\"%s\"",
"remark": "根据故事类型输出小说,例如奇幻、浪漫或历史等类型。"
},
{
"title": "诗人",
"description": "I want you to act as a poet. You will create poems that evoke emotions and have the power to stir people's soul. Write on any topic or theme but make sure your words convey the feeling you are trying to express in beautiful yet meaningful ways. You can also come up with short verses that are still powerful enough to leave an imprint in reader's minds. ",
"descn": "我希望你能作为一个诗人。你要创作出能唤起人们情感并有力量搅动人们灵魂的诗篇。写任何话题或主题,但要确保你的文字以美丽而有意义的方式传达你所要表达的感觉。你也可以想出一些短小的诗句,但仍有足够的力量在读者心中留下印记。",
"wrapper": "诗歌主题是:\n\"%s\"",
"remark": "根据话题或主题输出诗句。"
},
{
"title": "新闻记者",
"description": "I want you to act as a journalist. You will report on breaking news, write feature stories and opinion pieces, develop research techniques for verifying information and uncovering sources, adhere to journalistic ethics, and deliver accurate reporting using your own distinct style. ",
"descn": "我希望你能作为一名记者行事。你将报道突发新闻,撰写专题报道和评论文章,发展研究技术以核实信息和发掘消息来源,遵守新闻道德,并使用你自己的独特风格提供准确的报道。",
"wrapper": "新闻主题是:\n\"%s\"",
"remark": "引用已有数据资料,用新闻的写作风格输出主题文章。"
},
{
"title": "论文1",
"description": "I want you to act as an academician. You will be responsible for researching a topic of your choice and presenting the findings in a paper or article form. Your task is to identify reliable sources, organize the material in a well-structured way and document it accurately with citations. ",
"descn": "我希望你能作为一名学者行事。你将负责研究一个你选择的主题,并将研究结果以论文或文章的形式呈现出来。你的任务是确定可靠的来源,以结构良好的方式组织材料,并以引用的方式准确记录。",
"wrapper": "论文主题是:\n\"%s\"",
"remark": "根据主题撰写内容翔实、有信服力的论文。"
},
{
"title": "论文2",
"description": "I want you to act as an essay writer. You will need to research a given topic, formulate a thesis statement, and create a persuasive piece of work that is both informative and engaging. ",
"descn": "我想让你充当一名论文作家。你将需要研究一个给定的主题,制定一个论文声明,并创造一个有说服力的作品,既要有信息量,又要有吸引力。",
"wrapper": "论文主题是:\n\"%s\"",
"remark": "根据主题撰写内容翔实、有信服力的论文。"
},
{
"title": "同义词",
"description": "I want you to act as a synonyms provider. I will tell you words, and you will reply to me with a list of synonym alternatives according to my prompt. Provide a max of 10 synonyms per prompt. You will only reply the words list, and nothing else. Words should exist. Do not write explanations. ",
"descn": "我希望你能充当同义词提供者。我将告诉你许多词,你将根据我提供的词,为我提供一份同义词备选清单。每个提示最多可提供 10 个同义词。你只需要回复词列表。词语应该是存在的,不要写解释。",
"wrapper": "词语是:\n\"%s\"",
"remark": "输出同义词。"
},
{
"title": "文本情绪分析",
"description": "Specify the sentiment of the following text, assigning them the values of: positive, neutral or negative.",
"descn": "请为提供的文本分析情绪,赋予它们的值为:正面、中性或负面。",
"wrapper": "文本是:\n\"%s\"",
"remark": "判断文本情绪:正面、中性或负面。"
},
{
"title": "随机回复的疯子",
"description": "I want you to act as a lunatic. The lunatic's sentences are meaningless. The words used by lunatic are completely arbitrary. The lunatic does not make logical sentences in any way. ",
"descn": "我想让你扮演一个疯子。疯子的句子是毫无意义的。疯子使用的词语完全是任意的。疯子不会以任何方式做出符合逻辑的句子。",
"wrapper": "请回答句子:\n\"%s\"",
"remark": "扮演疯子,回复没有意义和逻辑的句子。"
},
{
"title": "随机回复的醉鬼",
"description": "I want you to act as a drunk person. You will only answer like a very drunk person texting and nothing else. Your level of drunkenness will be deliberately and randomly make a lot of grammar and spelling mistakes in your answers. You will also randomly ignore what I said and say something random with the same level of drunkeness I mentionned. Do not write explanations on replies. ",
"descn": "我希望你表现得像一个喝醉的人。你只会像一个很醉的人发短信一样回答,而不是其他。你的醉酒程度将是故意和随机地在你的答案中犯很多语法和拼写错误。你也会随意无视我说的话,用我提到的醉酒程度随意说一些话。不要在回复中写解释。",
"wrapper": "请回答句子:\n\"%s\"",
"remark": "扮演喝醉的人,可能会犯语法错误、答错问题,或者忽略某些问题。"
},
{
"title": "小红书风格",
"description": "Please edit the following passage in Chinese using the Xiaohongshu style, which is characterized by captivating headlines, the inclusion of emoticons in each paragraph, and the addition of relevant tags at the end. Be sure to maintain the original meaning of the text.",
"descn": "请用小红书风格编辑以下中文段落,小红书风格的特点是标题吸引人,每段都有表情符号,并在结尾加上相关标签。请务必保持文本的原始含义。",
"wrapper": "内容是:\n\"%s\"",
"remark": "用小红书风格改写文本"
},
{
"title": "周报生成器",
"description": "Using the provided text as the basis for a weekly report in Chinese, generate a concise summary that highlights the most important points. The report should be written in markdown format and should be easily readable and understandable for a general audience. In particular, focus on providing insights and analysis that would be useful to stakeholders and decision-makers. You may also use any additional information or sources as necessary. ",
"descn": "使用我提供的文本作为中文周报的基础,生成一个简洁的摘要,突出最重要的内容。该报告应以 markdown 格式编写,并应易于阅读和理解,以满足一般受众的需要。特别是要注重提供对利益相关者和决策者有用的见解和分析。你也可以根据需要使用任何额外的信息或来源。",
"wrapper": "工作内容是:\n\"%s\"",
"remark": "根据日常工作内容,提取要点并适当扩充,以生成周报。"
},
{
"title": "阴阳怪气语录生成器",
"description": "我希望你充当一个阴阳怪气讽刺语录生成器。当我给你一个主题时,你需要使用阴阳怪气的语气来评价该主题,评价的思路是挖苦和讽刺。如果有该主题的反例更好(比如失败经历,糟糕体验。注意不要直接说那些糟糕体验,而是通过反讽、幽默的类比等方式来说明)。",
"descn": "我希望你充当一个阴阳怪气讽刺语录生成器。当我给你一个主题时,你需要使用阴阳怪气的语气来评价该主题,评价的思路是挖苦和讽刺。如果有该主题的反例更好(比如失败经历,糟糕体验。注意不要直接说那些糟糕体验,而是通过反讽、幽默的类比等方式来说明)。",
"wrapper": "主题是:\n\"%s\"",
"remark": "根据主题生成阴阳怪气讽刺语录。"
},
{
"title": "舔狗语录生成器",
"description": "我希望你充当一个舔狗语录生成器,为我提供不同场景下的甜言蜜语。请根据提供的状态生成一句适当的舔狗语录,让女神感受到我的关心和温柔,给女神做牛做马。不需要提供背景解释,只需提供根据场景生成的舔狗语录。",
"descn": "我希望你充当一个舔狗语录生成器,为我提供不同场景下的甜言蜜语。请根据提供的状态生成一句适当的舔狗语录,让女神感受到我的关心和温柔,给女神做牛做马。不需要提供背景解释,只需提供根据场景生成的舔狗语录。",
"wrapper": "场景是:\n\"%s\"",
"remark": "根据场景生成舔狗语录。"
},
{
"title": "群聊取名",
"description": "我希望你充当微信群聊的命名专家。根据我提供的信息和背景为这个群聊起几个有趣顺口且贴切的名字每个不要超过8个字。请在回答中仅给出群聊名称不要写任何额外的解释。",
"descn": "我希望你充当微信群聊的命名专家。根据我提供的信息和背景为这个群聊起几个有趣顺口且贴切的名字每个不要超过8个字。请在回答中仅给出群聊名称不要写任何额外的解释。",
"wrapper": "信息和背景是:\n\"%s\"",
"remark": "根据给出的信息和背景为群聊取名。"
},
{
"title": "表情符号翻译器",
"description": "I want you to translate the sentences I wrote into emojis. I will write the sentence, and you will express it with emojis. I just want you to express it with emojis. I don't want you to reply with anything but emoji. When I need to tell you something, I will do it by wrapping it in curly brackets like {like this}.",
"descn": "我想让你把我写的句子翻译成表情符号。我写句子,你就用表情符号来表达。你只能用 emojis 来表达,除了表情符号不能使用任何文字。当我需要告诉你一些事情的时候,我会用大括号把它包起来,比如{像这样}。",
"wrapper": "需要翻译成表情符号的内容是:\n\"%s\"",
"remark": "将输入文字翻译为表情符号。"
},
{
"title": "AI 医生",
"description": "I want you to act as an AI assisted doctor. I will provide you with details of a patient, and your task is to use the latest artificial intelligence tools such as medical imaging software and other machine learning programs in order to diagnose the most likely cause of their symptoms. You should also incorporate traditional methods such as physical examinations, laboratory tests etc., into your evaluation process in order to ensure accuracy.",
"descn": "我想让你充当一名人工智能辅助的医生。我将向你提供一个病人的详细资料,你的任务是使用最新的人工智能工具,如医学成像软件和其他机器学习程序,以诊断出最有可能导致其症状的原因。你还应将传统方法,如体检、实验室测试等,纳入你的评估过程,以确保准确性。",
"wrapper": "需要诊断的资料是:\n\"%s\"",
"remark": "辅助诊断"
},
{
"title": "知识点阐述",
"description": "我会给予你词语,请你按照我给的词构建一个知识文字世界,你是此世界的导游,在世界里一切知识都是以象征的形式表达的,你在描述经历时应当适当加入五感的描述",
"descn": "我会给予你词语,请你按照我给的词构建一个知识文字世界,你是此世界的导游,在世界里一切知识都是以象征的形式表达的,你在描述经历时应当适当加入五感的描述",
"wrapper": "词语是:\n\"%s\"",
"remark": "用比喻的方式解释词语。"
}
]
}

View File

View File

@@ -0,0 +1,70 @@
{
"start":{
"host" : "127.0.0.1",
"port" : 7860
},
"defaults": {
"params": {
"sampler_name": "DPM++ 2M Karras",
"steps": 20,
"width": 512,
"height": 512,
"cfg_scale": 7,
"prompt":"masterpiece, best quality",
"negative_prompt": "(low quality, worst quality:1.4),(bad_prompt:0.8), (monochrome:1.1), (greyscale)",
"enable_hr": false,
"hr_scale": 2,
"hr_upscaler": "Latent",
"hr_second_pass_steps": 15,
"denoising_strength": 0.7
},
"options": {
"sd_model_checkpoint": "perfectWorld_v2Baked"
}
},
"rules": [
{
"keywords": [
"横版",
"壁纸"
],
"params": {
"width": 640,
"height": 384
},
"desc": "分辨率会变成640x384"
},
{
"keywords": [
"竖版"
],
"params": {
"width": 384,
"height": 640
}
},
{
"keywords": [
"高清"
],
"params": {
"enable_hr": true,
"hr_scale": 1.6
},
"desc": "出图分辨率长宽都会提高1.6倍"
},
{
"keywords": [
"二次元"
],
"params": {
"negative_prompt": "(low quality, worst quality:1.4),(bad_prompt:0.8), (monochrome:1.1), (greyscale)",
"prompt": "masterpiece, best quality"
},
"options": {
"sd_model_checkpoint": "meinamix_meinaV8"
},
"desc": "使用二次元风格模型出图"
}
]
}

88
plugins/sdwebui/readme.md Normal file
View File

@@ -0,0 +1,88 @@
## 插件描述
本插件用于将画图请求转发给stable diffusion webui。
## 环境要求
使用前先安装stable diffusion webui并在它的启动参数中添加 "--api"。
具体信息,请参考[文章](https://github.com/AUTOMATIC1111/stable-diffusion-webui/wiki/API)。
请**安装**本插件的依赖包```webuiapi```
```
pip install webuiapi
```
## 使用说明
请将`config.json.template`复制为`config.json`,并修改其中的参数和规则。
### 画图请求格式
用户的画图请求格式为:
```
<画图触发词><关键词1> <关键词2> ... <关键词n>:<prompt>
```
- 本插件会对画图触发词后的关键词进行逐个匹配,如果触发了规则中的关键词,则会在画图请求中重载对应的参数。
- 规则的匹配顺序参考`config.json`中的顺序每个关键词最多被匹配到1次如果多个关键词触发了重复的参数重复参数以最后一个关键词为准。
- 关键词中包含`help`或`帮助`,会打印出帮助文档。
第一个"**:**"号之后的内容会作为附加的**prompt**接在最终的prompt后
例如: 画横版 高清 二次元:cat
会触发三个关键词 "横版", "高清", "二次元"prompt为"cat"
若默认参数是:
```json
"width": 512,
"height": 512,
"enable_hr": false,
"prompt": "8k"
"negative_prompt": "nsfw",
"sd_model_checkpoint": "perfectWorld_v2Baked"
```
"横版"触发的规则参数为:
```json
"width": 640,
"height": 384,
```
"高清"触发的规则参数为:
```json
"enable_hr": true,
"hr_scale": 1.6,
```
"二次元"触发的规则参数为:
```json
"negative_prompt": "(low quality, worst quality:1.4),(bad_prompt:0.8), (monochrome:1.1), (greyscale)",
"steps": 20,
"prompt": "masterpiece, best quality",
"sd_model_checkpoint": "meinamix_meinaV8"
```
以上这些规则的参数会和默认参数合并。第一个":"后的内容cat会连接在prompt后。
得到最终参数为:
```json
"width": 640,
"height": 384,
"enable_hr": true,
"hr_scale": 1.6,
"negative_prompt": "(low quality, worst quality:1.4),(bad_prompt:0.8), (monochrome:1.1), (greyscale)",
"steps": 20,
"prompt": "masterpiece, best quality, cat",
"sd_model_checkpoint": "meinamix_meinaV8"
```
PS: 实际参数分为两部分:
- 一部分是`params`,为画画的参数;参数名**必须**与webuiapi包中[txt2img api](https://github.com/mix1009/sdwebuiapi/blob/fb2054e149c0a4e25125c0cd7e7dca06bda839d4/webuiapi/webuiapi.py#L163)的参数名一致
- 另一部分是`options`指sdwebui的设置使用的模型和vae需写在里面。它和(http://127.0.0.1:7860/sdapi/v1/options)所返回的键一致。

114
plugins/sdwebui/sdwebui.py Normal file
View File

@@ -0,0 +1,114 @@
# encoding:utf-8
import json
import os
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from config import conf
import plugins
from plugins import *
from common.log import logger
import webuiapi
import io
@plugins.register(name="sdwebui", desc="利用stable-diffusion webui来画图", version="2.0", author="lanvent")
class SDWebUI(Plugin):
def __init__(self):
super().__init__()
curdir = os.path.dirname(__file__)
config_path = os.path.join(curdir, "config.json")
try:
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
self.rules = config["rules"]
defaults = config["defaults"]
self.default_params = defaults["params"]
self.default_options = defaults["options"]
self.start_args = config["start"]
self.api = webuiapi.WebUIApi(**self.start_args)
self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context
logger.info("[SD] inited")
except FileNotFoundError:
logger.error(f"[SD] init failed, {config_path} not found")
except Exception as e:
logger.error("[SD] init failed, exception: %s" % e)
def on_handle_context(self, e_context: EventContext):
if e_context['context'].type != ContextType.IMAGE_CREATE:
return
logger.debug("[SD] on_handle_context. content: %s" %e_context['context'].content)
logger.info("[SD] image_query={}".format(e_context['context'].content))
reply = Reply()
try:
content = e_context['context'].content[:]
# 解析用户输入 如"横版 高清 二次元:cat"
if ":" in content:
keywords, prompt = content.split(":", 1)
else:
keywords = content
prompt = ""
keywords = keywords.split()
if "help" in keywords or "帮助" in keywords:
reply.type = ReplyType.INFO
reply.content = self.get_help_text()
else:
rule_params = {}
rule_options = {}
for keyword in keywords:
matched = False
for rule in self.rules:
if keyword in rule["keywords"]:
for key in rule["params"]:
rule_params[key] = rule["params"][key]
if "options" in rule:
for key in rule["options"]:
rule_options[key] = rule["options"][key]
matched = True
break # 一个关键词只匹配一个规则
if not matched:
logger.warning("[SD] keyword not matched: %s" % keyword)
params = {**self.default_params, **rule_params}
options = {**self.default_options, **rule_options}
params["prompt"] = params.get("prompt", "")+f", {prompt}"
if len(options) > 0:
logger.info("[SD] cover options={}".format(options))
self.api.set_options(options)
logger.info("[SD] params={}".format(params))
result = self.api.txt2img(
**params
)
reply.type = ReplyType.IMAGE
b_img = io.BytesIO()
result.image.save(b_img, format="PNG")
reply.content = b_img
e_context.action = EventAction.BREAK_PASS # 事件结束后跳过处理context的默认逻辑
except Exception as e:
reply.type = ReplyType.ERROR
reply.content = "[SD] "+str(e)
logger.error("[SD] exception: %s" % e)
e_context.action = EventAction.CONTINUE # 事件继续,交付给下个插件或默认逻辑
finally:
e_context['reply'] = reply
def get_help_text(self, **kwargs):
if not conf().get('image_create_prefix'):
return "画图功能未启用"
else:
trigger = conf()['image_create_prefix'][0]
help_text = f"请使用<{trigger}[关键词1] [关键词2]...:提示语>的格式作画,如\"{trigger}横版 高清:cat\"\n"
help_text += "目前可用关键词:\n"
for rule in self.rules:
keywords = [f"[{keyword}]" for keyword in rule['keywords']]
help_text += f"{','.join(keywords)}"
if "desc" in rule:
help_text += f"-{rule['desc']}\n"
else:
help_text += "\n"
return help_text

View File

@@ -1,2 +1,3 @@
itchat-uos==1.5.0.dev0
openai
wechaty

16
scripts/shutdown.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/bash
#关闭服务
cd `dirname $0`/..
export BASE_DIR=`pwd`
pid=`ps ax | grep -i app.py | grep "${BASE_DIR}" | grep python3 | grep -v grep | awk '{print $1}'`
if [ -z "$pid" ] ; then
echo "No chatgpt-on-wechat running."
exit -1;
fi
echo "The chatgpt-on-wechat(${pid}) is running..."
kill ${pid}
echo "Send shutdown request to chatgpt-on-wechat(${pid}) OK"

16
scripts/start.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/bash
#后台运行Chat_on_webchat执行脚本
cd `dirname $0`/..
export BASE_DIR=`pwd`
echo $BASE_DIR
# check the nohup.out log output file
if [ ! -f "${BASE_DIR}/nohup.out" ]; then
touch "${BASE_DIR}/nohup.out"
echo "create file ${BASE_DIR}/nohup.out"
fi
nohup python3 "${BASE_DIR}/app.py" & tail -f "${BASE_DIR}/nohup.out"
echo "Chat_on_webchat is startingyou can check the ${BASE_DIR}/nohup.out"

14
scripts/tout.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
#打开日志
cd `dirname $0`/..
export BASE_DIR=`pwd`
echo $BASE_DIR
# check the nohup.out log output file
if [ ! -f "${BASE_DIR}/nohup.out" ]; then
echo "No file ${BASE_DIR}/nohup.out"
exit -1;
fi
tail -f "${BASE_DIR}/nohup.out"

View File

@@ -0,0 +1,38 @@
"""
baidu voice service
"""
import time
from aip import AipSpeech
from bridge.reply import Reply, ReplyType
from common.log import logger
from common.tmp_dir import TmpDir
from voice.voice import Voice
from config import conf
class BaiduVoice(Voice):
APP_ID = conf().get('baidu_app_id')
API_KEY = conf().get('baidu_api_key')
SECRET_KEY = conf().get('baidu_secret_key')
client = AipSpeech(APP_ID, API_KEY, SECRET_KEY)
def __init__(self):
pass
def voiceToText(self, voice_file):
pass
def textToVoice(self, text):
result = self.client.synthesis(text, 'zh', 1, {
'spd': 5, 'pit': 5, 'vol': 5, 'per': 111
})
if not isinstance(result, dict):
fileName = TmpDir().path() + '语音回复_' + str(int(time.time())) + '.mp3'
with open(fileName, 'wb') as f:
f.write(result)
logger.info('[Baidu] textToVoice text={} voice file name={}'.format(text, fileName))
reply = Reply(ReplyType.VOICE, fileName)
else:
logger.error('[Baidu] textToVoice error={}'.format(result))
reply = Reply(ReplyType.ERROR, "抱歉,语音合成失败")
return reply

View File

@@ -0,0 +1,58 @@
"""
google voice service
"""
import pathlib
import subprocess
import time
from bridge.reply import Reply, ReplyType
import speech_recognition
import pyttsx3
from common.log import logger
from common.tmp_dir import TmpDir
from voice.voice import Voice
class GoogleVoice(Voice):
recognizer = speech_recognition.Recognizer()
engine = pyttsx3.init()
def __init__(self):
# 语速
self.engine.setProperty('rate', 125)
# 音量
self.engine.setProperty('volume', 1.0)
# 0为男声1为女声
voices = self.engine.getProperty('voices')
self.engine.setProperty('voice', voices[1].id)
def voiceToText(self, voice_file):
new_file = voice_file.replace('.mp3', '.wav')
subprocess.call('ffmpeg -i ' + voice_file +
' -acodec pcm_s16le -ac 1 -ar 16000 ' + new_file, shell=True)
with speech_recognition.AudioFile(new_file) as source:
audio = self.recognizer.record(source)
try:
text = self.recognizer.recognize_google(audio, language='zh-CN')
logger.info(
'[Google] voiceToText text={} voice file name={}'.format(text, voice_file))
reply = Reply(ReplyType.TEXT, text)
except speech_recognition.UnknownValueError:
reply = Reply(ReplyType.ERROR, "抱歉,我听不懂")
except speech_recognition.RequestError as e:
reply = Reply(ReplyType.ERROR, "抱歉,无法连接到 Google 语音识别服务;{0}".format(e))
finally:
return reply
def textToVoice(self, text):
try:
textFile = TmpDir().path() + '语音回复_' + str(int(time.time())) + '.mp3'
self.engine.save_to_file(text, textFile)
self.engine.runAndWait()
logger.info(
'[Google] textToVoice text={} voice file name={}'.format(text, textFile))
reply = Reply(ReplyType.VOICE, textFile)
except Exception as e:
reply = Reply(ReplyType.ERROR, str(e))
finally:
return reply

View File

@@ -0,0 +1,33 @@
"""
google voice service
"""
import json
import openai
from bridge.reply import Reply, ReplyType
from config import conf
from common.log import logger
from voice.voice import Voice
class OpenaiVoice(Voice):
def __init__(self):
openai.api_key = conf().get('open_ai_api_key')
def voiceToText(self, voice_file):
logger.debug(
'[Openai] voice file name={}'.format(voice_file))
try:
file = open(voice_file, "rb")
result = openai.Audio.transcribe("whisper-1", file)
text = result["text"]
reply = Reply(ReplyType.TEXT, text)
logger.info(
'[Openai] voiceToText text={} voice file name={}'.format(text, voice_file))
except Exception as e:
reply = Reply(ReplyType.ERROR, str(e))
finally:
return reply
def textToVoice(self, text):
pass

16
voice/voice.py Normal file
View File

@@ -0,0 +1,16 @@
"""
Voice service abstract class
"""
class Voice(object):
def voiceToText(self, voice_file):
"""
Send voice to voice service and get text
"""
raise NotImplementedError
def textToVoice(self, text):
"""
Send text to voice service and get voice
"""
raise NotImplementedError

20
voice/voice_factory.py Normal file
View File

@@ -0,0 +1,20 @@
"""
voice factory
"""
def create_voice(voice_type):
"""
create a voice instance
:param voice_type: voice type code
:return: voice instance
"""
if voice_type == 'baidu':
from voice.baidu.baidu_voice import BaiduVoice
return BaiduVoice()
elif voice_type == 'google':
from voice.google.google_voice import GoogleVoice
return GoogleVoice()
elif voice_type == 'openai':
from voice.openai.openai_voice import OpenaiVoice
return OpenaiVoice()
raise RuntimeError