├── .gitignore ├── README.md ├── __init__.py ├── database.py ├── image_not_found.jpg ├── migration.py ├── qa.py ├── requirements.txt └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | .idea/ 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | workspace.xml 81 | # SageMath parsed files 82 | *.sage.py 83 | .idea/workspace.xml 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 你问我答 2 | 3 | 基于主流的MySQL而非二进制存储的sqlitedict的可靠、易用、易维护的新版你问我答插件。 4 | 5 | ## 特点 6 | - 基于主流MySQL数据库,以文本方式存储,便于维护 7 | - 支持配置我问的问题与有人问的问题的优先度 8 | - 支持配置一个问题多个回答时的行为 9 | - 支持回答限频以免刷屏 10 | - 不允许回答与问题相同或回答与数据库中其他的问题相同的回答,以免机器人进入死循环 11 | - 回答列表过多时智能分页,交互式操作 12 | - 支持问答日志,以便溯源危险问答的添加者 13 | - 支持多种存储问答图片的方式 14 | - 支持单独删除一个问题下某一个回答,或清空某一问题下所有回答 15 | 16 | ## 安装方法 17 | 18 | 1. clone本仓库 19 | 2. `__bot__.py`中加入`QA` 20 | 3. (如果需要从eqa迁移数据)将`migration.py`放在eqa目录下,并运行 21 | 4. 将`qa.py`打开配置后复制到`config/`下 22 | 5. 安装必须依赖 23 | 24 | ### 注意事项 25 | 26 | 1. 为了使MySQL支持emoji的存储,请参考以下文章进行配置: 27 | [mysql下emoji的存储](https://www.jianshu.com/p/770c029ce5af) 28 | 29 | 2. 解决`create table: Specified key was too long; max key length is 767 bytes`错误: 30 | [解决字段太长的问题](https://blog.51cto.com/u_13476134/2377030) 31 | 32 | 3. 需要nonebot 1.8.0+(通常随hoshino一同安装的都是1.6.0,所以需要升级) 33 | 34 | 4. 长度限制及元素长度参考(InnoDB引擎限制) 35 | 问题限制:100字符,超出者会被截断 36 | 回答限制:665字符,超出者会被截断 37 | 其中, 38 | 换行符:占1或2个字符 39 | 图片: 40 | - 处于问题中的图片一律只存储md5,即每张占54字符 41 | - link方式存储的每张固定占97字符 42 | - rel_path方式存储的每张固定占57字符 43 | - abs_path方式存储的每张占 56+路径长度 字符 44 | 45 | ## 指令表 46 | 47 | 全部指令如下: 48 | 你问/有人问/大家问...你答... 49 | 全体问答/看看有人问/看看大家问/全部问答/所有问答 50 | 我的问答/看看我问 51 | 不要回答/删除回答 id+回答id/问题 52 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from nonebot import CommandSession 4 | from nonebot.command.argfilter import controllers, extractors 5 | from nonebot.command.argfilter.validators import ensure_true 6 | 7 | from hoshino import Service 8 | from hoshino.priv import ADMIN, check_priv 9 | from hoshino.typing import CQEvent 10 | from .database import Question, question_log 11 | from .utils import * 12 | 13 | sv = Service('你问我答') 14 | 15 | during_interactive = False 16 | 17 | try: 18 | session_timeout = hoshino.config.SESSION_EXPIRE_TIMEOUT.total_seconds() 19 | except: 20 | session_timeout = 60 21 | 22 | 23 | async def pagination_display(session: CommandSession, display_title: str, question: List[List[dict]], 24 | display_type: str = 'display'): 25 | """:params display_type: display(展示) , confirm(操作确认)""" 26 | global during_interactive 27 | i = 0 28 | for page in question: 29 | i += 1 30 | msg = f'{display_title}\n' \ 31 | f'-----第{i}/{len(question)}页-----\n' 32 | for _question in page: 33 | if display_type == 'display': 34 | msg += f'id:{_question["id"]} 问题:{_question["question"]} 回答:{_question["answer"]} \n' 35 | elif display_type == 'confirm': 36 | msg += f'id:{_question["id"]} 作用范围:{"全体" if _question["is_all"] else "个人"} 回答:{_question["answer"]} \n' 37 | tip = f'使用"不要回答 id+数字id"删除问题下的指定id的答案(如:不要回答 id1),使用"取消"退出流程({session_timeout}秒后会自动退出)' 38 | if i == len(question): 39 | msg += '-----完-----' 40 | tip = '※' + tip 41 | else: 42 | tip = '※使用"下一页"翻页,' + tip 43 | await session.send('\n' + msg, at_sender=True) 44 | await asyncio.sleep(0.15) 45 | during_interactive = True 46 | try: 47 | option = await session.aget(prompt=tip, 48 | arg_filters=[extractors.extract_text, str.strip, 49 | controllers.handle_cancellation(session), 50 | ensure_true( 51 | lambda _option: True if 52 | text_validator(_option, i, len(question)) else False, 53 | message='指令错误,请重试')]) 54 | except asyncio.exceptions.TimeoutError: 55 | return 56 | finally: 57 | during_interactive = False 58 | if validator_regex.search(option): 59 | await _delete_questions(session) 60 | return 61 | 62 | 63 | @sv.on_message() 64 | async def _handle_reply(bot: hoshino.HoshinoBot, ev: CQEvent): 65 | message = ev.raw_message.strip() 66 | if during_interactive: 67 | return 68 | try: 69 | query = Question.select().where( 70 | Question.question == message, Question.group_id == ev.group_id, 71 | (Question.is_all == True) | (Question.creator_id == ev.user_id)). \ 72 | order_by(Question.create_time.desc()) 73 | except Exception as e: 74 | traceback.print_exc() 75 | hoshino.logger.error(f"查询你问我答数据库时出错{e}") 76 | return 77 | if not query: 78 | return 79 | else: 80 | answer, is_all = await get_answer(query) 81 | if not answer: 82 | return 83 | else: 84 | await asyncio.sleep(answer_delay) 85 | await bot.send(ev, answer) 86 | if record_trigger_log: 87 | question_log.replace( 88 | {'operator_id': ev.user_id, 'group_id': ev.group_id, 89 | 'target_question': message, 'target_answer': answer, 90 | 'is_all': is_all, 'action': 'trigger'}).execute() 91 | 92 | 93 | @sv.on_message() 94 | async def _modify_question(bot: hoshino.HoshinoBot, ev: CQEvent): 95 | message = ev.raw_message.strip() 96 | if message.startswith(('有人问', '大家问', '我问')): # 我问/大家问/有人问...你答... 97 | question_list = message.split('你答', maxsplit=1) 98 | if len(question_list) == 1: 99 | await bot.send(ev, "回答呢回答呢回答呢?", at_sender=True) 100 | return 101 | question, answer = question_list 102 | is_all = True if type_regex.search(question).group() in ('有人问', '大家问') else False 103 | if is_all and not check_priv(ev, ADMIN): 104 | await bot.send(ev, f'只有管理员才能设置{question[:3]}哦~', at_sender=True) 105 | return 106 | question = type_regex.sub('', question, count=1).strip() 107 | answer = await handle_image(answer, 'save') 108 | if not question or not answer: 109 | await bot.send(ev, "问题呢问题呢回答呢回答呢?", at_sender=True) 110 | return 111 | if question == answer: 112 | await bot.send(ev, "你搁这搁这呢?[CQ:face,id=97]", at_sender=True) 113 | return 114 | if Question.select().where( 115 | Question.question == answer, Question.answer == question, Question.group_id == ev.group_id, 116 | (Question.is_all == True) | (Question.creator_id == ev.user_id)): 117 | await bot.send(ev, "死循环是吧?差不多得了[CQ:face,id=97]", at_sender=True) 118 | return 119 | try: 120 | Question.replace(question=question, 121 | answer=answer, 122 | group_id=ev.group_id, 123 | creator_id=ev.user_id, 124 | is_all=is_all).execute() 125 | except Exception as e: 126 | traceback.print_exc() 127 | await bot.send(ev, f"设置问题时出错{type(e)}", at_sender=True) 128 | return 129 | await bot.send(ev, '好的我记住了', at_sender=True) 130 | question_log.replace( 131 | {'operator_id': ev.user_id, 'group_id': ev.group_id, 132 | 'target_question': question, 'target_answer': answer, 133 | 'is_all': is_all, 'action': 'add'}).execute() 134 | elif wrong_command_remind: 135 | if wrong_command_regex.search(message): 136 | await bot.send(ev, "请使用\"不要回答 问题或id+数字id\"来删除,中间有空格(如:不要回答 id1/不要回答 这是问题)", at_sender=True) 137 | else: 138 | return 139 | 140 | 141 | @sv.on_command('删除问答', aliases=('删除回答', '不要回答')) 142 | async def _delete_questions(session: CommandSession): 143 | global during_interactive 144 | args = validator_regex.sub("", session.current_arg).strip() 145 | if args.startswith('id') and args.replace("id", "").isdigit(): 146 | args = int(args.replace("id", "")) 147 | param_list = [Question.id == args] 148 | if not check_priv(session.event, ADMIN): 149 | param_list.append(Question.is_all == False) 150 | try: 151 | question = Question.get_or_none(id=args) 152 | query = Question.delete().where(*param_list).execute() 153 | except Exception as e: 154 | session.finish(f"删除回答时出现错误{type(e)}", at_sender=True) 155 | if query == 0: 156 | session.finish(f"没有找到这个问题哦" 157 | f"{',有可能您试图删除的问题是全体问答(只有管理员才能删除)' if not check_priv(session.event, ADMIN) else ''}", 158 | at_sender=True) 159 | else: 160 | await session.send(f"我不再作出这个回答啦~", at_sender=True) 161 | question_log.replace( 162 | {'operator_id': session.event.user_id, 'group_id': session.event.group_id, 163 | 'target_question': question.question, 'target_answer': question.answer, 164 | 'is_all': question.is_all, 'action': 'delete'}).execute() 165 | return 166 | else: 167 | try: 168 | if not check_priv(session.event, ADMIN): 169 | question = await separate_questions(Question.select().where( 170 | Question.question == args, Question.group_id == session.event.group_id, 171 | Question.creator_id == session.event.user_id)) 172 | else: 173 | question = await separate_questions(Question.select().where( 174 | Question.question == args, Question.group_id == session.event.group_id, 175 | (Question.is_all == True) | (Question.creator_id == session.event.user_id))) 176 | except Exception as e: 177 | traceback.print_exc() 178 | session.finish(f"查询问答时出现错误{type(e)}", at_sender=True) 179 | if not question: 180 | session.finish("没有找到这个问题哦" 181 | f"{',有可能您试图删除的问题是全体问答(只有管理员才能删除)' if not check_priv(session.event, ADMIN) else ''}", 182 | at_sender=True) 183 | elif len(question) == 1 and len(question[0]) == 1: 184 | try: 185 | Question.delete().where(Question.id == question[0][0]['id']).execute() 186 | except Exception as e: 187 | traceback.print_exc() 188 | session.finish(f"删除回答时出现错误{type(e)}", at_sender=True) 189 | await session.send(f"我不再作出这个回答啦~", at_sender=True) 190 | question_log.replace_many( 191 | [{'operator_id': session.event.user_id, 'group_id': session.event.group_id, 192 | 'target_question': x['question'], 'target_answer': x['answer'], 193 | 'is_all': x['is_all'], 'action': 'delete'} for x in sum(question, [])] 194 | ).execute() 195 | return 196 | else: 197 | during_interactive = True 198 | option = await session.aget(prompt='\n您想删除的问题有多个回答,请选择:\n1.展开回答列表,删除个别回答\n2.全部删除', 199 | arg_filters=[extractors.extract_text, str.strip, 200 | controllers.handle_cancellation(session), 201 | ensure_true( 202 | lambda _option: True if 203 | int(_option) in [1, 2] else False, 204 | message='选项错误,请重试')], at_sender=True) 205 | during_interactive = False 206 | if int(option) == 1: 207 | await pagination_display(session, "请从其中选择要删除的回答", question, 'confirm') 208 | elif int(option) == 2: 209 | try: 210 | if not check_priv(session.event, ADMIN): 211 | Question.delete().where(Question.question == args, Question.is_all == False).execute() 212 | else: 213 | Question.delete().where(Question.question == args).execute() 214 | except Exception as e: 215 | traceback.print_exc() 216 | session.finish(f"删除回答时出现错误{type(e)}", at_sender=True) 217 | await session.send(f"我不再作出这个回答啦~", at_sender=True) 218 | question_log.replace_many( 219 | [{'operator_id': session.event.user_id, 'group_id': session.event.group_id, 220 | 'target_question': x['question'], 'target_answer': x['answer'], 221 | 'is_all': x['is_all'], 'action': 'delete'} for x in sum(question, [])] 222 | ).execute() 223 | return 224 | else: 225 | return 226 | 227 | 228 | @sv.on_command('全体问答', aliases=('看看有人问', '看看大家问', '我的问答', '看看我问', '全部问答', '所有问答')) 229 | async def _view_questions(session: CommandSession): 230 | try: 231 | if session.event.raw_message.strip() in ('我的问答', '看看我问'): 232 | question = await separate_questions(Question.select().where( 233 | Question.group_id == session.event.group_id, 234 | Question.creator_id == session.event.user_id, 235 | Question.is_all == False), separate=question_per_page) 236 | title = '你的个人问答:' 237 | else: 238 | if not check_priv(session.event, ADMIN): 239 | await session.send("只有管理员及以上才能查看全体的问答哦~", at_sender=True) 240 | return 241 | question = await separate_questions(Question.select().where( 242 | Question.group_id == session.event.group_id, 243 | Question.is_all == True), separate=question_per_page) 244 | title = '本群的全体问答:' 245 | except Exception as e: 246 | session.finish(f"查询问答时出错{e}", at_sender=True) 247 | if not question: 248 | session.finish(f"{title}\n空") 249 | await pagination_display(session, title, question) 250 | -------------------------------------------------------------------------------- /database.py: -------------------------------------------------------------------------------- 1 | import time 2 | import traceback 3 | 4 | from peewee import * 5 | 6 | import hoshino 7 | 8 | try: 9 | from hoshino.config.__bot__ import (MySQL_host, MySQL_password, MySQL_port, 10 | MySQL_username) 11 | from hoshino.config.qa import MySQL_database, enable_index 12 | except ImportError: 13 | from hoshino.config.qa import (MySQL_host, MySQL_password, MySQL_port, 14 | MySQL_username, MySQL_database, enable_index) 15 | 16 | database = MySQLDatabase( 17 | host=MySQL_host, 18 | port=MySQL_port, 19 | user=MySQL_username, 20 | password=MySQL_password, 21 | database=MySQL_database, 22 | charset='utf8mb4', 23 | autocommit=True 24 | ) 25 | 26 | 27 | class Question(Model): 28 | """ 29 | contains: 30 | answer 31 | create_time 32 | creator_id 33 | group_id 34 | id 35 | is_all 36 | question 37 | """ 38 | id = IntegerField(unique=True) 39 | question = CharField(max_length=100) 40 | answer = CharField(max_length=665) 41 | is_all = BooleanField(default=False) 42 | creator_id = BigIntegerField() 43 | group_id = BigIntegerField() 44 | create_time = TimestampField(default=time.time()) 45 | 46 | class Meta: 47 | table_name = 'question' 48 | primary_key = CompositeKey('answer', 'group_id', 'is_all', 'question') 49 | database = database 50 | table_settings = ['ENGINE=InnoDB', 'DEFAULT CHARSET=utf8mb4', 'ROW_FORMAT=DYNAMIC'] 51 | 52 | 53 | if enable_index: 54 | Question.add_index(Question.index(Question.question, Question.group_id, Question.creator_id, Question.is_all, 55 | Question.create_time)) 56 | else: 57 | pass 58 | 59 | 60 | class question_log(Model): 61 | """ 62 | action: trigger/delete/add 63 | """ 64 | operator_id = BigIntegerField() 65 | group_id = BigIntegerField() 66 | target_question = CharField(max_length=100) 67 | target_answer = CharField(max_length=665) 68 | is_all = BooleanField(default=False) 69 | action = CharField() 70 | time_created = TimestampField(default=time.time()) 71 | 72 | class Meta: 73 | database = database 74 | table_settings = ['ENGINE=InnoDB', 'DEFAULT CHARSET=utf8mb4', 'ROW_FORMAT=DYNAMIC'] 75 | 76 | 77 | try: 78 | database.connect() 79 | if not Question.table_exists(): 80 | database.create_tables([Question]) 81 | database.execute_sql(r'ALTER TABLE `question` MODIFY COLUMN `id` int(11) NOT NULL AUTO_INCREMENT FIRST;') 82 | if not question_log.table_exists(): 83 | database.create_tables([question_log]) 84 | database.close() 85 | except Exception as e: 86 | traceback.print_exc() 87 | hoshino.logger.error(f"初始化时出错{e}") 88 | -------------------------------------------------------------------------------- /image_not_found.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pcrbot/QA/a814fc4b69f6e161968b38e0e2ccca26c99142bd/image_not_found.jpg -------------------------------------------------------------------------------- /migration.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | import time 5 | import traceback 6 | 7 | import sqlitedict 8 | import yaml 9 | from peewee import * 10 | 11 | if __name__ == '__main__': 12 | print("==========eqa数据库迁移脚本==========") 13 | print("开始之前,请将本脚本置于eqa目录下,与eqa的配置文件config.yaml同级") 14 | if input('准备好之后请输入\"ready\"继续:') != 'ready': 15 | sys.exit() 16 | try: 17 | print('正在读取eqa配置文件...') 18 | with open(os.path.join(os.path.dirname(__file__), "../eqa/config.yaml"), 'r', encoding="utf-8") as f: 19 | config = yaml.load(f.read(), Loader=yaml.FullLoader) 20 | except FileNotFoundError: 21 | print('无法读取eqa配置文件,请确认目录正确后重试') 22 | time.sleep(3) 23 | sys.exit() 24 | data_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), config['cache_dir'])) 25 | print("正在装载eqa数据库...") 26 | try: 27 | question_dict = dict(sqlitedict.SqliteDict(os.path.join(data_dir, 'db.sqlite'), encode=json.dumps, 28 | decode=json.loads, autocommit=True)) 29 | except: 30 | traceback.print_exc() 31 | print(f"装载eqa数据库失败,请确认数据库存在后重试(应位于{os.path.join(data_dir, 'db.sqlite')})") 32 | time.sleep(3) 33 | sys.exit() 34 | print("询问MySQL数据库信息...") 35 | MySQL_host = input('请输入MySQL主机名:') 36 | MySQL_port = input('请输入端口号:') 37 | while not MySQL_port.isdigit(): 38 | MySQL_port = input('请输入端口号:') 39 | MySQL_port = int(MySQL_port) 40 | MySQL_username = input('请输入用户名:') 41 | MySQL_password = input('请输入密码:') 42 | MySQL_database = input('请输入数据库名:') 43 | 44 | database = MySQLDatabase( 45 | host=MySQL_host, 46 | port=MySQL_port, 47 | user=MySQL_username, 48 | password=MySQL_password, 49 | database=MySQL_database, 50 | charset='utf8mb4', 51 | autocommit=True 52 | ) 53 | 54 | 55 | class Question(Model): 56 | id = IntegerField(unique=True) 57 | question = CharField(max_length=100) 58 | answer = CharField(max_length=665) 59 | is_all = BooleanField(default=False) 60 | creator_id = BigIntegerField() 61 | group_id = BigIntegerField() 62 | create_time = TimestampField(default=time.time()) 63 | 64 | class Meta: 65 | table_name = 'question' 66 | primary_key = CompositeKey('answer', 'group_id', 'is_all', 'question') 67 | database = database 68 | table_settings = ['ENGINE=InnoDB', 'DEFAULT CHARSET=utf8mb4', 'ROW_FORMAT=DYNAMIC'] 69 | 70 | 71 | class question_log(Model): 72 | operator_id = BigIntegerField() 73 | group_id = BigIntegerField() 74 | target_question = CharField(max_length=100) 75 | target_answer = CharField(max_length=665) 76 | is_all = BooleanField(default=False) 77 | action = CharField() 78 | time_created = TimestampField(default=time.time()) 79 | 80 | class Meta: 81 | database = database 82 | table_settings = ['ENGINE=InnoDB', 'DEFAULT CHARSET=utf8mb4', 'ROW_FORMAT=DYNAMIC'] 83 | 84 | 85 | try: 86 | print("初始化MySQL数据库中...") 87 | database.connect() 88 | if not Question.table_exists(): 89 | database.create_tables([Question]) 90 | database.execute_sql(r'ALTER TABLE `question` MODIFY COLUMN `id` int(11) NOT NULL AUTO_INCREMENT FIRST;') 91 | if not question_log.table_exists(): 92 | database.create_tables([question_log]) 93 | database.close() 94 | except Exception as e: 95 | traceback.print_exc() 96 | print(f"初始化MySQL数据库时出错{e}") 97 | time.sleep(5) 98 | sys.exit() 99 | print("正在执行迁移...") 100 | print("整理数据中...") 101 | new_question_list = [] 102 | for question, data in question_dict.items(): 103 | for answer in data: 104 | data_dict = {'question': answer['qus'], 'is_all': True if not answer['is_me'] else False, 105 | 'creator_id': answer["user_id"], 'group_id': answer["group_id"], 'answer': ''} 106 | for a in answer['message']: 107 | if a['type'] == 'text': 108 | data_dict['answer'] += a['data']['text'] 109 | elif a['type'] == 'image': 110 | data_dict['answer'] += '[CQ:image,file=rfile:///' + a['data']['file'].split('\\')[-1] + ']' 111 | else: 112 | continue 113 | data_dict['answer'] = data_dict['answer'].strip() 114 | new_question_list.append(data_dict) 115 | print(f"数据整理完成,共{len(new_question_list)}条") 116 | print("正在更新数据库...") 117 | try: 118 | Question.replace_many(new_question_list).execute() 119 | print("正在清理数据库...") 120 | query = Question.delete().where( 121 | (Question.question == Question.answer) | (Question.question == '') | (Question.answer == '')).execute() 122 | print('数据库清理完毕,共清理' + str(query) + '条无效数据(答案或者问题为空/答案与问题相同)') 123 | except: 124 | traceback.print_exc() 125 | print("更新数据库时出错") 126 | print("数据库更新完成") 127 | print(f"最后一步:请将{os.path.join(data_dir, 'img')}文件夹中的文件手动复制到res/img/questions中") 128 | time.sleep(60) 129 | -------------------------------------------------------------------------------- /qa.py: -------------------------------------------------------------------------------- 1 | # 数据库设置 2 | 3 | MySQL_host = "" 4 | MySQL_port = 3306 5 | MySQL_username = '' 6 | MySQL_password = '' 7 | MySQL_database = 'datatest' 8 | enable_index = True 9 | # 启用索引(查询更快速,但插入&删除&更新时更慢,详见README) 10 | # 请注意:仅在初次配置(数据库未初始化)时有效.一经初始化数据库,不能修改 11 | 12 | # 基本功能设置 13 | 14 | to_all_question_override = True # 全体问答是否覆盖个人问答 15 | multiple_answer_return_random = True # 多个答案是否返回随机回答(否则返回最新的回答) 16 | question_per_page = 10 # 每页显示的问题数 17 | answer_delay = 0.1 # 触发回答时发送的延迟,用于限频(秒) 18 | wrong_command_remind = True # 触发旧指令(eqa的指令)时给予提示(设置旧指令请修改__init__.py:23) 19 | record_trigger_log = True # 记录触发日志(创建&删除日志会始终记录) 20 | image_save_method = 'link' 21 | # 图片存储方式 22 | # abs_path:直接存储绝对路径(/home/user/...) 23 | # rel_path:存储相对路径(res/img/...) 24 | # link:存储服务器上的图片链接(对于eqa的不在服务器上的本地图片,会以rel_path做兼容) 25 | image_send_method = 'base64' # 本地图片发送方式 base64 / file 26 | 27 | # 文本/图片鉴定功能设置 28 | # 目前还是个饼,计划接入阿里云,然后鉴定必须上传到阿里云的服务器,因此肯定会拖延添加的进程 29 | 30 | assert image_save_method in ('abs_path', 'rel_path', 'link'), \ 31 | "image_save_method must be one of 'abs_path', 'rel_path', 'link'" 32 | assert image_send_method in ('file', 'base64'), \ 33 | "image_send_method must be one of 'file', 'base64'" 34 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | httpx==0.16.1 2 | peewee==3.13.3 3 | nonebot==1.8.2 4 | Pillow==8.2.0 5 | pymysql 6 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import imghdr 3 | import os 4 | import random 5 | import re 6 | import traceback 7 | from io import BytesIO 8 | from typing import List, Optional, Tuple 9 | 10 | import httpx 11 | import peewee 12 | from PIL import Image 13 | 14 | import hoshino 15 | from hoshino import R 16 | from hoshino.config.qa import * 17 | 18 | image_regex = re.compile(r'\[CQ:image,file=([a-z0-9]+)\.image]') 19 | handle_image_regex = re.compile(r'\[CQ:image,file=([^,]*)]') 20 | type_regex = re.compile(r'^(有人问|大家问|我问)') 21 | wrong_command_regex = re.compile(r'^不要回答[^\s]') 22 | validator_regex = re.compile(r'^(删除问答|删除回答|不要回答)') 23 | resource_dir = R.get('img/questions').path 24 | 25 | if not os.path.exists(resource_dir): 26 | os.makedirs(resource_dir) 27 | 28 | 29 | async def _get_answer(answer_list: list) -> Optional[str]: 30 | if not answer_list: 31 | return None 32 | if not multiple_answer_return_random: 33 | return await handle_image(answer_list[0], 'show') 34 | return await handle_image(random.choice(answer_list), 'show') 35 | 36 | 37 | async def get_answer(query: peewee.Query) -> Tuple[Optional[str], bool]: 38 | """ 39 | :param query:peewee query 40 | :return 41 | 2-element tuple(answer,is_all) 42 | """ 43 | to_all_satisfied_answer = [x.answer for x in query if x.is_all] 44 | to_me_satisfied_answer = [x.answer for x in query if not x.is_all] 45 | if to_all_question_override: 46 | if answer := await _get_answer(to_all_satisfied_answer): 47 | return answer, True 48 | else: 49 | return await _get_answer(to_me_satisfied_answer), False 50 | else: 51 | if answer := await _get_answer(to_me_satisfied_answer): 52 | return answer, False 53 | else: 54 | return await _get_answer(to_all_satisfied_answer), True 55 | 56 | 57 | async def separate_questions(query: peewee.Query, separate: int = 10) -> List[List[dict]]: 58 | """ 59 | :param query: peewee query 60 | :param separate:每隔separate条分割为一个list,默认10条 61 | ... 62 | :return: 63 | [[{...},{...}],[{...},...],...] 64 | """ 65 | query = list(query.dicts()) 66 | for x in query: 67 | x['question'] = await handle_image(x['question'], 'show') 68 | x['answer'] = await handle_image(x['answer'], 'show') 69 | return [query[i:i + separate] for i in range(0, len(query), separate)] 70 | 71 | 72 | def text_validator(option: str, current_pages: int, max_pages: int) -> bool: 73 | if validator_regex.search(option): 74 | return True 75 | elif option == '下一页': 76 | return current_pages + 1 <= max_pages 77 | else: 78 | return False 79 | 80 | 81 | async def handle_image(message: str, _type: str) -> str: 82 | """ 83 | image may like: 84 | [CQ:image,file=https://xxxx.com/xxxx](link) 85 | [CQ:image,file=file:///C:/home/user/res/img/xxx.jpg](abs_path) 86 | [CQ:image,file=rfile:///res/img/xxx.jpg](rel_path) 87 | :param message 88 | :param _type: save/show 89 | :return: 90 | processed message(based on config) 91 | """ 92 | if _type == 'show': 93 | return handle_image_regex.sub(lambda match: '[CQ:image,file=' + get_image(match.group(1)) + ']', message) 94 | elif _type == 'save': 95 | if image_save_method in ['abs_path', 'rel_path']: 96 | for image in image_regex.finditer(message): 97 | filename = image.group(1) 98 | if os.path.isfile(filename): 99 | hoshino.logger.info(f"{filename} exists, skip downloading...") 100 | continue 101 | try: 102 | async with httpx.AsyncClient() as client: 103 | resp = await client.get( 104 | 'http://gchat.qpic.cn/gchatpic_new/0/0-0-' + filename.upper() + '/0?term=2', timeout=10) 105 | if resp.status_code != 200: 106 | hoshino.logger.error( 107 | f'error occurred when downloading image {filename}:http {resp.status_code}') 108 | continue 109 | img = Image.open(BytesIO(resp.content)) 110 | if img.mode != "RGB": 111 | img = img.convert('RGB') 112 | file_extension_name = imghdr.what(None, resp.content) 113 | img.save(os.path.join(resource_dir, f'{filename}'), file_extension_name) 114 | hoshino.logger.info(f"image saved to {resource_dir}(type:{file_extension_name})") 115 | except Exception as e: 116 | traceback.print_exc() 117 | hoshino.logger.error(f'error occurred when downloading image {filename}:{e}') 118 | continue 119 | if image_save_method == 'abs_path': 120 | return image_regex.sub(lambda match: 121 | r'[CQ:image,file=file:///{}]'.format( 122 | os.path.join(resource_dir, f'{filename}')), message).strip() 123 | else: 124 | return image_regex.sub(lambda match: 125 | r'[CQ:image,file=rfile:///{}]'.format(f'{filename}'), message).strip() 126 | elif image_save_method == 'link': 127 | return image_regex.sub(lambda match: 128 | '[CQ:image,file=http://gchat.qpic.cn/gchatpic_new/0/0-0-' + match.group( 129 | 1).upper() + '/0?term=2]', message).strip() 130 | else: 131 | return message.strip() 132 | else: 133 | return message.strip() 134 | 135 | 136 | def get_image(image_uri: str) -> str: 137 | """ 138 | :param image_uri:uri of image(with protocol prefix) 139 | :return: 140 | path to image(with prefix 'file:///') 141 | or 142 | base64 string of image(with prefix 'base64://') 143 | (based on config) 144 | """ 145 | if image_uri.startswith('rfile:///'): 146 | image_path = os.path.join(resource_dir, image_uri.replace('rfile:///', '')) 147 | return _get_image(image_path) 148 | elif image_uri.startswith('file:///'): 149 | return _get_image(image_uri.replace('file:///', '')) 150 | elif image_uri.endswith('.image'): 151 | return f'http://gchat.qpic.cn/gchatpic_new/0/0-0-{image_uri.replace(".image", "").upper()}/0?term=2' 152 | else: 153 | return image_uri 154 | 155 | 156 | def _get_image(image_uri: str) -> str: 157 | if image_send_method == 'base64': 158 | try: 159 | with open(image_uri, 'rb') as f: 160 | return 'base64://' + base64.b64encode(f.read()).decode('utf-8') 161 | except FileNotFoundError: 162 | hoshino.logger.error(f"image not found:{image_uri}") 163 | with open(os.path.join(os.path.dirname(__file__), 'image_not_found.jpg'), 'rb') as f: 164 | return 'base64://' + base64.b64encode(f.read()).decode('utf-8') 165 | elif image_send_method == 'file': 166 | return 'file:///' + image_uri 167 | --------------------------------------------------------------------------------