diff --git a/list.json b/list.json index a3368c1..498e40f 100644 --- a/list.json +++ b/list.json @@ -72,7 +72,7 @@ }, { "name": "pmcaptcha", - "version": "2.04", + "version": "2.1", "section": "chat", "maintainer": "cloudreflection,01101sam", "size": "54.6 kb", diff --git a/pmcaptcha/main.py b/pmcaptcha/main.py index 31347d5..389610c 100644 --- a/pmcaptcha/main.py +++ b/pmcaptcha/main.py @@ -1,8 +1,7 @@ -""" -PMCaptcha - A PagerMaid-Pyro plugin by cloudreflection -v2 rewritten by Sam -https://t.me/cloudreflection_channel/268 -ver 2022/07/01 +# -*- coding: utf-8 -*- +"""PMCaptcha v2 - A PagerMaid-Pyro plugin +v1 by xtaodata and cloudreflection +v2 by Sam """ import re @@ -12,18 +11,19 @@ import asyncio import inspect import traceback from dataclasses import dataclass, field -from io import BytesIO -from typing import Optional, Callable, Union, Dict, List +from typing import Optional, Callable, Union, Dict, List, Any -from pyrogram.errors import FloodWait +from pyrogram.errors import FloodWait, AutoarchiveNotAvailable, ChannelsAdminPublicTooMuch +from pyrogram.raw.functions.channels import UpdateUsername +from pyrogram.raw.types import GlobalPrivacySettings +from pyrogram.raw.functions.account import SetGlobalPrivacySettings, GetGlobalPrivacySettings from pyrogram.enums.chat_type import ChatType from pyrogram.enums.parse_mode import ParseMode -from pyrogram.raw.functions.account import UpdateNotifySettings, ReportPeer -from pyrogram.raw.functions.messages import DeleteHistory -from pyrogram.raw.types import InputNotifyPeer, InputPeerNotifySettings, InputReportReasonSpam, UpdateMessageID +from pyrogram.raw.functions import messages +from pyrogram.raw.types.messages import PeerSettings from pyrogram.types import User -from pagermaid import bot +from pagermaid import bot, logs from pagermaid.config import Config from pagermaid.sub_utils import Sub from pagermaid.utils import Message @@ -31,36 +31,570 @@ from pagermaid.listener import listener from pagermaid.single_utils import sqlite cmd_name = "pmcaptcha" -version = "2.01" +version = "2.1" -# Log Collect log_collect_bot = "CloudreflectionPmcaptchabot" img_captcha_bot = "PagerMaid_Sam_Bot" -whitelist = Sub("pmcaptcha.success") -punishment_queue = asyncio.Queue() -punishment_task: Optional[asyncio.Task] = None - -challenge_task: Dict[int, asyncio.Task] = {} -curr_captcha: Dict[int, Union["MathChallenge", "ImageChallenge"]] = {} +def sort_line_number(m): + try: + func = getattr(m[1], "__func__", m[1]) + return func.__code__.co_firstlineno + except AttributeError: + return -1 async def log(message: str, remove_prefix: bool = False): - if not Config.LOG: - return - message = message if remove_prefix else " ".join(("[PMCaptcha]", message)) - try: - await bot.send_message(Config.LOG_ID, message, ParseMode.HTML) - except Exception as e: # noqa - print(f"Err: {e}\n{traceback.format_exc()}") + console.info(message.replace('`', '\"')) + Config.LOG and logging.send_log(message, remove_prefix) def lang(lang_id: str, lang_code: str = Config.LANGUAGE) -> str: + lang_dict = { + # region General + "no_cmd_given": [ + "Please use this command in private chat, or add parameters to execute.", + "请在私聊时使用此命令,或添加参数执行。" + ], + "invalid_user_id": [ + "Invalid User ID", + "未知用户或无效的用户 ID" + ], + "invalid_param": [ + "Invalid Parameter", + "无效的参数" + ], + "enabled": [ + "Enabled", + "开启" + ], + "disabled": [ + "Disabled", + "关闭" + ], + "none": [ + "None", + "无" + ], + "tip_edit": [ + f"You can edit this by using {code('%s')}", + f"如需编辑,请使用 {code('%s')}" + ], + "tip_run_in_pm": [ + "You can only run this command in private chat, or by adding parameters.", + "请在私聊使用此命令,或添加参数执行。" + ], + # endregion + + # region Plugin + "plugin_desc": [ + "Captcha for PM", + "私聊人机验证插件" + ], + "check_usage": [ + "Please use %s to see available commands.", + "请使用 %s 查看可用命令" + ], + "curr_version": [ + f"Current {code('PMCaptcha')} Version: %s", + f"{code('PMCaptcha')} 当前版本:%s" + ], + "unknown_version": [ + italic("Unknown"), + italic("未知") + ], + # endregion + + # region Vocabs + "vocab_msg": [ + "Message", + "消息" + ], + "vocab_array": [ + "List", + "列表" + ], + "vocab_bool": [ + "Boolean", + "y / n" + ], + "vocab_int": [ + "Integer", + "整数" + ], + "vocab_cmd": [ + "Command", + "指令" + ], + # endregion + + # region Verify + "verify_verified": [ + "Verified user", + "已验证用户" + ], + "verify_unverified": [ + "Unverified user", + "未验证用户" + ], + "verify_blocked": [ + "You were blocked.", + "您已被封禁" + ], + "verify_log_punished": [ + "User %s has been %s.", + "已对用户 %s 执行`%s`操作" + ], + "verify_challenge": [ + "Please answer this question to prove you are human (1 chance)", + "请回答这个问题证明您不是机器人 (一次机会)" + ], + "verify_challenge_timed": [ + "You have %i seconds.", + "您有 %i 秒来回答这个问题" + ], + "verify_passed": [ + "Verification passed.", + "验证通过" + ], + "verify_failed": [ + "Verification failed.", + "验证失败" + ], + # endregion + + # region Help + "cmd_param": [ + "Parameter", + "参数" + ], + "cmd_param_optional": [ + "Optional", + "可选" + ], + "cmd_alias": [ + "Alias", + "别名/快捷命令" + ], + "cmd_detail": [ + f"Do {code(f',{cmd_name} h ')}[command ] for details", + f"详细指令请输入 {code(f',{cmd_name} h ')}[指令名称 ]", + ], + "cmd_not_found": [ + "Command Not Found", + "指令不存在" + ], + "cmd_list": [ + "Command List", + "指令列表" + ], + "priority": [ + "Priority", + "优先级" + ], + "cmd_search_result": [ + f"Search Result for `%s`", + f"`%s` 的搜索结果" + ], + "cmd_search_docs": [ + "Documentation", + "文档" + ], + "cmd_search_cmds": [ + "Commands", + "指令" + ], + "cmd_search_none": [ + "No result found.", + "未找到结果" + ], + # endregion + + # region Check + "user_verified": [ + f"User {code('%i')} {italic('verified')}", + f"用户 {code('%i')} {italic('已验证')}" + ], + "user_unverified": [ + f"User {code('%i')} {bold('unverified')}", + f"用户 {code('%i')} {bold('未验证')}" + ], + # endregion + + # region Add / Delete + "add_whitelist_success": [ + f"User {code('%i')} added to whitelist", + f"用户 {code('%i')} 已添加到白名单" + ], + "remove_verify_log_success": [ + f"Removed User {code('%i')}'s verify record", + f"已删除用户 {code('%i')} 的验证记录" + ], + "remove_verify_log_failed": [ + f"Failed to remove User {code('%i')}'s verify record.", + f"删除用户 {code('%i')} 的验证记录失败" + ], + "remove_verify_log_not_found": [ + f"Verify record not found for User {code('%i')}", + f"未找到用户 {code('%i')} 的验证记录" + ], + # endregion + + # region Unstuck + "unstuck_success": [ + f"User {code('%i')} has removed from challenge mode", + f"用户 {code('%i')} 已解除验证状态" + ], + "not_stuck": [ + f"User {code('%i')} is not stuck", + f"用户 {code('%i')} 未在验证状态" + ], + # endregion + + # region Welcome + "welcome_curr_rule": [ + "Current welcome rule", + "当前验证通过时消息规则" + ], + "welcome_set": [ + "Welcome message set.", + "已设置验证通过消息" + ], + "welcome_reset": [ + "Welcome message reset.", + "已重置验证通过消息" + ], + # endregion + + # region Whitelist + "whitelist_curr_rule": [ + "Current whitelist rule", + "当前白名单规则" + ], + "whitelist_set": [ + "Keywords whitelist set.", + "已设置关键词白名单" + ], + "whitelist_reset": [ + "Keywords whitelist reset.", + "已重置关键词白名单" + ], + # endregion + + # region Blacklist + "blacklist_curr_rule": [ + "Current blacklist rule", + "当前黑名单规则" + ], + "blacklist_set": [ + "Keywords blacklist set.", + "已设置关键词黑名单" + ], + "blacklist_reset": [ + "Keywords blacklist reset.", + "已重置关键词黑名单" + ], + "blacklist_triggered": [ + "Blacklist rule triggered", + "您触发了黑名单规则" + ], + # endregion + + # region Timeout + "timeout_curr_rule": [ + "Current timeout: %i second(s)", + "当前超时时间: %i 秒" + ], + "timeout_set": [ + "Verification timeout has been set to %i seconds.", + "已设置验证超时时间为 %i 秒" + ], + "timeout_off": [ + "Verification timeout disabled.", + "已关闭验证超时时间" + ], + "timeout_exceeded": [ + "Verification timeout.", + "验证超时" + ], + # endregion + + # region Disable PM + "disable_pm_curr_rule": [ + "Current disable PM status: %s", + "当前禁止私聊状态: 已%s" + ], + "disable_pm_tip_exception": [ + "This feature will automatically allow contents and whitelist users.", + "此功能会自动放行联系人与白名单用户" + ], + "disable_set": [ + f"Disable private chat has been set to {bold('%s')}.", + f"已设置禁止私聊为{bold('%s')}" + ], + "disable_pm_enabled": [ + "Owner has private chat disabled.", + "对方已禁止私聊。" + ], + # endregion + + # region Stats + "stats_display": [ + "PMCaptcha has verified %i users in total.\n%i users has passed, %i users has been blocked.", + "自上次重置起,已进行验证 %i 次\n其中验证通过 %i 次,拦截 %i 次" + ], + "stats_reset": [ + "Statistics has been reset.", + "已重置统计" + ], + # endregion + + # region Action + "action_param_name": [ + "Action", + "操作" + ], + "action_curr_rule": [ + "Current action rule", + "当前验证失败规则" + ], + "action_set": [ + f"Action has been set to {bold('%s')}.", + f"验证失败后将执行{bold('%s')}操作" + ], + "action_set_none": [ + "Action has been set to none.", + "验证失败后将不执行任何操作" + ], + "action_ban": [ + "Ban", + "封禁" + ], + "action_delete": [ + "Ban and delete", + "封禁并删除对话" + ], + "action_archive": [ + "Ban and archive", + "封禁并归档" + ], + # endregion + + # region Report + "report_curr_rule": [ + "Current report state: %s", + "当前举报状态为: %s" + ], + "report_set": [ + f"Report has been set to {bold('%s')}.", + f"已设置举报状态为{bold('%s')}" + ], + # endregion + + # region Premium + "premium_curr_rule": [ + "Current premium user rule", + "当前 Premium 用户规则" + ], + "premium_set_allow": [ + f"Telegram Premium users will be allowed to {bold('bypass')} the captcha.", + f"将{bold('不对')} Telegram Premium 用户{bold('发起验证')}" + ], + "premium_set_ban": [ + f"Telegram Premium users will be {bold('banned')} from private chat.", + f"将{bold('禁止')} Telegram Premium 用户私聊" + ], + "premium_set_only": [ + f"{bold('Only allowed')} Telegram Premium users to private chat.", + f"将{bold('仅允许')} Telegram Premium 用户私聊" + ], + "premium_set_none": [ + "Nothing will do to Telegram Premium", + "将不对 Telegram Premium 用户执行额外操作" + ], + "premium_only": [ + "Owner only allows Telegram Premium users to private chat.", + "对方只允许 Telegram Premium 用户私聊" + ], + "premium_ban": [ + "Owner bans Telegram Premium users from private chat.", + "对方禁止 Telegram Premium 用户私聊" + ], + # endregion + + # region Groups In Common + "groups_in_common_set": [ + f"Groups in common larger than {bold('%i')} will be whitelisted.", + f"共同群数量大于 {bold('%i')} 时将自动添加到白名单" + ], + "groups_in_common_disabled": [ + "Group in command is not enabled", + "未开启共同群数量检测" + ], + "groups_in_common_disable": [ + "Groups in common disabled.", + "已关闭共同群检查" + ], + # endregion + + # region Chat History + "chat_history_curr_rule": [ + f"Chat history equal or larger than {bold('%i')} will be whitelisted.", + f"聊天记录数量大于 {bold('%i')} 时将自动添加到白名单" + ], + "chat_history_disabled": [ + "Chat history check is not enabled", + "未开启聊天记录数量检测" + ], + # endregion + + # region Initiative + "initiative_curr_rule": [ + "Current initiative status: %s", + "当前对主动进行对话的用户添加白名单状态为: %s" + ], + "initiative_set": [ + f"Initiative has been set to {bold('%s')}.", + f"已设置对主动进行对话的用户添加白名单状态为{bold('%s')}" + ], + # endregion + + # region Silent + "silent_curr_rule": [ + "Current silent status: %s", + "当前静音状态: 已%s" + ], + "silent_set": [ + f"Silent has been set to {bold('%s')}.", + f"已设置静音模式为{bold('%s')}" + ], + # endregion + + # region Flood + "flood_curr_rule": [ + "Current flood detect limit was set to %i user(s)", + "当前轰炸人数已设置为 %i 人" + ], + # Username + "flood_username_curr_rule": [ + "Current flood username option was set to %s", + "当前轰炸时切换用户名选项已设置为 %s" + ], + "flood_username_set_confirm": [ + (f"The feature may lose your username, are you sure you want to enable this feature?\n" + f"Please enter {code(f',{cmd_name} flood_username y')} again to confirm."), + f"此功能有可能回导致您的用户名丢失,您是否确定要开启此功能?\n请再次输入 {code(f',{cmd_name} flood_username y')} 来确认" + ], + "flood_channel_desc": [ + ("This channel is a placeholder of username, which the owner is being flooded.\n" + "Please content him later after this channel is gone."), + "这是一个用于临时设置用户名的频道,该群主正在被私聊轰炸\n请在此频道消失后再联系他。" + ], + # Action + "flood_act_curr_rule": [ + "Current flood action was set to %s", + "当前轰炸操作已设置为 %s" + ], + "flood_act_set_ban": [ + f"All users in flood period will be {bold('blocked')}.", + f"所有在轰炸期间的用户将会被{bold('封禁')}" + ], + "flood_act_set_delete": [ + f"All users in flood period will be {bold('blocked and deleted')}.", + f"所有在轰炸期间的用户将会被{bold('封禁并删除对话')}" + ], + "flood_act_set_captcha": [ + f"All users in flood period will be {bold('asked for captcha')}.", + f"所有在轰炸期间的用户将会{bold('进行验证码挑战')}" + ], + "flood_act_set_none": [ + "Nothing will do to users in flood period.", + "所有在轰炸期间的用户将不会被进行任何处理" + ], + # endregion + + # region Collect Logs + "collect_logs_curr_rule": [ + "Current collect logs status: %s", + "当前收集日志状态: 已%s" + ], + "collect_logs_note": [ + ("This feature will only collect user information and chat logs of non-verifiers " + f"via @{log_collect_bot} , and is not provided to third parties (except @LivegramBot ).\n" + "Information collected will be used for PMCaptcha improvements, " + "toggling this feature does not affect the use of PMCaptcha."), + (f"此功能仅会通过 @{log_collect_bot} 收集未通过验证者的用户信息以及验证未通过的聊天记录;" + "且不会提供给第三方(@LivegramBot 除外)。\n收集的信息将用于 PMCaptcha 改进,开启或关闭此功能不影响 PMCaptcha 的使用。") + ], + "collect_logs_set": [ + "Collect logs has been set to %s.", + "已设置收集日志为 %s" + ], + # endregion + + # region Captcha Type + "type_curr_rule": [ + "Current captcha type: %s", + "当前验证码类型: %s" + ], + "type_set": [ + f"Captcha type has been set to {bold('%s')}.", + f"已设置验证码类型为 {bold('%s')}" + ], + "type_param_name": [ + "Type", + "类型" + ], + "type_captcha_img": [ + "Image", + "图像辨识" + ], + "type_captcha_math": [ + "Math", + "计算" + ], + # endregion + + # region Image Captcha Type + "img_captcha_type_func": [ + "funCaptcha", + "funCaptcha", + ], + "img_captcha_type_github": [ + "GitHub", + "GitHub", + ], + "img_captcha_type_rec": [ + "reCaptcha", + "reCaptcha" + ], + "img_captcha_retry_curr_rule": [ + "Current max retry for image captcha: %s", + "当前图像验证码最大重试次数: %s" + ], + "img_captcha_retry_set": [ + "Max retry for image captcha has been set to %s.", + "已设置图像验证码最大重试次数为 %s" + ], + # endregion + } + lang_code = lang_code or "en" return lang_dict.get(lang_id)[1 if lang_code.startswith("zh") else 0] +def get_version(): + from pagermaid import working_dir + from os import sep + from json import load + plugin_directory = f"{working_dir}{sep}plugins{sep}" + with open(f"{plugin_directory}version.json", 'r', encoding="utf-8") as f: + version_json = load(f) + return version_json.get(cmd_name, lang('unknown_version')) + + +# region Text Formatting def code(text: str) -> str: return f"{text}" @@ -77,433 +611,146 @@ def gen_link(text: str, url: str) -> str: return f"{text}" -lang_dict = { - # region General - "no_cmd_given": [ - "Please use this command in private chat, or add parameters to execute.", - "请在私聊时使用此命令,或添加参数执行。" - ], - "invalid_user_id": [ - "Invalid User ID", - "未知用户或无效的用户 ID" - ], - "invalid_param": [ - "Invalid Parameter", - "无效的参数" - ], - "enabled": [ - "Enabled", - "开启" - ], - "disabled": [ - "Disabled", - "关闭" - ], - "none": [ - "None", - "无" - ], - "tip_edit": [ - f"You can edit this by using {code('%s')}", - f"如需编辑,请使用 {code('%s')}" - ], - "tip_run_in_pm": [ - "You can only run this command in private chat, or by adding parameters.", - "请在私聊使用此命令,或添加参数执行。" - ], - # endregion +# endregion - # region Plugin - "plugin_desc": [ - "Captcha for PM\nPlease use %s to see available commands.", - f"私聊人机验证插件\n请使用 %s 查看可用命令" - ], - # endregion - - # region Vocabs - "vocab_msg": [ - "Message", - "消息" - ], - "vocab_array": [ - "List", - "列表" - ], - "vocab_bool": [ - "Boolean", - "y / n" - ], - "vocab_int": [ - "Integer", - "整数" - ], - "vocab_cmd": [ - "Command", - "指令" - ], - # endregion - - # region Verify - "verify_verified": [ - "Verified user", - "已验证用户" - ], - "verify_unverified": [ - "Unverified user", - "未验证用户" - ], - "verify_blocked": [ - "You were blocked.", - "您已被封禁" - ], - "verify_log_punished": [ - "User %s has been %s.", - "已对用户 %s 执行`%s`操作" - ], - "verify_challenge": [ - "Please answer this question to prove you are human (1 chance)", - "请回答这个问题证明您不是机器人 (一次机会)" - ], - "verify_challenge_timed": [ - "You have %i seconds.", - "您有 %i 秒来回答这个问题" - ], - "verify_passed": [ - "Verification passed.", - "验证通过" - ], - "verify_failed": [ - "Verification failed.", - "验证失败" - ], - # endregion - - # region Help - "cmd_param": [ - "Parameter", - "参数" - ], - "cmd_param_optional": [ - "Optional", - "可选" - ], - "cmd_alias": [ - "Alias", - "别名/快捷命令" - ], - "cmd_detail": [ - f"Do {code(f',{cmd_name} h [command]')} for details", - f"详细指令请输入 {code(f',{cmd_name} h [指令名称]')}", - ], - "cmd_not_found": [ - "Command Not Found", - "指令不存在" - ], - "cmd_list": [ - "Command List", - "指令列表" - ], - "priority": [ - "Priority", - "优先级" - ], - # endregion - - # region Check - "user_verified": [ - f"User {code('%i')} {italic('verified')}", - f"用户 {code('%i')} {italic('已验证')}" - ], - "user_unverified": [ - f"User {code('%i')} {bold('unverified')}", - f"用户 {code('%i')} {bold('未验证')}" - ], - # endregion - - # region Add / Delete - "add_whitelist_success": [ - f"User {code('%i')} added to whitelist", - f"用户 {code('%i')} 已添加到白名单" - ], - "remove_verify_log_success": [ - f"Removed User {code('%i')}'s verify record", - f"已删除用户 {code('%i')} 的验证记录" - ], - "verify_log_not_found": [ - f"Verify record not found for User {code('%i')}", - f"未找到用户 {code('%i')} 的验证记录" - ], - # endregion - - # region Unstuck - "unstuck_success": [ - f"User {code('%i')} has removed from challenge mode", - f"用户 {code('%i')} 已解除验证状态" - ], - "not_stuck": [ - f"User {code('%i')} is not stuck", - f"用户 {code('%i')} 未在验证状态" - ], - # endregion - - # region Welcome - "welcome_curr_rule": [ - "Current welcome rule", - "当前验证通过时消息规则" - ], - "welcome_set": [ - "Welcome message set.", - "已设置验证通过消息" - ], - "welcome_reset": [ - "Welcome message reset.", - "已重置验证通过消息" - ], - # endregion - - # region Whitelist - "whitelist_curr_rule": [ - "Current whitelist rule", - "当前白名单规则" - ], - "whitelist_set": [ - "Keywords whitelist set.", - "已设置关键词白名单" - ], - "whitelist_reset": [ - "Keywords whitelist reset.", - "已重置关键词白名单" - ], - # endregion - - # region Blacklist - "blacklist_curr_rule": [ - "Current blacklist rule", - "当前黑名单规则" - ], - "blacklist_set": [ - "Keywords blacklist set.", - "已设置关键词黑名单" - ], - "blacklist_reset": [ - "Keywords blacklist reset.", - "已重置关键词黑名单" - ], - "blacklist_triggered": [ - "Blacklist rule triggered", - "您触发了黑名单规则" - ], - # endregion - - # region Timeout - "timeout_curr_rule": [ - "Current timeout: %i second(s)", - "当前超时时间: %i 秒" - ], - "timeout_set": [ - "Verification timeout has been set to %i seconds.", - "已设置验证超时时间为 %i 秒" - ], - "timeout_off": [ - "Verification timeout disabled.", - "已关闭验证超时时间" - ], - "timeout_exceeded": [ - "Verification timeout.", - "验证超时" - ], - # endregion - - # region Disable PM - "disable_pm_curr_rule": [ - "Current disable PM status: %s", - "当前禁止私聊状态: 已%s" - ], - "disable_pm_tip_exception": [ - "This feature will automatically allow contents and whitelist users.", - "此功能会自动放行联系人与白名单用户" - ], - "disable_pm_set": [ - f"Disable private chat has been set to {bold('%s')}.", - f"已设置禁止私聊为{bold('%s')}" - ], - "disable_pm_enabled": [ - "Owner has private chat disabled.", - "对方已禁止私聊。" - ], - # endregion - - # region Stats - "stats_display": [ - "PMCaptcha has verified %i users in total.\n%i users has passed, %i users has been blocked.", - "自上次重置起,已进行验证 %i 次\n其中验证通过 %i 次,拦截 %i 次" - ], - "stats_reset": [ - "Statistics has been reset.", - "已重置统计" - ], - # endregion - - # region Action - "action_param_name": [ - "Action", - "操作" - ], - "action_curr_rule": [ - "Current action rule", - "当前验证失败规则" - ], - "action_set": [ - f"Action has been set to {bold('%s')}.", - f"验证失败后将执行{bold('%s')}操作" - ], - "action_set_none": [ - "Action has been set to none.", - "验证失败后将不执行任何操作" - ], - "action_ban": [ - "Ban", - "封禁" - ], - "action_delete": [ - "Ban and delete", - "封禁并删除对话" - ], - "action_archive": [ - "Ban and archive", - "封禁并归档" - ], - # endregion - - # region Report - "report_curr_rule": [ - "Current report state: %s", - "当前举报状态为: %s" - ], - "report_set": [ - f"Report has been set to {bold('%s')}.", - f"已设置举报状态为{bold('%s')}" - ], - # endregion - - # region Premium - "premium_curr_rule": [ - "Current premium user rule", - "当前 Premium 用户规则" - ], - "premium_set_allow": [ - f"Telegram Premium users will be allowed to {bold('bypass')} the captcha.", - f"将{bold('不对')} Telegram Premium 用户{bold('发起验证')}" - ], - "premium_set_ban": [ - f"Telegram Premium users will be {bold('banned')} from private chat.", - f"将{bold('禁止')} Telegram Premium 用户私聊" - ], - "premium_set_only": [ - f"{bold('Only allowed')} Telegram Premium users to private chat.", - f"将{bold('仅允许')} Telegram Premium 用户私聊" - ], - "premium_set_none": [ - "Nothing will do to Telegram Premium", - "将不对 Telegram Premium 用户执行额外操作" - ], - "premium_only": [ - "Owner only allows Telegram Premium users to private chat.", - "对方只允许 Telegram Premium 用户私聊" - ], - "premium_ban": [ - "Owner bans Telegram Premium users from private chat.", - "对方禁止 Telegram Premium 用户私聊" - ], - # endregion - - # region Silent - "silent_curr_rule": [ - "Current silent status: %s", - "当前静音状态: 已%s" - ], - "silent_set": [ - f"Silent has been set to {bold('%s')}.", - f"已设置静音模式为{bold('%s')}" - ], - # endregion - - # region Collect Logs - "collect_logs_curr_rule": [ - "Current collect logs status: %s", - "当前收集日志状态: 已%s" - ], - "collect_logs_note": [ - ("This feature will only collect user information and chat logs of non-verifiers " - f"via @{log_collect_bot} , and is not provided to third parties (except @LivegramBot ).\n" - "Information collected will be used for PMCaptcha improvements, " - "toggling this feature does not affect the use of PMCaptcha."), - (f"此功能仅会通过 @{log_collect_bot} 收集未通过验证者的用户信息以及验证未通过的聊天记录;" - "且不会提供给第三方(@LivegramBot 除外)。\n收集的信息将用于 PMCaptcha 改进,开启或关闭此功能不影响 PMCaptcha 的使用。") - ], - "collect_logs_set": [ - "Collect logs has been set to %s.", - "已设置收集日志为 %s" - ], - # endregion - - # region Captcha Type - "type_curr_rule": [ - "Current captcha type: %s", - "当前验证码类型: %s" - ], - "type_set": [ - f"Captcha type has been set to {bold('%s')}.", - f"已设置验证码类型为 {bold('%s')}" - ], - "type_param_name": [ - "Type", - "类型" - ], - "type_captcha_img": [ - "Image", - "图像辨识" - ], - "type_captcha_math": [ - "Math", - "计算" - ], - # endregion - - # region Image Captcha Type - "img_captcha_type_func": [ - "funCaptcha", - "funCaptcha", - ], - "img_captcha_type_github": [ - "GitHub", - "GitHub", - ], - "img_captcha_type_rec": [ - "reCaptcha", - "reCaptcha" - ], - "img_captcha_retry_curr_rule": [ - "Current max retry for image captcha: %s", - "当前图像验证码最大重试次数: %s" - ], - "img_captcha_retry_set": [ - "Max retry for image captcha has been set to %s.", - "已设置图像验证码最大重试次数为 %s" - ], - # endregion -} - - -# noinspection DuplicatedCode @dataclass -class SubCommand: +class Log: + task: Optional[asyncio.Task] = None + queue: asyncio.Queue = field(default_factory=asyncio.Queue) + last_send_time: int = field(init=False) + + async def worker(self): + while True: + text = None + try: + if int(time.time()) - self.last_send_time < 5: + await asyncio.sleep(5 - (int(time.time()) - self.last_send_time)) + continue + (text,) = self.queue.get() + await bot.send_message(Config.LOG_ID, text, ParseMode.HTML) + self.last_send_time = int(time.time()) + except asyncio.CancelledError: + break + except FloodWait as e: + console.debug(f"Flood triggered when sending log, wait for {e.value}s") + await asyncio.sleep(e.value) + except Exception as e: + console.error(f"Error when sending log: {e}\n{traceback.format_exc()}") + finally: + text and self.queue.task_done() + + def send_log(self, message: str, remove_prefix: bool): + if not self.task or self.task.done(): + self.task = asyncio.create_task(self.worker()) + message = message if remove_prefix else " ".join(("[PMCaptcha]", message)) + self.queue.put_nowait((message,)) + + +@dataclass +class Setting: + key_name: str + whitelist: Sub = field(init=False) + pending_ban_list: Sub = field(init=False) + pending_challenge_list: Sub = field(init=False) + + def __post_init__(self): + self.whitelist = Sub("pmcaptcha.success") + self.pending_ban_list = Sub("pmcaptcha.pending_ban") + self.pending_challenge_list = Sub("pmcaptcha.pending_challenge") + + def get(self, key: str, default=None): + if sqlite.get(self.key_name) is None: + return default + return sqlite[self.key_name].get(key, default) + + def set(self, key: str, value: Any): + """Set the value of a key in the database, return value""" + if sqlite.get(self.key_name) is None: + sqlite[self.key_name] = {} + sqlite[self.key_name][key] = value + return value + + def delete(self, key: str): + """Delete a key in the database, if key exists""" + if self.get(key): + del sqlite[self.key_name][key] + return self + + def is_verified(self, user_id: int) -> bool: + return self.whitelist.check_id(user_id) + + # region Captcha Challenge + + def get_challenge_state(self, user_id: int) -> dict: + return sqlite.get(f"{self.key_name}.challenge.{user_id}", {}) + + def set_challenge_state(self, user_id: int, state: dict): + sqlite[f"{self.key_name}.challenge.{user_id}"] = state + return state + + def del_challenge_state(self, user_id: int): + key = f"{self.key_name}.challenge.{user_id}" + if sqlite.get(key): + del sqlite[key] + + # endregion + + # region Flood State + + def get_flood_state(self) -> dict: + return sqlite.get(f"{self.key_name}.flood_state", {}) + + def set_flood_state(self, state: dict) -> dict: + sqlite[f"{self.key_name}.flood_state"] = state + return state + + def del_flood_state(self): + key = f"{self.key_name}.flood_state" + if sqlite.get(key): + del sqlite[key] + + # endregion + + +@dataclass +class Command: + user: User msg: Message # Regex alias_rgx = r":alias: (.+)" param_rgx = r":param (opt)?\s?(\w+):\s?(.+)" + async def _run_command(self): + command = len(self.msg.parameter) > 0 and self.msg.parameter[0] or cmd_name + if not (func := self[command]): + return False, "NOT_FOUND", command + full_arg_spec = inspect.getfullargspec(func) + args_len = None if full_arg_spec.varargs else len(full_arg_spec.args) + cmd_args = self.msg.parameter[1:args_len] + func_args = [] + for index, arg_type in enumerate(tuple(full_arg_spec.annotations.values())): # Check arg type + try: + if getattr(arg_type, "__origin__", None) == Union: + NoneType = type(None) + if ( + len(arg_type.__args__) != 2 + or arg_type.__args__[1] is not NoneType + ): + continue + if len(cmd_args) - 1 > index and not cmd_args[index] or len(cmd_args) - 1 < index: + func_args.append(None) + continue + arg_type = arg_type.__args__[0] + func_args.append(arg_type(cmd_args[index])) + except ValueError: + return False, "INVALID_PARAM", tuple(full_arg_spec.annotations.keys())[index] + except IndexError: # No more args + await self.help(command) + return True, None, None + await func(*func_args) + return True, None, None + def _extract_docs(self, subcmd_name: str, text: str) -> str: extras = [] if result := re.search(self.param_rgx, text): @@ -522,15 +769,16 @@ class SubCommand: extras.append(f"{lang('cmd_alias')}: {alia_text}") text = re.sub(self.alias_rgx, "", text) len(extras) and extras.insert(0, "") - subcmd_name = "" if cmd_name == subcmd_name else self._get_cmd_with_param(subcmd_name) return "\n".join([ - code(f",{cmd_name} {subcmd_name}".strip()), + code(f",{cmd_name} {self._get_cmd_with_param(subcmd_name)}".strip()), re.sub(r" {4,}", "", text).replace("{cmd_name}", cmd_name).strip() ] + extras) def _get_cmd_with_param(self, subcmd_name: str) -> str: + if subcmd_name == cmd_name: + return "" msg = subcmd_name - if result := re.search(self.param_rgx, getattr(self, msg).__doc__): + if result := re.search(self.param_rgx, getattr(self, msg).__doc__ or ''): param = result[2].lstrip("_") msg += f" [{param}]" if result[1] else html.escape(f" <{param}>") return msg @@ -540,9 +788,54 @@ class SubCommand: for name, func in inspect.getmembers(self, inspect.iscoroutinefunction): if name.startswith("_"): continue - if result := re.search(self.alias_rgx, func.__doc__): - if alias_name in result[1].replace(" ", "").split(","): - return func if ret_type == "func" else name + if ((result := re.search(self.alias_rgx, func.__doc__ or "")) and + alias_name in result[1].replace(" ", "").split(",")): + return func if ret_type == "func" else name + + # region Helpers (Formatting, User ID) + + async def _display_value(self, *, key: Optional[str] = None, display_text: str, sub_cmd: str, value_type: str): + text = [display_text, "", + lang('tip_edit') % html.escape(f",{cmd_name} {sub_cmd} <{lang(value_type)}>")] + key and text.insert(0, lang(f"{key}_curr_rule") + ":") + return await self.msg.edit_text("\n".join(text), parse_mode=ParseMode.HTML) + + # Set On / Off Boolean + async def _set_toggle(self, key: str, toggle: str): + if ((toggle := toggle.lower()[0]) not in ("y", "n", "t", "f", "1", "0") and + (toggle := toggle.lower()) not in ("on", "off")): + return await self.help(key) + toggle = toggle in ("y", "t", "1", "on") + toggle and setting.set(key, True) or setting.delete(key) + await self.msg.edit(lang(f"{key}_set") % lang("enabled" if toggle else "disabled"), parse_mode=ParseMode.HTML) + + async def _get_user_id(self, user_id: Union[str, int]) -> Optional[int]: + if not user_id and not self.msg.reply_to_message_id and self.msg.chat.type != ChatType.PRIVATE: + await self.msg.edit(lang('tip_run_in_pm'), parse_mode=ParseMode.HTML) + return + user = None + user_id = user_id or self.msg.reply_to_message_id or ( + self.msg.chat.type == ChatType.PRIVATE and self.msg.chat.id or 0) + if not user_id or not (user := await bot.get_users(user_id)) or ( + user.is_bot or user.is_verified or user.is_deleted): + return + return user.id + + # Set Black / White List + async def _set_list(self, _type: str, array: str): + if not array: + return await self._display_value( + key=_type, + display_text=code(setting.get(_type, lang('none'))), + sub_cmd=f"{_type[0]}l", + value_type="vocab_array") + if array.startswith("-c"): + setting.delete(_type) + return await self.msg.edit(lang(f'{_type}_reset'), parse_mode=ParseMode.HTML) + setting.set(_type, array.replace(" ", "").split(",")) + await self.msg.edit(lang(f'{_type}_set'), parse_mode=ParseMode.HTML) + + # endregion def __getitem__(self, cmd: str) -> Optional[Callable]: # Get subcommand function @@ -553,518 +846,967 @@ class SubCommand: return func return # Not found + def get(self, cmd: str, default=None): + return self[cmd] or default + async def pmcaptcha(self): - """查询当前私聊用户验证状态""" - if self.msg.chat.type != ChatType.PRIVATE: - await self.msg.edit(lang('tip_run_in_pm'), parse_mode=ParseMode.HTML) - else: - await self.msg.edit(lang(f'verify_{"" if whitelist.check_id(self.msg.chat.id) else "un"}verified')) + """查询当前用户的验证状态""" + if not (user_id := await self._get_user_id(self.msg.chat.id)): + return await self.msg.edit(lang('invalid_user_id'), parse_mode=ParseMode.HTML) + await self.msg.edit(lang(f'verify_{"" if setting.is_verified(user_id) else "un"}verified'), + parse_mode=ParseMode.HTML) await asyncio.sleep(5) await self.msg.safe_delete() - async def help(self, command: Optional[str] = None): + async def version(self): + """查看 PMCaptcha 当前版本 + + :alias: v, ver + """ + await self.msg.edit(f"{lang('curr_version') % get_version()}") + + async def help(self, command: Optional[str], search_str: Optional[str] = None): """显示指令帮助信息 + 搜索: + - 使用 ,{cmd_name} search [搜索内容] 进行文档、指令(和别名)搜索 :param opt command: 命令名称 + :param opt search_str: 搜索的文字,只有 command 为 search 时有效 :alias: h """ - help_msg = [f"{code('PMCaptcha')} {lang('cmd_list')} (v{version}):", ""] - footer = [ - italic(lang('cmd_detail')), - "", - f"{lang('priority')}: disable_pm > premium > whitelist > blacklist", - f"遇到任何问题请先 {code(',apt update')} 更新后复现再反馈", - "捐赠: cloudreflection.eu.org/donate" - ] - if command: # Single command help - func = getattr(self, command, self._get_mapped_alias(command, "func")) - return await ( - self.msg.edit_text(self._extract_docs(func.__name__, func.__doc__), parse_mode=ParseMode.HTML) - if func else self.msg.edit_text(f"{lang('cmd_not_found')}: {code(command)}", parse_mode=ParseMode.HTML)) - for name, func in inspect.getmembers(self, inspect.iscoroutinefunction): - if name.startswith("_"): - continue - help_msg.append( - ( - code(f",{cmd_name} {self._get_cmd_with_param(name)}") - + f"\n> {re.search(r'(.+)', func.__doc__)[1].strip()}\n" - ) - ) - - if self.msg.chat.type != ChatType.PRIVATE: + if not setting.is_verified(self.user.id) and self.msg.chat.type not in (ChatType.PRIVATE, ChatType.BOT): await self.msg.edit_text(lang('tip_run_in_pm'), parse_mode=ParseMode.HTML) await asyncio.sleep(5) return await self.msg.safe_delete() - await self.msg.edit_text("\n".join(help_msg + footer), parse_mode=ParseMode.HTML) + help_msg = [f"{code('PMCaptcha')} {lang('cmd_list')}:", ""] + footer = [ + italic(lang('cmd_detail')), + "", + f"{lang('priority')}:\n{' > '.join(Rule._get_rules_priority())}", + "", + f"遇到任何问题请先 {code(',apt update')} 、 {code(',restart')} 后复现再反馈", + (f"👉 {gen_link('捐赠网址', 'https://afdian.net/@xtaodada')} " + f"{gen_link('捐赠说明', 'https://t.me/PagerMaid_Modify/121')} " + f"(v{get_version()})"), + ] + if command == "search": # Search for commands or docs + if not search_str: + return await self.help("h") + search_str = search_str.lower() + search_results = [lang('cmd_search_result') % search_str] + have_doc = False + have_cmd = False + for name, func in inspect.getmembers(self, inspect.iscoroutinefunction): + if name.startswith("_"): + continue + # Search for docs + docs = func.__doc__ or "" + if docs.lower().find(search_str) != -1: + not have_doc and search_results.append(f"{lang('cmd_search_docs')}:") + have_doc = True + search_results.append(self._extract_docs(func.__name__, docs)) + # Search for commands + if name.find(search_str) != -1: + not have_cmd and search_results.append(f"{lang('cmd_search_cmds')}:") + have_cmd = True + search_results.append( + (code(f"- {code(self._get_cmd_with_param(name))}".strip()) + + f"\n· {re.search(r'(.+)', docs)[1].strip()}\n")) + # Search for aliases + elif result := re.search(self.alias_rgx, docs): + if search_str not in result[1].replace(" ", "").split(","): + continue + not have_cmd and search_results.append(f"{lang('cmd_search_cmds')}:") + have_cmd = True + search_results.append( + (f"* {code(search_str)} -> {code(self._get_cmd_with_param(func.__name__))}".strip() + + f"\n· {re.search(r'(.+)', docs)[1].strip()}\n")) + len(search_results) == 1 and search_results.append(italic(lang('cmd_search_none'))) + return await self.msg.edit("\n\n".join(search_results), parse_mode=ParseMode.HTML) + elif command: # Single command help + func = getattr(self, command, self._get_mapped_alias(command, "func")) + return await ( + self.msg.edit_text(self._extract_docs(func.__name__, func.__doc__ or ''), parse_mode=ParseMode.HTML) + if func else self.msg.edit_text(f"{lang('cmd_not_found')}: {code(command)}", parse_mode=ParseMode.HTML)) + members = inspect.getmembers(self, inspect.iscoroutinefunction) + members.sort(key=sort_line_number) + for name, func in members: + if name.startswith("_"): + continue + help_msg.append( + (code(f",{cmd_name} {self._get_cmd_with_param(name)}".strip()) + + f"\n· {re.search(r'(.+)', func.__doc__ or '')[1].strip()}\n")) + await self.msg.edit_text("\n".join(help_msg + footer), parse_mode=ParseMode.HTML, disable_web_page_preview=True) - async def check(self, _id: int): - """查询指定用户验证状态 + # region Checking User / Manual update - :param _id: 用户 ID + async def check(self, _id: Optional[str]): + """查询指定用户验证状态,如未指定为当前私聊用户 ID + + :param opt _id: 用户 ID """ - try: - _id = _id and int(_id) or self.msg.chat.id - verified = whitelist.check_id(_id) - await self.msg.edit(lang(f"user_{'' if verified else 'un'}verified") % _id, parse_mode=ParseMode.HTML) - except ValueError: - await self.msg.edit(lang('invalid_user_id'), parse_mode=ParseMode.HTML) + if not (user_id := await self._get_user_id(_id)): + return await self.msg.edit(lang('invalid_user_id'), parse_mode=ParseMode.HTML) + await self.msg.edit(lang(f"user_{'' if setting.is_verified(user_id) else 'un'}verified") % _id, + parse_mode=ParseMode.HTML) - async def add(self, _id: Optional[int] = None): + async def add(self, _id: Optional[str]): """将 ID 加入已验证,如未指定为当前私聊用户 ID :param opt _id: 用户 ID """ - try: - if not _id and self.msg.chat.type != ChatType.PRIVATE: - return await self.msg.edit(lang('tip_run_in_pm'), parse_mode=ParseMode.HTML) - _id = _id and int(_id) or self.msg.chat.id - whitelist.add_id(_id) - await bot.unarchive_chats(chat_ids=_id) - await self.msg.edit(lang('add_whitelist_success') % _id, parse_mode=ParseMode.HTML) - except ValueError: - await self.msg.edit(lang('invalid_user_id'), parse_mode=ParseMode.HTML) + if not (user_id := await self._get_user_id(_id)): + return await self.msg.edit(lang('invalid_user_id'), parse_mode=ParseMode.HTML) + result = setting.whitelist.add_id(user_id) + await bot.unarchive_chats(chat_ids=user_id) + await self.msg.edit(lang(f"add_whitelist_{'success' if result else 'failed'}") % user_id, + parse_mode=ParseMode.HTML) - async def delete(self, _id: Optional[int] = None): + async def delete(self, _id: Optional[str]): """移除 ID 验证记录,如未指定为当前私聊用户 ID :param opt _id: 用户 ID :alias: del """ - try: - if not _id and self.msg.chat.type != ChatType.PRIVATE: - return await self.msg.edit(lang('tip_run_in_pm'), parse_mode=ParseMode.HTML) - _id = _id and int(_id) or self.msg.chat.id - text = lang('remove_verify_log_success' if whitelist.del_id(_id) else 'verify_log_not_found') - await self.msg.edit(text % _id, parse_mode=ParseMode.HTML) - except ValueError: - await self.msg.edit(lang('invalid_user_id'), parse_mode=ParseMode.HTML) + if not (user_id := await self._get_user_id(_id)): + return await self.msg.edit(lang('invalid_user_id'), parse_mode=ParseMode.HTML) + text = lang(f"remove_verify_log_{'success' if setting.whitelist.del_id(user_id) else 'not_found'}") + await self.msg.edit(text % user_id, parse_mode=ParseMode.HTML) - async def unstuck(self, _id: Optional[int] = None): + # endregion + + async def unstuck(self, _id: Optional[str]): """解除一个用户的验证状态,通常用于解除卡死的验证状态 - :param _id: 用户 ID + :param opt _id: 用户 ID """ - try: - if not _id and self.msg.chat.type != ChatType.PRIVATE: - return await self.msg.edit(lang('tip_run_in_pm'), parse_mode=ParseMode.HTML) - _id = _id and int(_id) or self.msg.chat.id - if sqlite.get(f"pmcaptcha.challenge.{_id}") or curr_captcha.get(_id): - if sqlite.get(f"pmcaptcha.challenge.{_id}"): - del sqlite[f"pmcaptcha.challenge.{_id}"] - if curr_captcha.get(_id): - del curr_captcha[_id] - try: - await bot.unarchive_chats(chat_ids=_id) - await bot.invoke(UpdateNotifySettings( - peer=InputNotifyPeer(peer=await bot.resolve_peer(_id)), - settings=InputPeerNotifySettings(show_previews=True, silent=False))) - await bot.unblock_user(_id) - except: # noqa - pass - return await self.msg.edit(lang('unstuck_success') % _id, parse_mode=ParseMode.HTML) - await self.msg.edit(lang('not_stuck') % _id, parse_mode=ParseMode.HTML) - except ValueError: - await self.msg.edit(lang('invalid_user_id'), parse_mode=ParseMode.HTML) + if not (user_id := await self._get_user_id(_id)): + return await self.msg.edit(lang('invalid_user_id'), parse_mode=ParseMode.HTML) + captcha = None + if (state := setting.get_challenge_state(user_id)) or (captcha := curr_captcha.get(user_id)): + await CaptchaTask.archive(user_id, un_archive=True) + try: + (captcha and captcha.type or state.get("type", "math")) == "img" and await bot.unblock_user(user_id) + except Exception as e: + console.error(f"Error when unblocking user {user_id}: {e}\n{traceback.format_exc()}") + if captcha: + del curr_captcha[user_id] + state and setting.del_challenge_state(user_id) + return await self.msg.edit(lang('unstuck_success') % user_id, parse_mode=ParseMode.HTML) + await self.msg.edit(lang('not_stuck') % user_id, parse_mode=ParseMode.HTML) - async def welcome(self, *message: str): + async def welcome(self, *message: Optional[str]): """查看或设置验证通过时发送的消息 - 使用 ,{cmd_name} welcome -clear 可恢复默认规则 + 使用 ,{cmd_name} welcome -c 可恢复默认规则 - :param message: 消息内容 + :param opt message: 消息内容 :alias: wel """ - data = sqlite.get("pmcaptcha", {}) if not message: - return await self.msg.edit_text("\n".join(( - lang('welcome_curr_rule') + ":", - code(data.get('welcome', lang('none'))), - "", - lang('tip_edit') % html.escape(f",{cmd_name} wel <{lang('vocab_msg')}>") - )), parse_mode=ParseMode.HTML) + return await self._display_value( + key="welcome", + display_text=code(setting.get('welcome', lang('none'))), + sub_cmd="wel", + value_type="vocab_msg") message = " ".join(message) - if message == "-clear": - if data.get("welcome"): - del data["welcome"] - sqlite["pmcaptcha"] = data - await self.msg.edit(lang('welcome_reset'), parse_mode=ParseMode.HTML) - return - data["welcome"] = message - sqlite["pmcaptcha"] = data + if message.startswith("-c"): + setting.delete("welcome") + return await self.msg.edit(lang('welcome_reset'), parse_mode=ParseMode.HTML) + setting.set("welcome", message) await self.msg.edit(lang('welcome_set'), parse_mode=ParseMode.HTML) - async def whitelist(self, array: str): + async def whitelist(self, array: Optional[str]): """查看或设置关键词白名单列表(英文逗号分隔) - 使用 ,{cmd_name} whitelist -clear 可清空列表 + 使用 ,{cmd_name} whitelist -c 可清空列表 - :param array: 白名单列表 (英文逗号分隔) + :param opt array: 白名单列表 (英文逗号分隔) :alias: wl, whl """ - data = sqlite.get("pmcaptcha", {}) - if not array: - return await self.msg.edit_text("\n".join(( - lang('whitelist_curr_rule') + ":", - code(data.get('whitelist', lang('none'))), - "", - lang('tip_edit') % html.escape(f",{cmd_name} wl <{lang('vocab_array')}>") - )), parse_mode=ParseMode.HTML) - if array == "-clear": - if data.get("whitelist"): - del data["whitelist"] - sqlite["pmcaptcha"] = data - await self.msg.edit(lang('whitelist_reset'), parse_mode=ParseMode.HTML) - return - data["whitelist"] = array.replace(" ", "").split(",") - sqlite["pmcaptcha"] = data - await self.msg.edit(lang('whitelist_set'), parse_mode=ParseMode.HTML) + return await self._set_list("whitelist", array) - async def blacklist(self, array: str): + async def blacklist(self, array: Optional[str]): """查看或设置关键词黑名单列表 (英文逗号分隔) - 使用 ,{cmd_name} blacklist -clear 可清空列表 + 使用 ,{cmd_name} blacklist -c 可清空列表 - :param array: 黑名单列表 (英文逗号分隔) + :param opt array: 黑名单列表 (英文逗号分隔) :alias: bl """ - data = sqlite.get("pmcaptcha", {}) - if not array: - return await self.msg.edit_text("\n".join(( - lang('blacklist_curr_rule') + ":", - code(data.get('blacklist', lang('none'))), - "", - lang('tip_edit') % html.escape(f",{cmd_name} bl <{lang('vocab_array')}>") - )), parse_mode=ParseMode.HTML) - if array == "-clear": - if data.get("blacklist"): - del data["blacklist"] - sqlite["pmcaptcha"] = data - await self.msg.edit(lang('blacklist_reset'), parse_mode=ParseMode.HTML) - return - data["blacklist"] = array.replace(" ", "").split(",") - sqlite["pmcaptcha"] = data - await self.msg.edit(lang('blacklist_set'), parse_mode=ParseMode.HTML) + return await self._set_list("blacklist", array) - async def timeout(self, seconds: str): - """查看或设置超时时间,默认为 30 秒 (不适用于图像模式) + async def timeout(self, seconds: Optional[int], _type: Optional[str]): + """查看或设置超时时间,默认为 30 秒;图像模式为 5 分钟 使用 ,{cmd_name} wait off 可关闭验证时间限制 - :param seconds: 超时时间,单位秒 + 在图像模式中,此超时时间会于用户最后活跃而重置, + 建议数值设置大一点让机器人有一个时间可以处理后端操作 + + :param opt seconds: 超时时间,单位秒 + :param opt _type: 验证类型,默认为当前类型 :alias: wait """ - data = sqlite.get("pmcaptcha", {}) - if not seconds: - return await self.msg.edit_text("\n".join(( - lang('timeout_curr_rule') % int(data.get('timeout', 30)), - "", - lang('tip_edit') % html.escape(f",{cmd_name} wait <{lang('vocab_int')}>") - )), parse_mode=ParseMode.HTML) - if seconds == "off": - if data.get("timeout"): - data["timeout"] = 0 - sqlite["pmcaptcha"] = data - await self.msg.edit(lang('timeout_off'), parse_mode=ParseMode.HTML) - return - try: - data["timeout"] = int(seconds) - sqlite["pmcaptcha"] = data - except ValueError: + if _type and _type not in ("math", "img"): + return await self.help("wait") + captcha_type: str = _type or setting.get("type") + key_name: str = { + "img": "img_timeout", + "math": "timeout" + }.get(captcha_type) + default_timeout_time: int = { + "img": 300, + "math": 30 + }.get(captcha_type) + if seconds is None: + return await self._display_value( + display_text=lang('timeout_curr_rule') % int(setting.get(key_name, default_timeout_time)), + sub_cmd="wait", + value_type="vocab_int") + elif seconds == "off": + setting.delete(key_name) + return await self.msg.edit(lang('timeout_off'), parse_mode=ParseMode.HTML) + if seconds < 0: return await self.msg.edit(lang('invalid_param'), parse_mode=ParseMode.HTML) - await self.msg.edit(lang('timeout_set') % int(seconds), parse_mode=ParseMode.HTML) + setting.set(key_name, seconds) + await self.msg.edit(lang('timeout_set') % seconds, parse_mode=ParseMode.HTML) - async def disable_pm(self, toggle: str): + async def disable_pm(self, toggle: Optional[str]): """启用 / 禁止陌生人私聊,默认为 N (允许私聊) 此功能会放行联系人和白名单(已通过验证)用户 您可以使用 ,{cmd_name} add 将用户加入白名单 - :param toggle: 开关 (y / n) - :alias: disablepm + :param opt toggle: 开关 (y / n) + :alias: disablepm, disable """ - data = sqlite.get("pmcaptcha", {}) if not toggle: - return await self.msg.edit_text("\n".join(( - lang('disable_pm_curr_rule') % lang('enabled' if data.get('disable') else 'disabled'), - "", - lang('tip_edit') % html.escape(f",{cmd_name} disablepm <{lang('vocab_bool')}>") - )), parse_mode=ParseMode.HTML) - toggle = toggle.lower()[0] - if toggle not in ("y", "n", "t", "f", "1", "0"): - return await self.msg.edit(lang('invalid_param'), parse_mode=ParseMode.HTML) - if toggle not in ("y", "t", "1") and data.get("disable"): - del data["disable"] - else: - data["disable"] = True - sqlite["pmcaptcha"] = data - await self.msg.edit(lang('disable_pm_set') % lang("enabled" if data.get("disable") else "disabled"), - parse_mode=ParseMode.HTML) + return await self._display_value( + display_text=lang('disable_pm_curr_rule') % lang('enabled' if setting.get('disable') else 'disabled'), + sub_cmd="disable_pm", + value_type="vocab_bool") + await self._set_toggle("disable", toggle) - async def stats(self, arg: str): + async def stats(self, arg: Optional[str]): """查看验证统计 - 可以使用 ,{cmd_name} stats -clear 重置 + 使用 ,{cmd_name} stats -c 重置数据 + + :param opt arg: 参数 (reset) """ - data = sqlite.get("pmcaptcha", {}) if not arg: - data = (data.get('pass', 0) + data.get('banned', 0), data.get('pass', 0), data.get('banned', 0)) + data = (setting.get('pass', 0) + setting.get('banned', 0), setting.get('pass', 0), setting.get('banned', 0)) return await self.msg.edit_text(f"{code('PMCaptcha')} {lang('stats_display') % data}", parse_mode=ParseMode.HTML) - if arg == "-clear": - data["pass"] = 0 - data["banned"] = 0 - sqlite["pmcaptcha"] = data - await self.msg.edit(lang('stats_reset'), parse_mode=ParseMode.HTML) - return + if arg.startswith("-c"): + setting.delete('pass').delete('banned') + return await self.msg.edit(lang('stats_reset'), parse_mode=ParseMode.HTML) - async def action(self, action: str): - """选择验证失败的处理方式,默认为 archive + async def action(self, action: Optional[str]): + """选择验证失败的处理方式,默认为 none + 处理方式如下: + - ban | 封禁 + - delete | 封禁并删除对话 + - none | 不执行任何操作 - :param action: 处理方式 (ban / delete / archive / none) + :param opt action: 处理方式 (ban / delete / none) :alias: act """ - data = sqlite.get("pmcaptcha", {}) if not action: - action = data.get("action", "archive") - return await self.msg.edit_text("\n".join(( - lang('action_curr_rule') + ":", - lang('action_set_none') if action == "none" else lang('action_set') % lang(f'action_{action}'), - "", - lang('tip_edit') % html.escape(f",{cmd_name} act <{lang('action_param_name')}>") - )), parse_mode=ParseMode.HTML) - if action not in ("ban", "delete", "archive", "none"): - return await self.msg.edit(lang('invalid_param'), parse_mode=ParseMode.HTML) - if action in ("ban", "delete", "archive"): - await self.msg.edit(lang('action_set') % lang(f'action_{action}'), parse_mode=ParseMode.HTML) - elif action == "none": - await self.msg.edit(lang('action_set_none'), parse_mode=ParseMode.HTML) - data["action"] = action - sqlite["pmcaptcha"] = data + action = setting.get("action", "none") + return await self._display_value( + key="action", + display_text=lang(f"action_{action == 'none' and 'set_none' or action}"), + sub_cmd="act", + value_type="action_param_name") + if action not in ("ban", "delete", "none"): + return await self.help("act") + if (action == "none" and setting.delete("action") or setting.set("action", action)) == action: + return await self.msg.edit(lang('action_set') % lang(f'action_{action}'), parse_mode=ParseMode.HTML) + await self.msg.edit(lang('action_set_none'), parse_mode=ParseMode.HTML) - async def report(self, toggle: str): + async def report(self, toggle: Optional[str]): """选择验证失败后是否举报该用户,默认为 N - :param toggle: 开关 (y / n) + :param opt toggle: 开关 (y / n) """ - data = sqlite.get("pmcaptcha", {}) if not toggle: - return await self.msg.edit_text("\n".join(( - lang('report_curr_rule') % lang('enabled' if data.get('report') else 'disabled'), - "", - lang('tip_edit') % html.escape(f",{cmd_name} report <{lang('vocab_bool')}>") - )), parse_mode=ParseMode.HTML) - toggle = toggle.lower()[0] - if toggle not in ("y", "n", "t", "f", "1", "0"): - return await self.msg.edit(lang('invalid_param'), parse_mode=ParseMode.HTML) - if toggle not in ("y", "t", "1") and data.get("report"): - del data["report"] - else: - data["report"] = True - sqlite["pmcaptcha"] = data - await self.msg.edit(lang('report_set') % lang("enabled" if data.get("report") else "disabled"), - parse_mode=ParseMode.HTML) + return await self._display_value( + display_text=lang('report_curr_rule') % lang('enabled' if setting.get('report') else 'disabled'), + sub_cmd="report", + value_type="vocab_bool") + await self._set_toggle("report", toggle) - async def premium(self, action: str): + async def premium(self, action: Optional[str]): """选择对 Premium 用户的操作,默认为 none + 处理方式如下: + - allow | 白名单 + - ban | 封禁 + - only | 只允许 + - none | 不执行任何操作 - :param action: 操作方式 (allow / ban / only / none) + :param opt action: 处理方式 (allow / ban / only / none) :alias: vip, prem """ - data = sqlite.get("pmcaptcha", {}) if not action: - return await self.msg.edit_text("\n".join(( - lang('premium_curr_rule') + ":", - lang(f'premium_set_{data.get("premium", "none")}'), - "", - lang('tip_edit') % html.escape(f",{cmd_name} prem <{lang('action_param_name')}>") - )), parse_mode=ParseMode.HTML) + return await self._display_value( + key="premium", + display_text=lang(f'premium_set_{setting.get("premium", "none")}'), + sub_cmd="vip", + value_type="action_param_name") if action not in ("allow", "ban", "only", "none"): - return await self.msg.edit(lang('invalid_param'), parse_mode=ParseMode.HTML) - if action == "none" and data.get("premium"): - del data["premium"] - else: - data["premium"] = action - sqlite["pmcaptcha"] = data + return await self.help("vip") + action == "none" and setting.delete("action") or setting.set("action", action) await self.msg.edit(lang(f'premium_set_{action}'), parse_mode=ParseMode.HTML) - async def silent(self, toggle: Optional[str] = None): - """减少信息发送,默认为 no + async def groups_in_common(self, count: Optional[int]): + """设置是否对拥有一定数量的共同群的用户添加白名单 + 使用 ,{cmd_name} groups -1 重置设置 + + :param opt count: 共同群数量 + :alias: group, groups, common + """ + if not count: + groups = setting.get('groups_in_common') + text = lang(f"groups_in_common_{'set' if groups is not None else 'disabled'}") + if groups is not None: + text = text % groups + return await self._display_value( + display_text=text, + sub_cmd="groups", + value_type="vocab_int") + if count == -1: + setting.delete('groups_in_common') + return await self.msg.edit(lang('groups_in_common_disable'), parse_mode=ParseMode.HTML) + elif count < 0: + return await self.help("groups_in_common") + setting.set('groups_in_common', count) + await self.msg.edit(lang('groups_in_common_set') % count, parse_mode=ParseMode.HTML) + + async def chat_history(self, count: Optional[int]): + """设置对拥有一定数量的聊天记录的用户添加白名单(触发验证的信息不计算在内) + 使用 ,{cmd_name} his -1 重置设置 + + 请注意,由于 Telegram 内部限制,信息获取有可能会不完整,请不要设置过大的数值 + + :param opt count: 聊天记录数量 + :alias: his, history + """ + if not count: + history_count = setting.get('history_count') + text = lang("chat_history_curr_rule" if history_count is not None else "chat_history_disabled") + if history_count is not None: + text = text % history_count + return await self._display_value( + display_text=text, + sub_cmd="his", + value_type="vocab_bool") + setting.set('history_count', count) + await self.msg.edit(lang('chat_history_curr_rule') % count, parse_mode=ParseMode.HTML) + + async def initiative(self, toggle: Optional[str]): + """设置对主动进行对话的用户添加白名单,默认为 N + + :param opt toggle: 开关 (y / n) + """ + if not toggle: + return await self._display_value( + display_text=lang('initiative_curr_rule') % lang( + 'enabled' if setting.get('initiative', False) else 'disabled'), + sub_cmd="initiative", + value_type="vocab_bool") + await self._set_toggle("initiative", toggle) + + async def silent(self, toggle: Optional[str]): + """减少信息发送,默认为 N 开启后,将不会发送封禁提示 (不影响 log 发送) - :param toggle: 开关 (yes / no) + :param opt toggle: 开关 (y / n) :alias: quiet """ - data = sqlite.get("pmcaptcha", {}) if not toggle: - return await self.msg.edit_text("\n".join(( - lang('silent_curr_rule') % lang('enabled' if data.get('silent', False) else 'disabled'), - "", - lang('tip_edit') % html.escape(f",{cmd_name} silent <{lang('vocab_bool')}>") - )), parse_mode=ParseMode.HTML) - toggle = toggle.lower()[0] - if toggle not in ("y", "n", "t", "f", "1", "0"): - return await self.msg.edit(lang('invalid_param'), parse_mode=ParseMode.HTML) - if toggle not in ("y", "t", "1") and data.get("silent"): - del data["silent"] - else: - data["silent"] = True - sqlite["pmcaptcha"] = data - await self.msg.edit(lang('silent_set') % lang("enabled" if data.get('silent') else "disabled"), - parse_mode=ParseMode.HTML) + return await self._display_value( + display_text=lang('silent_curr_rule') % lang('enabled' if setting.get('silent', False) else 'disabled'), + sub_cmd="quiet", + value_type="vocab_bool") + await self._set_toggle("silent", toggle) - async def collect_logs(self, toggle: str): + async def flood(self, limit: Optional[int]): + """设置一分钟内超过 n 人开启轰炸检测机制,默认为 50 人 + 此机制会在用户被轰炸时启用,持续 5 分钟,假如有用户继续进行私聊计时将会重置 + + 当轰炸开始时,PMCaptcha 将会启动以下一系列机制 + - 强制开启自动归档(无论是否 Telegram Premium 用户都会尝试开启) + - 不向用户发送 CAPTCHA 挑战 + - 继上面的机制,记录未发送 CAPTCHA 的用户 ID + - (用户可选)创建临时频道,并把用户名转移到创建的频道上 【默认关闭】 + + 轰炸结束后,如果用户名已转移到频道上,将恢复用户名,并删除频道 + 并对记录收集机器人发送轰炸的用户数量轰炸开始时间轰炸结束时间轰炸时长(由于不存在隐私问题,此操作为强制性) + + 请参阅 ,{cmd_name} h flood_username 了解更多有关创建临时频道的机制 + 请参阅 ,{cmd_name} h flood_act 查看轰炸结束后的处理方式 + + :param opt limit: 人数限制 + :alias: boom + """ + if not limit: + return await self._display_value( + display_text=lang('flood_curr_rule') % setting.get('flood_limit', 50), + sub_cmd="flood", + value_type="vocab_int") + setting.set('flood_limit', limit) + await self.msg.edit(lang('flood_curr_rule') % limit, parse_mode=ParseMode.HTML) + + async def flood_username(self, toggle: Optional[str]): + """设置是否在轰炸时启用“转移用户名到临时频道”机制(如有用户名) + 将此机制分开出来的原因是此功能有可能会被抢注用户名(虽然经测试临时取消用户名并不会出现此问题) + 但为了万一依然分开出来作为一个选项了 + + 启用后,在轰炸机制开启时,会进行以下操作 + - 创建临时频道 + - (如创建成功)清空用户名,设置用户名为临时频道,并在频道简介设置正在受到轰炸提示 + - (如设置失败)恢复用户名,删除频道 + + :param opt toggle: 开关 (y / n) + :alias: boom_username + """ + global user_want_set_flood_username + if not toggle: + return await self._display_value( + display_text=lang('flood_username_curr_rule') % lang( + 'enabled' if setting.get('flood_username', False) else 'disabled'), + sub_cmd="flood_username", + value_type="vocab_bool") + if toggle in ("y", "t", "1", "on") and not user_want_set_flood_username: + user_want_set_flood_username = True + return await self.msg.edit(lang('flood_username_set_confirm'), parse_mode=ParseMode.HTML) + user_want_set_flood_username = None + await self._set_toggle("flood_username", toggle) + + async def flood_act(self, action: Optional[str]): + """设置轰炸结束后进行的处理方式,默认为 none + 可用的处理方式如下: + - asis | 与验证失败的处理方式一致,但不会进行验证失败通知 + - captcha | 对每个用户进行 CAPTCHA 挑战 + - none | 不进行任何操作 + + :param opt action: 处理方式 + :alias: boom_act + """ + if not action: + return await self._display_value( + display_text=lang('flood_act_curr_rule') % lang( + setting.get('flood_act', 'none')), + sub_cmd="flood_act", + value_type="vocab_str") + if action not in ("asis", "captcha", "none"): + return await self.help("flood_act") + action == "none" and setting.delete("flood_act") or setting.set("flood_act", action) + await self.msg.edit(lang(f'flood_act_set_{action}'), parse_mode=ParseMode.HTML) + + async def collect_logs(self, toggle: Optional[str]): """查看或设置是否允许 PMCaptcha 收集验证错误相关信息以帮助改进 - 默认为 N,收集的信息包括被验证者的信息以及未通过验证的信息记录 + 默认为 Y,收集的信息包括被验证者的信息以及未通过验证的信息记录 - :param toggle: 开关 (y / n) + :param opt toggle: 开关 (y / n) :alias: collect, log """ - data = sqlite.get("pmcaptcha", {}) if not toggle: - status = lang('enabled' if data.get('collect', False) else 'disabled') - return await self.msg.edit_text("\n".join(( - lang('collect_logs_curr_rule') % status, - lang("collect_logs_note"), - "", - lang('tip_edit') % html.escape(f",{cmd_name} log <{lang('vocab_bool')}>") - )), parse_mode=ParseMode.HTML) - toggle = toggle.lower()[0] - if toggle not in ("y", "n", "t", "f", "1", "0"): - return await self.msg.edit(lang('invalid_param'), parse_mode=ParseMode.HTML) - if toggle not in ("y", "t", "1") and data.get("collect"): - del data["collect"] - else: - data["collect"] = True - sqlite["pmcaptcha"] = data - await self.msg.edit(lang('collect_logs_set') % lang("enabled" if data.get("collect") else "disabled")) + status = lang('enabled' if setting.get('collect', True) else 'disabled') + return await self._display_value( + display_text=f"{lang('collect_logs_curr_rule') % status}\n{lang('collect_logs_note')}", + sub_cmd="log", + value_type="vocab_bool") + await self._set_toggle("collect_logs", toggle) + + async def change_type(self, _type: Optional[str]): + """切换验证码类型,默认为 math + 验证码类型如下: + - math | 计算验证 + - img | 图像辨识验证 + + 注意:如果图像验证不能使用将回退到计算验证 + + :param opt _type: 验证码类型 (img / math) + :alias: type, typ + """ + if not _type: + return await self._display_value( + display_text=lang('type_curr_rule') % lang(f'type_captcha_{setting.get("type", "math")}'), + sub_cmd="typ", + value_type="type_param_name") + if _type not in ("img", "math"): + return await self.help("typ") + _type == "math" and setting.delete("type") or setting.set("type", _type) + await self.msg.edit(lang('type_set') % lang(f'type_captcha_{_type}'), parse_mode=ParseMode.HTML) # Image Captcha - async def change_type(self, _type: str): - """切换验证码类型,默认为 math - 目前只有基础计算和图形辨识 - - 注意:如果图像验证不能使用将回退到计算验证 - - :param _type: 验证码类型 (img / math) - :alias: type, typ - """ - data = sqlite.get("pmcaptcha", {}) - if not _type: - return await self.msg.edit_text("\n".join(( - lang('type_curr_rule') % lang(f'type_captcha_{data.get("type", "math")}'), - "", - lang('tip_edit') % html.escape(f",{cmd_name} typ <{lang('type_param_name')}>") - )), parse_mode=ParseMode.HTML) - if _type not in ("img", "math"): - return await self.msg.edit(lang('invalid_param'), parse_mode=ParseMode.HTML) - data["type"] = _type - sqlite["pmcaptcha"] = data - await self.msg.edit(lang('type_set') % lang(f'type_captcha_{_type}'), parse_mode=ParseMode.HTML) - - async def change_img_type(self, _type: str): + async def change_img_type(self, _type: Optional[str]): """切换图像辨识使用接口,默认为 func 目前可用的接口: - func (ArkLabs funCaptcha ) - github (GitHub 螺旋星系 ) - rec (Google reCAPTCHA ) - 请注意, reCAPTCHA 难度相比前两个高出不少, - 因此验证码系统会在尝试过多后提供 func 接口让用户选择 - :param _type: 验证码类型 (func / github / rec) + 请注意, reCAPTCHA 难度相比前两个高出不少, + 因此验证码系统会在尝试过多后提供 funCaptcha 接口让用户选择 + + :param opt _type: 验证码类型 (func / github / rec) :alias: img_type, img_typ """ - data = sqlite.get("pmcaptcha", {}) if not _type: - return await self.msg.edit_text("\n".join(( - lang('type_curr_rule') % lang(f'img_captcha_type_{data.get("img_type", "func")}'), - "", - lang('tip_edit') % html.escape(f",{cmd_name} img_typ <{lang('type_param_name')}>") - )), parse_mode=ParseMode.HTML) + return await self._display_value( + display_text=lang('type_curr_rule') % lang(f'img_captcha_type_{setting.get("img_type", "func")}'), + sub_cmd="img_typ", + value_type="type_param_name") if _type not in ("func", "github", "rec"): - return await self.msg.edit(lang('invalid_param'), parse_mode=ParseMode.HTML) - data["img_type"] = _type - sqlite["pmcaptcha"] = data - await self.msg.edit( - lang('type_set') % lang(f'img_captcha_type_{_type}'), - parse_mode=ParseMode.HTML, - ) + return await self.help("img_typ") + _type == "func" and setting.delete("img_type") or setting.set("img_type", _type) + await self.msg.edit(lang('type_set') % lang(f'img_captcha_type_{_type}'), parse_mode=ParseMode.HTML) - async def img_retry_chance(self, number: str): + async def img_retry_chance(self, number: Optional[int]): """图形验证码最大可重试次数,默认为 3 - :param number: 重试次数 + :param opt number: 重试次数 :alias: img_re """ - data = sqlite.get("pmcaptcha", {}) - if not number: - return await self.msg.edit_text("\n".join(( - lang('img_captcha_retry_curr_rule') % data.get("img_max_retry", 3), - "", - lang('tip_edit') % html.escape(f",{cmd_name} img_re <{lang('vocab_int')}>") - )), parse_mode=ParseMode.HTML) - try: - data["img_max_retry"] = int(number) - sqlite["pmcaptcha"] = data - await self.msg.edit(lang('img_captcha_retry_set') % number, parse_mode=ParseMode.HTML) - except ValueError: + if number is None: + return await self._display_value( + display_text=lang('img_captcha_retry_curr_rule') % setting.get("img_max_retry", 3), + sub_cmd="img_re", + value_type="vocab_int") + if number < 0: return await self.msg.edit(lang('invalid_param'), parse_mode=ParseMode.HTML) + setting.set("img_max_retry", number) + await self.msg.edit(lang('img_captcha_retry_set') % number, parse_mode=ParseMode.HTML) # region Captcha -async def punishment_worker(q: asyncio.Queue): - data = None - flood_text = "Flood Triggered: %is, command: %s, target: %s" - while True: - data = data or sqlite.get("pmcaptcha", {}) - target = None + +@dataclass +class TheOrder: + """Worker of blocking user (Punishment)""" + queue = asyncio.Queue() + task: Optional[asyncio.Task] = None + flood_text = "[The Order] Flood Triggered: wait %is, Command: %s, Target: %s" + + def __post_init__(self): + if pending := setting.pending_ban_list.get_subs(): + console.debug(f"Pending user(s) to ban: {len(pending)}") + for user_id in pending: + self.queue.put_nowait((user_id,)) + + async def worker(self): + console.debug("Punishment Worker started") + while True: + target = None + try: + (target,) = await self.queue.get() + action = setting.get("action", "none") + if action in ("ban", "delete"): + for _ in range(3): + try: + await bot.block_user(user_id=target) + break + except FloodWait as e: + console.info(self.flood_text % (e.value, "Block", target)) + await asyncio.sleep(e.value) + except Exception as e: + console.debug(f"Failed to block user {target}: {e}\n{traceback.format_exc()}") + if action == "delete": + for _ in range(3): + try: + await bot.invoke(messages.DeleteHistory( + just_clear=False, + revoke=False, + peer=await bot.resolve_peer(target), + max_id=0)) + break + except FloodWait as e: + console.info(self.flood_text % (e.value, "Delete Message", target)) + await asyncio.sleep(e.value) + except Exception as e: + console.debug(f"Failed to delete user {target}: {e}\n{traceback.format_exc()}") + setting.pending_ban_list.del_id(target) + setting.get_challenge_state(target) and setting.del_challenge_state(target) + setting.set("banned", setting.get("banned", 0) + 1) + chat_link = gen_link(str(target), f"tg://user?id={target}") + text = f"[PMCaptcha - The Order] {lang('verify_log_punished')} (Punishment)" + action not in ("none", "archive") and await log(text % (chat_link, lang(f'action_{action}')), True) + except asyncio.CancelledError: + break + except Exception as e: + await log(f"Error occurred when punishing user: {e}\n{traceback.format_exc()}") + finally: + target and self.queue.task_done() + + async def active(self, user_id: int, reason_code: str): + if not self.task or self.task.done(): + self.task = asyncio.create_task(self.worker()) try: - (target,) = await q.get() - action = data.get("action", "archive") - if action in ("ban", "delete", "archive"): - for _ in range(3): - try: - await bot.block_user(user_id=target) - break - except FloodWait as e: - await log(flood_text % (e.value, "Block", target)) - await asyncio.sleep(e.value) - if action == "delete": - for _ in range(3): - try: - await bot.invoke(DeleteHistory(peer=await bot.resolve_peer(target), max_id=0)) - break - except FloodWait as e: - await log(flood_text % (e.value, "Delete Message", target)) - await asyncio.sleep(e.value) - elif action == "archive": - for _ in range(3): - try: - await bot.archive_chats(chat_ids=target) - break - except FloodWait as e: - await log(flood_text % (e.value, "Archive", target)) - await asyncio.sleep(e.value) - data['banned'] = data.get('banned', 0) + 1 - sqlite['pmcaptcha'] = data - chat_link = gen_link(str(target), f"tg://openmessage?user_id={target}") - await log(("[PMCaptcha - The Order] " - f"{lang('verify_log_punished') % (chat_link, lang(f'action_{action}'))} (Punishment)"), True) - except asyncio.CancelledError: - break - except Exception as e: # noqa - await log(f"Error occurred when punishing user: {e}\n{traceback.format_exc()}") + user = await bot.get_users(user_id) + not setting.get("silent") and await bot.send_message(user_id, " ".join(( + lang(reason_code, user.language_code), + lang("verify_blocked", user.language_code) + ))) + except FloodWait: + pass # Skip waiting finally: - target and q.task_done() + setting.pending_ban_list.add_id(user_id) + self.queue.put_nowait((user_id,)) + console.debug(f"User {user_id} added to ban queue") -async def punish(user_id: int, reason_code: str): - try: - user = await bot.get_users(user_id) - not sqlite.get("pmcaptcha", {}).get("silent", False) and await bot.send_message(user_id, " ".join(( - lang(reason_code, user.language_code), - lang("verify_blocked", user.language_code) - ))) - except FloodWait: - pass # Skip waiting - global punishment_task - if not punishment_task or punishment_task.done(): - punishment_task = asyncio.create_task(punishment_worker(punishment_queue)) - return punishment_queue.put_nowait((user_id,)) +@dataclass +class TheWorldEye: + """Anti-Flooding System + + Actual name of each functions: + - sophitia -> Watcher + - synchronize -> flood_triggered + - overload -> flood_ended + """ + queue = asyncio.Queue() + watcher: Optional[asyncio.Task] = None + timer_task: Optional[asyncio.Task] = None + + # Watcher + last_challenge_time: Optional[int] = None + level: int = 0 + + # Post Init Value + channel_id: Optional[int] = None + username: Optional[str] = None + triggered: bool = False + start: Optional[int] = None + update: Optional[int] = None + end: Optional[int] = None + user_ids: Optional[list] = field(init=False) + auto_archive_enabled_default: Optional[bool] = None + + def __post_init__(self): + self.user_ids = [] + if state := setting.get_flood_state(): # PMCaptcha restarts, flood keeps going + # Resume last flood state + now = int(time.time()) + self.triggered = True + self.channel_id = state.get("channel_id") + self.username = state.get("username") + self.start = state.get("start") + self.update = state.get("update") + self.user_ids = state.get("user_ids") + self.auto_archive_enabled_default = state.get("auto_archive_enabled_default") + self.reset_timer(300 - (now - self.start)) + console.debug("PMCaptcha restarted, flood state resume") + self.watcher = asyncio.create_task(self.sophitia()) + + # region Timer + + async def _flood_timer(self, interval: int): + try: + await asyncio.sleep(interval) + except asyncio.CancelledError: + return + console.debug("Flood ends") + self.triggered = False + self.end = int(time.time()) + await self.overload() + + def reset_timer(self, interval: int = 300): + if self.timer_task and not self.timer_task.done(): + self.timer_task.cancel() + self.update = int(time.time()) + self.timer_task = asyncio.create_task(self._flood_timer(interval)) + console.debug("Flood timer reset") + return self + + # endregion + + async def _set_channel_username(self): + console.debug("Creating temporary channel") + try: + channel = await bot.create_supergroup( + "PMCaptcha Temporary Channel", + about="\n\n".join((lang("flood_channel_desc", "en"), lang("flood_channel_desc", "zh")))) + console.debug("Temporary channel created") + self.channel_id = channel.id + except Exception as e: + await log(f"Failed to create temporary channel: {e}\n{traceback.format_exc()}") + return False + console.debug("Moving username to temporary channel") + try: + await bot.set_username(None) + except Exception as e: + await log(f"Failed to remove username: {e}\n{traceback.format_exc()}") + return False + result = False + try: + await bot.invoke(UpdateUsername(channel=await bot.resolve_peer(channel.id), username=self.username)) + result = True + except ChannelsAdminPublicTooMuch: + await log("Failed to move username to temporary channel, too many public channels") + except Exception as e: + await log(f"Failed to set username for channel: {e}\n{traceback.format_exc()}") + if not result: + console.debug("Setting back username") + try: + await bot.set_username(self.username) + await bot.delete_supergroup(channel.id) + except Exception as e: + await log(f"Failed to set username back: {e}\n{traceback.format_exc()}") + self.username = None + return result + + async def _restore_username(self): + if self.channel_id: + console.debug("Deleting temporary channel") + try: + await bot.invoke( + UpdateUsername(channel=await bot.resolve_peer(self.channel_id), username=self.username) + ) + except Exception as e: + await log(f"Failed to remove username for channel: {e}\n{traceback.format_exc()}") + try: + await bot.delete_supergroup(self.channel_id) + except Exception as e: + console.debug(f"Failed to delete temporary channel: {e}\n{traceback.format_exc()}") + if self.username: + console.debug("Setting back username") + try: + await bot.set_username(self.username) + except Exception as e: + await log(f"Failed to set username back: {e}\n{traceback.format_exc()}") + self.username = self.channel_id = None + + # region State + + def save_state(self): + setting.set_flood_state({ + "start": self.start, + "update": self.update, + "user_ids": self.user_ids, + "auto_archive_enabled_default": self.auto_archive_enabled_default, + "username": self.username, + "channel_id": self.channel_id + }) + + def update_state(self): + data = setting.get_flood_state() + data.update({ + "update": self.update, + "user_ids": self.user_ids, + }) + setting.set_flood_state(data) + + @staticmethod + def del_state(): + setting.del_flood_state() + + # endregion + + # noinspection SpellCheckingInspection + async def sophitia(self): + """Watches the private message chat (World)""" + console.debug("Flood Watcher started") + while True: + user_id = None + try: + (user_id,) = await self.queue.get() + if self.triggered: # Continues flooding, add to list and reset timer + self.reset_timer() + self.user_ids.append(user_id) + console.debug(f"User {user_id} added to flood list") + self.update_state() + continue + now = int(time.time()) + if self.last_challenge_time and now - self.last_challenge_time < 60: + # A user is challenged less than a min + self.level += 1 + elif not self.last_challenge_time or now - self.last_challenge_time > 60: + self.level = 1 + self.last_challenge_time = now + if self.level >= setting.get("flood_limit", 50): + console.warn(f"Flooding detected: {self.level} reached in 1 min") + self.triggered = True + self.start = self.update = now + self.reset_timer() + await self.synchronize() + except asyncio.CancelledError: + break + except Exception as e: + await log(f"Error occurred in flood watcher: {e}\n{traceback.format_exc()}") + finally: + user_id and self.queue.task_done() + + async def add_synchronize(self, user_id: int): + await self.queue.put((user_id,)) + + async def synchronize(self): + """Triggered when flood starts (Iris has started synchronizing people)""" + # Force enable auto archive to reduce api flood + settings: GlobalPrivacySettings = await bot.invoke(GetGlobalPrivacySettings()) + self.auto_archive_enabled_default = settings.archive_and_mute_new_noncontact_peers + if settings.archive_and_mute_new_noncontact_peers: + console.debug("Enabling auto archive") + try: + await bot.invoke(SetGlobalPrivacySettings( + settings=GlobalPrivacySettings(archive_and_mute_new_noncontact_peers=True) + )) + console.debug("Auto archive enabled") + except AutoarchiveNotAvailable: + console.warn("Auto archive is not available, API Flooding may be larger than expected") + except Exception as e: + console.error(f"Failed to enable auto archive: {e}\n{traceback.format_exc()}") + if setting.get("flood_username") and bot.me.username: + self.username = bot.me.username + console.debug("Moving username to temporary channel") + if not await self._set_channel_username(): + self.username = None + # Save state + self.save_state() + + async def overload(self): + """Executed after flood ends (Nine has performed load action)""" + console.info(f"Flood ended, {len(self.user_ids)} users were affected, duration: {self.end - self.start}s") + if self.channel_id or self.username: + console.debug("Changing back username") + await self._restore_username() + try: + await bot.send_message(log_collect_bot, "\n".join(( + "FLOOD", + f"User Count: {code(str(len(self.user_ids)))}" + f"Start: {code(str(self.start))}", + f"End: {code(str(self.end))}", + f"Duration: {code(str(self.end - self.start))}s", + ))) + except Exception as e: + console.debug(f"Failed to send flood log: {e}\n{traceback.format_exc()}") + if not self.auto_archive_enabled_default: # Restore auto archive setting + try: + await bot.invoke(SetGlobalPrivacySettings( + settings=GlobalPrivacySettings(archive_and_mute_new_noncontact_peers=False) + )) + console.debug("Auto archive disabled") + except Exception as e: + console.debug(f"Failed to disable auto archive: {e}\n{traceback.format_exc()}") + flood_act = setting.get("flood_act") + if flood_act == "asis": + if not the_order.task or the_order.task.done(): + the_order.task = asyncio.create_task(the_order.worker()) + for user_id in self.user_ids: + await the_order.queue.put((user_id,)) + await asyncio.sleep(5) + elif flood_act == "captcha": + if not captcha_task.task or captcha_task.task.done(): + captcha_task.task = asyncio.create_task(captcha_task.worker()) + for user_id in self.user_ids: + if (setting.pending_challenge_list.check_id(user_id) or curr_captcha.get(user_id) or + setting.get_challenge_state(user_id)): + continue + await self.queue.put((user_id, None, None, None)) + setting.pending_challenge_list.add_id(user_id) + console.debug(f"User {user_id} added to challenge queue") + await asyncio.sleep(8) + self.user_ids.clear() + self.start = self.end = self.update = self.auto_archive_enabled_default = None + self.del_state() + + +@dataclass +class CaptchaTask: + """A class to start, resume and verify the captcha challenge + and contains some nice function like archiving user, getting user's settings + + The main function of this class is to queue & start a captcha for the user + """ + queue = asyncio.Queue() + task: Optional[asyncio.Task] = None + flood_text = "[CaptchaTask] Flood Triggered: wait %is, Command: %s, Target: %s" + + def __post_init__(self): + if pending := setting.pending_challenge_list.get_subs(): + console.debug(f"Pending user(s) to challenge: {len(pending)}") + for user_id in pending: + self.queue.put_nowait((user_id, None, None, None)) + + @staticmethod + async def archive(user_id: int, *, un_archive: bool = False): + from pyrogram.raw.functions.account import UpdateNotifySettings + from pyrogram.raw.types import InputNotifyPeer, InputPeerNotifySettings + notify_setting = InputPeerNotifySettings(**{ + "mute_until": None if un_archive else 2147483647, + "show_previews": True if un_archive else None, + "silent": False if un_archive else None + }) + peer = InputNotifyPeer(peer=await bot.resolve_peer(user_id)) + for _ in range(3): + try: + await bot.invoke(UpdateNotifySettings(peer=peer, settings=notify_setting)) + await (bot.unarchive_chats if un_archive else bot.archive_chats)(user_id) + break + except FloodWait as e: + console.debug(f"{'Un' if un_archive else ''}Archive triggered flood for {user_id}, wait {e.value}s") + await asyncio.sleep(e.value) + except Exception as e: + console.debug(f"{'Un' if un_archive else ''}Archive failed for {user_id}, {e}") + + @staticmethod + async def get_user_settings(user_id: int) -> (bool, bool): + can_report = True + auto_archived = False + for _ in range(3): + try: + peer_settings: PeerSettings = await bot.invoke( + messages.GetPeerSettings(peer=await bot.resolve_peer(user_id))) + can_report = peer_settings.settings.report_spam + auto_archived = peer_settings.settings.autoarchived + break + except FloodWait as e: + console.debug(f"GetPeerSettings triggered flood for {user_id}, wait {e.value}s") + await asyncio.sleep(e.value) + except Exception as e: + console.debug(f"GetPeerSettings failed for {user_id}, {e}") + return can_report, auto_archived + + async def worker(self): + console.debug("Captcha Challenge Worker started") + while True: + user_id: Optional[int] = None + try: + user_id, msg, can_report, auto_archived = await self.queue.get() + user = msg and msg.from_user or await bot.get_users(user_id) + if can_report is None or auto_archived is None: + can_report, auto_archived = await self.get_user_settings(user_id) + if (last_captcha := setting.get_challenge_state(user_id)) and not curr_captcha.get(user_id): + # Resume last captcha challenge + if last_captcha["type"] not in captcha_challenges: + console.info("Failed to resume last captcha challenge: " + f"Unknown challenge type {last_captcha['type']}") + continue + await captcha_challenges[last_captcha["type"]].resume(user=user, msg=msg, state=last_captcha) + continue + # Start a captcha challenge + await self.archive(user_id) + captcha = (captcha_challenges.get(setting.get("type", "math"), MathChallenge) + (msg.from_user, can_report)) + captcha.log_msg(msg and (msg.text or msg.caption or "") or None) + captcha = await captcha.start() or captcha + curr_captcha[user_id] = captcha + setting.pending_challenge_list.del_id(user_id) + except asyncio.CancelledError: + break + except Exception as e: + await log(f"Error occurred when challenging user: {e}\n{traceback.format_exc()}") + finally: + user_id and self.queue.task_done() + + async def add(self, user_id: int, msg: Optional[Message], can_report: Optional[bool], + auto_archived: Optional[bool]): + await the_world_eye.add_synchronize(user_id) + if not self.task or self.task.done(): + self.task = asyncio.create_task(self.worker()) + if not (setting.pending_challenge_list.check_id(user_id) or curr_captcha.get(user_id) or + setting.get_challenge_state(user_id)): + setting.pending_challenge_list.add_id(user_id) + self.queue.put_nowait((user_id, msg, can_report, auto_archived)) + console.debug(f"User {user_id} added to challenge queue") @dataclass @@ -1072,20 +1814,27 @@ class CaptchaChallenge: type: str user: User input: bool - logs: List[str] = field(default_factory=list) - captcha_write_lock: asyncio.Lock = asyncio.Lock() + logs: List[str] = field(init=False, default_factory=list) + captcha_write_lock: asyncio.Lock = field(init=False, default_factory=asyncio.Lock) + + # User Settings + can_report: bool = True # Post Init Value captcha_start: int = 0 challenge_msg_id: Optional[int] = None + timer_task: Optional[asyncio.Task] = None # region Logging - def log_msg(self, msg: str): - msg.strip() and self.logs.append(msg.strip()) + def log_msg(self, msg: Optional[str]): + if isinstance(msg, str) and not msg.strip(): + return + self.logs.append(isinstance(msg, str) and msg.strip() or msg) async def send_log(self, ban_code: Optional[str] = None): - if not sqlite.get("pmcaptcha", {}).get("collect", False): + from io import BytesIO + if not setting.get("collect", True): return import json user = self.user @@ -1112,22 +1861,21 @@ class CaptchaChallenge: self.type and caption.append(f"Captcha Type: {code(self.type)}") ban_code and caption.append(f"Block Reason: {code(ban_code)}") send = False - last_exp = None + has_exp = False try: await bot.unblock_user(log_collect_bot) - except: # noqa - pass + except Exception as e: + console.error(f"Failed to unblock log collect bot: {e}\n{traceback.format_exc()}") for _ in range(3): try: - await bot.send_document(log_collect_bot, log_file, - caption="\n".join(caption), parse_mode=ParseMode.HTML) + await bot.send_document( + log_collect_bot, log_file, caption="\n".join(caption), parse_mode=ParseMode.HTML) send = True break - except Exception as e: # noqa - last_exp = f"{e}\n{traceback.format_exc()}" - if not send and last_exp: - return await log(f"Error occurred when sending log: {last_exp}") - elif not send: + except Exception as e: + console.error(f"Failed to send log to log collector bot: {e}\n{traceback.format_exc()}") + has_exp = True + if not send and not has_exp: return await log("Failed to send log") await log(f"Log collected from user {user.id}") @@ -1142,95 +1890,125 @@ class CaptchaChallenge: "start": self.captcha_start, "logs": self.logs, "msg_id": self.challenge_msg_id, + "report": self.can_report } extra and data.update(extra) - sqlite[f"pmcaptcha.challenge.{self.user.id}"] = data + setting.set_challenge_state(self.user.id, data) def update_state(self, changes: Optional[dict] = None): - data = sqlite.get(f"pmcaptcha.challenge.{self.user.id}", {}) + data = setting.get_challenge_state(self.user.id) changes and data.update(changes) - sqlite[f"pmcaptcha.challenge.{self.user.id}"] = data + setting.set_challenge_state(self.user.id, data) def del_state(self): - key = f"pmcaptcha.challenge.{self.user.id}" - if sqlite.get(key): - del sqlite[key] + setting.del_challenge_state(self.user.id) # endregion # region Verify Result async def _verify_success(self): - data = sqlite.get("pmcaptcha", {}) - whitelist.add_id(self.user.id) - data['pass'] = data.get('pass', 0) + 1 - sqlite['pmcaptcha'] = data - success_msg = data.get("welcome") or lang("verify_passed", self.user.language_code) + setting.whitelist.add_id(self.user.id) + setting.set("pass", setting.get("pass", 0) + 1) + success_msg = setting.get("welcome") or lang("verify_passed", self.user.language_code) welcome_msg: Optional[Message] = None try: if self.challenge_msg_id: welcome_msg = await bot.edit_message_text(self.user.id, self.challenge_msg_id, success_msg) - except: # noqa - pass + except Exception as e: + console.error(f"Failed to edit welcome message: {e}\n{traceback.format_exc()}") else: try: welcome_msg = await bot.send_message(self.user.id, success_msg) self.challenge_msg_id = welcome_msg.id - except: # noqa - pass + except Exception as e: + console.error(f"Failed to send welcome message: {e}\n{traceback.format_exc()}") await asyncio.sleep(3) welcome_msg and await welcome_msg.safe_delete() - try: - await bot.unarchive_chats(chat_ids=self.user.id) - await bot.invoke(UpdateNotifySettings( - peer=InputNotifyPeer(peer=await bot.resolve_peer(self.user.id)), - settings=InputPeerNotifySettings(show_previews=True, silent=False))) - except: # noqa - pass + await CaptchaTask.archive(self.user.id, un_archive=True) async def _verify_failed(self): try: self.challenge_msg_id and await bot.delete_messages(self.user.id, self.challenge_msg_id) - sqlite.get("pmcaptcha", {}).get("report", False) and await bot.invoke(ReportPeer( - peer=await bot.resolve_peer(self.user.id), - reason=InputReportReasonSpam(), - message="" - )) - except: # noqa - pass - await punish(self.user.id, "verify_failed") + (self.can_report and setting.get("report") and + await bot.invoke(messages.ReportSpam(peer=await bot.resolve_peer(self.user.id)))) + except Exception as e: + console.debug(f"Error occurred when executing verify failed function: {e}\n{traceback.format_exc()}") + await the_order.active(self.user.id, "verify_failed") await self.send_log() async def action(self, success: bool): async with self.captcha_write_lock: self.del_state() - if task := challenge_task.get(self.user.id): - task.cancel() - del challenge_task[self.user.id] + self.remove_timer() await getattr(self, f"_verify_{'success' if success else 'failed'}")() + console.debug(f"User {self.user.id} verify {'success' if success else 'failed'}") # endregion + # region Timer + + async def _challenge_timer(self, timeout: int): + try: + await asyncio.sleep(timeout) + except asyncio.CancelledError: + return + if self.captcha_write_lock.locked(): + return + async with self.captcha_write_lock: + console.debug(f"User {self.user.id} verification timed out") + await self.action(False) + if curr_captcha.get(self.user.id): + del curr_captcha[self.user.id] + + def reset_timer(self, timeout: Optional[int] = None): + if self.timer_task and not self.timer_task.done(): + self.timer_task.cancel() + timeout = timeout is not None and timeout or setting.get( + f"{self.type == 'img' and 'img_' or ''}timeout", self.type == "img" and 300 or 30) + if timeout > 0: + self.timer_task = asyncio.create_task(self._challenge_timer(timeout)) + console.debug(f"User {self.user.id} verification timer reset") + return self + + def remove_timer(self): + if task := self.timer_task: + task.cancel() + return self + + # endregion + + @classmethod + async def resume(cls, *, user: User, msg: Optional[Message] = None, state: dict): + console.debug(f"User {user.id} resumed captcha challenge {state['type']}") + + async def start(self): + console.debug(f"User {self.user.id} started {self.type} captcha challenge") + class MathChallenge(CaptchaChallenge): answer: int - def __init__(self, user: User): - super().__init__("math", user, True) + def __init__(self, user: User, can_report: bool): + super().__init__("math", user, True, can_report) @classmethod - async def resume(cls, msg: Message, state: dict): - user = msg.from_user - captcha = cls(user) + async def resume(cls, *, user: User, msg: Optional[Message] = None, state: dict): + captcha = cls(user, state['report']) captcha.captcha_start = state['start'] captcha.logs = state['logs'] captcha.challenge_msg_id = state['msg_id'] - now = int(time.time()) - timeout = sqlite.get("pmcaptcha", {}).get("timeout", 30) - if timeout > 0 and now - state['start'] > timeout: - return await captcha.action(False) captcha.answer = state['answer'] - await captcha.verify(msg.text) + if (timeout := setting.get("timeout", 30)) > 0: + time_passed = int(time.time()) - int(state['start']) + if time_passed > timeout: + # Timeout + return await captcha.action(False) + if msg: # Verify result + await captcha.verify(msg.text or msg.caption or "") + else: # Restore timer + captcha.reset_timer(timeout - time_passed) + await super(MathChallenge, captcha).resume(user=user, msg=msg, state=state) async def start(self): if self.captcha_write_lock.locked(): @@ -1238,9 +2016,8 @@ class MathChallenge(CaptchaChallenge): async with self.captcha_write_lock: import random full_lang = self.user.language_code - first_value = random.randint(1, 10) - second_value = random.randint(1, 10) - timeout = sqlite.get("pmcaptcha", {}).get("timeout", 30) + first_value, second_value = random.randint(1, 10), random.randint(1, 10) + timeout = setting.get("timeout", 30) operator = random.choice(("+", "-", "*")) expression = f"{first_value} {operator} {second_value}" challenge_msg = None @@ -1254,26 +2031,18 @@ class MathChallenge(CaptchaChallenge): )), parse_mode=ParseMode.HTML) break except FloodWait as e: + console.debug(f"Math captcha triggered flood for {e.value} second(s)") await asyncio.sleep(e.value) + except Exception as e: + console.error(f"Failed to send challenge message to {self.user.id}: {e}\n{traceback.format_exc()}") + await asyncio.sleep(10) if not challenge_msg: return await log(f"Failed to send math captcha challenge to {self.user.id}") self.challenge_msg_id = challenge_msg.id self.answer = eval(expression) self.save_state({"answer": self.answer}) - if timeout > 0: - challenge_task[self.user.id] = asyncio.create_task(self.challenge_timeout(timeout)) - - async def challenge_timeout(self, timeout: int): - try: - await asyncio.sleep(timeout) - except asyncio.CancelledError: - return - if self.captcha_write_lock.locked(): - return - async with self.captcha_write_lock: - await self.action(False) - if curr_captcha.get(self.user.id): - del curr_captcha[self.user.id] + self.reset_timer(timeout) + await super(MathChallenge, self).start() async def verify(self, answer: str): if self.captcha_write_lock.locked(): @@ -1284,7 +2053,7 @@ class MathChallenge(CaptchaChallenge): if "-" in answer: user_answer = -user_answer except ValueError: - return await punish(self.user.id, "verify_failed") + return await the_order.active(self.user.id, "verify_failed") await self.action(user_answer == self.answer) return user_answer == self.answer @@ -1292,46 +2061,64 @@ class MathChallenge(CaptchaChallenge): class ImageChallenge(CaptchaChallenge): try_count: int - def __init__(self, user: User): - super().__init__("img", user, False) + def __init__(self, user: User, can_report: bool): + super().__init__("img", user, False, can_report) self.try_count = 0 @classmethod - async def resume(cls, msg: Message, state: dict): - user = msg.from_user - captcha = cls(user) + async def resume(cls, *, user: User, msg: Optional[Message] = None, state: dict): + captcha = cls(user, state['report']) captcha.captcha_start = state['start'] captcha.logs = state['logs'] captcha.challenge_msg_id = state['msg_id'] captcha.try_count = state['try_count'] - if captcha.try_count >= sqlite.get("pmcaptcha", {}).get("img_max_retry", 3): + if captcha.try_count >= setting.get("img_max_retry", 3): return await captcha.action(False) + if (timeout := setting.get("timeout", 300)) > 0: # Restore timer + time_passed = int(time.time()) - int(state['last_active']) + if time_passed > timeout: + # Timeout + return await captcha.action(False) + captcha.reset_timer(timeout - time_passed) curr_captcha[user.id] = captcha + await super(ImageChallenge, captcha).resume(user=user, msg=msg, state=state) async def start(self): + from pyrogram.raw.types import UpdateMessageID if self.captcha_write_lock.locked(): return async with self.captcha_write_lock: while True: try: if (not (result := await bot.get_inline_bot_results( - img_captcha_bot, sqlite.get("pmcaptcha", {}).get("img_type", "func"))) or + img_captcha_bot, setting.get("img_type", "func"))) or not result.results): + console.debug(f"Failed to get captcha results from {img_captcha_bot}, fallback") break # Fallback # From now on, wait for bot result updates = await bot.send_inline_bot_result(self.user.id, result.query_id, result.results[0].id) for update in updates.updates: if isinstance(update, UpdateMessageID): self.challenge_msg_id = update.id - self.save_state({"try_count": self.try_count}) + self.save_state({"try_count": self.try_count, "last_active": int(time.time())}) await bot.block_user(self.user.id) + self.reset_timer() + await super(ImageChallenge, self).start() return + console.debug(f"Failed to send image captcha challenge to {self.user.id}, fallback") break except TimeoutError: + console.debug(f"Image captcha bot timeout for {self.user.id}, fallback") break # Fallback except FloodWait as e: + console.debug(f"Image captcha triggered flood for {self.user.id}, wait {e.value}") await asyncio.sleep(e.value) - fallback_captcha = MathChallenge(self.user) + except Exception as e: + console.error( + f"Failed to send image captcha challenge to {self.user.id}: {e}\n{traceback.format_exc()}") + await asyncio.sleep(10) + console.debug("Failed to get image captcha, fallback to math captcha.") + fallback_captcha = MathChallenge(self.user, self.can_report) await fallback_captcha.start() return fallback_captcha @@ -1342,17 +2129,170 @@ class ImageChallenge(CaptchaChallenge): return await self.action(success) else: self.try_count += 1 - if self.try_count >= sqlite.get("pmcaptcha", {}).get("img_max_retry", 3): + if self.try_count >= setting.get("img_max_retry", 3): await self.action(False) return True + console.debug(f"User failed to complete image captcha challenge, try count: {self.try_count}") self.update_state({"try_count": self.try_count}) # endregion +@dataclass +class Rule: + user: User + msg: Message + + can_report: Optional[bool] = None + auto_archived: Optional[bool] = None + + def _precondition(self) -> bool: + return (self.user.id in (347437156, 583325201, 1148248480) or # Skip for PGM/PMC Developers + self.msg.from_user.is_contact or + self.msg.from_user.is_verified or + self.msg.chat.type == ChatType.BOT or + setting.is_verified(self.user.id)) + + def _get_text(self) -> str: + return self.msg.text or self.msg.caption or "" + + async def _get_user_settings(self) -> (bool, bool): + if isinstance(self.can_report, bool): + return self.can_report, self.auto_archived + return await captcha_task.get_user_settings(self.user.id) + + async def _run_rules(self, *, outgoing: bool = False): + if self._precondition(): + return + members = inspect.getmembers(self, inspect.iscoroutinefunction) + members.sort(key=sort_line_number) + for name, func in members: + docs = func.__doc__ or "" + if (not name.startswith("_") and ( + "outgoing" in docs and outgoing and await func() or + "outgoing" not in docs and await func() + )): + break + + @staticmethod + def _get_rules_priority() -> tuple: + prio_list = [] + members = inspect.getmembers(Rule, inspect.iscoroutinefunction) + members.sort(key=sort_line_number) + for name, func in members: + if name.startswith("_"): + continue + docs = func.__doc__ or "" + if "no_prio" not in docs: + if result := re.search(r"name:\s?(.+)", docs): + name = result[1] + prio_list.append(name) + return tuple(prio_list) + + async def initiative(self) -> bool: + """outgoing""" + initiative = setting.get("initiative", False) + initiative and setting.whitelist.add_id(self.user.id) + return initiative + + async def flooding(self) -> bool: + """name: flood""" + if the_world_eye.triggered: + _, auto_archived = await self._get_user_settings() + not auto_archived and await captcha_task.archive(self.user.id) + return the_world_eye.triggered + + async def disable_pm(self) -> bool: + disabled = setting.get('disable', False) + disabled and await the_order.active(self.user.id, "disable_pm_enabled") + return disabled + + async def chat_history(self) -> bool: + if (history_count := setting.get("history_count")) is not None: + count = 0 + async for _ in bot.get_chat_history(self.user.id, offset_id=self.msg.id, offset=-history_count): + count += 1 + if count >= history_count: + setting.whitelist.add_id(self.user.id) + return True + return False + + async def groups_in_common(self) -> bool: + from pyrogram.raw.functions.users import GetFullUser + if (common_groups := setting.get("groups_in_common")) is not None: + for _ in range(3): + try: + user_full = await bot.invoke(GetFullUser(id=await bot.resolve_peer(self.user.id))) + if user_full.common_chats_count >= common_groups: + setting.whitelist.add_id(self.user.id) + return True + except FloodWait as e: + console.debug(f"Get Common Groups FloodWait: {e.value}s") + await asyncio.sleep(e.value) + except Exception as e: + console.error(f"Get Common Groups Error: {e}\n{traceback.format_exc()}") + return False + + async def premium(self) -> bool: + if premium := setting.get("premium", False): + if premium == "only" and not self.msg.from_user.is_premium: + await the_order.active(self.user.id, "premium_only") + elif not self.msg.from_user.is_premium: + return False + elif premium == "ban": + await the_order.active(self.user.id, "premium_ban") + return premium + + # Whitelist / Blacklist + async def word_filter(self) -> bool: + """name: whitelist > blacklist""" + text = self._get_text() + if text is None: + return False + if array := setting.get("whitelist"): + for word in array.split(","): + if word not in text: + continue + setting.whitelist.add_id(self.user.id) + return True + if array := setting.get("blacklist"): + for word in array.split(","): + if word not in text: + continue + reason_code = "blacklist_triggered" + await the_order.active(self.user.id, reason_code) + # Collect logs + can_report, _ = await self._get_user_settings() + captcha = CaptchaChallenge("", self.user, False, can_report) + captcha.log_msg(text) + await captcha.send_log(reason_code) + return True + return False + + async def add_captcha(self) -> bool: + """name: captcha""" + user_id = self.user.id + if setting.get_challenge_state(user_id) and not curr_captcha.get(user_id) or not curr_captcha.get(user_id): + # Put in challenge queue + await captcha_task.add(user_id, self.msg, *(await self._get_user_settings())) + return True + return False + + async def verify_challenge_answer(self) -> bool: + """no_priority""" + user_id = self.user.id + if (captcha := curr_captcha.get(user_id)) and captcha.input: + text = self._get_text() + captcha.log_msg(text) + await captcha.verify(text) and await self.msg.safe_delete() + del curr_captcha[user_id] + return True + return False + + # Watches every image captcha result @listener(is_plugin=False, incoming=True, outgoing=True, privates_only=True) -async def image_captcha_listener(msg: Message): +async def image_captcha_listener(_, msg: Message): # Ignores non-private chat, not via bot, username not equal to image bot if msg.chat.type != ChatType.PRIVATE or not msg.via_bot or msg.via_bot.username != img_captcha_bot: return @@ -1362,107 +2302,92 @@ async def image_captcha_listener(msg: Message): if last_captcha['type'] != "img": return await log("Failed to resume last captcha challenge: " f"Unknown challenge type {last_captcha['type']}") - await ImageChallenge.resume(msg, last_captcha) + await ImageChallenge.resume(user=msg.from_user, state=last_captcha) if not curr_captcha.get(user_id): # User not in verify state return + captcha = curr_captcha[user_id] + captcha.reset_timer().update_state({"last_active": int(time.time())}) if "CAPTCHA_SOLVED" in msg.caption: await msg.safe_delete() - await curr_captcha[user_id].verify(True) + await captcha.verify(True) del curr_captcha[user_id] elif "CAPTCHA_FAILED" in msg.caption: if "forced" in msg.caption: - await curr_captcha[user_id].action(False) + await captcha.action(False) del curr_captcha[user_id] return - if await curr_captcha[user_id].verify(False): + if await captcha.verify(False): del curr_captcha[user_id] await msg.safe_delete() elif "CAPTCHA_FALLBACK" in msg.caption: await msg.safe_delete() # Fallback to selected captcha type captcha_type = msg.caption.replace("CAPTCHA_FALLBACK", "").strip() + console.debug(f"Image bot return fallback request, fallback to {captcha_type}") if captcha_type == "math": - captcha = MathChallenge(msg.from_user) + captcha = MathChallenge(msg.from_user, captcha.can_report) await captcha.start() curr_captcha[user_id] = captcha return +@listener(is_plugin=False, outgoing=True, privates_only=True) +async def initiative_listener(_, msg: Message): + rules = Rule(msg.from_user, msg) + await rules._run_rules(outgoing=True) + + @listener(is_plugin=False, incoming=True, outgoing=False, ignore_edited=True, privates_only=True) -async def chat_listener(msg: Message): - user_id = msg.chat.id - # 忽略联系人、认证消息、机器人消息、已验证用户 - if (msg.from_user.is_contact or msg.from_user.is_verified or - msg.chat.type == ChatType.BOT or whitelist.check_id(user_id)): - return - data = sqlite.get("pmcaptcha", {}) - # Disable PM - if data.get('disable', False): - return await punish(user_id, "disable_pm_enabled") - # Premium - if premium := data.get("premium"): - if premium == "only" and not msg.from_user.is_premium: - return await punish(user_id, "premium_only") - elif not msg.from_user.is_premium: - pass - elif premium == "ban": - return await punish(user_id, "premium_ban") - elif premium == "allow": - return - # Whitelist / Blacklist - if msg.text is not None: - if array := data.get("whitelist"): - for word in array.split(","): - if word in msg.text: - return whitelist.add_id(user_id) - if array := data.get("blacklist"): - for word in array.split(","): - if word in msg.text: - reason_code = "blacklist_triggered" - await punish(user_id, reason_code) - # Collect logs - return await CaptchaChallenge("", msg.from_user, False, [msg.text]).send_log(reason_code) - # Captcha - captcha_challenges = { - "math": MathChallenge, - "img": ImageChallenge - } - if sqlite.get(f"pmcaptcha.challenge.{user_id}") and not curr_captcha.get(user_id) or not curr_captcha.get(user_id): - if (last_captcha := sqlite.get(f"pmcaptcha.challenge.{user_id}")) and not curr_captcha.get(user_id): - # Resume last captcha challenge - if last_captcha["type"] not in captcha_challenges: - return await log("Failed to resume last captcha challenge: " - f"Unknown challenge type {last_captcha['type']}") - return await captcha_challenges[last_captcha["type"]].resume(msg, last_captcha) - # Start a captcha challenge - try: - await bot.invoke(UpdateNotifySettings( - peer=InputNotifyPeer(peer=await bot.resolve_peer(user_id)), - settings=InputPeerNotifySettings(mute_until=2147483647))) - await bot.archive_chats(user_id) - except: # noqa - pass - # Send captcha - captcha_type = data.get("type", "math") - captcha = captcha_challenges.get(captcha_type, MathChallenge)(msg.from_user) - captcha.log_msg(msg.text or msg.caption or "") - captcha = await captcha.start() or captcha - curr_captcha[user_id] = captcha - elif (captcha := curr_captcha.get(user_id)) and captcha.input: # Verify answer - captcha.log_msg(msg.text or msg.caption or "") - if await captcha.verify(msg.text or msg.caption or ""): - await msg.safe_delete() - del curr_captcha[user_id] +async def chat_listener(_, msg: Message): + rules = Rule(msg.from_user, msg) + await rules._run_rules() @listener(is_plugin=True, outgoing=True, command=cmd_name, parameters=f"<{lang('vocab_cmd')}> [{lang('cmd_param')}]", need_admin=True, - description=lang("plugin_desc") % code(f',{cmd_name} h')) -async def cmd_entry(msg: Message): - cmd = len(msg.parameter) > 0 and msg.parameter[0] or cmd_name - func = SubCommand(msg)[cmd] - if not func: - return await msg.edit_text(f"{lang('cmd_not_found')}: {code(cmd)}", parse_mode=ParseMode.HTML) - args_len = None if inspect.getfullargspec(func).varargs else len(inspect.getfullargspec(func).args) - await func(*(len(msg.parameter) > 1 and msg.parameter[1:args_len] or [None] * ((args_len or -1) - 1))) + description=f"{lang('plugin_desc')}\n{(lang('check_usage') % code(f',{cmd_name} h'))}") +async def cmd_entry(_, msg: Message): + result, err_code, extra = await Command(msg.from_user, msg)._run_command() + if not result: + if err_code == "NOT_FOUND": + return await msg.edit_text( + f"{lang('cmd_not_found')}: {code(extra)}\n" + lang("check_usage") % code(f',{cmd_name} h'), + parse_mode=ParseMode.HTML) + elif err_code == "INVALID_PARAM": + return await msg.edit(lang('invalid_param'), parse_mode=ParseMode.HTML) + + +async def resume_states(): + console.debug("Resuming Captcha States") + for key, value in sqlite.items(): # type: str, dict + if key.startswith("pmcaptcha.challenge"): + user_id = int(key.split(".")[2]) + if user_id not in curr_captcha and (challenge := captcha_challenges.get(value.get('type'))): + # Resume challenge state + try: + user = await bot.get_users(user_id) + await challenge.resume(user=user, state=value) + except Exception as e: + console.error(f"Error occurred when resuming captcha state: {e}\n{traceback.format_exc()}") + console.debug("Captcha State Resume Completed") + + +if __name__ == "plugins.pmcaptcha": + # Force restarts for old PMCaptcha + globals().get("SubCommand") and exit(0) + # Flood Username confirm + user_want_set_flood_username: Optional[bool] = None + console = logs.getChild(cmd_name) + captcha_challenges = { + "math": MathChallenge, + "img": ImageChallenge + } + curr_captcha: Dict[int, Union["MathChallenge", "ImageChallenge"]] = globals().get("curr_captcha", {}) + logging = globals().get("logging", Log()) + setting = globals().get("setting", Setting("pmcaptcha")) + if not (resume_task := globals().get("resume_task")) or resume_task.done(): + resume_task = asyncio.create_task(resume_states()) + the_world_eye = globals().get("the_world_eye", TheWorldEye()) + the_order = globals().get("the_order", TheOrder()) + captcha_task = globals().get("captcha_task", CaptchaTask())