diff --git a/handlers/admin.py b/handlers/admin.py index 47bfc09..3aecd08 100644 --- a/handlers/admin.py +++ b/handlers/admin.py @@ -10,7 +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 +from services.message_utils import record_bot_message, safe_reply logger = logging.getLogger("spam_guard") @@ -80,6 +80,7 @@ async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE): f"📦 历史封禁: {stats['total_bans']} 条\n" ) reply = await safe_reply(update, context, text) + await record_bot_message(reply) asyncio.create_task(auto_delete(reply)) @@ -91,6 +92,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 safe_reply(update, context, "用法: 回复用户消息 /ban,或 /ban 用户ID/@历史用户名") + await record_bot_message(reply) asyncio.create_task(auto_delete(reply)) return @@ -112,6 +114,7 @@ async def cmd_ban(update: Update, context: ContextTypes.DEFAULT_TYPE): 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)) @@ -123,6 +126,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 safe_reply(update, context, "用法: /unban 用户ID/@历史用户名") + await record_bot_message(reply) asyncio.create_task(auto_delete(reply)) return @@ -135,6 +139,7 @@ async def cmd_unban(update: Update, context: ContextTypes.DEFAULT_TYPE): 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)) @@ -146,6 +151,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 safe_reply(update, context, "用法: 回复用户消息 /warn [原因],或 /warn 用户ID/@历史用户名 [原因]") + await record_bot_message(reply) asyncio.create_task(auto_delete(reply)) return @@ -169,6 +175,7 @@ async def cmd_warn(update: Update, context: ContextTypes.DEFAULT_TYPE): 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): @@ -179,6 +186,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 safe_reply(update, context, "用法: 回复用户消息 /mute [分钟],或 /mute 用户ID/@历史用户名 [分钟]") + await record_bot_message(reply) asyncio.create_task(auto_delete(reply)) return @@ -207,6 +215,7 @@ async def cmd_mute(update: Update, context: ContextTypes.DEFAULT_TYPE): 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)) @@ -215,6 +224,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 safe_reply(update, context, f"用法: 回复目标消息后使用 {usage}") + await record_bot_message(reply) asyncio.create_task(auto_delete(reply)) return False @@ -253,11 +263,13 @@ async def cmd_purge(update: Update, context: ContextTypes.DEFAULT_TYPE): 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 @@ -279,6 +291,7 @@ async def cmd_purge(update: Update, context: ContextTypes.DEFAULT_TYPE): 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)) @@ -311,6 +324,7 @@ async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE): "🧹 回复消息 /purge 数量 - 从被引用消息开始尝试批量删除,最多 50 条\n" ) reply = await safe_reply(update, context, text) + await record_bot_message(reply) asyncio.create_task(auto_delete(reply, seconds=60)) diff --git a/handlers/bind.py b/handlers/bind.py index f44225e..7fd1023 100644 --- a/handlers/bind.py +++ b/handlers/bind.py @@ -8,7 +8,7 @@ from telegram.ext import ContextTypes, CommandHandler, MessageHandler, filters from db.models import Action, GameScore from services.bind_service import bind_with_email_token, unbind, get_binding -from services.message_utils import safe_reply +from services.message_utils import record_bot_message, safe_reply from services.time_utils import format_dt from services.game_service import get_scores_summary @@ -32,6 +32,7 @@ async def cmd_bind(update: Update, context: ContextTypes.DEFAULT_TYPE): ) reply = await safe_reply(update, context, text) asyncio.create_task(auto_delete(update.message, seconds=60)) + await record_bot_message(reply) asyncio.create_task(auto_delete(reply, seconds=60)) return @@ -50,6 +51,7 @@ async def cmd_bind(update: Update, context: ContextTypes.DEFAULT_TYPE): logger.warning(f"🔗 绑定跳转按钮发送失败,改发普通文本: {type(e).__name__}: {e}") reply = await update.effective_chat.send_message(text, disable_web_page_preview=True) asyncio.create_task(auto_delete(update.message, seconds=60)) + await record_bot_message(reply) asyncio.create_task(auto_delete(reply, seconds=60)) return @@ -151,6 +153,7 @@ async def cmd_me(update: Update, context: ContextTypes.DEFAULT_TYPE): reply = await safe_reply(update, context, text) if update.effective_chat.type != "private": asyncio.create_task(auto_delete(update.message, seconds=60)) + await record_bot_message(reply) asyncio.create_task(auto_delete(reply, seconds=60)) @@ -164,6 +167,7 @@ async def cmd_unbind(update: Update, context: ContextTypes.DEFAULT_TYPE): else: reply = await safe_reply(update, context, "❌ 你没有绑定的账号") asyncio.create_task(auto_delete(update.message, seconds=60)) + await record_bot_message(reply) asyncio.create_task(auto_delete(reply, seconds=60)) diff --git a/handlers/fun.py b/handlers/fun.py index 335fef7..860bba5 100644 --- a/handlers/fun.py +++ b/handlers/fun.py @@ -3,6 +3,7 @@ import asyncio import logging import config +from services.message_utils import record_bot_message from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import ApplicationHandlerStop, CallbackQueryHandler, ContextTypes, CommandHandler, MessageHandler, filters @@ -91,6 +92,7 @@ async def cmd_checkin(update: Update, context: ContextTypes.DEFAULT_TYPE): else: reply = await update.message.reply_text("❌ 你今天已经签到过了") asyncio.create_task(auto_delete(update.message, seconds=60)) + await record_bot_message(reply) asyncio.create_task(auto_delete(reply, seconds=60)) @@ -105,6 +107,7 @@ async def cmd_trivia(update: Update, context: ContextTypes.DEFAULT_TYPE): context.user_data["trivia_options"] = q["opts"] reply = await update.message.reply_text(text) asyncio.create_task(auto_delete(update.message, seconds=60)) + await record_bot_message(reply) asyncio.create_task(auto_delete(reply, seconds=60)) @@ -121,6 +124,7 @@ async def cmd_score(update: Update, context: ContextTypes.DEFAULT_TYPE): text += f"🎮 {s.game_type}: {s.score} 分 (胜率 {s.wins}/{s.total})\n" reply = await update.message.reply_text(text) asyncio.create_task(auto_delete(update.message, seconds=60)) + await record_bot_message(reply) asyncio.create_task(auto_delete(reply, seconds=60)) @@ -137,6 +141,7 @@ async def cmd_rank(update: Update, context: ContextTypes.DEFAULT_TYPE): text += f"{medal} @{s.username or s.user_id} — {s.score} 分\n" reply = await update.message.reply_text(text) asyncio.create_task(auto_delete(update.message, seconds=60)) + await record_bot_message(reply) asyncio.create_task(auto_delete(reply, seconds=60)) @@ -195,11 +200,13 @@ async def create_lottery_from_text(update: Update, context: ContextTypes.DEFAULT creator_name=user.username or user.first_name or str(user.id), spec=spec, ) + lottery_text = await build_lottery_text(event) sent = await context.bot.send_message( chat_id=target_chat_id, - text=await build_lottery_text(event), + text=lottery_text, reply_markup=lottery_keyboard(event.id), ) + await record_bot_message(sent, lottery_text) event.message_id = sent.message_id await event.save() if event.end_type == "minutes": @@ -220,6 +227,7 @@ async def send_lottery_private入口(update: Update, context: ContextTypes.DEFAU ) reply = await update.effective_message.reply_text(text, reply_markup=keyboard, disable_web_page_preview=True) asyncio.create_task(auto_delete(update.effective_message, seconds=60)) + await record_bot_message(reply) asyncio.create_task(auto_delete(reply, seconds=60)) return reply @@ -231,13 +239,14 @@ async def cmd_lottery(update: Update, context: ContextTypes.DEFAULT_TYPE): context.user_data["awaiting_lottery_spec"] = True context.user_data["lottery_target_chat_id"] = config.TG_CHAT_ID - await update.message.reply_text( + reply = await update.message.reply_text( "🎁 发起群内抽奖\n" "请发送:奖品 | 参与条件 | 结束条件\n\n" "示例:月卡一张 | all | people:3\n" "参与条件:all / checkin / points:N\n" "结束条件:people:N / minutes:N" ) + await record_bot_message(reply) async def cmd_shop(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -250,6 +259,7 @@ async def cmd_shop(update: Update, context: ContextTypes.DEFAULT_TYPE): text += "\n购买:/buy 商品编号,例如 /buy 1" reply = await update.message.reply_text(text) asyncio.create_task(auto_delete(update.message, seconds=60)) + await record_bot_message(reply) asyncio.create_task(auto_delete(reply, seconds=60)) @@ -267,6 +277,7 @@ async def cmd_buy(update: Update, context: ContextTypes.DEFAULT_TYPE): extra = f",需要 {item['cost']} 分" if item else "" reply = await update.message.reply_text(f"❌ 兑换失败:{result['reason']}{extra}") asyncio.create_task(auto_delete(update.message, seconds=60)) + await record_bot_message(reply) asyncio.create_task(auto_delete(reply, seconds=60)) @@ -354,6 +365,7 @@ async def handle_trivia_answer(update: Update, context: ContextTypes.DEFAULT_TYP ) reply = await msg.reply_text(text) asyncio.create_task(auto_delete(msg, seconds=60)) + await record_bot_message(reply) asyncio.create_task(auto_delete(reply, seconds=60)) raise ApplicationHandlerStop @@ -400,7 +412,7 @@ async def cmd_lottery_start(update: Update, context: ContextTypes.DEFAULT_TYPE): target_chat_id = config.TG_CHAT_ID context.user_data["awaiting_lottery_spec"] = True context.user_data["lottery_target_chat_id"] = target_chat_id - await update.message.reply_text( + reply = await update.message.reply_text( "🎁 发起群内抽奖\n" "请发送:奖品 | 参与条件 | 结束条件\n\n" "示例:月卡一张 | all | people:3\n" diff --git a/handlers/knowledge.py b/handlers/knowledge.py index ed0f5f4..f7a34c8 100644 --- a/handlers/knowledge.py +++ b/handlers/knowledge.py @@ -8,6 +8,7 @@ from telegram import Update from telegram.ext import CommandHandler, ContextTypes, filters import config +from services.message_utils import record_bot_message logger = logging.getLogger("spam_guard") @@ -89,23 +90,27 @@ async def safe_send(update: Update, context: ContextTypes.DEFAULT_TYPE, text: st use_markup = looks_like_markdown(text) if markdown is None else markdown send_text = markdown_to_html(text) if use_markup else text + async def _record(msg, body): + await record_bot_message(msg, body) + return msg + if use_markup: try: - return await update.message.reply_text(send_text, parse_mode="HTML", disable_web_page_preview=True) + return await _record(await update.message.reply_text(send_text, parse_mode="HTML", disable_web_page_preview=True), send_text) except Exception as e: if "Message to be replied not found" in str(e): try: - return await context.bot.send_message(chat_id=chat_id, text=send_text, parse_mode="HTML", disable_web_page_preview=True) + return await _record(await context.bot.send_message(chat_id=chat_id, text=send_text, parse_mode="HTML", disable_web_page_preview=True), send_text) except Exception: - return await context.bot.send_message(chat_id=chat_id, text=text, disable_web_page_preview=True) + return await _record(await context.bot.send_message(chat_id=chat_id, text=text, disable_web_page_preview=True), text) logger.warning(f"HTML 格式发送失败,降级普通文本: {e}") try: - return await update.message.reply_text(text, disable_web_page_preview=True) + return await _record(await update.message.reply_text(text, disable_web_page_preview=True), text) except Exception as e: if "Message to be replied not found" not in str(e): - logger.warning(f"reply_text 失败,改用 send_message: {e}") - return await context.bot.send_message(chat_id=chat_id, text=text, disable_web_page_preview=True) + logger.warning(f"发送回复失败,降级普通发送: {e}") + return await _record(await context.bot.send_message(chat_id=chat_id, text=text, disable_web_page_preview=True), text) async def cmd_ask(update: Update, context: ContextTypes.DEFAULT_TYPE): diff --git a/handlers/night_mode.py b/handlers/night_mode.py index d0f2e7d..e23d5a3 100644 --- a/handlers/night_mode.py +++ b/handlers/night_mode.py @@ -6,6 +6,7 @@ from telegram import Update from telegram.ext import ContextTypes, CommandHandler, filters import config +from services.message_utils import record_bot_message from services.admin_utils import require_admin from services.night_scheduler import enable_night_mode, disable_night_mode @@ -26,6 +27,7 @@ async def cmd_night(update: Update, context: ContextTypes.DEFAULT_TYPE): "/night off 关闭" ) asyncio.create_task(auto_delete(update.message, seconds=60)) + await record_bot_message(reply) asyncio.create_task(auto_delete(reply, seconds=60)) return @@ -40,6 +42,7 @@ async def cmd_night(update: Update, context: ContextTypes.DEFAULT_TYPE): reply = await update.message.reply_text("❓ 用法: /night on|off") asyncio.create_task(auto_delete(update.message, seconds=60)) + await record_bot_message(reply) asyncio.create_task(auto_delete(reply, seconds=60)) diff --git a/handlers/verify.py b/handlers/verify.py index 0277410..dbd0fee 100644 --- a/handlers/verify.py +++ b/handlers/verify.py @@ -6,6 +6,7 @@ from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram.ext import ContextTypes, CallbackQueryHandler, MessageHandler, filters import config +from services.message_utils import record_bot_message from db.models import Action from services.verify_service import create_verify, check_answer, get_pending_verify @@ -77,6 +78,7 @@ async def handle_verify_answer(update: Update, context: ContextTypes.DEFAULT_TYP logger.error(f"⚠️ 恢复权限失败: {e}") reply = await update.message.reply_text("🎉 验证通过!欢迎加入群组!") + await record_bot_message(reply) asyncio.create_task(auto_delete(reply)) await Action.create(action="VERIFY_PASS", chat_id=update.effective_chat.id, user_id=user_id, username=update.effective_user.username) logger.info(f"✅ 验证通过: {user_id}") @@ -85,6 +87,7 @@ async def handle_verify_answer(update: Update, context: ContextTypes.DEFAULT_TYP if result["reason"] == "max_attempts": context.user_data["verifying"] = False reply = await update.message.reply_text("❌ 验证失败次数过多,你将被移出群组") + await record_bot_message(reply) asyncio.create_task(auto_delete(reply)) try: await update.effective_chat.ban_member(user_id=user_id) @@ -95,6 +98,7 @@ async def handle_verify_answer(update: Update, context: ContextTypes.DEFAULT_TYP await Action.create(action="VERIFY_FAIL", chat_id=update.effective_chat.id, user_id=user_id, username=update.effective_user.username) else: reply = await update.message.reply_text(f"❌ 答案错误,剩余 {config.VERIFY_MAX_ATTEMPTS - result.get('attempts', 0)} 次机会") + await record_bot_message(reply) asyncio.create_task(auto_delete(reply)) diff --git a/handlers/welcome.py b/handlers/welcome.py index e688de0..9ac1562 100644 --- a/handlers/welcome.py +++ b/handlers/welcome.py @@ -6,6 +6,7 @@ from telegram import ChatMember, InlineKeyboardButton, InlineKeyboardMarkup, Upd from telegram.ext import ChatMemberHandler, ContextTypes import config +from services.message_utils import record_bot_message from db.models import Action, Ban from services.message_utils import schedule_delete from services.spam_detector import classify_username_spam diff --git a/services/admin_utils.py b/services/admin_utils.py index b796e69..c87fb46 100644 --- a/services/admin_utils.py +++ b/services/admin_utils.py @@ -9,7 +9,7 @@ from telegram.ext import ContextTypes import config from db.models import Action, Ban, Message -from services.message_utils import schedule_delete +from services.message_utils import schedule_delete, record_bot_message logger = logging.getLogger("spam_guard") @@ -38,6 +38,7 @@ async def require_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> b return True reply = await update.message.reply_text("⛔ 只有群管理员可以使用这个命令") schedule_delete(update.message) + await record_bot_message(reply) schedule_delete(reply) return False diff --git a/services/message_utils.py b/services/message_utils.py index d4d1c6f..3513712 100644 --- a/services/message_utils.py +++ b/services/message_utils.py @@ -29,11 +29,56 @@ async def safe_reply(update, context, text: str, **kwargs): """优先回复原消息;原消息已被删除/不可回复时,降级为普通群消息。""" try: if update and update.message: - return await update.message.reply_text(text, **kwargs) + msg = await update.message.reply_text(text, **kwargs) + await record_bot_message(msg, text) + return msg 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) + msg = await context.bot.send_message(chat_id=chat_id, text=text, **kwargs) + await record_bot_message(msg, text) + return msg + + +async def record_bot_message(message, text: str | None = None, username: str = "tg-bot"): + """把机器人发出的群消息写入聊天记录,作为工作记录。""" + if not message or not getattr(message, "chat_id", None): + return None + try: + chat_type = getattr(getattr(message, "chat", None), "type", None) + if chat_type == "private": + return None + from db.models import Message + from web.api import broadcast_sse, json_safe + + body = text if text is not None else (getattr(message, "text", None) or getattr(message, "caption", None) or "") + rec = await Message.get_or_none(chat_id=message.chat_id, msg_id=message.message_id) + if rec: + return rec + rec = await Message.create( + msg_id=message.message_id, + chat_id=message.chat_id, + user_id=0, + username=username, + first_name="机器人工作记录", + text=str(body)[:500], + ) + await broadcast_sse("chat", json_safe({ + "id": rec.id, + "msg_id": rec.msg_id, + "chat_id": rec.chat_id, + "user_id": 0, + "username": username, + "first_name": "机器人工作记录", + "text": rec.text, + "time": rec.created_at, + "spam": False, + "bot_record": True, + })) + return rec + except Exception as e: + logger.debug(f"记录机器人消息失败: {e}") + return None