feat: add activity based group lottery

This commit is contained in:
ngfchl
2026-07-07 12:27:13 +08:00
parent 89c509e61d
commit 19971b2e82
7 changed files with 333 additions and 17 deletions
+2
View File
@@ -14,4 +14,6 @@ main.py.bak
backups/
cache/
botpy.log
front/node_modules/
front/dist/
.patch_autodelete.py
+29
View File
@@ -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}")
+35
View File
@@ -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"),)
+3
View File
@@ -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"
+135 -17
View File
@@ -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):
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 先赚积分吧。")
else:
reply = await update.message.reply_text(
"🎁 抽奖结果\n"
f"🎯 {result['prize']}\n"
f"💸 消耗:{result['cost']}\n"
f"🎉 奖励:+{result['reward']}\n"
f"📊 当前总积分:{result['total']}"
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
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"
"请发送:奖品 | 参与条件 | 结束条件\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)
+1 -1
View File
@@ -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", "🔗 绑定账号"),
+131 -2
View File
@@ -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)}