diff --git a/.env.example b/.env.example index 829dcd7..712fb80 100644 --- a/.env.example +++ b/.env.example @@ -23,4 +23,7 @@ ERROR_NOTIFICATION_CHAT_ID=chat_id CHANNELS=[{ "name": "", "chat_id": 1}] # bot 管理员 -ADMINS=[{ "username": "", "user_id": 1 }] +ADMINS=[{ "username": "", "user_id": -1 }] + +# 群验证功能 +JOINING_VERIFICATION_GROUPS=[] diff --git a/config.py b/config.py index 6d87851..e8323c3 100644 --- a/config.py +++ b/config.py @@ -11,6 +11,7 @@ load_dotenv() env = os.getenv + def str_to_bool(value: Any) -> bool: """Return whether the provided string (or any value really) represents true. Otherwise false. Just like plugin server stringToBoolean. @@ -19,8 +20,9 @@ def str_to_bool(value: Any) -> bool: return False return str(value).lower() in ("y", "yes", "t", "true", "on", "1") + _config = { - "debug": str_to_bool(os.getenv('DEBUG', 'True')), + "debug": str_to_bool(os.getenv('DEBUG', 'False')), "mysql": { "host": env("DB_HOST", "127.0.0.1"), @@ -55,6 +57,8 @@ _config = { # 在环境变量里的格式是 json: [{"username": "", "user_id": 1}] *ujson.loads(env('ADMINS', '[]')) ], + + "joining_verification_groups": env('JOINING_VERIFICATION_GROUPS', '[]'), } config = Storage(_config) diff --git a/core/quiz/__init__.py b/core/quiz/__init__.py index e69de29..e8725e7 100644 --- a/core/quiz/__init__.py +++ b/core/quiz/__init__.py @@ -0,0 +1,14 @@ +from utils.mysql import MySQL +from utils.redisdb import RedisDB +from utils.service.manager import listener_service +from .cache import QuizCache +from .repositories import QuizRepository +from .services import QuizService + + +@listener_service() +def create_quiz_service(mysql: MySQL, redis: RedisDB): + _repository = QuizRepository(mysql) + _cache = QuizCache(redis) + _service = QuizService(_repository, _cache) + return _service diff --git a/core/quiz/cache.py b/core/quiz/cache.py index df35f08..ff17175 100644 --- a/core/quiz/cache.py +++ b/core/quiz/cache.py @@ -1,9 +1,9 @@ from typing import List import ujson -from app.quiz.models import Question, Answer from utils.redisdb import RedisDB +from .models import Question, Answer class QuizCache: @@ -30,15 +30,18 @@ class QuizCache: async def get_one_question(self, question_id: int) -> Question: qname = f"{self.question_qname}:{question_id}" data = await self.client.get(qname) - return Question.de_json(ujson.loads(data)) + json_data = str(data, encoding="utf-8") + return Question.de_json(ujson.loads(json_data)) - async def get_one_answer(self, answer_id: int) -> str: + async def get_one_answer(self, answer_id: int) -> Answer: qname = f"{self.answer_qname}:{answer_id}" - return await self.client.get(qname) + data = await self.client.get(qname) + json_data = str(data, encoding="utf-8") + return Answer.de_json(ujson.loads(json_data)) async def add_question(self, question_list: List[Question] = None): for question in question_list: - await self.client.set(f"{self.question_qname}:{question.question_id}", str(question)) + await self.client.set(f"{self.question_qname}:{question.question_id}", ujson.dumps(question.to_dict())) question_id_list = [question.question_id for question in question_list] await self.client.lpush(f"{self.question_qname}:id_list", *question_id_list) return await self.client.llen(f"{self.question_qname}:id_list") @@ -57,7 +60,7 @@ class QuizCache: async def add_answer(self, answer_list: List[Answer] = None): for answer in answer_list: - await self.client.set(f"{self.answer_qname}:{answer.answer_id}", str(answer)) + await self.client.set(f"{self.answer_qname}:{answer.answer_id}", ujson.dumps(answer.to_dict())) answer_id_list = [answer.answer_id for answer in answer_list] await self.client.lpush(f"{self.answer_qname}:id_list", *answer_id_list) return await self.client.llen(f"{self.answer_qname}:id_list") diff --git a/core/quiz/models.py b/core/quiz/models.py index 49ef324..0485847 100644 --- a/core/quiz/models.py +++ b/core/quiz/models.py @@ -78,7 +78,7 @@ class Question(BaseObject): def to_dict(self) -> JSONDict: data = super().to_dict() if self.answers: - data["sub_item"] = [e.to_dict() for e in self.answers] + data["answers"] = [e.to_dict() for e in self.answers] return data @classmethod @@ -86,7 +86,7 @@ class Question(BaseObject): data = cls._parse_data(data) if not data: return None - data["sub_item"] = Answer.de_list(data.get("sub_item")) + data["answers"] = Answer.de_list(data.get("answers")) return cls(**data) __slots__ = ("question_id", "text", "answers") diff --git a/core/quiz/repositories.py b/core/quiz/repositories.py index 0f457df..8c4ab71 100644 --- a/core/quiz/repositories.py +++ b/core/quiz/repositories.py @@ -15,14 +15,14 @@ class QuizRepository: query = select(QuestionDB) results = await session.exec(query) questions = results.all() - return [question[0] for question in questions] + return questions async def get_answers_form_question_id(self, question_id: int) -> List[AnswerDB]: async with self.mysql.Session() as session: query = select(AnswerDB).where(AnswerDB.question_id == question_id) results = await session.exec(query) answers = results.all() - return [answer[0] for answer in answers] + return answers async def add_question(self, question: QuestionDB): async with self.mysql.Session() as session: diff --git a/core/quiz/services.py b/core/quiz/services.py index 0d213b5..598dc74 100644 --- a/core/quiz/services.py +++ b/core/quiz/services.py @@ -1,7 +1,6 @@ +import asyncio from typing import List -import ujson - from .cache import QuizCache from .models import Question, Answer from .repositories import QuizRepository @@ -11,6 +10,7 @@ class QuizService: def __init__(self, repository: QuizRepository, cache: QuizCache): self._repository = repository self._cache = cache + self.lock = asyncio.Lock() async def get_quiz_form_database(self) -> List[Question]: """从数据库获取问题列表 @@ -21,7 +21,6 @@ class QuizService: for question in question_list: question_id = question.id answers = await self._repository.get_answers_form_question_id(question_id) - question.answers = answers data = Question.de_database_data(question) data.answers = [Answer.de_database_data(a) for a in answers] temp.append(data) @@ -33,20 +32,30 @@ class QuizService: await self._repository.add_answer(answers.to_database_data()) async def refresh_quiz(self) -> int: - question_list = await self.get_quiz_form_database() - await self._cache.del_all_question() - question_count = await self._cache.add_question(question_list) - await self._cache.del_all_answer() - for question in question_list: - await self._cache.add_answer(question.answers) - return question_count + """从数据库刷新问题到Redis缓存 线程安全 + :return: 已经缓存问题的数量 + """ + # 只允许一个线程访问该区域 让数据被安全有效的访问 + async with self.lock: + question_list = await self.get_quiz_form_database() + await self._cache.del_all_question() + question_count = await self._cache.add_question(question_list) + await self._cache.del_all_answer() + for question in question_list: + await self._cache.add_answer(question.answers) + return question_count async def get_question_id_list(self) -> List[int]: return [int(question_id) for question_id in await self._cache.get_all_question_id_list()] - async def get_answer(self, answer_id: int): - data = await self._cache.get_one_answer(answer_id) - return ujson.loads(data) + async def get_answer(self, answer_id: int) -> Answer: + return await self._cache.get_one_answer(answer_id) async def get_question(self, question_id: int) -> Question: return await self._cache.get_one_question(question_id) + + async def delete_question_by_id(self, question_id: int): + return await self._repository.delete_question_by_id(question_id) + + async def delete_answer_by_id(self, answer_id: int): + return await self._repository.delete_answer_by_id(answer_id) diff --git a/plugins/base.py b/plugins/base.py index c86f24d..6a79c64 100644 --- a/plugins/base.py +++ b/plugins/base.py @@ -1,4 +1,6 @@ +import asyncio import datetime +from typing import List, Tuple, Callable from telegram import Update, ReplyKeyboardRemove from telegram.error import BadRequest @@ -55,6 +57,12 @@ class NewChatMembersHandler: @inject def __init__(self, bot_admin_service: BotAdminService): self.bot_admin_service = bot_admin_service + self.callback: List[Tuple[Callable, int]] = [] + + def add_callback(self, callback, chat_id: int): + if chat_id >= 0: + raise ValueError + self.callback.append((callback, chat_id)) async def new_member(self, update: Update, context: CallbackContext) -> None: message = update.message @@ -79,4 +87,11 @@ class NewChatMembersHandler: Log.warning("不是管理员邀请!退出群聊。") await context.bot.send_message(message.chat_id, "派蒙不想进去!不是旅行者的邀请!") await context.bot.leave_chat(chat.id) - + else: + tasks = [] + for callback, chat_id in self.callback: + if chat.id == chat_id: + task = asyncio.create_task(callback(update, context)) + tasks.append(task) + if len(tasks) >= 1: + await asyncio.gather(*tasks) diff --git a/plugins/genshin/quiz.py b/plugins/genshin/quiz.py new file mode 100644 index 0000000..3f871cc --- /dev/null +++ b/plugins/genshin/quiz.py @@ -0,0 +1,291 @@ +import random +import re +from typing import List, Optional + +from redis import DataError, ResponseError +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardMarkup, Poll, \ + ReplyKeyboardRemove, Message +from telegram.constants import ChatAction +from telegram.ext import CallbackContext, ConversationHandler, CommandHandler, MessageHandler, filters +from telegram.helpers import escape_markdown + +from core.admin import BotAdminService +from core.quiz import QuizService +from core.quiz.models import Answer, Question +from logger import Log +from plugins.base import BasePlugins +from utils.decorators.restricts import restricts +from utils.plugins.manager import listener_plugins_class +from utils.random import MT19937_Random + + +class QuizCommandData: + question_id: int = -1 + new_question: str = "" + new_correct_answer: str = "" + new_wrong_answer: List[str] = [] + status: int = 0 + + +@listener_plugins_class() +class QuizPlugin(BasePlugins): + """派蒙的十万个为什么 + 合并了问题修改/添加/删除 + """ + + CHECK_COMMAND, VIEW_COMMAND, CHECK_QUESTION, \ + GET_NEW_QUESTION, GET_NEW_CORRECT_ANSWER, GET_NEW_WRONG_ANSWER, \ + QUESTION_EDIT, SAVE_QUESTION = range(10300, 10308) + + def __init__(self, quiz_service: QuizService = None, bot_admin_service: BotAdminService = None): + self.bot_admin_service = bot_admin_service + self.user_time = {} + self.quiz_service = quiz_service + self.time_out = 120 + self.random = MT19937_Random() + + @classmethod + def create_handlers(cls): + quiz = cls() + quiz_handler = ConversationHandler( + entry_points=[CommandHandler('quiz', quiz.command_start, block=True)], + states={ + quiz.CHECK_COMMAND: [MessageHandler(filters.TEXT & ~filters.COMMAND, + quiz.check_command, block=True)], + quiz.CHECK_QUESTION: [MessageHandler(filters.TEXT & ~filters.COMMAND, + quiz.check_question, block=True)], + quiz.GET_NEW_QUESTION: [MessageHandler(filters.TEXT & ~filters.COMMAND, + quiz.get_new_question, block=True)], + quiz.GET_NEW_CORRECT_ANSWER: [MessageHandler(filters.TEXT & ~filters.COMMAND, + quiz.get_new_correct_answer, block=True)], + quiz.GET_NEW_WRONG_ANSWER: [MessageHandler(filters.TEXT & ~filters.COMMAND, + quiz.get_new_wrong_answer, block=True), + CommandHandler("finish", quiz.finish_edit)], + quiz.SAVE_QUESTION: [MessageHandler(filters.TEXT & ~filters.COMMAND, + quiz.save_question, block=True)], + }, + fallbacks=[CommandHandler('cancel', quiz.cancel, block=True)] + ) + return [quiz_handler] + + async def send_poll(self, update: Update) -> Optional[Message]: + chat = update.message.chat + user = update.effective_user + question_id_list = await self.quiz_service.get_question_id_list() + if filters.ChatType.GROUPS.filter(update.message): + Log.info(f"用户 {user.full_name}[{user.id}] 在群 {chat.title}[{chat.id}] 发送挑战问题命令请求") + if len(question_id_list) == 0: + return None + if len(question_id_list) == 0: + return None + index = self.random.random(0, len(question_id_list)) + question = await self.quiz_service.get_question(question_id_list[index]) + _options = [] + correct_option = None + for answer in question.answers: + _options.append(answer.text) + if answer.is_correct: + correct_option = answer.text + if correct_option is None: + question_id = question["question_id"] + Log.warning(f"Quiz模块 correct_option 异常 question_id[{question_id}] ") + return None + random.shuffle(_options) + index = _options.index(correct_option) + return await update.effective_message.reply_poll(question.text, _options, + correct_option_id=index, is_anonymous=False, + open_period=self.time_out, type=Poll.QUIZ) + + @restricts(filters.ChatType.GROUPS, ConversationHandler.END, restricts_time=20, try_delete_message=True) + @restricts(filters.ChatType.PRIVATE, ConversationHandler.END) + async def command_start(self, update: Update, context: CallbackContext) -> int: + user = update.effective_user + message = update.message + if filters.ChatType.PRIVATE.filter(message): + Log.info(f"用户 {user.full_name}[{user.id}] quiz命令请求") + admin_list = await self.bot_admin_service.get_admin_list() + if user.id in admin_list: + quiz_command_data: QuizCommandData = context.chat_data.get("quiz_command_data") + if quiz_command_data is None: + quiz_command_data = QuizCommandData() + context.chat_data["quiz_command_data"] = quiz_command_data + text = f'你好 {user.mention_markdown_v2()} {escape_markdown("!请选择你的操作!")}' + reply_keyboard = [ + ["查看问题", "添加问题"], + ["重载问题"], + ["退出"] + ] + await message.reply_markdown_v2(text, reply_markup=ReplyKeyboardMarkup(reply_keyboard, + one_time_keyboard=True)) + return self.CHECK_COMMAND + else: + await self.send_poll(update) + elif filters.ChatType.GROUPS.filter(update.message): + await update.message.reply_chat_action(ChatAction.TYPING) + poll_message = await self.send_poll(update) + if poll_message is None: + return ConversationHandler.END + self._add_delete_message_job(context, update.message.chat_id, update.message.message_id, 300) + self._add_delete_message_job(context, poll_message.chat_id, poll_message.message_id, 300) + return ConversationHandler.END + + async def view_command(self, update: Update, _: CallbackContext) -> int: + keyboard = [ + [ + InlineKeyboardButton(text="选择问题", switch_inline_query_current_chat="查看问题 ") + ] + ] + await update.message.reply_text("请回复你要查看的问题", + reply_markup=InlineKeyboardMarkup(keyboard)) + return self.CHECK_COMMAND + + async def check_question(self, update: Update, _: CallbackContext) -> int: + reply_keyboard = [ + ["删除问题"], + ["退出"] + ] + await update.message.reply_text("请选择你的操作", reply_markup=ReplyKeyboardMarkup(reply_keyboard)) + return self.CHECK_COMMAND + + async def check_command(self, update: Update, context: CallbackContext) -> int: + quiz_command_data: QuizCommandData = context.chat_data.get("quiz_command_data") + if update.message.text == "退出": + await update.message.reply_text("退出任务", reply_markup=ReplyKeyboardRemove()) + return ConversationHandler.END + elif update.message.text == "查看问题": + return await self.view_command(update, context) + elif update.message.text == "添加问题": + return await self.add_question(update, context) + elif update.message.text == "删除问题": + return await self.delete_question(update, context) + # elif update.message.text == "修改问题": + # return await self.edit_question(update, context) + elif update.message.text == "重载问题": + return await self.refresh_question(update, context) + else: + result = re.findall(r"问题ID (\d+)", update.message.text) + if len(result) == 1: + try: + question_id = int(result[0]) + except ValueError: + await update.message.reply_text("获取问题ID失败") + return ConversationHandler.END + quiz_command_data.question_id = question_id + await update.message.reply_text("获取问题ID成功") + return await self.check_question(update, context) + await update.message.reply_text("命令错误", reply_markup=ReplyKeyboardRemove()) + return ConversationHandler.END + + async def refresh_question(self, update: Update, _: CallbackContext) -> int: + try: + await self.quiz_service.refresh_quiz() + except DataError: + await update.message.reply_text("Redis数据错误,重载失败", reply_markup=ReplyKeyboardRemove()) + return ConversationHandler.END + except ResponseError as error: + Log.error("重载问题失败", error) + await update.message.reply_text("重载问题失败,异常抛出Redis请求错误异常,详情错误请看日记", + reply_markup=ReplyKeyboardRemove()) + return ConversationHandler.END + await update.message.reply_text("重载成功", reply_markup=ReplyKeyboardRemove()) + return ConversationHandler.END + + async def add_question(self, update: Update, context: CallbackContext) -> int: + quiz_command_data: QuizCommandData = context.chat_data.get("quiz_command_data") + quiz_command_data.new_wrong_answer = [] + quiz_command_data.new_question = "" + quiz_command_data.new_correct_answer = "" + quiz_command_data.status = 1 + await update.message.reply_text("请回复你要添加的问题,或发送 /cancel 取消操作", reply_markup=ReplyKeyboardRemove()) + return self.GET_NEW_QUESTION + + async def get_new_question(self, update: Update, context: CallbackContext) -> int: + quiz_command_data: QuizCommandData = context.chat_data.get("quiz_command_data") + reply_text = f"问题:`{escape_markdown(update.message.text, version=2)}`\n" \ + f"请填写正确答案:" + quiz_command_data.new_question = update.message.text + await update.message.reply_markdown_v2(reply_text) + return self.GET_NEW_CORRECT_ANSWER + + async def get_new_correct_answer(self, update: Update, context: CallbackContext) -> int: + quiz_command_data: QuizCommandData = context.chat_data.get("quiz_command_data") + reply_text = f"正确答案:`{escape_markdown(update.message.text, version=2)}`\n" \ + f"请填写错误答案:" + await update.message.reply_markdown_v2(reply_text) + quiz_command_data.new_correct_answer = update.message.text + return self.GET_NEW_WRONG_ANSWER + + async def get_new_wrong_answer(self, update: Update, context: CallbackContext) -> int: + quiz_command_data: QuizCommandData = context.chat_data.get("quiz_command_data") + reply_text = f"错误答案:`{escape_markdown(update.message.text, version=2)}`\n" \ + f"可继续填写,并使用 {escape_markdown('/finish', version=2)} 结束。" + await update.message.reply_markdown_v2(reply_text) + quiz_command_data.new_wrong_answer.append(update.message.text) + return self.GET_NEW_WRONG_ANSWER + + async def finish_edit(self, update: Update, context: CallbackContext): + quiz_command_data: QuizCommandData = context.chat_data.get("quiz_command_data") + reply_text = f"问题:`{escape_markdown(quiz_command_data.new_question, version=2)}`\n" \ + f"正确答案:`{escape_markdown(quiz_command_data.new_correct_answer, version=2)}`\n" \ + f"错误答案:`{escape_markdown(' '.join(quiz_command_data.new_wrong_answer), version=2)}`" + await update.message.reply_markdown_v2(reply_text) + reply_keyboard = [["保存并重载配置", "抛弃修改并退出"]] + await update.message.reply_text("请核对问题,并选择下一步操作。", reply_markup=ReplyKeyboardMarkup(reply_keyboard)) + return self.SAVE_QUESTION + + async def save_question(self, update: Update, context: CallbackContext): + quiz_command_data: QuizCommandData = context.chat_data.get("quiz_command_data") + if update.message.text == "抛弃修改并退出": + await update.message.reply_text("退出任务", reply_markup=ReplyKeyboardRemove()) + return ConversationHandler.END + elif update.message.text == "保存并重载配置": + if quiz_command_data.status == 1: + answer = [ + Answer(text=wrong_answer, is_correct=False) for wrong_answer in + quiz_command_data.new_wrong_answer + ] + answer.append(Answer(text=quiz_command_data.new_correct_answer, is_correct=True)) + await self.quiz_service.save_quiz( + Question(text=quiz_command_data.new_question)) + await update.message.reply_text("保存成功", reply_markup=ReplyKeyboardRemove()) + try: + await self.quiz_service.refresh_quiz() + except ResponseError as error: + Log.error("重载问题失败", error) + await update.message.reply_text("重载问题失败,异常抛出Redis请求错误异常,详情错误请看日记", + reply_markup=ReplyKeyboardRemove()) + return ConversationHandler.END + await update.message.reply_text("重载配置成功", reply_markup=ReplyKeyboardRemove()) + return ConversationHandler.END + else: + await update.message.reply_text("回复错误,请重新选择") + return self.SAVE_QUESTION + + async def edit_question(self, update: Update, context: CallbackContext) -> int: + quiz_command_data: QuizCommandData = context.chat_data.get("quiz_command_data") + quiz_command_data.new_wrong_answer = [] + quiz_command_data.new_question = "" + quiz_command_data.new_correct_answer = "" + quiz_command_data.status = 2 + await update.message.reply_text("请回复你要修改的问题", reply_markup=ReplyKeyboardRemove()) + return self.GET_NEW_QUESTION + + async def delete_question(self, update: Update, context: CallbackContext) -> int: + quiz_command_data: QuizCommandData = context.chat_data.get("quiz_command_data") + # 再问题重载Redis 以免redis数据为空时出现奔溃 + try: + await self.quiz_service.refresh_quiz() + question = await self.quiz_service.get_question(quiz_command_data.question_id) + # 因为外键的存在,先删除答案 + for answer in question.answers: + await self.quiz_service.delete_question_by_id(answer.answer_id) + await self.quiz_service.delete_question_by_id(question.question_id) + await update.message.reply_text("删除问题成功", reply_markup=ReplyKeyboardRemove()) + await self.quiz_service.refresh_quiz() + except ResponseError as error: + Log.error("重载问题失败", error) + await update.message.reply_text("重载问题失败,异常抛出Redis请求错误异常,详情错误请看日记", + reply_markup=ReplyKeyboardRemove()) + return ConversationHandler.END + await update.message.reply_text("重载配置成功", reply_markup=ReplyKeyboardRemove()) + return ConversationHandler.END diff --git a/plugins/system/auth.py b/plugins/system/auth.py new file mode 100644 index 0000000..d7f54f8 --- /dev/null +++ b/plugins/system/auth.py @@ -0,0 +1,290 @@ +import asyncio +import random +import time +from typing import Tuple, Union, Dict + +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, ChatPermissions, ChatMember +from telegram.constants import ParseMode +from telegram.error import BadRequest +from telegram.ext import CallbackContext +from telegram.helpers import escape_markdown + +from core.quiz import QuizService +from logger import Log +from utils.random import MT19937_Random +from utils.service.inject import inject + +FullChatPermissions = ChatPermissions( + can_send_messages=True, + can_send_media_messages=True, + can_send_polls=True, + can_send_other_messages=True, + can_add_web_page_previews=True, + can_change_info=True, + can_invite_users=True, + can_pin_messages=True, +) + + +class GroupJoiningVerification: + """群验证模块""" + + @inject + def __init__(self, quiz_service: QuizService = None): + self.quiz_service = quiz_service + self.time_out = 120 + self.kick_time = 120 + self.random = MT19937_Random() + self.lock = asyncio.Lock() + self.chat_administrators_cache: Dict[Union[str, int], Tuple[float, list[ChatMember]]] = {} + self.is_refresh_quiz = False + + async def refresh_quiz(self): + async with self.lock: + if not self.is_refresh_quiz: + await self.quiz_service.refresh_quiz() + self.is_refresh_quiz = True + + async def get_chat_administrators(self, context: CallbackContext, chat_id: Union[str, int]) -> list[ChatMember]: + async with self.lock: + cache_data = self.chat_administrators_cache.get(f"{chat_id}") + if cache_data is not None: + cache_time, chat_administrators = cache_data + if time.time() >= cache_time + 360: + return chat_administrators + chat_administrators = await context.bot.get_chat_administrators(chat_id) + self.chat_administrators_cache[f"{chat_id}"] = (time.time(), chat_administrators) + return chat_administrators + + @staticmethod + def is_admin(chat_administrators: list[ChatMember], user_id: int) -> bool: + return any(admin.user.id == user_id for admin in chat_administrators) + + async def kick_member_job(self, context: CallbackContext): + job = context.job + Log.debug(f"踢出用户 user_id[{job.user_id}] 在 chat_id[{job.chat_id}]") + try: + await context.bot.ban_chat_member(chat_id=job.chat_id, user_id=job.user_id, + until_date=int(time.time()) + self.kick_time) + except BadRequest as error: + Log.error(f"Auth模块在 chat_id[{job.chat_id}] user_id[{job.user_id}] 执行kick失败", error) + + @staticmethod + async def clean_message_job(context: CallbackContext): + job = context.job + Log.debug(f"删除消息 chat_id[{job.chat_id}] 的 message_id[{job.data}]") + try: + await context.bot.delete_message(chat_id=job.chat_id, message_id=job.data) + except BadRequest as error: + if "not found" in str(error): + Log.warning(f"Auth模块删除消息 chat_id[{job.chat_id}] message_id[{job.data}]失败 消息不存在") + elif "Message can't be deleted" in str(error): + Log.warning( + f"Auth模块删除消息 chat_id[{job.chat_id}] message_id[{job.data}]失败 消息无法删除 可能是没有授权") + else: + Log.error(f"Auth模块删除消息 chat_id[{job.chat_id}] message_id[{job.data}]失败", error) + + @staticmethod + async def restore_member(context: CallbackContext, chat_id: int, user_id: int): + Log.debug(f"重置用户权限 user_id[{user_id}] 在 chat_id[{chat_id}]") + try: + await context.bot.restrict_chat_member(chat_id=chat_id, user_id=user_id, permissions=FullChatPermissions) + except BadRequest as error: + Log.error(f"Auth模块在 chat_id[{chat_id}] user_id[{user_id}] 执行restore失败", error) + + async def admin(self, update: Update, context: CallbackContext) -> None: + + async def admin_callback(callback_query_data: str) -> Tuple[str, int]: + _data = callback_query_data.split("|") + _result = _data[1] + _user_id = int(_data[2]) + Log.debug(f"admin_callback函数返回 result[{_result}] user_id[{_user_id}]") + return _result, _user_id + + callback_query = update.callback_query + user = callback_query.from_user + message = callback_query.message + chat = message.chat + Log.info(f"用户 {user.full_name}[{user.id}] 在群 {chat.title}[{chat.id}] 点击Auth管理员命令") + chat_administrators = await self.get_chat_administrators(context, chat_id=chat.id) + if not self.is_admin(chat_administrators, user.id): + Log.debug(f"用户 {user.full_name}[{user.id}] 在群 {chat.title}[{chat.id}] 非群管理") + await callback_query.answer(text="你不是管理!\n" + "再乱点我叫西风骑士团、千岩军和天领奉行了!", show_alert=True) + return + result, user_id = await admin_callback(callback_query.data) + try: + member_info = await context.bot.get_chat_member(chat.id, user_id) + except BadRequest as error: + Log.warning(f"获取用户 {user_id} 在群 {chat.title}[{chat.id}] 信息失败 \n", error) + user_info = f"{user_id}" + else: + user_info = member_info.user.mention_markdown_v2() + + if result == "pass": + await callback_query.answer(text="放行", show_alert=False) + await self.restore_member(context, chat.id, user_id) + if schedule := context.job_queue.scheduler.get_job(f"{chat.id}|{user_id}|clean_join"): + schedule.remove() + await message.edit_text(f"{user_info} 被 {user.mention_markdown_v2()} 放行", + parse_mode=ParseMode.MARKDOWN_V2) + Log.info(f"用户 user_id[{user_id}] 在群 {chat.title}[{chat.id}] 被管理放行") + elif result == "kick": + await callback_query.answer(text="驱离", show_alert=False) + await context.bot.ban_chat_member(chat.id, user_id) + await message.edit_text(f"{user_info} 被 {user.mention_markdown_v2()} 驱离", + parse_mode=ParseMode.MARKDOWN_V2) + Log.info(f"用户 user_id[{user_id}] 在群 {chat.title}[{chat.id}] 被管理踢出") + elif result == "unban": + await callback_query.answer(text="解除驱离", show_alert=False) + await self.restore_member(context, chat.id, user_id) + if schedule := context.job_queue.scheduler.get_job(f"{chat.id}|{user_id}|clean_join"): + schedule.remove() + await message.edit_text(f"{user_info} 被 {user.mention_markdown_v2()} 解除驱离", + parse_mode=ParseMode.MARKDOWN_V2) + Log.info(f"用户 user_id[{user_id}] 在群 {chat.title}[{chat.id}] 被管理解除封禁") + else: + Log.warning(f"auth 模块 admin 函数 发现未知命令 result[{result}]") + await context.bot.send_message(chat.id, "派蒙这边收到了错误的消息!请检查详细日记!") + if schedule := context.job_queue.scheduler.get_job(f"{chat.id}|{user_id}|auth_kick"): + schedule.remove() + + async def query(self, update: Update, context: CallbackContext) -> None: + + async def query_callback(callback_query_data: str) -> Tuple[int, bool, str, str]: + _data = callback_query_data.split("|") + _user_id = int(_data[1]) + _question_id = int(_data[2]) + _answer_id = int(_data[3]) + _answer = await self.quiz_service.get_answer(_answer_id) + _question = await self.quiz_service.get_question(_question_id) + _result = _answer.is_correct + _answer_encode = _answer.text + _question_encode = _question.text + Log.debug(f"query_callback函数返回 user_id[{_user_id}] result[{_result}] \n" + f"question_encode[{_question_encode}] answer_encode[{_answer_encode}]") + return _user_id, _result, _question_encode, _answer_encode + + callback_query = update.callback_query + user = callback_query.from_user + message = callback_query.message + chat = message.chat + user_id, result, question, answer = await query_callback(callback_query.data) + Log.info(f"用户 {user.full_name}[{user.id}] 在群 {chat.title}[{chat.id}] 点击Auth认证命令 ") + if user.id != user_id: + await callback_query.answer(text="这不是你的验证!\n" + "再乱点再按我叫西风骑士团、千岩军和天领奉行了!", show_alert=True) + return + Log.info(f"用户 {user.full_name}[{user.id}] 在群 {chat.title}[{chat.id}] 认证结果为 {'通过' if result else '失败'}") + if result: + buttons = [[InlineKeyboardButton("驱离", callback_data=f"auth_admin|kick|{user.id}")]] + await callback_query.answer(text="验证成功", show_alert=False) + await self.restore_member(context, chat.id, user_id) + if schedule := context.job_queue.scheduler.get_job(f"{chat.id}|{user.id}|clean_join"): + schedule.remove() + text = f"{user.mention_markdown_v2()} 验证成功,向着星辰与深渊!\n" \ + f"问题:{escape_markdown(question, version=2)} \n" \ + f"回答:{escape_markdown(answer, version=2)}" + Log.info(f"用户 user_id[{user_id}] 在群 {chat.title}[{chat.id}] 验证成功") + else: + buttons = [[InlineKeyboardButton("驱离", callback_data=f"auth_admin|kick|{user.id}"), + InlineKeyboardButton("撤回驱离", callback_data=f"auth_admin|unban|{user.id}")]] + await callback_query.answer(text=f"验证失败,请在 {self.time_out} 秒后重试", show_alert=True) + await context.bot.ban_chat_member(chat_id=chat.id, user_id=user_id, + until_date=int(time.time()) + self.kick_time) + text = f"{user.mention_markdown_v2()} 验证失败,已经赶出提瓦特大陆!\n" \ + f"问题:{escape_markdown(question, version=2)} \n" \ + f"回答:{escape_markdown(answer, version=2)}" + Log.info(f"用户 user_id[{user_id}] 在群 {chat.title}[{chat.id}] 验证失败") + try: + await message.edit_text(text, reply_markup=InlineKeyboardMarkup(buttons), parse_mode=ParseMode.MARKDOWN_V2) + except BadRequest as exc: + if 'are exactly the same as ' in str(exc): + Log.warning("编辑消息发生异常,可能为用户点按多次键盘导致") + else: + raise exc + if schedule := context.job_queue.scheduler.get_job(f"{chat.id}|{user.id}|auth_kick"): + schedule.remove() + + async def new_mem(self, update: Update, context: CallbackContext) -> None: + await self.refresh_quiz() + message = update.message + chat = message.chat + for user in message.new_chat_members: + if user.id == context.bot.id: + return + Log.info(f"用户 {user.full_name}[{user.id}] 尝试加入群 {chat.title}[{chat.id}]") + not_enough_rights = context.chat_data.get("not_enough_rights", False) + if not_enough_rights: + return + chat_administrators = await self.get_chat_administrators(context, chat_id=chat.id) + if self.is_admin(chat_administrators, message.from_user.id): + await message.reply_text("派蒙检测到管理员邀请,自动放行了!") + return + for user in message.new_chat_members: + if user.is_bot: + continue + question_id_list = await self.quiz_service.get_question_id_list() + if len(question_id_list) == 0: + await message.reply_text("旅行者!!!派蒙的问题清单你还没给我!!快去私聊我给我问题!") + return + try: + await context.bot.restrict_chat_member(chat_id=message.chat.id, user_id=user.id, + permissions=ChatPermissions(can_send_messages=False)) + except BadRequest as err: + if "Not enough rights" in str(err): + Log.warning(f"权限不够 chat_id[{message.chat_id}]") + # reply_message = await message.reply_markdown_v2(f"派蒙无法修改 {user.mention_markdown_v2()} 的权限!" + # f"请检查是否给派蒙授权管理了") + context.chat_data["not_enough_rights"] = True + # await context.bot.delete_message(chat.id, reply_message.message_id) + return + else: + raise err + index = self.random.random(0, len(question_id_list)) + question = await self.quiz_service.get_question(question_id_list[index]) + buttons = [ + [ + InlineKeyboardButton( + answer.text, + callback_data=f"auth_challenge|{user.id}|{question['question_id']}|{answer['answer_id']}", + ) + ] + for answer in question.answers + ] + random.shuffle(buttons) + buttons.append( + [ + InlineKeyboardButton( + "放行", + callback_data=f"auth_admin|pass|{user.id}", + ), + InlineKeyboardButton( + "驱离", + callback_data=f"auth_admin|kick|{user.id}", + ), + ] + ) + reply_message = f"*欢迎来到「提瓦特」世界!* \n" \ + f"问题: {escape_markdown(question.text, version=2)} \n" \ + f"请在 {self.time_out}S 内回答问题" + Log.debug(f"发送入群验证问题 question_id[{question.question_id}] question[{question.text}] \n" + f"给{user.full_name}[{user.id}] 在 {chat.title}[{chat.id}]") + try: + question_message = await message.reply_markdown_v2(reply_message, + reply_markup=InlineKeyboardMarkup(buttons)) + except BadRequest as error: + await message.reply_text("派蒙分心了一下,不小心忘记你了,你只能先退出群再重新进来吧。") + raise error + context.job_queue.run_once(callback=self.kick_member_job, when=self.time_out, + name=f"{chat.id}|{user.id}|auth_kick", chat_id=chat.id, user_id=user.id, + job_kwargs={"replace_existing": True}) + context.job_queue.run_once(callback=self.clean_message_job, when=self.time_out, data=message.message_id, + name=f"{chat.id}|{user.id}|auth_clean_join_message", + chat_id=chat.id, user_id=user.id, + job_kwargs={"replace_existing": True}) + context.job_queue.run_once(callback=self.clean_message_job, when=self.time_out, + data=question_message.message_id, + name=f"{chat.id}|{user.id}|auth_clean_question_message", + chat_id=chat.id, user_id=user.id, + job_kwargs={"replace_existing": True}) diff --git a/utils/plugins/register.py b/utils/plugins/register.py index 5aceb67..3c73865 100644 --- a/utils/plugins/register.py +++ b/utils/plugins/register.py @@ -3,8 +3,10 @@ from typing import Optional from telegram.ext import CommandHandler, MessageHandler, filters, CallbackQueryHandler, Application, InlineQueryHandler +from config import config from logger import Log from plugins.base import NewChatMembersHandler +from plugins.system.auth import GroupJoiningVerification from plugins.system.errorhandler import error_handler from plugins.system.inline import Inline from plugins.system.start import start, ping, reply_keyboard_remove, unknown_command @@ -32,10 +34,12 @@ def register_plugin_handlers(application: Application): # 初始化 Log.info("正在加载动态插件管理器") plugins_manager = PluginsManager() - plugins_manager.add_exclude(["start", "auth", "inline", "errorhandler"]) # 忽略内置模块 + plugins_manager.add_exclude(["start", "auth", "inline", "errorhandler"]) # 忽略内置模块 + Log.info("正在加载插件") plugins_manager.refresh_list("plugins/genshin/*") plugins_manager.refresh_list("plugins/system/*") + Log.info("加载插件管理器正在加载插件") plugins_manager.import_module() plugins_manager.add_handler(application) @@ -44,6 +48,13 @@ def register_plugin_handlers(application: Application): inline = Inline() new_chat_members_handler = NewChatMembersHandler() + if len(config.joining_verification_groups) >= 1: + auth = GroupJoiningVerification() + for chat_id in config.joining_verification_groups: + new_chat_members_handler.add_callback(auth.new_mem, chat_id) + add_handler(auth.query, query=r"^auth_challenge\|") + add_handler(auth.admin, query=r"^auth_admin\|") + add_handler(start, command="start") add_handler(ping, command="ping") # 调试功能 @@ -55,6 +66,6 @@ def register_plugin_handlers(application: Application): application.add_handler(MessageHandler(filters.COMMAND & filters.ChatType.PRIVATE, unknown_command)) application.add_error_handler(error_handler, block=False) - import_module(f"plugins.system.admin") + import_module("plugins.system.admin") Log.info("插件加载成功")