Files
2026-07-07 22:17:43 +08:00

350 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""管理命令 handler"""
import asyncio
import logging
from datetime import datetime, timedelta
from telegram import Update
from telegram.ext import CommandHandler, ContextTypes, MessageHandler, filters
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 record_bot_message, safe_reply
logger = logging.getLogger("spam_guard")
async def ban_user_with_record(chat, bot, user_id: int, target: str | None, operator_id: int | None, reason: str, method: str = "command"):
unban_at = datetime.now() + timedelta(minutes=config.BAN_DURATION_MINUTES)
await chat.ban_member(user_id=user_id, until_date=int(unban_at.timestamp()))
ban = await Ban.create(
chat_id=chat.id,
user_id=user_id,
username=target,
reason=reason,
method=method,
duration_minutes=config.BAN_DURATION_MINUTES,
unban_at=unban_at,
)
await Action.create(action="BAN", chat_id=chat.id, user_id=user_id, username=target, operator_id=operator_id, details={"method": method})
return ban
async def mark_reply_message_deleted(reply_msg, reason: str = "command_ban_reply"):
rec = await Message.get_or_none(chat_id=reply_msg.chat_id, msg_id=reply_msg.message_id)
if rec:
rec.manually_deleted = True
rec.deleted_at = datetime.now()
rec.delete_reason = reason
await rec.save()
async def delete_replied_message(update: Update, reason: str) -> None:
"""删除管理命令引用的目标消息,并同步标记本地消息记录。"""
reply_msg = update.message.reply_to_message if update.message else None
if not reply_msg:
return
try:
await reply_msg.delete()
await mark_reply_message_deleted(reply_msg, reason=reason)
except Exception as e:
logger.debug(f"删除引用消息失败: {type(e).__name__}: {e}")
async def delete_command_message(update: Update) -> None:
"""尽量删除管理员命令消息,失败不影响主流程。"""
try:
await update.message.delete()
except Exception as e:
logger.debug(f"删除命令消息失败: {type(e).__name__}: {e}")
async def log_admin_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
logger.info(f"⌨️ 收到指令: {update.message.text} from={update.effective_user.id}")
async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""今日统计"""
stats = await get_daily_stats()
text = (
f"📊 今日群组统计 ({stats['date']})\n"
f"━━━━━━━━━━━━━━━━━━━━\n"
f"💬 消息: {stats['messages']}\n"
f"🗑️ 删除垃圾: {stats['deleted']}\n"
f"🚨 垃圾判定: {stats['spam_messages']}\n"
f"🆕 入群: {stats['joins']}\n"
f"🚪 退群: {stats['leaves']}\n"
f"🔨 封禁: {stats['today_bans']}\n"
f"🔓 解封: {stats['unbans']}\n"
f"📦 历史封禁: {stats['total_bans']}\n"
)
reply = await safe_reply(update, context, text)
await record_bot_message(reply)
asyncio.create_task(auto_delete(reply))
async def cmd_ban(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""手动封禁。支持回复消息、用户ID、历史 username。"""
if not await require_admin(update, context):
return
user_id, target = await resolve_target_user(update, context.args)
if not user_id:
reply = await safe_reply(update, context, "用法: 回复用户消息 /ban,或 /ban 用户ID/@历史用户名")
await record_bot_message(reply)
asyncio.create_task(auto_delete(reply))
return
try:
await ban_user_with_record(
update.effective_chat,
context.bot,
user_id,
target,
update.effective_user.id,
f"手动封禁 by {update.effective_user.username or update.effective_user.id}",
method="reply_command" if update.message.reply_to_message else "command",
)
if update.message.reply_to_message:
await delete_replied_message(update, reason="command_ban_reply")
await delete_command_message(update)
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 safe_reply(update, context, "❌ 封禁失败,请检查 Bot 权限或目标用户状态")
await record_bot_message(reply)
asyncio.create_task(auto_delete(reply))
async def cmd_unban(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""手动解封"""
if not await require_admin(update, context):
return
user_id, target = await resolve_target_user(update, context.args)
if not user_id:
reply = await safe_reply(update, context, "用法: /unban 用户ID/@历史用户名")
await record_bot_message(reply)
asyncio.create_task(auto_delete(reply))
return
try:
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", chat_id=update.effective_chat.id, user_id=user_id, username=target, operator_id=update.effective_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 safe_reply(update, context, "❌ 解封失败,请检查 Bot 权限或目标用户状态")
await record_bot_message(reply)
asyncio.create_task(auto_delete(reply))
async def cmd_warn(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""警告用户"""
if not await require_admin(update, context):
return
user_id, target = await resolve_target_user(update, context.args)
if not user_id:
reply = await safe_reply(update, context, "用法: 回复用户消息 /warn [原因],或 /warn 用户ID/@历史用户名 [原因]")
await record_bot_message(reply)
asyncio.create_task(auto_delete(reply))
return
# 回复模式下 args 全部是原因;非回复模式下 args[1:] 是原因
if update.message.reply_to_message:
reason = " ".join(context.args) if context.args else "未说明原因"
else:
reason = " ".join(context.args[1:]) if len(context.args) > 1 else "未说明原因"
try:
warn_count = await Warning.filter(user_id=user_id).count() + 1
await Warning.create(chat_id=update.effective_chat.id, user_id=user_id, username=target, reason=reason, operator_id=update.effective_user.id)
await Action.create(action="WARN", chat_id=update.effective_chat.id, user_id=user_id, username=target, operator_id=update.effective_user.id, details={"reason": reason})
if update.message.reply_to_message:
await delete_replied_message(update, reason="command_warn_reply")
await delete_command_message(update)
reply = await update.effective_chat.send_message(f"⚠️ 已警告 {target} ({user_id})\n原因: {reason}\n累计警告: {warn_count}")
else:
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 safe_reply(update, context, "❌ 警告失败,请检查 Bot 权限或数据库状态")
await record_bot_message(reply)
asyncio.create_task(auto_delete(reply))
async def cmd_mute(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""禁言用户"""
if not await require_admin(update, context):
return
user_id, target = await resolve_target_user(update, context.args)
if not user_id:
reply = await safe_reply(update, context, "用法: 回复用户消息 /mute [分钟],或 /mute 用户ID/@历史用户名 [分钟]")
await record_bot_message(reply)
asyncio.create_task(auto_delete(reply))
return
minutes = 10
minute_arg = context.args[0] if update.message.reply_to_message and context.args else (context.args[1] if len(context.args) > 1 else "")
if minute_arg.isdigit():
minutes = max(1, min(int(minute_arg), 1440))
try:
from telegram import ChatPermissions
until_date = int((datetime.now() + timedelta(minutes=minutes)).timestamp())
await update.effective_chat.restrict_member(
user_id=user_id,
permissions=ChatPermissions(can_send_messages=False),
until_date=until_date,
)
await Action.create(action="MUTE", chat_id=update.effective_chat.id, user_id=user_id, username=target, operator_id=update.effective_user.id, details={"minutes": minutes})
if update.message.reply_to_message:
await delete_replied_message(update, reason="command_mute_reply")
await delete_command_message(update)
reply = await update.effective_chat.send_message(f"🔇 已禁言 {target} ({user_id}) {minutes} 分钟")
else:
reply = await safe_reply(update, context, f"🔇 已禁言 {target} ({user_id}) {minutes} 分钟")
except Exception as e:
logger.error(f"禁言失败: {type(e).__name__}: {e}")
reply = await safe_reply(update, context, "❌ 禁言失败,请检查 Bot 权限或目标用户状态")
await record_bot_message(reply)
asyncio.create_task(auto_delete(reply))
async def require_reply_shortcut(update: Update, context: ContextTypes.DEFAULT_TYPE, usage: str) -> bool:
"""引用消息快捷管理命令必须回复目标消息。"""
if update.message and update.message.reply_to_message:
return True
reply = await safe_reply(update, context, f"用法: 回复目标消息后使用 {usage}")
await record_bot_message(reply)
asyncio.create_task(auto_delete(reply))
return False
async def cmd_dban(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""删除被引用消息并封禁用户。"""
if not await require_admin(update, context):
return
if not await require_reply_shortcut(update, context, "/dban"):
return
await cmd_ban(update, context)
async def cmd_dmute(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""删除被引用消息并禁言用户。"""
if not await require_admin(update, context):
return
if not await require_reply_shortcut(update, context, "/dmute [分钟]"):
return
await cmd_mute(update, context)
async def cmd_dwarn(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""删除被引用消息并警告用户。"""
if not await require_admin(update, context):
return
if not await require_reply_shortcut(update, context, "/dwarn [原因]"):
return
await cmd_warn(update, context)
async def cmd_purge(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""批量删除消息。Telegram Bot API 无法拉历史,只支持回复某条消息后向前按已知 message_id 尝试删除。"""
if not await require_admin(update, context):
return
if not context.args or not context.args[0].isdigit():
reply = await safe_reply(update, context, "用法: 回复一条消息后 /purge 数量(最多50")
await record_bot_message(reply)
asyncio.create_task(auto_delete(reply))
return
if not update.message.reply_to_message:
reply = await safe_reply(update, context, "❌ /purge 需要回复一条消息作为删除起点")
await record_bot_message(reply)
asyncio.create_task(auto_delete(reply))
return
count = min(max(int(context.args[0]), 1), 50)
start_id = update.message.reply_to_message.message_id
deleted = 0
for mid in range(start_id, start_id - count, -1):
try:
await context.bot.delete_message(chat_id=update.effective_chat.id, message_id=mid)
deleted += 1
except Exception:
pass
try:
await update.message.delete()
except Exception:
pass
reply = await update.effective_chat.send_message(f"🧹 已尝试清理 {count} 条,成功删除 {deleted} 条")
await Action.create(action="PURGE", chat_id=update.effective_chat.id, operator_id=update.effective_user.id, details={"count": count, "deleted": deleted})
await record_bot_message(reply)
asyncio.create_task(auto_delete(reply))
async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""帮助"""
text = (
"📖 收割机群管机器人帮助\n"
"━━━━━━━━━━━━━━━━━━━━\n"
"普通操作:\n"
"🤖 /ask <问题> - 向知识库提问\n"
"📊 /stats - 查看今日群组统计\n"
"✅ /checkin - 每日签到\n"
"🎮 /trivia - 知识答题\n"
"🏆 /rank - 查看积分排行\n"
"🔗 /bind - 绑定账号\n\n"
"管理员操作:\n"
"🎁 /lottery - 发起群内抽奖,进入私聊设置奖品和条件\n"
"🎛️ /panel - 打开管理操作面板\n"
"🌙 /night on/off - 开启/关闭夜间模式\n"
"🔨 /ban 用户ID/@历史用户名 - 封禁用户\n"
"🔇 /mute 用户ID/@历史用户名 [分钟] - 禁言用户,默认 10 分钟\n"
"⚠️ /warn 用户ID/@历史用户名 [原因] - 警告用户\n"
"🔓 /unban 用户ID/@历史用户名 - 解封用户\n\n"
"引用消息操作(管理员):\n"
"🔨 回复消息 /dban - 删除被引用消息并封禁发送者\n"
"🔇 回复消息 /dmute [分钟] - 删除被引用消息并禁言发送者\n"
"⚠️ 回复消息 /dwarn [原因] - 删除被引用消息并警告发送者\n"
"↪️ 回复消息 /ban /mute /warn 也支持同样处理\n"
"🧹 回复消息 /purge 数量 - 从被引用消息开始尝试批量删除,最多 50 条\n"
)
reply = await safe_reply(update, context, text)
await record_bot_message(reply)
asyncio.create_task(auto_delete(reply, seconds=60))
async def auto_delete(message, seconds=60):
try:
await asyncio.sleep(seconds)
await message.delete()
except Exception:
pass
def register_admin(app):
app.add_handler(MessageHandler(filters.COMMAND & filters.ChatType.GROUPS, log_admin_command), group=-10)
app.add_handler(CommandHandler("help", cmd_help, filters.ChatType.GROUPS))
app.add_handler(CommandHandler("stats", cmd_stats, filters.ChatType.GROUPS))
app.add_handler(CommandHandler("ban", cmd_ban, filters.ChatType.GROUPS))
app.add_handler(CommandHandler("dban", cmd_dban, filters.ChatType.GROUPS))
app.add_handler(CommandHandler("unban", cmd_unban, filters.ChatType.GROUPS))
app.add_handler(CommandHandler("warn", cmd_warn, filters.ChatType.GROUPS))
app.add_handler(CommandHandler("dwarn", cmd_dwarn, filters.ChatType.GROUPS))
app.add_handler(CommandHandler("mute", cmd_mute, filters.ChatType.GROUPS))
app.add_handler(CommandHandler("dmute", cmd_dmute, filters.ChatType.GROUPS))
app.add_handler(CommandHandler("purge", cmd_purge, filters.ChatType.GROUPS))