Compare commits

...

51 Commits

Author SHA1 Message Date
Rock Chin
61c29829ed Release v2.1.2 2023-03-06 08:35:04 +08:00
Rock Chin
5afcc03e8b fix: 错误的!version指令处理逻辑 2023-03-05 20:07:08 +08:00
Rock Chin
fbeb4673f4 Merge pull request #226 from RockChinQ/text2img-perf
[Feat] 不再自带字体文件
2023-03-05 19:59:16 +08:00
Rock Chin
4aba319560 fix: 错误的加载过程 2023-03-05 19:57:39 +08:00
Rock Chin
74f79e002c perf: 优化字体加载过程 2023-03-05 19:54:51 +08:00
Rock Chin
2668ef2b3f feat: 不再自带字体文件 2023-03-05 19:36:09 +08:00
Rock Chin
74c018e271 Merge pull request #225 from RockChinQ/fix-switch-exce
[Fix] 修复插件开关问题
2023-03-05 17:36:03 +08:00
Rock Chin
64776fd601 doc: OpenAI注册教程链接 2023-03-05 16:47:42 +08:00
Rock Chin
ffef944119 fix: 热重载后插件开关状态被重置 (#177) 2023-03-05 16:04:45 +08:00
Rock Chin
651b291ef6 doc: 添加部分注释 2023-03-05 15:39:13 +08:00
Rock Chin
e4b581f197 doc: 致谢添加贡献者 2023-03-05 14:37:14 +08:00
Rock Chin
4f3939e2d9 Merge pull request #219 from LINSTCL/modelmgr_optimization
优化模型接口底层的异常处理
2023-03-05 14:18:24 +08:00
LINSTCL
1048ca612d 补充错误情况 2023-03-05 14:06:07 +08:00
LINSTCL
b1a2d21ee9 优化异常处理 2023-03-05 13:52:43 +08:00
Rock Chin
dd4e8bdc8b perf: 优化版本识别逻辑 2023-03-05 12:26:51 +08:00
Rock Chin
e28c9bae0c feat: 修改上报功能识别版本的逻辑 2023-03-05 12:21:28 +08:00
Rock Chin
5c10f520fb Merge pull request #215 from RockChinQ/semantic-versions
[Feature] 使用语义化版本进行更新
2023-03-05 12:17:43 +08:00
Rock Chin
f8abe90674 perf: 完善更新检查功能 2023-03-05 12:09:44 +08:00
Rock Chin
964ad42cb4 perf: 完善更新提示 2023-03-05 12:02:59 +08:00
Rock Chin
424b970469 feat: 将依赖检查更改到main流程中 2023-03-05 11:58:18 +08:00
Rock Chin
792366e221 feat: 支持基于语义化版本的自动更新 2023-03-05 11:56:40 +08:00
Rock Chin
79e970c4c3 chore(deps): 删除dulwich依赖 2023-03-05 11:09:24 +08:00
Rock Chin
d12acd5f31 chore: git忽略temp目录 2023-03-05 11:05:42 +08:00
Rock Chin
13e55e05a4 doc: 增加长消息处理功能 2023-03-05 10:54:00 +08:00
Rock Chin
9a7490bc2f feat: 支持拒绝回复包含敏感词的提问 (#210) 2023-03-05 10:49:07 +08:00
Rock Chin
a610a9d3d3 fix: 无法根据ban_person忽略群内指定人消息 (#211) 2023-03-05 10:33:16 +08:00
Rock Chin
56e906c83f feat: 删除sensitive.json以sensitive-template.json替换 2023-03-05 10:21:32 +08:00
Rock Chin
101f26e5a3 Merge pull request #212 from Haibersut/feat-baiducloud
增加百度云内容审核
2023-03-05 10:13:20 +08:00
Rock Chin
0bba205cf2 feat: 优化配置文件注释 2023-03-05 10:12:49 +08:00
Rock Chin
cc3beb191f fix: 百度云审核的配置低版本兼容 2023-03-05 09:54:44 +08:00
Haibersut
42f5092bb9 更新了日记级别
将错误信息调整为warning
2023-03-05 01:45:36 +08:00
Haibersut
bc6728d123 根据建议修改 2023-03-05 01:17:23 +08:00
Rock Chin
754278f80f feat: 启动时自动安装Pillow库 2023-03-05 00:09:31 +08:00
Rock Chin
c9c980b6fe Merge pull request #203 from RockChinQ/blob_message_strategy
[Feature] 长消息处理策略
2023-03-04 23:53:54 +08:00
Rock Chin
a457d13d2c perf: 优化图片渲染 2023-03-04 23:53:22 +08:00
Rock Chin
7440e9e5d2 fix(blob.py): 错误的图片压缩处理 2023-03-04 21:36:07 +08:00
Rock Chin
39d901a5cb feat: 支持将长消息转换成图片进行回复 2023-03-04 21:14:10 +08:00
Haibersut
2e1ebff985 change value name 2023-03-04 21:12:50 +08:00
Haibersut
b8ed9ba321 Update README.md 2023-03-04 21:08:48 +08:00
Haibersut
c89a8e1cd1 Update README.md 2023-03-04 21:06:58 +08:00
Haibersut
480d201c55 增加百度云内容审核 2023-03-04 21:02:10 +08:00
Rock Chin
a4b7d4a012 feat: 支持将长消息转换成转发消息组件发送 2023-03-04 13:53:18 +08:00
Rock Chin
7fe676712b perf: 删除配置模板冗余项 2023-03-04 11:16:20 +08:00
Rock Chin
552733129c feat: 配置文件中增加长消息处理策略字段 2023-03-04 10:36:43 +08:00
Rock Chin
a4d73090f8 feat: 默认在启动时更新openai依赖库 2023-03-04 10:16:47 +08:00
Rock Chin
7d39b72800 feat: 更改默认的max_tokens为1024 2023-03-03 21:18:31 +08:00
Rock Chin
f1e12563e9 feat(gather.py): 未设置版本时默认为undetermined 2023-03-03 21:15:26 +08:00
Rock Chin
0ac5e5b35e fix(session.py): 错误的undo()方法逻辑 2023-03-03 21:13:31 +08:00
Rock Chin
6b3f74a39a Merge branch 'master' of https://github.com/RockChinQ/QChatGPT 2023-03-03 20:53:23 +08:00
Rock Chin
3c3e2e86c3 doc: README.md中一览已适配的模型 2023-03-03 20:53:19 +08:00
Rock Chin
204a778db2 Create CONTRIBUTING.md 2023-03-03 19:48:55 +08:00
30 changed files with 870 additions and 184 deletions

6
.gitignore vendored
View File

@@ -3,10 +3,12 @@ config.py
__pycache__/
database.db
qchatgpt.log
config.py
/banlist.py
plugins/
!plugins/__init__.py
/revcfg.py
prompts/
logs/
logs/
sensitive.json
temp/
current_tag

19
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,19 @@
## 参与项目
欢迎为此项目贡献代码或其他支持,以使您的点子或众人期待的功能成为现实,助力社区成长。
### 贡献形式
- 提交PR解决issues中提到的bug或期待的功能
- 提交PR实现您设想的功能请先提出issue与作者沟通
- 优化代码架构,使各个模块的组织更加整洁优雅
- 在issues中提出发现的bug或者期待的功能
- 为本项目在其他社交平台撰写文章、制作视频等
- 为本项目的衍生项目作出贡献,或开发插件增加功能
### 如何开始
- 加入本项目交流群,一同探讨项目相关事务
- 解决本项目或衍生项目的issues中亟待解决的问题
- 阅读并完善本项目文档
- 在各个社交媒体撰写本项目教程等

View File

@@ -1,5 +1,5 @@
# QChatGPT🤖
> 2023/3/3 官方接口疑似被墙,请自行测试,或等待近期敏感时期结束。
> 2023/3/3 官方接口疑似被墙,可考虑使用网络代理 [#198](https://github.com/RockChinQ/QChatGPT/issues/198)
> 2023/3/3 现已在主线支持官方ChatGPT接口使用方法查看[#195](https://github.com/RockChinQ/QChatGPT/issues/195)
> 2023/3/2 OpenAI已发布ChatGPT官方接口我们正在全力接入预计明日前完成请查看[此PR](https://github.com/RockChinQ/QChatGPT/pull/194)
> 2023/2/16 现已支持接入ChatGPT网页版详情请完成部署并查看底部**插件**小节或[此仓库](https://github.com/RockChinQ/revLibs)
@@ -8,18 +8,38 @@
- 由bilibili TheLazy制作的[视频教程](https://www.bilibili.com/video/BV15v4y1X7aP)
- 交流、答疑群: ~~204785790~~已满、691226829、656285629
- **进群提问前请您`确保`已经找遍文档和issue均无法解决**
- **进群提问前请您`确保`已经找遍文档和issue均无法解决**
- **进群提问前请您`确保`已经找遍文档和issue均无法解决**
- QQ频道机器人见[QQChannelChatGPT](https://github.com/Soulter/QQChannelChatGPT)
通过调用OpenAI的ChatGPT等语言模型来实现一个更加智能的QQ机器人
## 🍺模型适配一览
### 文字对话
- OpenAI GPT-3.5模型(ChatGPT API), 本项目原生支持, 默认使用
- OpenAI GPT-3模型, 本项目原生支持, 部署完成后前往config.py切换
- ChatGPT网页版逆向API, 由[插件](https://github.com/RockChinQ/revLibs)接入
### 故事续写
- NovelAI API, 由[插件](https://github.com/dominoar/QCPNovelAi)接入
### 图片绘制
- OpenAI DALL·E模型, 本项目原生支持, 使用方法查看[Wiki功能使用页](https://github.com/RockChinQ/QChatGPT/wiki/%E5%8A%9F%E8%83%BD%E4%BD%BF%E7%94%A8#%E5%8A%9F%E8%83%BD%E7%82%B9%E5%88%97%E4%B8%BE)
- NovelAI API, 由[插件](https://github.com/dominoar/QCPNovelAi)接入
### 语音生成
- TTS+VITS, 由[插件](https://github.com/dominoar/QChatPlugins)接入
## ✅功能
<details>
<summary>✅支持敏感词过滤,避免账号风险</summary>
- 难以监测机器人与用户对话时的内容,故引入此功能以减少机器人风险
- 加入了百度云内容审核,在`config.py`中修改`baidu_check`的值,并填写`baidu_api_key``baidu_secret_key`以开启此功能
- 编辑`sensitive.json`,并在`config.py`中修改`sensitive_word_filter`的值以开启此功能
</details>
@@ -72,6 +92,12 @@
- 详见Wiki`加入黑名单`
</details>
<details>
<summary>✅长消息处理策略</summary>
- 支持将长消息转换成图片或消息记录组件,避免消息刷屏
- 请查看`config.py``blob_message_strategy`等字段
</details>
<details>
<summary>✅回复速度限制</summary>
- 支持限制单会话内每分钟可进行的对话次数
@@ -89,10 +115,9 @@
### - 注册OpenAI账号
**可以直接进群找群主购买**
或参考以下文章自行注册
参考以下文章自行注册
> ~~[只需 1 元搞定 ChatGPT 注册](https://zhuanlan.zhihu.com/p/589470082)~~(已失效)
> [国内注册ChatGPT的方法(100%可用)](https://www.pythonthree.com/register-openai-chatgpt/)
> [手把手教你如何注册ChatGPT超级详细](https://guxiaobei.com/51461)
注册成功后请前往[个人中心查看](https://beta.openai.com/account/api-keys)api_key
@@ -118,10 +143,7 @@
<details>
<summary>手动部署适用于所有平台</summary>
- 请使用Python 3.9.x以上版本
- 请注意OpenAI账号额度消耗
- 每个账户仅有18美元免费额度如未绑定银行卡则会在超出时报错
- OpenAI收费标准默认使用的`text-davinci-003`模型 0.02美元/千字
- 请使用Python 3.9.x以上版本
#### 配置Mirai
@@ -164,7 +186,7 @@ python3 main.py
**常见问题**
- mirai登录提示`QQ版本过低`,见[此issue](https://github.com/RockChinQ/QChatGPT/issues/38)
- mirai登录提示`QQ版本过低`,见[此issue](https://github.com/RockChinQ/QChatGPT/issues/137)
- 如提示安装`uvicorn``hypercorn`请*不要*安装这两个不是必需的目前存在未知原因bug
- 如报错`TypeError: As of 3.10, the *loop* parameter was removed from Lock() since it is no longer necessary`, 请参考 [此处](https://github.com/RockChinQ/QChatGPT/issues/5)
@@ -204,6 +226,7 @@ python3 main.py
- [@dominoar](https://github.com/dominoar) 为本项目开发多种插件
- [@hissincn](https://github.com/hissincn) 本项目贡献者
- [@LINSTCL](https://github.com/LINSTCL) GPT-3.5官方模型适配贡献者
- [@Haibersut](https://github.com/Haibersut) 本项目贡献者
以及其他所有为本项目提供支持的朋友们。

View File

@@ -102,10 +102,27 @@ ignore_rules = {
"regexp": []
}
# 是否检查收到的消息中是否包含敏感词
# 若收到的消息无法通过下方指定的敏感词检查策略,则发送提示信息
income_msg_check = False
# 敏感词过滤开关,以同样数量的*代替敏感词回复
# 请在sensitive.json中添加敏感词
sensitive_word_filter = True
# 是否启用百度云内容安全审核
# 注册方式查看 https://cloud.baidu.com/doc/ANTIPORN/s/Wkhu9d5iy
baidu_check = False
# 百度云API_KEY 24位英文数字字符串
baidu_api_key = ""
# 百度云SECRET_KEY 32位的英文数字字符串
baidu_secret_key = ""
# 不合规消息自定义返回
inappropriate_message_tips = "[百度云]请珍惜机器人,当前返回内容不合规"
# 启动时是否发送赞赏码
# 仅当使用量已经超过2048字时发送
encourage_sponsor_at_start = True
@@ -133,7 +150,7 @@ prompt_submit_length = 1024
completion_api_params = {
"model": "gpt-3.5-turbo",
"temperature": 0.9, # 数值越低得到的回答越理性,取值范围[0, 1]
"max_tokens": 512, # 每次获取OpenAI接口响应的文字量上限, 不高于4096
"max_tokens": 1024, # 每次获取OpenAI接口响应的文字量上限, 不高于4096
"top_p": 1, # 生成的文本的文本与要求的符合度, 取值范围[0, 1]
"frequency_penalty": 0.2,
"presence_penalty": 1.0,
@@ -154,13 +171,24 @@ include_image_description = True
# 消息处理的超时时间,单位为秒
process_message_timeout = 30
# [暂未实现] 群内会话是否启用多对象名称
# 若不启用群内会话的prompt只使用user_name和bot_name
multi_subject = False
# 回复消息时是否显示[GPT]前缀
show_prefix = False
# 应用长消息处理策略的阈值
# 当回复消息长度超过此值时,将使用长消息处理策略
blob_message_threshold = 256
# 长消息处理策略
# - "image": 将长消息转换为图片发送
# - "forward": 将长消息转换为转发消息组件发送
blob_message_strategy = "forward"
# 文字转图片时使用的字体文件路径
# 当策略为"image"时生效
# 若在Windows系统下程序会自动使用Windows自带的微软雅黑字体
# 若未填写或不存在且不是Windows将禁用文字转图片功能改为使用转发消息组件
font_path = ""
# 消息处理超时重试次数
retry_times = 3
@@ -194,6 +222,9 @@ rate_limit_strategy = "wait"
# 若设置为空字符串,则不发送提示信息
rate_limit_drop_tip = "本分钟对话次数超过限速次数,此对话被丢弃"
# 是否在启动时进行依赖库更新
upgrade_dependencies = True
# 是否上报统计信息
# 用于统计机器人的使用情况,不会收集任何用户信息
# 仅上报时间、字数使用量、绘图使用量,其他信息不会上报

39
main.py
View File

@@ -43,6 +43,11 @@ def init_db():
database.initialize_database()
def ensure_dependencies():
import pkg.utils.pkgmgr as pkgmgr
pkgmgr.run_pip(["install", "openai", "Pillow", "--upgrade"])
known_exception_caught = False
log_file_name = "qchatgpt.log"
@@ -102,11 +107,18 @@ def reset_logging():
def main(first_time_init=False):
global known_exception_caught
# 检查并创建plugins、prompts目录
check_path = ["plugins", "prompts"]
for path in check_path:
if not os.path.exists(path):
os.mkdir(path)
import config
# 更新openai库到最新版本
if not hasattr(config, 'upgrade_dependencies') or config.upgrade_dependencies:
print("正在更新依赖库,请等待...")
if not hasattr(config, 'upgrade_dependencies'):
print("这个操作不是必须的,如果不想更新,请在config.py中添加upgrade_dependencies=False")
else:
print("这个操作不是必须的,如果不想更新,请在config.py中将upgrade_dependencies设置为False")
try:
ensure_dependencies()
except Exception as e:
print("更新openai库失败:{}, 请忽略或自行更新".format(e))
known_exception_caught = False
try:
@@ -258,7 +270,7 @@ def main(first_time_init=False):
import pkg.utils.updater
try:
if pkg.utils.updater.is_new_version_available():
pkg.utils.context.get_qqbot_manager().notify_admin("新版本可用,请发送 !update 进行自动更新")
pkg.utils.context.get_qqbot_manager().notify_admin("新版本可用,请发送 !update 进行自动更新\n更新日志:\n{}".format("\n".join(pkg.utils.updater.get_rls_notes())))
else:
logging.info("当前已是最新版本")
@@ -309,6 +321,20 @@ if __name__ == '__main__':
if not os.path.exists('banlist.py'):
shutil.copy('banlist-template.py', 'banlist.py')
# 检查是否有sensitive.json
if not os.path.exists("sensitive.json"):
shutil.copy("sensitive-template.json", "sensitive.json")
# 检查temp目录
if not os.path.exists("temp/"):
os.mkdir("temp/")
# 检查并创建plugins、prompts目录
check_path = ["plugins", "prompts"]
for path in check_path:
if not os.path.exists(path):
os.mkdir(path)
if len(sys.argv) > 1 and sys.argv[1] == 'init_db':
init_db()
sys.exit(0)
@@ -333,4 +359,5 @@ if __name__ == '__main__':
#
# pkg.utils.configmgr.set_config_and_reload("quote_origin", False)
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
main(True)

View File

@@ -0,0 +1,3 @@
"""
审计相关操作
"""

View File

@@ -1,3 +1,7 @@
"""
使用量统计以及数据上报功能实现
"""
import hashlib
import json
import logging
@@ -10,8 +14,11 @@ import pkg.utils.updater
class DataGatherer:
"""数据收集器"""
usage = {}
"""以key值md5为key,{
"""各api-key的使用量
以key值md5为key,{
"text": {
"text-davinci-003": 文字量:int,
},
@@ -20,16 +27,21 @@ class DataGatherer:
}
}为值的字典"""
version_str = "0.1.0"
version_str = "undetermined"
def __init__(self):
self.load_from_db()
try:
self.version_str = pkg.utils.updater.get_commit_id_and_time_and_msg()[:40 if len(pkg.utils.updater.get_commit_id_and_time_and_msg()) > 40 else len(pkg.utils.updater.get_commit_id_and_time_and_msg())]
self.version_str = pkg.utils.updater.get_current_tag() # 从updater模块获取版本号
except:
pass
def report_to_server(self, subservice_name: str, count: int):
"""向中央服务器报告使用量
只会报告此次请求的使用量,不会报告总量。
不包含除版本号、使用类型、使用量以外的任何信息,仅供开发者分析使用情况。
"""
try:
config = pkg.utils.context.get_config()
if hasattr(config, "report_usage") and not config.report_usage:
@@ -44,7 +56,9 @@ class DataGatherer:
return self.usage[key_md5] if key_md5 in self.usage else {}
def report_text_model_usage(self, model, total_tokens):
key_md5 = pkg.utils.context.get_openai_manager().key_mgr.get_using_key_md5()
"""调用方报告文字模型请求文字使用量"""
key_md5 = pkg.utils.context.get_openai_manager().key_mgr.get_using_key_md5() # 以key的md5进行储存
if key_md5 not in self.usage:
self.usage[key_md5] = {}
@@ -62,6 +76,8 @@ class DataGatherer:
self.report_to_server("text", length)
def report_image_model_usage(self, size):
"""调用方报告图片模型请求图片使用量"""
key_md5 = pkg.utils.context.get_openai_manager().key_mgr.get_using_key_md5()
if key_md5 not in self.usage:
@@ -79,6 +95,7 @@ class DataGatherer:
self.report_to_server("image", 1)
def get_text_length_of_key(self, key):
"""获取指定api-key (明文) 的文字总使用量(本地记录)"""
key_md5 = hashlib.md5(key.encode('utf-8')).hexdigest()
if key_md5 not in self.usage:
return 0
@@ -88,6 +105,8 @@ class DataGatherer:
return sum(self.usage[key_md5]["text"].values())
def get_image_count_of_key(self, key):
"""获取指定api-key (明文) 的图片总使用量(本地记录)"""
key_md5 = hashlib.md5(key.encode('utf-8')).hexdigest()
if key_md5 not in self.usage:
return 0
@@ -97,6 +116,7 @@ class DataGatherer:
return sum(self.usage[key_md5]["image"].values())
def get_total_text_length(self):
"""获取所有api-key的文字总使用量(本地记录)"""
total = 0
for key in self.usage:
if "text" not in self.usage[key]:

View File

@@ -0,0 +1,3 @@
"""
数据库操作封装
"""

View File

@@ -1,3 +1,6 @@
"""
数据库管理模块
"""
import hashlib
import json
import logging
@@ -9,9 +12,9 @@ import sqlite3
import pkg.utils.context
# 数据库管理
# 为其他模块提供数据库操作接口
class DatabaseManager:
"""封装数据库底层操作,并提供方法给上层使用"""
conn = None
cursor = None
@@ -23,13 +26,14 @@ class DatabaseManager:
# 连接到数据库文件
def reconnect(self):
"""连接到数据库"""
self.conn = sqlite3.connect('database.db', check_same_thread=False)
self.cursor = self.conn.cursor()
def close(self):
self.conn.close()
def execute(self, *args, **kwargs) -> Cursor:
def __execute__(self, *args, **kwargs) -> Cursor:
# logging.debug('SQL: {}'.format(sql))
c = self.cursor.execute(*args, **kwargs)
self.conn.commit()
@@ -37,7 +41,9 @@ class DatabaseManager:
# 初始化数据库的函数
def initialize_database(self):
self.execute("""
"""创建数据表"""
self.__execute__("""
create table if not exists `sessions` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT,
`name` varchar(255) not null,
@@ -50,7 +56,7 @@ class DatabaseManager:
)
""")
self.execute("""
self.__execute__("""
create table if not exists `account_fee`(
`id` INTEGER PRIMARY KEY AUTOINCREMENT,
`key_md5` varchar(255) not null,
@@ -59,7 +65,7 @@ class DatabaseManager:
)
""")
self.execute("""
self.__execute__("""
create table if not exists `account_usage`(
`id` INTEGER PRIMARY KEY AUTOINCREMENT,
`json` text not null
@@ -70,10 +76,12 @@ class DatabaseManager:
# session持久化
def persistence_session(self, subject_type: str, subject_number: int, create_timestamp: int,
last_interact_timestamp: int, prompt: str):
"""持久化指定session"""
# 检查是否已经有了此name和create_timestamp的session
# 如果有就更新prompt和last_interact_timestamp
# 如果没有,就插入一条新的记录
self.execute("""
self.__execute__("""
select count(*) from `sessions` where `type` = '{}' and `number` = {} and `create_timestamp` = {}
""".format(subject_type, subject_number, create_timestamp))
count = self.cursor.fetchone()[0]
@@ -84,8 +92,8 @@ class DatabaseManager:
values (?, ?, ?, ?, ?, ?)
"""
self.execute(sql,
("{}_{}".format(subject_type, subject_number), subject_type, subject_number, create_timestamp,
self.__execute__(sql,
("{}_{}".format(subject_type, subject_number), subject_type, subject_number, create_timestamp,
last_interact_timestamp, prompt))
else:
sql = """
@@ -93,23 +101,23 @@ class DatabaseManager:
where `type` = ? and `number` = ? and `create_timestamp` = ?
"""
self.execute(sql, (last_interact_timestamp, prompt, subject_type,
subject_number, create_timestamp))
self.__execute__(sql, (last_interact_timestamp, prompt, subject_type,
subject_number, create_timestamp))
# 显式关闭一个session
def explicit_close_session(self, session_name: str, create_timestamp: int):
self.execute("""
self.__execute__("""
update `sessions` set `status` = 'explicitly_closed' where `name` = '{}' and `create_timestamp` = {}
""".format(session_name, create_timestamp))
def set_session_ongoing(self, session_name: str, create_timestamp: int):
self.execute("""
self.__execute__("""
update `sessions` set `status` = 'on_going' where `name` = '{}' and `create_timestamp` = {}
""".format(session_name, create_timestamp))
# 设置session为过期
def set_session_expired(self, session_name: str, create_timestamp: int):
self.execute("""
self.__execute__("""
update `sessions` set `status` = 'expired' where `name` = '{}' and `create_timestamp` = {}
""".format(session_name, create_timestamp))
@@ -117,7 +125,7 @@ class DatabaseManager:
def load_valid_sessions(self) -> dict:
# 从数据库中加载所有还没过期的session
config = pkg.utils.context.get_config()
self.execute("""
self.__execute__("""
select `name`, `type`, `number`, `create_timestamp`, `last_interact_timestamp`, `prompt`, `status`
from `sessions` where `last_interact_timestamp` > {}
""".format(int(time.time()) - config.session_expire_time))
@@ -150,7 +158,7 @@ class DatabaseManager:
# 获取此session_name前一个session的数据
def last_session(self, session_name: str, cursor_timestamp: int):
self.execute("""
self.__execute__("""
select `name`, `type`, `number`, `create_timestamp`, `last_interact_timestamp`, `prompt`, `status`
from `sessions` where `name` = '{}' and `last_interact_timestamp` < {} order by `last_interact_timestamp` desc
limit 1
@@ -179,7 +187,7 @@ class DatabaseManager:
# 获取此session_name后一个session的数据
def next_session(self, session_name: str, cursor_timestamp: int):
self.execute("""
self.__execute__("""
select `name`, `type`, `number`, `create_timestamp`, `last_interact_timestamp`, `prompt`, `status`
from `sessions` where `name` = '{}' and `last_interact_timestamp` > {} order by `last_interact_timestamp` asc
limit 1
@@ -207,7 +215,7 @@ class DatabaseManager:
# 列出与某个对象的所有对话session
def list_history(self, session_name: str, capacity: int, page: int):
self.execute("""
self.__execute__("""
select `name`, `type`, `number`, `create_timestamp`, `last_interact_timestamp`, `prompt`, `status`
from `sessions` where `name` = '{}' order by `last_interact_timestamp` desc limit {} offset {}
""".format(session_name, capacity, capacity * page))
@@ -246,22 +254,22 @@ class DatabaseManager:
usage_count = usage[key_md5]
# 将使用量存进数据库
# 先检查是否已存在
self.execute("""
self.__execute__("""
select count(*) from `api_key_usage` where `key_md5` = '{}'""".format(key_md5))
result = self.cursor.fetchone()
if result[0] == 0:
# 不存在则插入
self.execute("""
self.__execute__("""
insert into `api_key_usage` (`key_md5`, `usage`,`timestamp`) values ('{}', {}, {})
""".format(key_md5, usage_count, int(time.time())))
else:
# 存在则更新timestamp设置为当前
self.execute("""
self.__execute__("""
update `api_key_usage` set `usage` = {}, `timestamp` = {} where `key_md5` = '{}'
""".format(usage_count, int(time.time()), key_md5))
def load_api_key_usage(self):
self.execute("""
self.__execute__("""
select `key_md5`, `usage` from `api_key_usage`
""")
results = self.cursor.fetchall()
@@ -273,23 +281,24 @@ class DatabaseManager:
return usage
def dump_usage_json(self, usage: dict):
json_str = json.dumps(usage)
self.execute("""
self.__execute__("""
select count(*) from `account_usage`""")
result = self.cursor.fetchone()
if result[0] == 0:
# 不存在则插入
self.execute("""
self.__execute__("""
insert into `account_usage` (`json`) values ('{}')
""".format(json_str))
else:
# 存在则更新
self.execute("""
self.__execute__("""
update `account_usage` set `json` = '{}' where `id` = 1
""".format(json_str))
def load_usage_json(self):
self.execute("""
self.__execute__("""
select `json` from `account_usage` order by id desc limit 1
""")
result = self.cursor.fetchone()

View File

@@ -0,0 +1,2 @@
"""OpenAI 接口处理及会话管理相关
"""

View File

@@ -1,8 +1,13 @@
# 多情景预设值管理
__current__ = "default"
"""当前默认使用的情景预设的名称
由管理员使用`!default <名称>`指令切换
"""
__prompts_from_files__ = {}
"""从文件中读取的情景预设值"""
def read_prompt_from_file() -> str:

View File

@@ -5,18 +5,26 @@ import logging
import pkg.plugin.host as plugin_host
import pkg.plugin.models as plugin_models
class KeysManager:
api_key = {}
"""所有api-key"""
# api-key的使用量
# 其中键为api-key的md5值值为使用量
using_key = ""
"""当前使用的api-key
"""
alerted = []
"""已提示过超额的key
记录在此以避免重复提示
"""
# 在此list中的都是经超额报错标记过的api-key
# 记录的是key值仅在运行时有效
exceeded = []
"""已超额的key
供自动切换功能识别
"""
def get_using_key(self):
return self.using_key
@@ -25,8 +33,6 @@ class KeysManager:
return hashlib.md5(self.using_key.encode('utf-8')).hexdigest()
def __init__(self, api_key):
# if hasattr(config, 'api_key_usage_threshold'):
# self.api_key_usage_threshold = config.api_key_usage_threshold
if type(api_key) is dict:
self.api_key = api_key
@@ -42,9 +48,13 @@ class KeysManager:
self.auto_switch()
# 根据tested自动切换到可用的api-key
# 返回是否切换成功, 切换后的api-key的别名
def auto_switch(self) -> (bool, str):
"""尝试切换api-key
Returns:
是否切换成功, 切换后的api-key的别名
"""
for key_name in self.api_key:
if self.api_key[key_name] not in self.exceeded:
self.using_key = self.api_key[key_name]
@@ -68,12 +78,9 @@ class KeysManager:
def add(self, key_name, key):
self.api_key[key_name] = key
# 设置当前使用的api-key使用量超限
# 这是在尝试调用api时发生超限异常时调用的
def set_current_exceeded(self):
# md5 = hashlib.md5(self.using_key.encode('utf-8')).hexdigest()
# self.usage[md5] = self.api_key_usage_threshold
# self.fee[md5] = self.api_key_fee_threshold
"""设置当前使用的api-key使用量超限
"""
self.exceeded.append(self.using_key)
def get_key_name(self, api_key):

View File

@@ -7,9 +7,12 @@ import pkg.utils.context
import pkg.audit.gatherer
from pkg.openai.modelmgr import ModelRequest, create_openai_model_request
# 为其他模块提供与OpenAI交互的接口
class OpenAIInteract:
api_params = {}
"""OpenAI 接口封装
将文字接口和图片接口封装供调用方使用
"""
key_mgr: pkg.openai.keymgr.KeysManager = None
@@ -20,7 +23,6 @@ class OpenAIInteract:
}
def __init__(self, api_key: str):
# self.api_key = api_key
self.key_mgr = pkg.openai.keymgr.KeysManager(api_key)
self.audit_mgr = pkg.audit.gatherer.DataGatherer()
@@ -32,7 +34,16 @@ class OpenAIInteract:
pkg.utils.context.set_openai_manager(self)
# 请求OpenAI Completion
def request_completion(self, prompts):
def request_completion(self, prompts) -> str:
"""请求补全接口回复
Parameters:
prompts (str): 提示语
Returns:
str: 回复
"""
config = pkg.utils.context.get_config()
# 根据模型选择使用的接口
@@ -58,8 +69,15 @@ class OpenAIInteract:
return ai.get_message()
def request_image(self, prompt):
def request_image(self, prompt) -> dict:
"""请求图片接口回复
Parameters:
prompt (str): 提示语
Returns:
dict: 响应
"""
config = pkg.utils.context.get_config()
params = config.image_api_params if hasattr(config, "image_api_params") else self.default_image_api_params

View File

@@ -1,5 +1,12 @@
# 提供与模型交互的抽象接口
"""OpenAI 接口底层封装
目前使用的对话接口有:
ChatCompletion - gpt-3.5-turbo 等模型
Completion - text-davinci-003 等模型
此模块封装此两个接口的请求实现,为上层提供统一的调用方式
"""
import openai, logging, threading, asyncio
import openai.error as aiE
COMPLETION_MODELS = {
'text-davinci-003',
@@ -25,26 +32,46 @@ IMAGE_MODELS = {
}
class ModelRequest():
"""GPT父类"""
can_chat = False
runtime:threading.Thread = None
ret = ""
proxy:str = None
class ModelRequest:
"""模型接口请求父类"""
def __init__(self, model_name, user_name, request_fun, http_proxy:str = None):
can_chat = False
runtime: threading.Thread = None
ret = {}
proxy: str = None
request_ready = True
error_info: str = "若在没有任何错误的情况下看到这句话请带着配置文件上报Issues"
def __init__(self, model_name, user_name, request_fun, http_proxy:str = None, time_out = None):
self.model_name = model_name
self.user_name = user_name
self.request_fun = request_fun
self.time_out = time_out
if http_proxy != None:
self.proxy = http_proxy
openai.proxy = self.proxy
self.request_ready = False
async def __a_request__(self, **kwargs):
self.ret = await self.request_fun(**kwargs)
"""异步请求"""
try:
self.ret:dict = await self.request_fun(**kwargs)
self.request_ready = True
except aiE.APIConnectionError as e:
self.error_info = "{}\n请检查网络连接或代理是否正常".format(e)
raise ConnectionError(self.error_info)
except ValueError as e:
self.error_info = "{}\n该错误可能是由于http_proxy格式设置错误引起的"
except Exception as e:
self.error_info = "{}\n由于请求异常产生的未知错误,请查看日志".format(e)
raise Exception(self.error_info)
def request(self, **kwargs):
"""向接口发起请求"""
if self.proxy != None: #异步请求
self.request_ready = False
loop = asyncio.new_event_loop()
self.runtime = threading.Thread(
target=loop.run_until_complete,
@@ -64,13 +91,15 @@ class ModelRequest():
若重写该方法应检查异步线程状态或在需要检查处super该方法
'''
if self.runtime != None and isinstance(self.runtime, threading.Thread):
self.runtime.join()
return
self.runtime.join(self.time_out)
if self.request_ready:
return
raise Exception(self.error_info)
def get_total_tokens(self):
try:
return self.ret['usage']['total_tokens']
except Exception:
except:
return 0
def get_message(self):
@@ -79,8 +108,10 @@ class ModelRequest():
def get_response(self):
return self.ret
class ChatCompletionModel(ModelRequest):
"""ChatCompletion类模型"""
"""ChatCompletion接口的请求实现"""
Chat_role = ['system', 'user', 'assistant']
def __init__(self, model_name, user_name, http_proxy:str = None, **kwargs):
if http_proxy == None:
@@ -108,7 +139,8 @@ class ChatCompletionModel(ModelRequest):
class CompletionModel(ModelRequest):
"""Completion类模型"""
"""Completion接口的请求实现"""
def __init__(self, model_name, user_name, http_proxy:str = None, **kwargs):
if http_proxy == None:
request_fun = openai.Completion.create

View File

@@ -1,3 +1,8 @@
"""主线使用的会话管理模块
每个人、每个群单独一个sessionsession内部保留了对话的上下文
"""
import logging
import threading
import time
@@ -19,6 +24,7 @@ class SessionOfflineStatus:
ON_GOING = 'on_going'
EXPLICITLY_CLOSED = 'explicitly_closed'
# 重置session.prompt
def reset_session_prompt(session_name, prompt):
# 备份原始数据
@@ -43,11 +49,14 @@ def reset_session_prompt(session_name, prompt):
用户[{}]的数据已被重置,有可能是因为数据版本过旧或存储错误
原始数据将备份在:
{}""".format(session_name, bak_path)
)
) # 为保证多行文本格式正确故无缩进
return prompt
# 从数据加载session
def load_sessions():
"""从数据库加载sessions"""
global sessions
db_inst = pkg.utils.context.get_database_manager()
@@ -93,10 +102,13 @@ class Session:
name = ''
prompt = []
"""使用list来保存会话中的回合"""
create_timestamp = 0
"""会话创建时间"""
last_interact_timestamp = 0
"""上次交互(产生回复)时间"""
just_switched_to_exist_session = False
@@ -116,7 +128,7 @@ class Session:
logging.debug('{},lock release successfully,{}'.format(self.name, self.response_lock))
# 从配置文件获取会话预设信息
def get_default_prompt(self, use_default: str=None):
def get_default_prompt(self, use_default: str = None):
config = pkg.utils.context.get_config()
import pkg.openai.dprompt as dprompt
@@ -130,7 +142,7 @@ class Session:
{
'role': 'user',
'content': current_default_prompt
},{
}, {
'role': 'assistant',
'content': 'ok'
}
@@ -182,6 +194,8 @@ class Session:
# 请求回复
# 这个函数是阻塞的
def append(self, text: str) -> str:
"""向session中添加一条消息返回接口回复"""
self.last_interact_timestamp = int(time.time())
# 触发插件事件
@@ -215,41 +229,40 @@ class Session:
res_ans = '\n\n'.join(res_ans_spt)
# 将此次对话的双方内容加入到prompt中
self.prompt.append({'role':'user', 'content':text})
self.prompt.append({'role':'assistant', 'content':res_ans})
self.prompt.append({'role': 'user', 'content': text})
self.prompt.append({'role': 'assistant', 'content': res_ans})
if self.just_switched_to_exist_session:
self.just_switched_to_exist_session = False
self.set_ongoing()
return res_ans if res_ans[0]!='\n' else res_ans[1:]
return res_ans if res_ans[0] != '\n' else res_ans[1:]
# 删除上一回合并返回上一回合的问题
def undo(self) -> str:
self.last_interact_timestamp = int(time.time())
# 删除上一回合
if self.prompt[-1]['role'] != 'user':
res = self.prompt[-1]['content']
self.prompt.remove(self.prompt[-2])
else:
res = self.prompt[-2]['content']
self.prompt.remove(self.prompt[-1])
# 删除最后两个消息
if len(self.prompt) < 2:
raise Exception('之前无对话,无法撤销')
question = self.prompt[-2]['content']
self.prompt = self.prompt[:-2]
# 返回上一回合的问题
return res
return question
# 构建对话体
def cut_out(self, msg: str, max_tokens: int) -> list:
"""将现有prompt进行切割处理使得新的prompt长度不超过max_tokens"""
# 如果用户消息长度超过max_tokens直接返回
temp_prompt = [
{
'role': 'user',
'content': msg
}
]
{
'role': 'user',
'content': msg
}
]
token_count = len(msg)
# 倒序遍历prompt

View File

@@ -0,0 +1,4 @@
"""插件支持包
包含插件基类、插件宿主以及部分API接口
"""

View File

@@ -116,7 +116,9 @@ def initialize_plugins():
def unload_plugins():
""" 卸载插件 """
""" 卸载插件
"""
# 不再显式卸载插件,因为当程序结束时,插件的析构函数会被系统执行
# for plugin in __plugins__.values():
# if plugin['enabled'] and plugin['instance'] is not None:
# if not hasattr(plugin['instance'], '__del__'):

View File

@@ -145,6 +145,7 @@ __current_registering_plugin__ = ""
class Plugin:
"""插件基类"""
host: host.PluginHost
"""插件宿主,提供插件的一些基础功能"""

View File

@@ -1,30 +1,34 @@
import pkg.utils.context
def is_banned(launcher_type: str, launcher_id: int) -> bool:
def is_banned(launcher_type: str, launcher_id: int, sender_id: int) -> bool:
if not pkg.utils.context.get_qqbot_manager().enable_banlist:
return False
result = False
if launcher_type == 'group':
for group_rule in pkg.utils.context.get_qqbot_manager().ban_group:
if type(group_rule) == int:
if group_rule == launcher_id: # 此群群号被禁用
result = True
elif type(group_rule) == str:
if group_rule.startswith('!'):
# 截取!后面的字符串作为表达式,判断是否匹配
reg_str = group_rule[1:]
import re
if re.match(reg_str, str(launcher_id)): # 被豁免,最高级别
result = False
break
else:
# 判断是否匹配regexp
import re
if re.match(group_rule, str(launcher_id)): # 此群群号被禁用
# 检查是否显式声明发起人QQ要被person忽略
if sender_id in pkg.utils.context.get_qqbot_manager().ban_person:
result = True
else:
for group_rule in pkg.utils.context.get_qqbot_manager().ban_group:
if type(group_rule) == int:
if group_rule == launcher_id: # 此群群号被禁用
result = True
elif type(group_rule) == str:
if group_rule.startswith('!'):
# 截取!后面的字符串作为表达式,判断是否匹配
reg_str = group_rule[1:]
import re
if re.match(reg_str, str(launcher_id)): # 被豁免,最高级别
result = False
break
else:
# 判断是否匹配regexp
import re
if re.match(group_rule, str(launcher_id)): # 此群群号被禁用
result = True
else:
# ban_person, 与群规则相同

105
pkg/qqbot/blob.py Normal file
View File

@@ -0,0 +1,105 @@
# 长消息处理相关
import logging
import os
import time
import base64
import config
from mirai.models.message import MessageComponent, MessageChain, Image
from mirai.models.message import ForwardMessageNode
from mirai.models.base import MiraiBaseModel
from typing import List
import pkg.utils.context as context
import pkg.utils.text2img as text2img
class ForwardMessageDiaplay(MiraiBaseModel):
title: str = "群聊的聊天记录"
brief: str = "[聊天记录]"
source: str = "聊天记录"
preview: List[str] = []
summary: str = "查看x条转发消息"
class Forward(MessageComponent):
"""合并转发。"""
type: str = "Forward"
"""消息组件类型。"""
display: ForwardMessageDiaplay
"""显示信息"""
node_list: List[ForwardMessageNode]
"""转发消息节点列表。"""
def __init__(self, *args, **kwargs):
if len(args) == 1:
self.node_list = args[0]
super().__init__(**kwargs)
super().__init__(*args, **kwargs)
def __str__(self):
return '[聊天记录]'
def text_to_image(text: str) -> MessageComponent:
"""将文本转换成图片"""
# 检查temp文件夹是否存在
if not os.path.exists('temp'):
os.mkdir('temp')
img_path = text2img.text_to_image(text_str=text, save_as='temp/{}.png'.format(int(time.time())))
compressed_path, size = text2img.compress_image(img_path, outfile="temp/{}_compressed.png".format(int(time.time())))
# 读取图片转换成base64
with open(compressed_path, 'rb') as f:
img = f.read()
b64 = base64.b64encode(img)
# 删除图片
os.remove(img_path)
# 判断compressed_path是否存在
if os.path.exists(compressed_path):
os.remove(compressed_path)
# 返回图片
return Image(base64=b64.decode('utf-8'))
def check_text(text: str) -> list:
"""检查文本是否为长消息,并转换成该使用的消息链组件"""
if not hasattr(config, 'blob_message_threshold'):
return [text]
if len(text) > config.blob_message_threshold:
if not hasattr(config, 'blob_message_strategy'):
raise AttributeError('未定义长消息处理策略')
# logging.info("长消息: {}".format(text))
if config.blob_message_strategy == 'image':
# 转换成图片
return [text_to_image(text)]
elif config.blob_message_strategy == 'forward':
# 敏感词屏蔽
text = context.get_qqbot_manager().reply_filter.process(text)
# 包装转发消息
display = ForwardMessageDiaplay(
title='群聊的聊天记录',
brief='[聊天记录]',
source='聊天记录',
preview=["bot: "+text],
summary="查看1条转发消息"
)
node = ForwardMessageNode(
sender_id=config.mirai_http_api_config['qq'],
sender_name='bot',
message_chain=MessageChain([text])
)
forward = Forward(
display=display,
node_list=[node]
)
return [forward]
else:
return [text]

View File

@@ -1,19 +1,77 @@
# 敏感词过滤模块
import re
import requests
import json
import logging
class ReplyFilter:
sensitive_words = []
# 默认值( 兼容性考虑 )
baidu_check = False
baidu_api_key = ""
baidu_secret_key = ""
inappropriate_message_tips = "[百度云]请珍惜机器人,当前返回内容不合规"
def __init__(self, sensitive_words: list):
self.sensitive_words = sensitive_words
import config
if hasattr(config, 'baidu_check') and hasattr(config, 'baidu_api_key') and hasattr(config, 'baidu_secret_key'):
self.baidu_check = config.baidu_check
self.baidu_api_key = config.baidu_api_key
self.baidu_secret_key = config.baidu_secret_key
self.inappropriate_message_tips = config.inappropriate_message_tips
def is_illegal(self, message: str) -> bool:
processed = self.process(message)
if processed != message:
return True
return False
def process(self, message: str) -> str:
# 本地关键词屏蔽
for word in self.sensitive_words:
match = re.findall(word, message)
if len(match) > 0:
for i in range(len(match)):
message = message.replace(match[i], "*" * len(match[i]))
# 百度云审核
if self.baidu_check:
# 百度云审核URL
baidu_url = "https://aip.baidubce.com/rest/2.0/solution/v1/text_censor/v2/user_defined?access_token=" + \
str(requests.post("https://aip.baidubce.com/oauth/2.0/token",
params={"grant_type": "client_credentials",
"client_id": self.baidu_api_key,
"client_secret": self.baidu_secret_key}).json().get("access_token"))
# 百度云审核
payload = "text=" + message
logging.info("向百度云发送:" + payload)
headers = {'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json'}
if isinstance(payload, str):
payload = payload.encode('utf-8')
response = requests.request("POST", baidu_url, headers=headers, data=payload)
response_dict = json.loads(response.text)
if "error_code" in response_dict:
error_msg = response_dict.get("error_msg")
logging.warning(f"百度云判定出错,错误信息:{error_msg}")
conclusion = f"百度云判定出错,错误信息:{error_msg}\n以下是原消息:{message}"
else:
conclusion = response_dict["conclusion"]
if conclusion in ("合规"):
logging.info(f"百度云判定结果:{conclusion}")
return message
else:
logging.warning(f"百度云判定结果:{conclusion}")
conclusion = self.inappropriate_message_tips
# 返回百度云审核结果
return conclusion
return message

View File

@@ -7,6 +7,7 @@ import pkg.openai.session
import pkg.plugin.host as plugin_host
import pkg.plugin.models as plugin_models
import pkg.qqbot.blob as blob
def handle_exception(notify_admin: str = "", set_reply: str = "") -> list:
@@ -63,7 +64,7 @@ def process_normal_message(text_message: str, mgr, config, launcher_type: str,
reply = event.get_return_value("reply")
if not event.is_prevented_default():
reply = [prefix + text]
reply = blob.check_text(prefix + text)
except openai.error.APIConnectionError as e:
err_msg = str(e)
if err_msg.__contains__('Error communicating with OpenAI'):

View File

@@ -49,7 +49,7 @@ def process_message(launcher_type: str, launcher_id: int, text_message: str, mes
session_name = "{}_{}".format(launcher_type, launcher_id)
# 检查发送方是否被禁用
if banlist.is_banned(launcher_type, launcher_id):
if banlist.is_banned(launcher_type, launcher_id, sender_id):
logging.info("根据禁用列表忽略{}_{}的消息".format(launcher_type, launcher_id))
return []
@@ -66,6 +66,11 @@ def process_message(launcher_type: str, launcher_id: int, text_message: str, mes
result.mute_time_remaining))
return reply
import config
if hasattr(config, 'income_msg_check') and config.income_msg_check:
if mgr.reply_filter.is_illegal(text_message):
return MessageChain(Plain("[bot] 你的提问中有不合适的内容, 请更换措辞~"))
pkg.openai.session.get_session(session_name).acquire_response_lock()
text_message = text_message.strip()
@@ -153,7 +158,7 @@ def process_message(launcher_type: str, launcher_id: int, text_message: str, mes
"..." if len(reply[0]) > 100 else "")))
reply = [mgr.reply_filter.process(reply[0])]
else:
logging.info("回复[{}]图片消息:{}".format(session_name, reply))
logging.info("回复[{}]消息".format(session_name))
finally:
processing.remove(session_name)

File diff suppressed because one or more lines are too long

View File

@@ -8,6 +8,11 @@ def install(package):
main.reset_logging()
def run_pip(params: list):
pipmain(params)
main.reset_logging()
def install_requirements(file):
pipmain(['install', '-r', file, "--upgrade"])
main.reset_logging()

View File

@@ -7,13 +7,15 @@ import pkg.utils.context
import pkg.plugin.host
def walk(module, prefix=''):
def walk(module, prefix='', path_prefix=''):
"""遍历并重载所有模块"""
for item in pkgutil.iter_modules(module.__path__):
if item.ispkg:
walk(__import__(module.__name__ + '.' + item.name, fromlist=['']), prefix + item.name + '.')
walk(__import__(module.__name__ + '.' + item.name, fromlist=['']), prefix + item.name + '.', path_prefix + item.name + '/')
else:
logging.info('reload module: {}'.format(prefix + item.name))
logging.info('reload module: {}, path: {}'.format(prefix + item.name, path_prefix + item.name + '.py'))
pkg.plugin.host.__current_module_path__ = "plugins/" + path_prefix + item.name + '.py'
importlib.reload(__import__(module.__name__ + '.' + item.name, fromlist=['']))

193
pkg/utils/text2img.py Normal file
View File

@@ -0,0 +1,193 @@
import logging
from PIL import Image, ImageDraw, ImageFont
import re
import os
import config
import traceback
text_render_font: ImageFont = None
if hasattr(config, "blob_message_strategy") and config.blob_message_strategy == "image": # 仅在启用了image时才加载字体
use_font = config.font_path if hasattr(config, "font_path") else ""
try:
# 检查是否存在
if not os.path.exists(use_font):
# 若是windows系统使用微软雅黑
if os.name == "nt":
use_font = "C:/Windows/Fonts/msyh.ttc"
if not os.path.exists(use_font):
logging.warn("未找到字体文件且无法使用Windows自带字体更换为转发消息组件以发送长消息您可以在config.py中调整相关设置。")
config.blob_message_strategy = "forward"
else:
logging.info("使用Windows自带字体" + use_font)
text_render_font = ImageFont.truetype(use_font, 32, encoding="utf-8")
else:
logging.warn("未找到字体文件且无法使用Windows自带字体更换为转发消息组件以发送长消息您可以在config.py中调整相关设置。")
config.blob_message_strategy = "forward"
else:
text_render_font = ImageFont.truetype(use_font, 32, encoding="utf-8")
except:
traceback.print_exc()
logging.error("加载字体文件失败({})更换为转发消息组件以发送长消息您可以在config.py中调整相关设置。".format(use_font))
config.blob_message_strategy = "forward"
def indexNumber(path=''):
"""
查找字符串中数字所在串中的位置
:param path:目标字符串
:return:<class 'list'>: <class 'list'>: [['1', 16], ['2', 35], ['1', 51]]
"""
kv = []
nums = []
beforeDatas = re.findall('[\d]+', path)
for num in beforeDatas:
indexV = []
times = path.count(num)
if times > 1:
if num not in nums:
indexs = re.finditer(num, path)
for index in indexs:
iV = []
i = index.span()[0]
iV.append(num)
iV.append(i)
kv.append(iV)
nums.append(num)
else:
index = path.find(num)
indexV.append(num)
indexV.append(index)
kv.append(indexV)
# 根据数字位置排序
indexSort = []
resultIndex = []
for vi in kv:
indexSort.append(vi[1])
indexSort.sort()
for i in indexSort:
for v in kv:
if i == v[1]:
resultIndex.append(v)
return resultIndex
def get_size(file):
# 获取文件大小:KB
size = os.path.getsize(file)
return size / 1024
def get_outfile(infile, outfile):
if outfile:
return outfile
dir, suffix = os.path.splitext(infile)
outfile = '{}-out{}'.format(dir, suffix)
return outfile
def compress_image(infile, outfile='', kb=100, step=20, quality=90):
"""不改变图片尺寸压缩到指定大小
:param infile: 压缩源文件
:param outfile: 压缩文件保存地址
:param mb: 压缩目标,KB
:param step: 每次调整的压缩比率
:param quality: 初始压缩比率
:return: 压缩文件地址,压缩文件大小
"""
o_size = get_size(infile)
if o_size <= kb:
return infile, o_size
outfile = get_outfile(infile, outfile)
while o_size > kb:
im = Image.open(infile)
im.save(outfile, quality=quality)
if quality - step < 0:
break
quality -= step
o_size = get_size(outfile)
return outfile, get_size(outfile)
def text_to_image(text_str: str, save_as="temp.png", width=800):
global text_render_font
text_str = text_str.replace("\t", " ")
# 分行
lines = text_str.split('\n')
# 计算并分割
final_lines = []
text_width = width-80
for line in lines:
# 如果长了就分割
line_width = text_render_font.getlength(line)
if line_width < text_width:
final_lines.append(line)
continue
else:
rest_text = line
while True:
# 分割最前面的一行
point = int(len(rest_text) * (text_width / line_width))
# 检查断点是否在数字中间
numbers = indexNumber(rest_text)
for number in numbers:
if number[1] < point < number[1] + len(number[0]) and number[1] != 0:
point = number[1]
break
final_lines.append(rest_text[:point])
rest_text = rest_text[point:]
line_width = text_render_font.getlength(rest_text)
if line_width < text_width:
final_lines.append(rest_text)
break
else:
continue
# 准备画布
img = Image.new('RGBA', (width, max(280, len(final_lines) * 35 + 65)), (255, 255, 255, 255))
draw = ImageDraw.Draw(img, mode='RGBA')
# 绘制正文
line_number = 0
offset_x = 20
offset_y = 30
for final_line in final_lines:
draw.text((offset_x, offset_y + 35 * line_number), final_line, fill=(0, 0, 0), font=text_render_font)
# 遍历此行,检查是否有emoji
idx_in_line = 0
for ch in final_line:
# if self.is_emoji(ch):
# emoji_img_valid = ensure_emoji(hex(ord(ch))[2:])
# if emoji_img_valid: # emoji图像可用,绘制到指定位置
# emoji_image = Image.open("emojis/{}.png".format(hex(ord(ch))[2:]), mode='r').convert('RGBA')
# emoji_image = emoji_image.resize((32, 32))
# x, y = emoji_image.size
# final_emoji_img = Image.new('RGBA', emoji_image.size, (255, 255, 255))
# final_emoji_img.paste(emoji_image, (0, 0, x, y), emoji_image)
# img.paste(final_emoji_img, box=(int(offset_x + idx_in_line * 32), offset_y + 35 * line_number))
# 检查字符占位宽
char_code = ord(ch)
if char_code >= 127:
idx_in_line += 1
else:
idx_in_line += 0.5
line_number += 1
img.save(save_as)
return save_as

View File

@@ -1,6 +1,11 @@
import datetime
import logging
import os.path
import pkg.utils.context
import requests
import json
import pkg.utils.constants
def check_dulwich_closure():
@@ -28,34 +33,100 @@ def pull_latest(repo_path: str) -> bool:
return True
def get_release_list() -> list:
"""获取发行列表"""
rls_list_resp = requests.get(
url="https://api.github.com/repos/RockChinQ/QChatGPT/releases"
)
rls_list = rls_list_resp.json()
return rls_list
def get_current_tag() -> str:
"""获取当前tag"""
current_tag = pkg.utils.constants.semantic_version
if os.path.exists("current_tag"):
with open("current_tag", "r") as f:
current_tag = f.read()
return current_tag
def update_all() -> bool:
"""使用dulwich更新源码"""
check_dulwich_closure()
import dulwich
try:
before_commit_id = get_current_commit_id()
from dulwich import porcelain
repo = porcelain.open_repo('.')
porcelain.pull(repo)
"""检查更新并下载源码"""
current_tag = get_current_tag()
change_log = ""
rls_list = get_release_list()
for entry in repo.get_walker():
if str(entry.commit.id)[2:-1] == before_commit_id:
break
tz = datetime.timezone(datetime.timedelta(hours=entry.commit.commit_timezone // 3600))
dt = datetime.datetime.fromtimestamp(entry.commit.commit_time, tz)
change_log += dt.strftime('%Y-%m-%d %H:%M:%S') + " [" + str(entry.commit.message, encoding="utf-8").strip()+"]\n"
latest_rls = {}
rls_notes = []
for rls in rls_list:
rls_notes.append(rls['name']) # 使用发行名称作为note
if rls['tag_name'] == current_tag:
break
if change_log != "":
pkg.utils.context.get_qqbot_manager().notify_admin("代码拉取完成,更新内容如下:\n"+change_log)
return True
else:
return False
except ModuleNotFoundError:
raise Exception("dulwich模块未安装,请查看 https://github.com/RockChinQ/QChatGPT/issues/77")
except dulwich.porcelain.DivergedBranches:
raise Exception("分支不一致,自动更新仅支持master分支,请手动更新(https://github.com/RockChinQ/QChatGPT/issues/76)")
if latest_rls == {}:
latest_rls = rls
logging.info("更新日志: {}".format(rls_notes))
if latest_rls == {}: # 没有新版本
return False
# 下载最新版本的zip到temp目录
logging.info("开始下载最新版本: {}".format(latest_rls['zipball_url']))
zip_url = latest_rls['zipball_url']
zip_resp = requests.get(url=zip_url)
zip_data = zip_resp.content
# 检查temp/updater目录
if not os.path.exists("temp"):
os.mkdir("temp")
if not os.path.exists("temp/updater"):
os.mkdir("temp/updater")
with open("temp/updater/{}.zip".format(latest_rls['tag_name']), "wb") as f:
f.write(zip_data)
logging.info("下载最新版本完成: {}".format("temp/updater/{}.zip".format(latest_rls['tag_name'])))
# 解压zip到temp/updater/<tag_name>/
import zipfile
# 检查目标文件夹
if os.path.exists("temp/updater/{}".format(latest_rls['tag_name'])):
import shutil
shutil.rmtree("temp/updater/{}".format(latest_rls['tag_name']))
os.mkdir("temp/updater/{}".format(latest_rls['tag_name']))
with zipfile.ZipFile("temp/updater/{}.zip".format(latest_rls['tag_name']), 'r') as zip_ref:
zip_ref.extractall("temp/updater/{}".format(latest_rls['tag_name']))
# 覆盖源码
source_root = ""
# 找到temp/updater/<tag_name>/中的第一个子目录路径
for root, dirs, files in os.walk("temp/updater/{}".format(latest_rls['tag_name'])):
if root != "temp/updater/{}".format(latest_rls['tag_name']):
source_root = root
break
# 覆盖源码
import shutil
for root, dirs, files in os.walk(source_root):
# 覆盖所有子文件子目录
for file in files:
src = os.path.join(root, file)
dst = src.replace(source_root, ".")
if os.path.exists(dst):
os.remove(dst)
shutil.copy(src, dst)
# 把current_tag写入文件
current_tag = latest_rls['tag_name']
with open("current_tag", "w") as f:
f.write(current_tag)
# 通知管理员
import pkg.utils.context
pkg.utils.context.get_qqbot_manager().notify_admin("已更新到最新版本: {}\n更新日志:\n{}\n新功能通常可以在config-template.py中看到完整的更新日志请前往 https://github.com/RockChinQ/QChatGPT/releases 查看".format(current_tag, "\n".join(rls_notes)))
return True
def is_repo(path: str) -> bool:
@@ -81,24 +152,12 @@ def get_remote_url(repo_path: str) -> str:
def get_current_version_info() -> str:
"""获取当前版本信息"""
check_dulwich_closure()
from dulwich import porcelain
repo = porcelain.open_repo('.')
version_str = ""
for entry in repo.get_walker():
version_str += "提交编号: "+str(entry.commit.id)[2:9] + "\n"
tz = datetime.timezone(datetime.timedelta(hours=entry.commit.commit_timezone // 3600))
dt = datetime.datetime.fromtimestamp(entry.commit.commit_time, tz)
version_str += "时间: "+dt.strftime('%m-%d %H:%M:%S') + "\n"
version_str += "说明: "+str(entry.commit.message, encoding="utf-8").strip() + "\n"
version_str += "提交作者: '" + str(entry.commit.author)[2:-1] + "'"
break
return version_str
rls_list = get_release_list()
current_tag = get_current_tag()
for rls in rls_list:
if rls['tag_name'] == current_tag:
return rls['name'] + "\n" + rls['body']
return "未知版本"
def get_commit_id_and_time_and_msg() -> str:
@@ -132,15 +191,42 @@ def get_current_commit_id() -> str:
def is_new_version_available() -> bool:
"""检查是否有新版本"""
check_dulwich_closure()
# 从github获取release列表
rls_list = get_release_list()
if rls_list is None:
return False
from dulwich import porcelain
# 获取当前版本
current_tag = get_current_tag()
repo = porcelain.open_repo('.')
fetch_res = porcelain.ls_remote(porcelain.get_remote_repo(repo, "origin")[1])
# 检查是否有新版本
for rls in rls_list:
if rls['tag_name'] == current_tag:
return False
else:
return True
current_commit_id = get_current_commit_id()
latest_commit_id = str(fetch_res[b'HEAD'])[2:-1]
def get_rls_notes() -> list:
"""获取更新日志"""
# 从github获取release列表
rls_list = get_release_list()
if rls_list is None:
return None
return current_commit_id != latest_commit_id
# 获取当前版本
current_tag = get_current_tag()
# 检查是否有新版本
rls_notes = []
for rls in rls_list:
if rls['tag_name'] == current_tag:
break
rls_notes.append(rls['name'])
return rls_notes
if __name__ == "__main__":
update_all()

View File

@@ -1,9 +1,9 @@
requests~=2.28.1
openai~=0.27.0
pip~=22.3.1
dulwich~=0.21.3
colorlog~=6.6.0
yiri-mirai~=0.2.6.1
websockets~=10.4
urllib3~=1.26.10
func_timeout~=4.3.5
func_timeout~=4.3.5
Pillow

View File

@@ -9,6 +9,7 @@
"毛泽东",
"邓小平",
"周恩来",
"马克思",
"社会主义",
"共产党",
"共产主义",
@@ -21,6 +22,8 @@
"天安门",
"六四",
"政治局常委",
"两会",
"共青团",
"学潮",
"八九",
"二十大",
@@ -48,6 +51,7 @@
"作爱",
"做爱",
"性交",
"性爱",
"自慰",
"阴茎",
"淫妇",