From 52ee83a23512a8634af6f8d91af095d09da65e5d Mon Sep 17 00:00:00 2001 From: ngfchl Date: Tue, 7 Jul 2026 10:14:34 +0800 Subject: [PATCH] fix: fallback command replies when original message is unavailable --- handlers/admin.py | 33 +++++++++++++++++---------------- handlers/bind.py | 21 +++++++++++---------- services/message_utils.py | 14 ++++++++++++++ 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/handlers/admin.py b/handlers/admin.py index 4aebbe5..4b56d34 100644 --- a/handlers/admin.py +++ b/handlers/admin.py @@ -10,6 +10,7 @@ import config from db.models import Action, Ban, Message, Warning from services.admin_utils import require_admin, resolve_target_user from services.stats_service import get_daily_stats +from services.message_utils import safe_reply logger = logging.getLogger("spam_guard") @@ -77,7 +78,7 @@ async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE): f"🔓 解封: {stats['unbans']} 人\n" f"📦 历史封禁: {stats['total_bans']} 条\n" ) - reply = await update.message.reply_text(text) + reply = await safe_reply(update, context, text) asyncio.create_task(auto_delete(reply)) @@ -88,7 +89,7 @@ async def cmd_ban(update: Update, context: ContextTypes.DEFAULT_TYPE): user_id, target = await resolve_target_user(update, context.args) if not user_id: - reply = await update.message.reply_text("用法: 回复用户消息 /ban,或 /ban 用户ID/@历史用户名") + reply = await safe_reply(update, context, "用法: 回复用户消息 /ban,或 /ban 用户ID/@历史用户名") asyncio.create_task(auto_delete(reply)) return @@ -108,7 +109,7 @@ async def cmd_ban(update: Update, context: ContextTypes.DEFAULT_TYPE): reply = await update.effective_chat.send_message(f"🔨 已封禁 {target} ({user_id}) {config.BAN_DURATION_MINUTES} 分钟") except Exception as e: logger.error(f"手动封禁失败: {type(e).__name__}: {e}") - reply = await update.message.reply_text("❌ 封禁失败,请检查 Bot 权限或目标用户状态") + reply = await safe_reply(update, context, "❌ 封禁失败,请检查 Bot 权限或目标用户状态") asyncio.create_task(auto_delete(reply)) @@ -120,7 +121,7 @@ async def cmd_unban(update: Update, context: ContextTypes.DEFAULT_TYPE): user_id, target = await resolve_target_user(update, context.args) if not user_id: - reply = await update.message.reply_text("用法: /unban 用户ID/@历史用户名") + reply = await safe_reply(update, context, "用法: /unban 用户ID/@历史用户名") asyncio.create_task(auto_delete(reply)) return @@ -128,10 +129,10 @@ async def cmd_unban(update: Update, context: ContextTypes.DEFAULT_TYPE): await context.bot.unban_chat_member(chat_id=config.TG_CHAT_ID, user_id=user_id) await Ban.filter(user_id=user_id).update(auto_unban=False, unban_at=datetime.now()) await Action.create(action="UNBAN", user_id=user_id, username=target, operator_id=update.effective_user.id) - reply = await update.message.reply_text(f"🔓 已解封用户 {target} ({user_id})") + reply = await safe_reply(update, context, f"🔓 已解封用户 {target} ({user_id})") except Exception as e: logger.error(f"手动解封失败: {type(e).__name__}: {e}") - reply = await update.message.reply_text("❌ 解封失败,请检查 Bot 权限或目标用户状态") + reply = await safe_reply(update, context, "❌ 解封失败,请检查 Bot 权限或目标用户状态") asyncio.create_task(auto_delete(reply)) @@ -143,7 +144,7 @@ async def cmd_warn(update: Update, context: ContextTypes.DEFAULT_TYPE): user_id, target = await resolve_target_user(update, context.args) if not user_id: - reply = await update.message.reply_text("用法: 回复用户消息 /warn [原因],或 /warn 用户ID/@历史用户名 [原因]") + reply = await safe_reply(update, context, "用法: 回复用户消息 /warn [原因],或 /warn 用户ID/@历史用户名 [原因]") asyncio.create_task(auto_delete(reply)) return @@ -162,10 +163,10 @@ async def cmd_warn(update: Update, context: ContextTypes.DEFAULT_TYPE): await delete_command_message(update) reply = await update.effective_chat.send_message(f"⚠️ 已警告 {target} ({user_id})\n原因: {reason}\n累计警告: {warn_count}") else: - reply = await update.message.reply_text(f"⚠️ 已警告 {target} ({user_id})\n原因: {reason}\n累计警告: {warn_count}") + reply = await safe_reply(update, context, f"⚠️ 已警告 {target} ({user_id})\n原因: {reason}\n累计警告: {warn_count}") except Exception as e: logger.error(f"警告失败: {type(e).__name__}: {e}") - reply = await update.message.reply_text("❌ 警告失败,请检查 Bot 权限或数据库状态") + reply = await safe_reply(update, context, "❌ 警告失败,请检查 Bot 权限或数据库状态") asyncio.create_task(auto_delete(reply)) @@ -176,7 +177,7 @@ async def cmd_mute(update: Update, context: ContextTypes.DEFAULT_TYPE): user_id, target = await resolve_target_user(update, context.args) if not user_id: - reply = await update.message.reply_text("用法: 回复用户消息 /mute [分钟],或 /mute 用户ID/@历史用户名 [分钟]") + reply = await safe_reply(update, context, "用法: 回复用户消息 /mute [分钟],或 /mute 用户ID/@历史用户名 [分钟]") asyncio.create_task(auto_delete(reply)) return @@ -200,10 +201,10 @@ async def cmd_mute(update: Update, context: ContextTypes.DEFAULT_TYPE): await delete_command_message(update) reply = await update.effective_chat.send_message(f"🔇 已禁言 {target} ({user_id}) {minutes} 分钟") else: - reply = await update.message.reply_text(f"🔇 已禁言 {target} ({user_id}) {minutes} 分钟") + reply = await safe_reply(update, context, f"🔇 已禁言 {target} ({user_id}) {minutes} 分钟") except Exception as e: logger.error(f"禁言失败: {type(e).__name__}: {e}") - reply = await update.message.reply_text("❌ 禁言失败,请检查 Bot 权限或目标用户状态") + reply = await safe_reply(update, context, "❌ 禁言失败,请检查 Bot 权限或目标用户状态") asyncio.create_task(auto_delete(reply)) @@ -212,7 +213,7 @@ async def require_reply_shortcut(update: Update, command: str, usage: str) -> bo """引用消息快捷管理命令必须回复目标消息。""" if update.message and update.message.reply_to_message: return True - reply = await update.message.reply_text(f"用法: 回复目标消息后使用 {usage}") + reply = await safe_reply(update, context, f"用法: 回复目标消息后使用 {usage}") asyncio.create_task(auto_delete(reply)) return False @@ -250,12 +251,12 @@ async def cmd_purge(update: Update, context: ContextTypes.DEFAULT_TYPE): return if not context.args or not context.args[0].isdigit(): - reply = await update.message.reply_text("用法: 回复一条消息后 /purge 数量(最多50)") + reply = await safe_reply(update, context, "用法: 回复一条消息后 /purge 数量(最多50)") asyncio.create_task(auto_delete(reply)) return if not update.message.reply_to_message: - reply = await update.message.reply_text("❌ /purge 需要回复一条消息作为删除起点") + reply = await safe_reply(update, context, "❌ /purge 需要回复一条消息作为删除起点") asyncio.create_task(auto_delete(reply)) return @@ -305,7 +306,7 @@ async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE): "↪️ 回复消息 /ban /mute /warn 也支持同样处理\n" "🧹 回复消息 /purge 数量 - 从被引用消息开始尝试批量删除,最多 50 条\n" ) - reply = await update.message.reply_text(text) + reply = await safe_reply(update, context, text) asyncio.create_task(auto_delete(reply, seconds=60)) diff --git a/handlers/bind.py b/handlers/bind.py index c9b7391..9dc5be4 100644 --- a/handlers/bind.py +++ b/handlers/bind.py @@ -8,6 +8,7 @@ from telegram.ext import ContextTypes, CommandHandler, MessageHandler, filters from db.models import Action from services.bind_service import bind_with_email_token, unbind, get_binding +from services.message_utils import safe_reply logger = logging.getLogger("spam_guard") @@ -27,7 +28,7 @@ async def cmd_bind(update: Update, context: ContextTypes.DEFAULT_TYPE): f"📅 绑定时间: {existing.get('bound_at')}\n\n" "如需重新绑定,先 /unbind" ) - reply = await update.message.reply_text(text) + reply = await safe_reply(update, context, text) asyncio.create_task(auto_delete(update.message, seconds=60)) asyncio.create_task(auto_delete(reply, seconds=60)) return @@ -42,7 +43,7 @@ async def cmd_bind(update: Update, context: ContextTypes.DEFAULT_TYPE): f"👉 点击进入:{url}" ) try: - reply = await update.message.reply_text(text, reply_markup=keyboard, disable_web_page_preview=True) + reply = await safe_reply(update, context, text, reply_markup=keyboard, disable_web_page_preview=True) except Exception as e: logger.warning(f"🔗 绑定跳转按钮发送失败,改发普通文本: {type(e).__name__}: {e}") reply = await update.effective_chat.send_message(text, disable_web_page_preview=True) @@ -51,7 +52,7 @@ async def cmd_bind(update: Update, context: ContextTypes.DEFAULT_TYPE): return context.user_data["awaiting_bind"] = True - await update.message.reply_text( + await safe_reply(update, context, "🔐 授权绑定\n" "请发送邮箱和 Token,支持空格或换行分隔:\n\n" "邮箱 Token\n\n" @@ -71,15 +72,15 @@ async def handle_private_bind_text(update: Update, context: ContextTypes.DEFAULT text = (update.message.text or "").strip() parts = text.replace("\n", " ").split() if len(parts) < 2: - await update.message.reply_text("❌ 格式不对,请发送:邮箱 Token") + await safe_reply(update, context, "❌ 格式不对,请发送:邮箱 Token") return email, token = parts[0].strip(), parts[1].strip() if not EMAIL_RE.match(email): - await update.message.reply_text("❌ 邮箱格式不正确,请重新发送:邮箱 Token") + await safe_reply(update, context, "❌ 邮箱格式不正确,请重新发送:邮箱 Token") return - processing = await update.message.reply_text("⏳ 正在验证授权信息...") + processing = await safe_reply(update, context, "⏳ 正在验证授权信息...") ok, result = await bind_with_email_token( tg_user_id=update.effective_user.id, tg_username=update.effective_user.username or update.effective_user.first_name, @@ -96,14 +97,14 @@ async def handle_private_bind_text(update: Update, context: ContextTypes.DEFAULT if ok: data = result.get("data") or {} await Action.create(action="BIND", user_id=update.effective_user.id, username=update.effective_user.username, details={"email": data.get("email")}) - await update.message.reply_text( + await safe_reply(update, context, "✅ 绑定成功\n" f"📧 邮箱: {data.get('email') or email}\n" f"⏳ 到期: {data.get('time_expire') or '-'}" ) else: context.user_data["awaiting_bind"] = True - await update.message.reply_text(f"❌ 验证失败:{result.get('msg') or result.get('reason') or '授权信息无效'}\n请重新发送:邮箱 Token") + await safe_reply(update, context, f"❌ 验证失败:{result.get('msg') or result.get('reason') or '授权信息无效'}\n请重新发送:邮箱 Token") async def cmd_unbind(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -111,10 +112,10 @@ async def cmd_unbind(update: Update, context: ContextTypes.DEFAULT_TYPE): user = update.effective_user deleted = await unbind(user.id) if deleted: - reply = await update.message.reply_text("🔓 已解除账号绑定") + reply = await safe_reply(update, context, "🔓 已解除账号绑定") await Action.create(action="UNBIND", user_id=user.id, username=user.username) else: - reply = await update.message.reply_text("❌ 你没有绑定的账号") + reply = await safe_reply(update, context, "❌ 你没有绑定的账号") asyncio.create_task(auto_delete(update.message, seconds=60)) asyncio.create_task(auto_delete(reply, seconds=60)) diff --git a/services/message_utils.py b/services/message_utils.py index ba76375..d4d1c6f 100644 --- a/services/message_utils.py +++ b/services/message_utils.py @@ -23,3 +23,17 @@ async def auto_delete(message, seconds: int | None = None) -> None: def schedule_delete(message, seconds: int | None = None) -> None: """创建自动删除任务。""" asyncio.create_task(auto_delete(message, seconds=seconds)) + + +async def safe_reply(update, context, text: str, **kwargs): + """优先回复原消息;原消息已被删除/不可回复时,降级为普通群消息。""" + try: + if update and update.message: + return await update.message.reply_text(text, **kwargs) + except Exception as e: + if "Message to be replied not found" not in str(e): + logger.warning(f"回复消息失败,降级普通发送: {type(e).__name__}: {e}") + chat_id = update.effective_chat.id if update and update.effective_chat else None + if not chat_id: + raise RuntimeError("missing chat_id for safe_reply") + return await context.bot.send_message(chat_id=chat_id, text=text, **kwargs)