Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 46 additions & 28 deletions src/fogmoe_telegram_bot/features/moderation/member_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"""

import logging
from functools import partial

from telegram import ChatPermissions, Update
from telegram.constants import ChatMemberStatus
Expand All @@ -16,6 +17,11 @@

from fogmoe_telegram_bot.core.command_cooldown import cooldown
from fogmoe_telegram_bot.core.redaction import report_error
from fogmoe_telegram_bot.core.telegram_utils import (
is_retryable_telegram_error,
retry_telegram_send,
telegram_error_summary,
)
from fogmoe_telegram_bot.features.moderation import member_verify_views as views
from fogmoe_telegram_bot.features.moderation import spam_strikes
from fogmoe_telegram_bot.features.moderation.operations import join_messages, verify_game
Expand All @@ -28,6 +34,26 @@
# 没通过验证(被移出、验证前离开)之后,欢迎消息换成的说明在群里留这么多秒再删掉
NOTICE_SECONDS = 60

logger = logging.getLogger(__name__)

# 新成员验证前的权限:什么都不能发
MUTED = ChatPermissions(
can_send_messages=False,
can_send_polls=False,
can_send_other_messages=False,
can_add_web_page_previews=False,
can_change_info=False,
can_invite_users=False,
can_pin_messages=False,
can_manage_topics=False,
can_send_audios=False,
can_send_documents=False,
can_send_photos=False,
can_send_videos=False,
can_send_video_notes=False,
can_send_voice_notes=False,
)

# 在开启验证功能前详细检查必要权限
async def check_bot_permissions(bot, chat_id):
bot_member = await bot.get_chat_member(chat_id, bot.id)
Expand Down Expand Up @@ -83,6 +109,19 @@ async def verify_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
f"答错 {verify_game.MAX_MISTAKES} 次或超时会被移出群组。"
)

# 新成员没禁言上:缺权限和网络不通分开说,其他原因(比如对方是管理员)不提示
async def _announce_restrict_failure(bot, chat_id, user_id, error, notice):
if any(reason in str(error).lower() for reason in ("not enough rights", "chat_admin_required")):
text = views.restrict_no_rights_text(user_id)
elif is_retryable_telegram_error(error):
text = views.restrict_network_text(user_id)
else:
return
try:
await bot.send_message(chat_id, f"{text}\n{notice}", parse_mode="HTML")
except Exception as send_error:
logger.warning("没禁言上成员 %s,提示也没发出去: %s", user_id, telegram_error_summary(send_error))

# 新成员加入事件处理
async def new_member_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
chat_id = update.effective_chat.id
Expand All @@ -107,36 +146,15 @@ async def new_member_handler(update: Update, context: ContextTypes.DEFAULT_TYPE)
continue

try:
# 禁言新成员(禁止发送消息)
await context.bot.restrict_chat_member(
chat_id,
user_id,
ChatPermissions(can_send_messages=False,
can_send_polls=False,
can_send_other_messages=False,
can_add_web_page_previews=False,
can_change_info=False,
can_invite_users=False,
can_pin_messages=False,
can_manage_topics=False,
can_send_audios=False,
can_send_documents=False,
can_send_photos=False,
can_send_videos=False,
can_send_video_notes=False,
can_send_voice_notes=False)
# 禁言新成员;连 Telegram 偶尔握手失败,重试几次,免得没禁言上、新成员不验证就能发言
await retry_telegram_send(
partial(context.bot.restrict_chat_member, chat_id, user_id, MUTED),
logger=logger,
action=f"restrict new member {user_id}",
)
except Exception as e:
error_str = str(e)
notice = report_error(
logging.getLogger(__name__), f"限制成员 {user_id} 失败", e
)
if "httpx.ConnectError" in error_str or "Not enough rights" in error_str:
await context.bot.send_message(
chat_id,
f"验证错误: 无法限制成员 {user_id},"
f"请检查机器人的管理员权限与网络。\n{notice}"
)
notice = report_error(logger, f"限制成员 {user_id} 失败", e)
await _announce_restrict_failure(context.bot, chat_id, user_id, e, notice)
continue

# 发送欢迎信息,包含开始验证按钮;不显示名字
Expand Down
16 changes: 16 additions & 0 deletions src/fogmoe_telegram_bot/features/moderation/member_verify_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,22 @@ def timeout_text(user_id: int) -> str:
return _removed("验证超时", user_id)


def restrict_no_rights_text(user_id: int) -> str:
"""没禁言上新成员,因为机器人缺权限。"""
return (
f"验证错误:机器人没有限制成员的权限,没能禁言 {admin_ban_views.neutral_mention(user_id, '新成员')},"
"该成员未经验证就能发言。请在管理员设置里给机器人打开「封禁用户」权限。"
)


def restrict_network_text(user_id: int) -> str:
"""没禁言上新成员,因为重试后仍连不上 Telegram。"""
return (
f"验证错误:暂时连不上 Telegram,重试后仍没能禁言 {admin_ban_views.neutral_mention(user_id, '新成员')},"
"该成员未经验证就能发言,请管理员留意。"
)


def prompt(game: Game, outcome: Outcome | None = None) -> str:
"""弹框里的题目,只有本人看得到;刚点完格子时前面先说答对、答错还是题目已经换了。"""
if outcome is Outcome.NEXT:
Expand Down
67 changes: 62 additions & 5 deletions tests/test_member_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@

import pytest
from telegram.constants import ChatMemberStatus
from telegram.error import BadRequest
from telegram.error import BadRequest, NetworkError

from fogmoe_telegram_bot.core import telegram_utils
from fogmoe_telegram_bot.features.moderation import admin_ban_views, member_verify
from fogmoe_telegram_bot.features.moderation import member_verify_views as views
from fogmoe_telegram_bot.features.moderation.operations import join_messages, verify_game
Expand Down Expand Up @@ -96,12 +97,18 @@ def __init__(self, status=ChatMemberStatus.RESTRICTED, can_send_messages=False):
self.edited_texts = []
self.parse_modes = []
self.deleted = []
self.sent = []
self.fail_restrict = False
self.restrict_errors = [] # 依次抛出,抛完才成功
self.restrict_attempts = 0

async def get_chat_member(self, chat_id, user_id):
return self.member

async def restrict_chat_member(self, chat_id, user_id, permissions):
self.restrict_attempts += 1
if self.restrict_errors:
raise self.restrict_errors.pop(0)
if self.fail_restrict:
raise RuntimeError("network down")
self.restricted.append(user_id)
Expand All @@ -117,8 +124,8 @@ async def edit_message_text(self, chat_id, message_id, text, parse_mode=None):
self.edited_texts.append(text)
self.parse_modes.append(parse_mode)

async def send_message(self, chat_id, text):
pass
async def send_message(self, chat_id, text, parse_mode=None):
self.sent.append((text, parse_mode))

async def delete_message(self, chat_id, message_id):
self.deleted.append(message_id)
Expand Down Expand Up @@ -443,7 +450,7 @@ def test_a_timed_out_member_also_loses_the_join_message(tasks):
assert bot.kicked == [USER_ID] and bot.deleted == [1]


def _join(monkeypatch, *, enabled):
def _join(monkeypatch, *, enabled, bot=None):
async def is_enabled(chat_id):
return enabled

Expand All @@ -460,7 +467,7 @@ async def reply_text(text, **kwargs):
message=SimpleNamespace(message_id=1, new_chat_members=[new_member], reply_text=reply_text),
)
context = SimpleNamespace(
bot=_FakeBot(), chat_data={}, job_queue=SimpleNamespace(run_once=lambda *args, **kwargs: None)
bot=bot or _FakeBot(), chat_data={}, job_queue=SimpleNamespace(run_once=lambda *args, **kwargs: None)
)
asyncio.run(member_verify.new_member_handler(update, context))
return replies
Expand All @@ -480,3 +487,53 @@ def test_the_welcome_hides_the_name_and_offers_admins_a_ban_button(tasks, monkey
def test_join_messages_are_remembered_even_without_verification(tasks, monkeypatch):
assert _join(monkeypatch, enabled=False) == []
assert join_messages.JOINS.pop(CHAT_ID, USER_ID) == 1


@pytest.fixture
def no_retry_wait(monkeypatch):
async def instant(delay):
pass

monkeypatch.setattr(telegram_utils.asyncio, "sleep", instant)


def test_a_network_blip_while_muting_is_retried(tasks, monkeypatch, no_retry_wait):
bot = _FakeBot()
bot.restrict_errors = [NetworkError("httpx.ConnectError: ")]

replies = _join(monkeypatch, enabled=True, bot=bot)

assert bot.restricted == [USER_ID] and len(replies) == 1 and bot.sent == []
assert (USER_ID, CHAT_ID) in tasks.rows


def test_a_lasting_network_failure_says_the_member_is_unverified(tasks, monkeypatch, no_retry_wait):
bot = _FakeBot()
bot.restrict_errors = [NetworkError("httpx.ConnectError: ")] * 10

replies = _join(monkeypatch, enabled=True, bot=bot)

assert bot.restrict_attempts == telegram_utils.TELEGRAM_SEND_RETRY_ATTEMPTS
(text, parse_mode), = bot.sent
assert text.startswith(views.restrict_network_text(USER_ID)) and "ERR-" in text
assert parse_mode == "HTML" and "权限" not in text
assert replies == [] and tasks.rows == {}


def test_missing_rights_are_reported_without_retrying(tasks, monkeypatch, no_retry_wait):
bot = _FakeBot()
bot.restrict_errors = [BadRequest("Not enough rights to restrict/unrestrict chat member")]

_join(monkeypatch, enabled=True, bot=bot)

assert bot.restrict_attempts == 1
(text, _), = bot.sent
assert text.startswith(views.restrict_no_rights_text(USER_ID)) and "网络" not in text


def test_an_admin_joining_is_not_announced(tasks, monkeypatch, no_retry_wait):
bot = _FakeBot()
bot.restrict_errors = [BadRequest("User is an administrator of the chat")]

assert _join(monkeypatch, enabled=True, bot=bot) == []
assert bot.sent == []
Loading