from __future__ import annotations import asyncio import re from html import escape from typing import Any from pyrogram import filters from pyrogram.enums import ParseMode from pyrogram.types import ( CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, ) from wbb import app, log from wbb.core.decorators.permissions import adminsOnly from wbb.services.giveaway_templates import list_templates, materialize_template from wbb.services.giveaways import ( GiveawayServiceError, buy_additional_tickets, cancel_and_refund_giveaway, create_and_publish_giveaway, finish_and_publish_giveaway, format_giveaway_time, giveaway_starts_at, join_giveaway, parse_giveaway_time, publish_finished, reconcile_pending_ticket_orders, refresh_giveaway_message, remove_and_optionally_refund_participant, render_winners, reroll_giveaway, resume_pending_giveaway, ) from wbb.services.interaction_settings import command_enabled from wbb.services.member_identity import display_name from wbb.utils.dbgiveaway import ( add_giveaway_ban, as_utc, count_participants, get_giveaway, giveawaysdb, list_due_giveaways, list_giveaways_page, list_participants_page, list_pending_cancellations, list_pending_publications, list_running_giveaways, list_unannounced_started_giveaways, mark_giveaway_start_announced, normalize_giveaway_id, remove_giveaway_ban, utc_now, ) from wbb.utils.functions import extract_user_and_reason __MODULE__ = "抽奖管理" __HELP__ = """/giveaway <报名开始时间 ~ 开奖时间> | <奖项> | <标题> | [说明] | [最低积分] | [报名消耗] | [参与奖励] 在当前群创建抽奖。 群成员也可以直接发送“抽奖”查看当前抽奖、报名和参与者, 或发送“开奖”“中奖名单”查看最近一次开奖结果。 奖项格式:`1`、`一等奖:1, 二等奖:3`,或 `一等奖:1:50`; 最后的 50 表示该奖项每位中奖者获得 50 积分。 时间格式:`YYYY-MM-DD HH:mm`,按北京时间计算。 /gjoin <编号> [张数] - 报名并购买奖票;免费抽奖只能领一张。 /glist - 查看进行中的抽奖。 /gparticipants <编号> - 查看参与者。 /gend <编号> - 立即开奖。 /gcancel <编号> - 取消抽奖并退还报名积分。 /greroll <编号> [奖项] - 排除历次中奖者后重抽。 /gremove <编号> <用户 ID> [no-refund] - 移除参与者,可选择不退款。 /gban <成员> [原因] - 禁止成员参加本群抽奖。 /gunban <成员> - 解除抽奖参与限制。""" MAX_TOTAL_WINNERS = 100 PARTICIPANTS_PAGE_SIZE = 10 SWEEP_INTERVAL_SECONDS = 30 _sweeper_started = False def _parse_schedule(raw: str): pieces = re.split(r"\s*(?:~|至)\s*", raw.strip()) if len(pieces) != 2: return None starts_at = parse_giveaway_time(pieces[0]) ends_at = parse_giveaway_time(pieces[1]) if not starts_at or not ends_at or starts_at >= ends_at or ends_at <= utc_now(): return None return starts_at, ends_at def _parse_prizes(raw: str) -> list[dict[str, Any]] | None: raw = raw.strip() if raw.isdigit(): count = int(raw) if 0 < count <= MAX_TOTAL_WINNERS: return [{"name": "中奖者", "count": count, "points_reward": 0}] return None prizes: list[dict[str, Any]] = [] for piece in re.split(r"[,;\n]+", raw): parts = [part.strip() for part in re.split(r"[::]", piece.strip())] if len(parts) not in {2, 3} or not parts[0] or not parts[1].isdigit(): return None count = int(parts[1]) points_reward = int(parts[2]) if len(parts) == 3 and parts[2].isdigit() else 0 if count <= 0 or points_reward < 0: return None prizes.append( {"name": parts[0], "count": count, "points_reward": points_reward} ) if not prizes or sum(item["count"] for item in prizes) > MAX_TOTAL_WINNERS: return None return prizes def _parse_optional_points(pieces: list[str], index: int) -> int | None: if len(pieces) <= index or not pieces[index]: return 0 if not pieces[index].isdigit(): return None return int(pieces[index]) def _create_usage() -> str: return ( "用法\n" "/giveaway 2026-09-22 00:00 ~ 2026-09-22 23:59 | 1 | 月度会员抽奖\n" "/giveaway 2026-09-22 00:00 ~ 2026-09-23 20:00 | " "一等奖:1:50, 二等奖:3:10 | 活动抽奖 | " "抽奖说明 | 20 | 5 | 2" ) def _actor_id(message: Message) -> int: if message.from_user: return message.from_user.id if message.sender_chat: return message.sender_chat.id return 0 def _actor_name(message: Message) -> str: if message.from_user: return message.from_user.first_name or str(message.from_user.id) if message.sender_chat: return message.sender_chat.title or str(message.sender_chat.id) return "匿名管理员" def _participant_mention(participant: dict[str, Any]) -> str: user_id = int(participant["user_id"]) label = ( participant.get("display_name") or ("@" + str(participant["username"]) if participant.get("username") else None) or participant.get("first_name") or str(user_id) ) return f'{escape(str(label))}' def _participants_markup( giveaway_id: str, *, page: int, total_pages: int ) -> InlineKeyboardMarkup | None: if total_pages <= 1: return None buttons: list[InlineKeyboardButton] = [] if page > 1: buttons.append( InlineKeyboardButton( "‹ 上一页", callback_data=f"giveaway_participants_page:{giveaway_id}:{page - 1}", ) ) buttons.append( InlineKeyboardButton( f"{page}/{total_pages}", callback_data=f"giveaway_participants_noop:{giveaway_id}", ) ) if page < total_pages: buttons.append( InlineKeyboardButton( "下一页 ›", callback_data=f"giveaway_participants_page:{giveaway_id}:{page + 1}", ) ) return InlineKeyboardMarkup([buttons]) async def _render_participants_page( giveaway_id: str, page: int ) -> tuple[str, InlineKeyboardMarkup | None]: page = max(1, page) participants, total = await list_participants_page( giveaway_id=giveaway_id, active_only=True, page=page, page_size=PARTICIPANTS_PAGE_SIZE, ) total_pages = max(1, (total + PARTICIPANTS_PAGE_SIZE - 1) // PARTICIPANTS_PAGE_SIZE) if page > total_pages: page = total_pages participants, total = await list_participants_page( giveaway_id=giveaway_id, active_only=True, page=page, page_size=PARTICIPANTS_PAGE_SIZE, ) lines = [ f"抽奖 #{escape(giveaway_id)} 的参与者", f"第 {page}/{total_pages} 页 · 共 {total} 人", ] if participants: offset = (page - 1) * PARTICIPANTS_PAGE_SIZE lines.extend( f"{offset + index}. {_participant_mention(item)}" f" · {int(item.get('paid_ticket_count', item.get('ticket_count', 1)))} 票" for index, item in enumerate(participants, 1) ) else: lines.append("暂时还没有参与者。") return "\n".join(lines), _participants_markup( giveaway_id, page=page, total_pages=total_pages ) def _keyword_giveaways_markup( giveaways: list[dict[str, Any]], ) -> InlineKeyboardMarkup: rows: list[list[InlineKeyboardButton]] = [] for giveaway in giveaways: giveaway_id = giveaway["giveaway_id"] title = str(giveaway["title"]) short_title = title if len(title) <= 12 else title[:11] + "…" rows.append( [ InlineKeyboardButton( f"报名 · {short_title}", callback_data=f"giveaway_join:{giveaway_id}", ), InlineKeyboardButton( "查看参与者", callback_data=f"giveaway_participants:{giveaway_id}", ), ] ) return InlineKeyboardMarkup(rows) def _winners_markup( giveaway_id: str, *, page: int, total_pages: int ) -> InlineKeyboardMarkup | None: if total_pages <= 1: return None buttons: list[InlineKeyboardButton] = [] if page > 1: buttons.append( InlineKeyboardButton( "‹ 上一页", callback_data=f"giveaway_winners_page:{giveaway_id}:{page - 1}", ) ) buttons.append( InlineKeyboardButton( f"{page}/{total_pages}", callback_data=f"giveaway_winners_noop:{giveaway_id}", ) ) if page < total_pages: buttons.append( InlineKeyboardButton( "下一页 ›", callback_data=f"giveaway_winners_page:{giveaway_id}:{page + 1}", ) ) return InlineKeyboardMarkup([buttons]) def _render_winners_page( giveaway: dict[str, Any], page: int ) -> tuple[str, InlineKeyboardMarkup | None]: winners = list(giveaway.get("winners", [])) total = len(winners) total_pages = max(1, (total + PARTICIPANTS_PAGE_SIZE - 1) // PARTICIPANTS_PAGE_SIZE) page = min(max(1, page), total_pages) offset = (page - 1) * PARTICIPANTS_PAGE_SIZE current = winners[offset : offset + PARTICIPANTS_PAGE_SIZE] lines = [ f"抽奖结果 #{escape(giveaway['giveaway_id'])}", f"标题:{escape(giveaway['title'])}", f"第 {page}/{total_pages} 页 · 共 {total} 位中奖者", ] if current: for index, winner in enumerate(current, offset + 1): points = int(winner.get("points_reward", 0)) suffix = f"(+{points} 积分)" if points else "" lines.append( f"{index}. {escape(str(winner['tier']))}:" f"{_participant_mention(winner)}{suffix}" ) else: lines.append("本次没有符合条件的中奖者。") return "\n".join(lines), _winners_markup( giveaway["giveaway_id"], page=page, total_pages=total_pages ) async def _join_user( giveaway_id: str, user, *, quantity: int = 1, request_id: str = "" ) -> str: giveaway = await get_giveaway(giveaway_id) if not giveaway: return "未找到该抽奖。" paid = int(giveaway.get("entry_cost", 0)) > 0 if paid: status, _ = await buy_additional_tickets( giveaway_id, user_id=user.id, quantity=quantity, request_id=request_id or f"join:{user.id}", username=user.username, first_name=user.first_name, display_name=display_name(user.first_name, user.last_name), ) elif quantity != 1: status = "free_single_ticket" else: status, _ = await join_giveaway( giveaway_id, user_id=user.id, username=user.username, first_name=user.first_name, display_name=display_name(user.first_name, user.last_name), ) return { "ok": f"已购买 {quantity} 张奖票。" if paid else "报名成功。", "duplicate": "该次购买已经处理。" if paid else "你已经报名过该抽奖。", "missing": "未找到该抽奖。", "closed": "该抽奖已经关闭。", "not_started": "该抽奖尚未开始报名。", "ended": "该抽奖已经结束。", "banned": "你已被禁止参加本群抽奖。", "not_member": "只有当前群成员可以参加该抽奖。", "not_eligible": "尚未满足抽奖要求的频道或群成员资格。", "eligibility_unavailable": "资格核验暂不可用,请稍后重试。", "insufficient_points": "你的积分不足,无法报名。", "ticket_limit": "已达到本期每人奖票上限。", "free_single_ticket": "免费抽奖每人只能领取一张奖票。", "invalid_quantity": "奖票数量无效。", "not_joined": "该成员已被移除,不能继续购买奖票。", }.get(status, "报名失败,请稍后重试。") async def _sweep_due_giveaways() -> None: await asyncio.sleep(5) while True: try: await reconcile_pending_ticket_orders() for template in await list_templates(): try: await materialize_template(template) except Exception as exc: log.error(f"循环抽奖模板 {template.get('template_id')} 生成失败:{exc}") for giveaway in await list_unannounced_started_giveaways(): try: if giveaway.get("template_id"): previous = await giveawaysdb.find_one( { "template_id": giveaway["template_id"], "period_key": {"$lt": giveaway["period_key"]}, "result_pinned": True, }, sort=[("period_key", -1)], ) if previous and previous.get("result_message_id"): await app.unpin_chat_message( int(giveaway["chat_id"]), int(previous["result_message_id"]) ) await giveawaysdb.update_one( {"giveaway_id": previous["giveaway_id"]}, {"$set": {"result_pinned": False}}, ) if await refresh_giveaway_message(giveaway): await mark_giveaway_start_announced(giveaway["giveaway_id"]) except Exception as exc: log.error(f"抽奖 {giveaway.get('giveaway_id')} 开始处理失败:{exc}") pending = await list_due_giveaways() pending.extend(await list_pending_cancellations()) for giveaway in pending: await resume_pending_giveaway(giveaway) for giveaway in await list_pending_publications(): await publish_finished( giveaway, list(giveaway.get("winners", [])) ) except Exception as exc: log.error(f"抽奖定时任务失败:{exc}") await asyncio.sleep(SWEEP_INTERVAL_SECONDS) def _start_sweeper() -> None: global _sweeper_started if _sweeper_started: return try: loop = asyncio.get_running_loop() except RuntimeError: return _sweeper_started = True loop.create_task(_sweep_due_giveaways(), name="giveaway-sweeper") @app.on_message(filters.command(["giveaway", "gcreate"]) & ~filters.private) @adminsOnly("can_change_info") async def create_giveaway_command(_, message: Message): payload = message.text.split(None, 1)[1].strip() if message.text and " " in message.text else "" pieces = [piece.strip() for piece in payload.split("|")] if len(pieces) < 3: return await message.reply_text(_create_usage(), parse_mode=ParseMode.HTML) schedule = _parse_schedule(pieces[0]) prizes = _parse_prizes(pieces[1]) minimum_points = _parse_optional_points(pieces, 4) entry_cost = _parse_optional_points(pieces, 5) participation_reward = _parse_optional_points(pieces, 6) if ( not schedule or not prizes or not pieces[2] or minimum_points is None or entry_cost is None or participation_reward is None ): return await message.reply_text(_create_usage(), parse_mode=ParseMode.HTML) starts_at, ends_at = schedule giveaway = await create_and_publish_giveaway( chat_id=message.chat.id, creator_id=_actor_id(message), creator_name=_actor_name(message), title=pieces[2], description=pieces[3] if len(pieces) > 3 else "", prizes=prizes, starts_at=starts_at, ends_at=ends_at, minimum_points=minimum_points, entry_cost=entry_cost, participation_reward=participation_reward, ) return await message.reply_text(f"抽奖 #{giveaway['giveaway_id']} 已创建。") @app.on_callback_query(filters.regex(r"^giveaway_join:")) async def giveaway_join_callback(_, query: CallbackQuery): if not await command_enabled(query.message.chat.id, "giveaway"): return await query.answer("本群已关闭抽奖指令。", show_alert=True) giveaway_id = normalize_giveaway_id(query.data.split(":", 1)[1]) await query.answer( await _join_user(giveaway_id, query.from_user, request_id=str(query.id)), show_alert=True, ) @app.on_callback_query(filters.regex(r"^giveaway_buy:[a-f0-9]+:(?:1|5)$")) async def giveaway_buy_callback(_, query: CallbackQuery): if not await command_enabled(query.message.chat.id, "giveaway"): return await query.answer("本群已关闭抽奖指令。", show_alert=True) _, giveaway_id, quantity = query.data.split(":") await query.answer( await _join_user( giveaway_id, query.from_user, quantity=int(quantity), request_id=str(query.id), ), show_alert=True, ) @app.on_callback_query(filters.regex(r"^giveaway_participants:[a-f0-9]+$")) async def giveaway_participants_callback(_, query: CallbackQuery): giveaway_id = normalize_giveaway_id(query.data.rsplit(":", 1)[1]) giveaway = await get_giveaway(giveaway_id) if not giveaway or int(giveaway["chat_id"]) != int(query.message.chat.id): return await query.answer("本群中未找到该抽奖。", show_alert=True) text, markup = await _render_participants_page(giveaway_id, 1) await query.message.reply_text( text, parse_mode=ParseMode.HTML, reply_markup=markup, disable_web_page_preview=True, ) return await query.answer() @app.on_callback_query( filters.regex(r"^giveaway_participants_page:[a-f0-9]+:\d+$") ) async def giveaway_participants_page_callback(_, query: CallbackQuery): _, giveaway_id, raw_page = query.data.split(":", 2) giveaway_id = normalize_giveaway_id(giveaway_id) giveaway = await get_giveaway(giveaway_id) if not giveaway or int(giveaway["chat_id"]) != int(query.message.chat.id): return await query.answer("本群中未找到该抽奖。", show_alert=True) text, markup = await _render_participants_page(giveaway_id, int(raw_page)) await query.message.edit_text( text, parse_mode=ParseMode.HTML, reply_markup=markup, disable_web_page_preview=True, ) return await query.answer() @app.on_callback_query(filters.regex(r"^giveaway_participants_noop:")) async def giveaway_participants_noop_callback(_, query: CallbackQuery): return await query.answer() @app.on_callback_query(filters.regex(r"^giveaway_winners_page:[a-f0-9]+:\d+$")) async def giveaway_winners_page_callback(_, query: CallbackQuery): _, giveaway_id, raw_page = query.data.split(":", 2) giveaway_id = normalize_giveaway_id(giveaway_id) giveaway = await get_giveaway(giveaway_id) if ( not giveaway or giveaway["status"] != "finished" or int(giveaway["chat_id"]) != int(query.message.chat.id) ): return await query.answer("本群中未找到该开奖结果。", show_alert=True) text, markup = _render_winners_page(giveaway, int(raw_page)) await query.message.edit_text( text, parse_mode=ParseMode.HTML, reply_markup=markup, disable_web_page_preview=True, ) return await query.answer() @app.on_callback_query(filters.regex(r"^giveaway_winners_noop:")) async def giveaway_winners_noop_callback(_, query: CallbackQuery): return await query.answer() @app.on_message(filters.command("gjoin") & ~filters.private) async def giveaway_join_command(_, message: Message): if not await command_enabled(message.chat.id, "giveaway"): return None if not message.from_user: return await message.reply_text("只有普通用户身份可以参加抽奖。") if len(message.command) < 2: return await message.reply_text("用法:/gjoin <抽奖编号> [奖票张数]") try: quantity = int(message.command[2]) if len(message.command) > 2 else 1 except ValueError: return await message.reply_text("奖票张数必须是整数。") return await message.reply_text(await _join_user( message.command[1], message.from_user, quantity=quantity, request_id=f"message:{message.chat.id}:{message.id}", )) @app.on_message(filters.regex(r"^抽奖$") & filters.group) async def giveaway_keyword_command(_, message: Message): if not await command_enabled(message.chat.id, "giveaway"): return None giveaways = await list_running_giveaways(chat_id=message.chat.id, limit=10) now = utc_now() giveaways = [ giveaway for giveaway in giveaways if as_utc(giveaway["ends_at"]) > now ] if not giveaways: return await message.reply_text("本群当前没有进行中的抽奖。") lines = ["本群抽奖"] for giveaway in giveaways: state = "报名中" if giveaway_starts_at(giveaway) <= now else "待报名" lines.append( "#{id} · {title} · {state} · {count} 人参与 · {ends_at} 开奖".format( id=escape(giveaway["giveaway_id"]), title=escape(giveaway["title"]), state=state, count=await count_participants(giveaway["giveaway_id"]), ends_at=format_giveaway_time(giveaway["ends_at"]), ) ) return await message.reply_text( "\n".join(lines), parse_mode=ParseMode.HTML, reply_markup=_keyword_giveaways_markup(giveaways), disable_web_page_preview=True, ) @app.on_message(filters.regex(r"^(?:开奖|中奖名单)$") & filters.group) async def giveaway_winners_keyword_command(_, message: Message): if not await command_enabled(message.chat.id, "giveaway"): return None giveaways, _ = await list_giveaways_page( chat_id=message.chat.id, status="finished", page=1, page_size=20, ) if not giveaways: return await message.reply_text("本群暂无已开奖的抽奖。") giveaway = max( giveaways, key=lambda item: as_utc(item.get("finished_at") or item["updated_at"]), ) text, markup = _render_winners_page(giveaway, 1) return await message.reply_text( text, parse_mode=ParseMode.HTML, reply_markup=markup, disable_web_page_preview=True, ) @app.on_message(filters.command("glist") & ~filters.private) @adminsOnly("can_change_info") async def list_giveaways_command(_, message: Message): giveaways = await list_running_giveaways(chat_id=message.chat.id, limit=20) if not giveaways: return await message.reply_text("本群当前没有进行中的抽奖。") lines = ["进行中的抽奖"] for giveaway in giveaways: lines.append( "#{id} - {title} - {count} 人参与 - 开奖于 {ends_at}".format( id=escape(giveaway["giveaway_id"]), title=escape(giveaway["title"]), count=await count_participants(giveaway["giveaway_id"]), ends_at=format_giveaway_time(giveaway["ends_at"]), ) ) return await message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML) @app.on_message(filters.command("gparticipants") & ~filters.private) @adminsOnly("can_change_info") async def participants_command(_, message: Message): if len(message.command) < 2: return await message.reply_text("用法:/gparticipants <抽奖编号>") giveaway_id = normalize_giveaway_id(message.command[1]) giveaway = await get_giveaway(giveaway_id) if not giveaway or giveaway["chat_id"] != message.chat.id: return await message.reply_text("本群中未找到该抽奖。") text, markup = await _render_participants_page(giveaway_id, 1) return await message.reply_text( text, parse_mode=ParseMode.HTML, reply_markup=markup, disable_web_page_preview=True, ) @app.on_message(filters.command("gend") & ~filters.private) @adminsOnly("can_change_info") async def end_giveaway_command(_, message: Message): if len(message.command) < 2: return await message.reply_text("用法:/gend <抽奖编号>") giveaway_id = normalize_giveaway_id(message.command[1]) giveaway = await get_giveaway(giveaway_id) if not giveaway or giveaway["chat_id"] != message.chat.id: return await message.reply_text("本群中未找到该抽奖。") _, text, _ = await finish_and_publish_giveaway(giveaway_id) return await message.reply_text(text) @app.on_message(filters.command("gcancel") & ~filters.private) @adminsOnly("can_change_info") async def cancel_giveaway_command(_, message: Message): if len(message.command) < 2: return await message.reply_text("用法:/gcancel <抽奖编号>") giveaway_id = normalize_giveaway_id(message.command[1]) giveaway = await get_giveaway(giveaway_id) if not giveaway or giveaway["chat_id"] != message.chat.id: return await message.reply_text("本群中未找到该抽奖。") _, text, _ = await cancel_and_refund_giveaway( giveaway_id, chat_id=message.chat.id ) return await message.reply_text(text) @app.on_message(filters.command("greroll") & ~filters.private) @adminsOnly("can_change_info") async def reroll_giveaway_command(_, message: Message): parts = message.text.split(None, 2) if message.text else [] if len(parts) < 2: return await message.reply_text("用法:/greroll <抽奖编号> [奖项名称]") giveaway_id = normalize_giveaway_id(parts[1]) giveaway = await get_giveaway(giveaway_id) if not giveaway or giveaway["chat_id"] != message.chat.id: return await message.reply_text("本群中未找到该抽奖。") try: winners, _ = await reroll_giveaway( giveaway_id=giveaway_id, moderator_id=_actor_id(message), tier_name=parts[2].strip() if len(parts) > 2 else None, publish=False, ) except GiveawayServiceError as exc: return await message.reply_text(str(exc)) return await message.reply_text( render_winners( giveaway, winners, reroll=True, tier_name=parts[2].strip() if len(parts) > 2 else None, ), parse_mode=ParseMode.HTML, disable_web_page_preview=True, ) @app.on_message(filters.command("gremove") & ~filters.private) @adminsOnly("can_change_info") async def remove_participant_command(_, message: Message): if len(message.command) < 3 or not message.command[2].lstrip("-").isdigit(): return await message.reply_text( "用法:/gremove <抽奖编号> <用户 ID> [no-refund]" ) giveaway_id = normalize_giveaway_id(message.command[1]) giveaway = await get_giveaway(giveaway_id) if not giveaway or giveaway["chat_id"] != message.chat.id: return await message.reply_text("本群中未找到该抽奖。") refund = not (len(message.command) > 3 and message.command[3].lower() == "no-refund") removed = await remove_and_optionally_refund_participant( giveaway_id=giveaway_id, user_id=int(message.command[2]), moderator_id=_actor_id(message), reason="由群管理员移除", refund=refund, ) return await message.reply_text( "参与者已移除。" if removed else "未找到该参与者,或该参与者此前已被移除。" ) @app.on_message(filters.command("gban") & ~filters.private) @adminsOnly("can_change_info") async def giveaway_ban_command(_, message: Message): user_id, reason = await extract_user_and_reason(message) if not user_id: return await message.reply_text("用法:/gban <成员> [原因]") await add_giveaway_ban( chat_id=message.chat.id, user_id=int(user_id), moderator_id=_actor_id(message), reason=reason, ) return await message.reply_text(f"已禁止用户 {user_id} 参加本群抽奖。") @app.on_message(filters.command("gunban") & ~filters.private) @adminsOnly("can_change_info") async def giveaway_unban_command(_, message: Message): user_id, _ = await extract_user_and_reason(message) if not user_id: return await message.reply_text("用法:/gunban <成员>") removed = await remove_giveaway_ban(message.chat.id, int(user_id)) return await message.reply_text( f"已允许用户 {user_id} 参加本群抽奖。" if removed else "该用户没有被限制参加抽奖。" ) _start_sweeper()