feat: record bot messages in chat history

This commit is contained in:
ngfchl
2026-07-07 13:07:27 +08:00
parent 2b38fc91bd
commit a6923924e7
9 changed files with 103 additions and 14 deletions
+15 -1
View File
@@ -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))
+5 -1
View File
@@ -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))
+15 -3
View File
@@ -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"
+11 -6
View File
@@ -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):
+3
View File
@@ -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))
+4
View File
@@ -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))
+1
View File
@@ -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
+2 -1
View File
@@ -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
+47 -2
View File
@@ -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