From 19971b2e825caac82002a31847a346caa7477831 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Tue, 7 Jul 2026 12:27:13 +0800 Subject: [PATCH] feat: add activity based group lottery --- .gitignore | 2 + db/__init__.py | 29 ++++++++ db/models.py | 35 ++++++++++ handlers/admin.py | 3 + handlers/fun.py | 146 +++++++++++++++++++++++++++++++++++---- main.py | 2 +- services/game_service.py | 133 ++++++++++++++++++++++++++++++++++- 7 files changed, 333 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index dabc3e0..5546fd8 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,6 @@ main.py.bak backups/ cache/ botpy.log +front/node_modules/ +front/dist/ .patch_autodelete.py diff --git a/db/__init__.py b/db/__init__.py index 7668df9..46a9f16 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -33,6 +33,35 @@ async def ensure_runtime_schema(): ALTER TABLE messages ADD COLUMN IF NOT EXISTS delete_reason VARCHAR(255) NULL; ALTER TABLE user_bindings ADD COLUMN IF NOT EXISTS auth_email VARCHAR(255) NULL; ALTER TABLE user_bindings ADD COLUMN IF NOT EXISTS time_expire VARCHAR(100) NULL; + CREATE TABLE IF NOT EXISTS lottery_events ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + chat_id BIGINT NOT NULL, + message_id BIGINT NULL, + creator_id BIGINT NOT NULL, + creator_name VARCHAR(255) NULL, + prize VARCHAR(500) NOT NULL, + condition_type VARCHAR(50) NOT NULL DEFAULT 'all', + condition_value INT NOT NULL DEFAULT 0, + end_type VARCHAR(50) NOT NULL DEFAULT 'people', + end_value INT NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'active', + winner_id BIGINT NULL, + winner_name VARCHAR(255) NULL, + ended_at TIMESTAMPTZ NULL + ); + CREATE TABLE IF NOT EXISTS lottery_participants ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + event_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + username VARCHAR(255) NULL, + first_name VARCHAR(255) NULL + ); + CREATE INDEX IF NOT EXISTS idx_lottery_events_chat_status ON lottery_events(chat_id, status); + CREATE INDEX IF NOT EXISTS idx_lottery_participants_event_user ON lottery_participants(event_id, user_id); """) except Exception as e: logger.warning(f"⚠️ 自动补齐数据库字段失败: {type(e).__name__}: {e}") diff --git a/db/models.py b/db/models.py index ec8ca8b..b715bfe 100644 --- a/db/models.py +++ b/db/models.py @@ -132,3 +132,38 @@ class GroupConfig(TimestampMixin, Model): class Meta: table = "group_config" + + +class LotteryEvent(TimestampMixin, Model): + """群内抽奖活动""" + id = fields.BigIntField(pk=True, generated=True) + chat_id = fields.BigIntField() + message_id = fields.BigIntField(null=True) + creator_id = fields.BigIntField() + creator_name = fields.CharField(max_length=255, null=True) + prize = fields.CharField(max_length=500) + condition_type = fields.CharField(max_length=50, default="all") # all/checkin/min_points + condition_value = fields.IntField(default=0) + end_type = fields.CharField(max_length=50, default="people") # people/minutes/manual + end_value = fields.IntField(default=0) + status = fields.CharField(max_length=20, default="active") + winner_id = fields.BigIntField(null=True) + winner_name = fields.CharField(max_length=255, null=True) + ended_at = fields.DatetimeField(null=True) + + class Meta: + table = "lottery_events" + indexes = (("chat_id", "status"), ("creator_id", "created_at")) + + +class LotteryParticipant(TimestampMixin, Model): + """抽奖参与者""" + id = fields.BigIntField(pk=True, generated=True) + event_id = fields.BigIntField() + user_id = fields.BigIntField() + username = fields.CharField(max_length=255, null=True) + first_name = fields.CharField(max_length=255, null=True) + + class Meta: + table = "lottery_participants" + indexes = (("event_id", "user_id"),) diff --git a/handlers/admin.py b/handlers/admin.py index 4b56d34..8cf4621 100644 --- a/handlers/admin.py +++ b/handlers/admin.py @@ -292,6 +292,9 @@ async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE): "✅ /checkin - 每日签到\n" "🎮 /trivia - 知识答题\n" "🏆 /rank - 查看积分排行\n" + "🎁 /lottery - 发起群内抽奖\n" + " 格式:/lottery 奖品 | 参与条件 | 结束条件\n" + " 示例:/lottery 月卡一张 | all | people:3\n" "🔗 /bind - 绑定账号\n\n" "管理员操作:\n" "🌙 /night on/off - 开启/关闭夜间模式\n" diff --git a/handlers/fun.py b/handlers/fun.py index a5dbec6..33197d1 100644 --- a/handlers/fun.py +++ b/handlers/fun.py @@ -9,7 +9,9 @@ from db.models import Action from services.game_service import ( get_trivia_question, check_trivia_answer, get_leaderboard, daily_checkin, get_scores_summary, - lottery_draw, SHOP_ITEMS, buy_shop_item, + SHOP_ITEMS, buy_shop_item, + parse_lottery_spec, create_lottery_event, lottery_condition_text, lottery_end_text, + count_lottery_participants, join_lottery_event, draw_lottery_event, ) logger = logging.getLogger("spam_guard") @@ -19,7 +21,7 @@ PANEL_IDLE_SECONDS = 60 def panel_keyboard(): return InlineKeyboardMarkup([ [InlineKeyboardButton("👤 我的信息", callback_data="panel:me"), InlineKeyboardButton("✅ 签到", callback_data="panel:checkin")], - [InlineKeyboardButton("🎁 抽奖", callback_data="panel:lottery"), InlineKeyboardButton("🛒 商城", callback_data="panel:shop")], + [InlineKeyboardButton("🎁 发起抽奖", callback_data="panel:lottery"), InlineKeyboardButton("🛒 商城", callback_data="panel:shop")], [InlineKeyboardButton("📖 帮助", callback_data="panel:help")], ]) @@ -136,21 +138,90 @@ async def cmd_rank(update: Update, context: ContextTypes.DEFAULT_TYPE): asyncio.create_task(auto_delete(reply, seconds=60)) -async def cmd_lottery(update: Update, context: ContextTypes.DEFAULT_TYPE): +async def build_lottery_text(event, count: int | None = None) -> str: + if count is None: + count = await count_lottery_participants(event.id) + return ( + "🎁 群内抽奖\n" + "━━━━━━━━━━━━\n" + f"🎯 奖品:{event.prize}\n" + f"👤 发起人:@{event.creator_name or event.creator_id}\n" + f"✅ 参与条件:{await lottery_condition_text(event)}\n" + f"⏱️ 结束条件:{await lottery_end_text(event)}\n" + f"👥 当前参与:{count} 人\n\n" + "点击下方按钮参与抽奖。" + ) + + +def lottery_keyboard(event_id: int) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup([[InlineKeyboardButton("🎁 参与抽奖", callback_data=f"lottery:join:{event_id}")]]) + + +async def schedule_lottery_draw(context: ContextTypes.DEFAULT_TYPE, event_id: int, seconds: int): + async def _run(): + try: + await asyncio.sleep(seconds) + result = await draw_lottery_event(event_id) + event = result.get("event") + if not event or not event.message_id: + return + if result.get("ok"): + winner = result["winner"] + text = ( + "🎉 抽奖已开奖\n" + "━━━━━━━━━━━━\n" + f"🎯 奖品:{event.prize}\n" + f"👥 参与人数:{result['count']} 人\n" + f"🏆 中奖用户:@{winner.username or winner.first_name or winner.user_id}" + ) + else: + text = f"🎁 抽奖已结束\n━━━━━━━━━━━━\n🎯 奖品:{event.prize}\n❌ {result.get('reason', '未开奖')}" + await context.bot.edit_message_text(chat_id=event.chat_id, message_id=event.message_id, text=text) + except Exception as e: + logger.warning(f"⚠️ 自动开奖失败: {type(e).__name__}: {e}") + asyncio.create_task(_run()) + + +async def create_lottery_from_text(update: Update, context: ContextTypes.DEFAULT_TYPE, spec_text: str): user = update.effective_user - result = await lottery_draw(user.id, user.username or user.first_name or str(user.id)) - if not result["ok"]: - reply = await update.message.reply_text(f"🎁 抽奖需要 {result.get('cost', 10)} 积分,当前积分不足。\n发送 /checkin 或 /trivia 先赚积分吧。") + msg = update.effective_message + spec = parse_lottery_spec(spec_text) + event = await create_lottery_event( + chat_id=msg.chat_id, + creator_id=user.id, + creator_name=user.username or user.first_name or str(user.id), + spec=spec, + ) + sent = await msg.reply_text(await build_lottery_text(event), reply_markup=lottery_keyboard(event.id)) + event.message_id = sent.message_id + await event.save() + if event.end_type == "minutes": + await schedule_lottery_draw(context, event.id, event.end_value * 60) + return sent + + +async def cmd_lottery(update: Update, context: ContextTypes.DEFAULT_TYPE): + parts = (update.message.text or "").split(maxsplit=1) + if len(parts) > 1: + try: + await create_lottery_from_text(update, context, parts[1]) + except Exception as e: + reply = await update.message.reply_text(f"❌ 抽奖格式错误:{e}\n\n示例:/lottery 月卡一张 | all | people:3") + asyncio.create_task(auto_delete(reply, seconds=90)) else: + context.user_data["awaiting_lottery_spec"] = True reply = await update.message.reply_text( - "🎁 抽奖结果\n" - f"🎯 {result['prize']}\n" - f"💸 消耗:{result['cost']} 分\n" - f"🎉 奖励:+{result['reward']} 分\n" - f"📊 当前总积分:{result['total']} 分" + "🎁 发起群内抽奖\n" + "请发送:奖品 | 参与条件 | 结束条件\n\n" + "示例:\n" + "月卡一张 | all | people:3\n" + "邀请码 | checkin | minutes:10\n" + "徽章 | points:100 | people:5\n\n" + "参与条件:all / checkin / points:N\n" + "结束条件:people:N / minutes:N" ) + asyncio.create_task(auto_delete(reply, seconds=120)) asyncio.create_task(auto_delete(update.message, seconds=60)) - asyncio.create_task(auto_delete(reply, seconds=60)) async def cmd_shop(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -200,8 +271,14 @@ async def handle_panel_callback(update: Update, context: ContextTypes.DEFAULT_TY result = await daily_checkin(user.id, user.username or user.first_name or str(user.id)) text = "❌ 你今天已经签到过了" if not result["ok"] else f"✅ 签到成功!奖励 +{result['bonus']} 分,连续 {result['streak']} 天" elif action == "lottery": - result = await lottery_draw(user.id, user.username or user.first_name or str(user.id)) - text = f"❌ {result.get('reason')},抽奖需要 {result.get('cost', 10)} 分" if not result["ok"] else f"🎁 {result['prize']}\n当前总积分:{result['total']} 分" + context.user_data["awaiting_lottery_spec"] = True + text = ( + "🎁 发起群内抽奖\n" + "请在群里发送:奖品 | 参与条件 | 结束条件\n\n" + "示例:月卡一张 | all | people:3\n" + "参与条件:all / checkin / points:N\n" + "结束条件:people:N / minutes:N" + ) elif action == "shop": summary = await get_scores_summary(user.id) text = f"🛒 积分商城\n当前总积分:{summary['total']} 分\n" + "\n".join([f"{k}. {v['name']} — {v['cost']} 分" for k, v in SHOP_ITEMS.items()]) + "\n\n购买请发送 /buy 商品编号" @@ -224,6 +301,16 @@ async def handle_trivia_answer(update: Update, context: ContextTypes.DEFAULT_TYP if not msg or not msg.text or not user or user.is_bot: return + if context.user_data.get("awaiting_lottery_spec"): + try: + await create_lottery_from_text(update, context, msg.text.strip()) + context.user_data.pop("awaiting_lottery_spec", None) + asyncio.create_task(auto_delete(msg, seconds=60)) + except Exception as e: + reply = await msg.reply_text(f"❌ 抽奖格式错误:{e}\n请重新发送,或发送 /panel 重新开始。") + asyncio.create_task(auto_delete(reply, seconds=90)) + raise ApplicationHandlerStop + correct_answer = context.user_data.get("trivia_answer") options = context.user_data.get("trivia_options") or [] if not correct_answer: @@ -259,6 +346,36 @@ async def handle_trivia_answer(update: Update, context: ContextTypes.DEFAULT_TYP raise ApplicationHandlerStop +async def handle_lottery_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): + query = update.callback_query + await query.answer() + parts = query.data.split(":") + event_id = int(parts[-1]) + user = query.from_user + result = await join_lottery_event(event_id, user.id, user.username or "", user.first_name or "") + if not result["ok"]: + await query.answer(result["reason"], show_alert=True) + return + event = result["event"] + if result["should_draw"]: + draw = await draw_lottery_event(event.id) + if draw.get("ok"): + winner = draw["winner"] + text = ( + "🎉 抽奖已开奖\n" + "━━━━━━━━━━━━\n" + f"🎯 奖品:{event.prize}\n" + f"👥 参与人数:{draw['count']} 人\n" + f"🏆 中奖用户:@{winner.username or winner.first_name or winner.user_id}" + ) + await query.edit_message_text(text) + else: + await query.edit_message_text(f"🎁 抽奖已结束\n❌ {draw.get('reason', '未开奖')}") + else: + await query.edit_message_text(await build_lottery_text(event, result["count"]), reply_markup=lottery_keyboard(event.id)) + await query.answer(f"参与成功,当前 {result['count']} 人") + + async def auto_delete(message, seconds=60): try: await asyncio.sleep(seconds) @@ -277,5 +394,6 @@ def register_fun(app): app.add_handler(CommandHandler("buy", cmd_buy, filters.ChatType.GROUPS)) app.add_handler(CommandHandler("panel", cmd_panel, filters.ChatType.GROUPS)) app.add_handler(CallbackQueryHandler(handle_panel_callback, pattern=r"^panel:")) + app.add_handler(CallbackQueryHandler(handle_lottery_callback, pattern=r"^lottery:")) app.add_handler(MessageHandler(filters.TEXT & filters.ChatType.GROUPS & ~filters.COMMAND, handle_trivia_answer), group=-1) app.add_handler(MessageHandler(filters.TEXT & filters.ChatType.GROUPS, handle_trigger), group=2) diff --git a/main.py b/main.py index a90718a..d8cd7e4 100644 --- a/main.py +++ b/main.py @@ -130,7 +130,7 @@ async def start_tg_bot(): BotCommand("stats", "📊 今日统计"), BotCommand("checkin", "✅ 每日签到"), BotCommand("trivia", "🎮 知识答题"), - BotCommand("lottery", "🎁 积分抽奖"), + BotCommand("lottery", "🎁 发起群内抽奖"), BotCommand("shop", "🛒 积分商城"), BotCommand("panel", "🎛️ 操作面板"), BotCommand("bind", "🔗 绑定账号"), diff --git a/services/game_service.py b/services/game_service.py index 2f385e2..3e91e61 100644 --- a/services/game_service.py +++ b/services/game_service.py @@ -1,9 +1,9 @@ """小游戏服务""" import random import logging -from datetime import datetime +from datetime import datetime, timedelta -from db.models import GameScore +from db.models import GameScore, LotteryEvent, LotteryParticipant logger = logging.getLogger("spam_guard") @@ -127,3 +127,132 @@ async def buy_shop_item(user_id: int, item_id: str) -> dict: return {"ok": False, "reason": "积分不足", "item": item} summary = await get_scores_summary(user_id) return {"ok": True, "item": item, "total": summary["total"]} + + +LOTTERY_CONDITION_HELP = "参与条件:all=所有人;checkin=今日已签到;points:N=总积分不少于 N" +LOTTERY_END_HELP = "结束条件:people:N=满 N 人开奖;minutes:N=N 分钟后开奖;manual=手动开奖(预留)" + + +def parse_lottery_condition(raw: str) -> tuple[str, int, str]: + text = (raw or "all").strip().lower() + if text in {"all", "所有人", "不限"}: + return "all", 0, "所有人可参与" + if text in {"checkin", "签到", "今日签到"}: + return "checkin", 0, "今日已签到用户" + if text.startswith("points:") or text.startswith("积分:"): + value = int(text.split(":", 1)[1]) + return "min_points", value, f"总积分 ≥ {value}" + raise ValueError(LOTTERY_CONDITION_HELP) + + +def parse_lottery_end(raw: str) -> tuple[str, int, str]: + text = (raw or "people:3").strip().lower() + if text.startswith("people:") or text.startswith("人数:"): + value = max(1, int(text.split(":", 1)[1])) + return "people", value, f"满 {value} 人自动开奖" + if text.startswith("minutes:") or text.startswith("分钟:"): + value = max(1, int(text.split(":", 1)[1])) + return "minutes", value, f"{value} 分钟后自动开奖" + if text in {"manual", "手动"}: + return "manual", 0, "手动开奖" + raise ValueError(LOTTERY_END_HELP) + + +def parse_lottery_spec(text: str) -> dict: + parts = [p.strip() for p in text.replace("|", "|").split("|")] + if len(parts) != 3: + raise ValueError("格式:奖品 | 参与条件 | 结束条件") + prize = parts[0] + if not prize: + raise ValueError("奖品不能为空") + condition_type, condition_value, condition_text = parse_lottery_condition(parts[1]) + end_type, end_value, end_text = parse_lottery_end(parts[2]) + return { + "prize": prize, + "condition_type": condition_type, + "condition_value": condition_value, + "condition_text": condition_text, + "end_type": end_type, + "end_value": end_value, + "end_text": end_text, + } + + +async def create_lottery_event(chat_id: int, creator_id: int, creator_name: str, spec: dict) -> LotteryEvent: + return await LotteryEvent.create( + chat_id=chat_id, + creator_id=creator_id, + creator_name=creator_name, + prize=spec["prize"], + condition_type=spec["condition_type"], + condition_value=spec["condition_value"], + end_type=spec["end_type"], + end_value=spec["end_value"], + ) + + +async def lottery_condition_text(event: LotteryEvent) -> str: + if event.condition_type == "checkin": + return "今日已签到用户" + if event.condition_type == "min_points": + return f"总积分 ≥ {event.condition_value}" + return "所有人可参与" + + +async def lottery_end_text(event: LotteryEvent) -> str: + if event.end_type == "people": + return f"满 {event.end_value} 人自动开奖" + if event.end_type == "minutes": + return f"{event.end_value} 分钟后自动开奖" + return "手动开奖" + + +async def count_lottery_participants(event_id: int) -> int: + return await LotteryParticipant.filter(event_id=event_id).count() + + +async def check_lottery_eligible(event: LotteryEvent, user_id: int) -> tuple[bool, str]: + if event.condition_type == "checkin": + score = await GameScore.filter(user_id=user_id, game_type="checkin").first() + today = datetime.now().strftime("%Y-%m-%d") + if not score or not score.last_played or score.last_played.strftime("%Y-%m-%d") != today: + return False, "需要今日已签到后才能参与" + elif event.condition_type == "min_points": + summary = await get_scores_summary(user_id) + if summary["total"] < event.condition_value: + return False, f"总积分需要 ≥ {event.condition_value},当前 {summary['total']}" + return True, "ok" + + +async def join_lottery_event(event_id: int, user_id: int, username: str = "", first_name: str = "") -> dict: + event = await LotteryEvent.filter(id=event_id).first() + if not event or event.status != "active": + return {"ok": False, "reason": "抽奖不存在或已结束"} + ok, reason = await check_lottery_eligible(event, user_id) + if not ok: + return {"ok": False, "reason": reason} + exists = await LotteryParticipant.filter(event_id=event_id, user_id=user_id).exists() + if exists: + return {"ok": False, "reason": "你已经参与过了"} + await LotteryParticipant.create(event_id=event_id, user_id=user_id, username=username, first_name=first_name) + count = await count_lottery_participants(event_id) + return {"ok": True, "event": event, "count": count, "should_draw": event.end_type == "people" and count >= event.end_value} + + +async def draw_lottery_event(event_id: int) -> dict: + event = await LotteryEvent.filter(id=event_id).first() + if not event or event.status != "active": + return {"ok": False, "reason": "抽奖不存在或已结束"} + participants = await LotteryParticipant.filter(event_id=event_id).all() + if not participants: + event.status = "cancelled" + event.ended_at = datetime.now() + await event.save() + return {"ok": False, "reason": "无人参与,抽奖已取消", "event": event} + winner = random.choice(participants) + event.status = "ended" + event.winner_id = winner.user_id + event.winner_name = winner.username or winner.first_name or str(winner.user_id) + event.ended_at = datetime.now() + await event.save() + return {"ok": True, "event": event, "winner": winner, "count": len(participants)}