فهرست منبع

feat: add giveaway views and Chinese shortcuts

AI-Co-Authored-By: Codex
chendeben 1 هفته پیش
والد
کامیت
d4bc81ab4e

+ 60 - 10
admin-web/src/pages/Giveaways.tsx

@@ -66,7 +66,8 @@ export default function GiveawaysPage() {
   const [editSaving, setEditSaving] = useState(false);
   const [editForm] = Form.useForm();
   const [selected, setSelected] = useState<Giveaway | null>(null);
-  const [participants, setParticipants] = useState<Participant[]>([]);
+  const [participants, setParticipants] = useState<Paged<Participant>>({ items: [], total: 0, page: 1, page_size: 20 });
+  const [participantLoading, setParticipantLoading] = useState(false);
   const [banOpen, setBanOpen] = useState(false);
   const [bans, setBans] = useState<Paged<GiveawayBan>>({ items: [], total: 0, page: 1, page_size: 20 });
   const [banLoading, setBanLoading] = useState(false);
@@ -86,12 +87,20 @@ export default function GiveawaysPage() {
     }
   };
 
-  const loadParticipants = async (giveaway: Giveaway) => {
-    const result = await apiRequest<Paged<Participant>>(
-      `/giveaways/${giveaway.giveaway_id}/participants?page_size=100`,
-    );
-    setParticipants(result.items);
+  const loadParticipants = async (giveaway: Giveaway, page = 1) => {
     setSelected(giveaway);
+    setParticipantLoading(true);
+    try {
+      setParticipants(
+        await apiRequest<Paged<Participant>>(
+          `/giveaways/${giveaway.giveaway_id}/participants?page=${page}&page_size=20`,
+        ),
+      );
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : '参与者加载失败');
+    } finally {
+      setParticipantLoading(false);
+    }
   };
 
   const loadBans = async (page = 1) => {
@@ -117,7 +126,7 @@ export default function GiveawaysPage() {
         reason: refund ? '管理员面板移除' : '违规移除,不退还报名积分',
       }),
     );
-    if (selected) await loadParticipants(selected);
+    if (selected) await loadParticipants(selected, participants.page);
   };
 
   const openEdit = (giveaway: Giveaway) => {
@@ -204,7 +213,7 @@ export default function GiveawaysPage() {
       width: 330,
       render: (_, record) => (
         <Space size={4} wrap>
-          <Button type="link" onClick={() => void loadParticipants(record)}>参与者</Button>
+          <Button type="link" onClick={() => void loadParticipants(record)}>参与者 / 中奖者</Button>
           {record.status === 'running' ? (
             <>
               <Button
@@ -295,6 +304,22 @@ export default function GiveawaysPage() {
     },
   ];
 
+  const winnerColumns: ColumnsType<NonNullable<Giveaway['winners']>[number]> = [
+    {
+      title: '中奖者',
+      key: 'user',
+      render: (_, item) => userDisplayName(item),
+    },
+    { title: '用户 ID', dataIndex: 'user_id', width: 150, className: 'numeric' },
+    { title: '奖项', dataIndex: 'tier', width: 160 },
+    {
+      title: '中奖积分',
+      dataIndex: 'points_reward',
+      width: 110,
+      render: (value) => (Number(value) > 0 ? `+${value}` : '0'),
+    },
+  ];
+
   const banColumns: ColumnsType<GiveawayBan> = [
     { title: '用户 ID', dataIndex: 'user_id', width: 150, className: 'numeric' },
     { title: '原因', dataIndex: 'reason', render: (value) => value || '-' },
@@ -606,7 +631,7 @@ export default function GiveawaysPage() {
         </Form>
       </Modal>
       <Drawer
-        title={selected ? `参与者 · #${selected.giveaway_id}` : '参与者'}
+        title={selected ? `参与者 / 中奖者 · #${selected.giveaway_id}` : '参与者 / 中奖者'}
         open={Boolean(selected)}
         width="min(760px, 100vw)"
         onClose={() => setSelected(null)}
@@ -631,7 +656,32 @@ export default function GiveawaysPage() {
               <Descriptions.Item label="报名消耗">{selected.entry_cost}</Descriptions.Item>
               <Descriptions.Item label="参与奖励">{selected.participation_reward}</Descriptions.Item>
             </Descriptions>
-            <Table rowKey="user_id" columns={participantColumns} dataSource={participants} pagination={false} scroll={{ x: 820 }} size="small" />
+            <Divider orientation="left">参与者({participants.total})</Divider>
+            <Table
+              rowKey="user_id"
+              columns={participantColumns}
+              dataSource={participants.items}
+              loading={participantLoading}
+              pagination={{
+                current: participants.page,
+                pageSize: participants.page_size,
+                total: participants.total,
+                showSizeChanger: false,
+              }}
+              onChange={(pagination) => void loadParticipants(selected, pagination.current || 1)}
+              scroll={{ x: 820 }}
+              size="small"
+            />
+            <Divider orientation="left">中奖者({selected.winners?.length || 0})</Divider>
+            <Table
+              rowKey={(winner) => `${winner.tier}:${winner.user_id}`}
+              columns={winnerColumns}
+              dataSource={selected.winners || []}
+              pagination={false}
+              locale={{ emptyText: selected.status === 'finished' ? '本次没有符合条件的中奖者' : '尚未开奖' }}
+              scroll={{ x: 620 }}
+              size="small"
+            />
           </>
         ) : null}
       </Drawer>

+ 9 - 1
admin-web/src/types.ts

@@ -412,7 +412,15 @@ export interface Giveaway {
   ends_at: string;
   created_at: string;
   updated_at: string;
-  winners?: Array<{ user_id: string; tier: string; points_reward: number }>;
+  winners?: Array<{
+    user_id: string;
+    username?: string | null;
+    first_name?: string | null;
+    last_name?: string | null;
+    display_name?: string | null;
+    tier: string;
+    points_reward: number;
+  }>;
 }
 
 export interface AuditLog {

+ 5 - 1
admin-web/tests/unit/Giveaways.test.tsx

@@ -42,6 +42,7 @@ beforeEach(() => {
           ends_at: '2099-07-25T08:00:00Z',
           created_at: '2026-07-24T08:00:00Z',
           updated_at: '2026-07-24T08:00:01Z',
+          winners: [{ user_id: '10002', display_name: '中奖用户', tier: '一等奖', points_reward: 50 }],
         }],
         total: 1,
         page: 1,
@@ -65,10 +66,13 @@ it('参与者优先显示 Telegram 昵称', async () => {
   render(<GiveawaysPage />);
   await screen.findByText('七月抽奖');
 
-  fireEvent.click(screen.getByRole('button', { name: '参与者' }));
+  fireEvent.click(screen.getByRole('button', { name: '参与者 / 中奖者' }));
 
   expect(await screen.findByText('爱丽丝 示例')).toBeInTheDocument();
   expect(screen.queryByText('@alice')).not.toBeInTheDocument();
+  expect(screen.getByText('中奖用户')).toBeInTheDocument();
+  expect(screen.getByText('一等奖')).toBeInTheDocument();
+  expect(screen.getByText('+50')).toBeInTheDocument();
 });
 
 it('展示抽奖积分配置并在危险操作确认后开奖', async () => {

+ 14 - 0
tests/conftest.py

@@ -21,6 +21,9 @@ class FakeTelegramApp:
         self.sent_messages: list[tuple[int, str]] = []
         self.sent_photos: list[tuple[int, object, str]] = []
         self.edited_messages: list[tuple[int, int, str]] = []
+        self.pinned_messages: list[tuple[int, int]] = []
+        self.unpinned_messages: list[tuple[int, int]] = []
+        self.deleted_messages: list[tuple[int, int]] = []
 
     async def get_chat_member(self, chat_id: int, user_id: int):
         member = self.members.get((int(chat_id), int(user_id)))
@@ -65,6 +68,17 @@ class FakeTelegramApp:
         self.edited_messages.append((int(chat_id), int(message_id), text))
         return SimpleNamespace(chat=SimpleNamespace(id=int(chat_id)), id=int(message_id))
 
+    async def pin_chat_message(
+        self, chat_id: int, message_id: int, **_kwargs
+    ) -> None:
+        self.pinned_messages.append((int(chat_id), int(message_id)))
+
+    async def unpin_chat_message(self, chat_id: int, message_id: int) -> None:
+        self.unpinned_messages.append((int(chat_id), int(message_id)))
+
+    async def delete_messages(self, chat_id: int, message_id: int) -> None:
+        self.deleted_messages.append((int(chat_id), int(message_id)))
+
     def on_message(self, *_args, **_kwargs):
         return lambda function: function
 

+ 30 - 0
tests/test_admin_shortcuts.py

@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+import pytest
+
+
+@pytest.mark.parametrize(
+    ("text", "expected"),
+    [
+        ("封禁", ("/ban", ["ban"])),
+        ("解封", ("/unban", ["unban"])),
+        ("禁言", ("/mute", ["mute"])),
+        ("解除禁言", ("/unmute", ["unmute"])),
+        ("警告", ("/warn", ["warn"])),
+        ("禁言10分钟", ("/tmute 10m", ["tmute", "10m"])),
+        ("禁言 2 小时", ("/tmute 2h", ["tmute", "2h"])),
+        ("禁言1天", ("/tmute 1d", ["tmute", "1d"])),
+        ("踢出", ("/kick", ["kick"])),
+        ("踢", ("/kick", ["kick"])),
+        ("飞", ("/kick", ["kick"])),
+    ],
+)
+def test_member_action_shortcut_parser(app_modules, text, expected):
+    shortcuts = app_modules.load("wbb.services.member_shortcuts")
+    assert shortcuts.member_action_shortcut_command(text) == expected
+
+
+@pytest.mark.parametrize("text", ["禁言0分钟", "禁言100分钟", "封禁 原因", "踢他"])
+def test_member_action_shortcut_rejects_ambiguous_text(app_modules, text):
+    shortcuts = app_modules.load("wbb.services.member_shortcuts")
+    assert shortcuts.member_action_shortcut_command(text) is None

+ 169 - 0
tests/test_giveaway_commands.py

@@ -0,0 +1,169 @@
+from __future__ import annotations
+
+import asyncio
+import sys
+import types
+from datetime import UTC, datetime, timedelta
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+
+@pytest.fixture
+def giveaway_module(app_modules, monkeypatch):
+    permissions = types.ModuleType("wbb.core.decorators.permissions")
+    permissions.adminsOnly = lambda *_args, **_kwargs: lambda function: function
+    functions = types.ModuleType("wbb.utils.functions")
+
+    async def extract_user_and_reason(_message):
+        return None, ""
+
+    functions.extract_user_and_reason = extract_user_and_reason
+    monkeypatch.setitem(
+        sys.modules, "wbb.core.decorators.permissions", permissions
+    )
+    monkeypatch.setitem(sys.modules, "wbb.utils.functions", functions)
+
+    def no_running_loop():
+        raise RuntimeError
+
+    with monkeypatch.context() as scoped:
+        scoped.setattr(asyncio, "get_running_loop", no_running_loop)
+        return app_modules.load("wbb.modules.giveaway")
+
+
+async def _giveaway_with_participants(
+    app_modules, count: int = 12, winner_count: int = 1
+):
+    giveaway_db = app_modules.load("wbb.utils.dbgiveaway")
+    giveaway = await giveaway_db.create_giveaway(
+        chat_id=-100,
+        creator_id=1,
+        creator_name="Admin",
+        title="每周抽奖",
+        description="",
+        prizes=[{"name": "Winner", "count": winner_count}],
+        ends_at=datetime.now(UTC) + timedelta(hours=1),
+    )
+    for user_id in range(1, count + 1):
+        assert (
+            await giveaway_db.add_participant(
+                giveaway_id=giveaway["giveaway_id"],
+                user_id=user_id,
+                username=f"user{user_id}",
+                first_name=f"User {user_id}",
+            )
+            == "ok"
+        )
+    return giveaway
+
+
+async def test_giveaway_keyword_lists_draw_and_participant_button(
+    app_modules, giveaway_module
+):
+    giveaway = await _giveaway_with_participants(app_modules)
+    message = SimpleNamespace(
+        chat=SimpleNamespace(id=-100),
+        reply_text=AsyncMock(return_value=SimpleNamespace(id=1)),
+    )
+
+    await giveaway_module.giveaway_keyword_command(None, message)
+
+    text = message.reply_text.await_args.args[0]
+    markup = message.reply_text.await_args.kwargs["reply_markup"]
+    assert "每周抽奖" in text
+    assert "12 人参与" in text
+    callback_data = [button.callback_data for row in markup.inline_keyboard for button in row]
+    assert f"giveaway_join:{giveaway['giveaway_id']}" in callback_data
+    assert f"giveaway_participants:{giveaway['giveaway_id']}" in callback_data
+
+
+async def test_participant_callback_paginates_ten_users_per_page(
+    app_modules, giveaway_module
+):
+    giveaway = await _giveaway_with_participants(app_modules)
+    callback_message = SimpleNamespace(
+        chat=SimpleNamespace(id=-100),
+        reply_text=AsyncMock(return_value=SimpleNamespace(id=2)),
+        edit_text=AsyncMock(),
+    )
+    open_query = SimpleNamespace(
+        data=f"giveaway_participants:{giveaway['giveaway_id']}",
+        message=callback_message,
+        answer=AsyncMock(),
+    )
+
+    await giveaway_module.giveaway_participants_callback(None, open_query)
+
+    first_page = callback_message.reply_text.await_args.args[0]
+    first_markup = callback_message.reply_text.await_args.kwargs["reply_markup"]
+    assert "第 1/2 页 · 共 12 人" in first_page
+    assert "User 1" in first_page
+    assert "User 10" in first_page
+    assert "User 11" not in first_page
+    assert any(
+        button.callback_data
+        == f"giveaway_participants_page:{giveaway['giveaway_id']}:2"
+        for row in first_markup.inline_keyboard
+        for button in row
+    )
+
+    page_query = SimpleNamespace(
+        data=f"giveaway_participants_page:{giveaway['giveaway_id']}:2",
+        message=callback_message,
+        answer=AsyncMock(),
+    )
+    await giveaway_module.giveaway_participants_page_callback(None, page_query)
+
+    second_page = callback_message.edit_text.await_args.args[0]
+    assert "第 2/2 页 · 共 12 人" in second_page
+    assert "11. " in second_page
+    assert "User 11" in second_page
+    assert "User 12" in second_page
+
+
+async def test_draw_keyword_shows_latest_winners_with_pagination(
+    app_modules, giveaway_module
+):
+    giveaway = await _giveaway_with_participants(
+        app_modules, count=12, winner_count=12
+    )
+    service = app_modules.load("wbb.services.giveaways")
+    ok, _, _ = await service.finish_and_publish_giveaway(
+        giveaway["giveaway_id"], publish=False
+    )
+    assert ok is True
+    message = SimpleNamespace(
+        chat=SimpleNamespace(id=-100),
+        reply_text=AsyncMock(return_value=SimpleNamespace(id=3)),
+    )
+
+    await giveaway_module.giveaway_winners_keyword_command(None, message)
+
+    first_page = message.reply_text.await_args.args[0]
+    first_markup = message.reply_text.await_args.kwargs["reply_markup"]
+    assert "抽奖结果" in first_page
+    assert "第 1/2 页 · 共 12 位中奖者" in first_page
+    assert any(
+        button.callback_data
+        == f"giveaway_winners_page:{giveaway['giveaway_id']}:2"
+        for row in first_markup.inline_keyboard
+        for button in row
+    )
+
+    callback_message = SimpleNamespace(
+        chat=SimpleNamespace(id=-100),
+        edit_text=AsyncMock(),
+    )
+    page_query = SimpleNamespace(
+        data=f"giveaway_winners_page:{giveaway['giveaway_id']}:2",
+        message=callback_message,
+        answer=AsyncMock(),
+    )
+    await giveaway_module.giveaway_winners_page_callback(None, page_query)
+
+    second_page = callback_message.edit_text.await_args.args[0]
+    assert "第 2/2 页 · 共 12 位中奖者" in second_page
+    assert "11. Winner" in second_page
+    assert "12. Winner" in second_page

+ 33 - 0
tests/test_giveaway_points.py

@@ -275,6 +275,39 @@ async def test_draw_rewards_are_idempotent_under_competing_finish_calls(app_modu
     assert balances[10 if winner_id == 11 else 11] == 19
 
 
+async def test_draw_pins_result_and_deletes_original_giveaway(app_modules):
+    giveaway_db = app_modules.load("wbb.utils.dbgiveaway")
+    service = app_modules.load("wbb.services.giveaways")
+    giveaway = await giveaway_db.create_giveaway(
+        chat_id=-100,
+        creator_id=1,
+        creator_name="Admin",
+        title="Pinned draw",
+        description="",
+        prizes=[{"name": "Winner", "count": 1}],
+        ends_at=datetime.now(UTC) + timedelta(hours=1),
+    )
+    await giveaway_db.attach_giveaway_message(
+        giveaway["giveaway_id"], -100, 99
+    )
+    await giveaway_db.add_participant(
+        giveaway_id=giveaway["giveaway_id"],
+        user_id=10,
+        username="alice",
+        first_name="Alice",
+    )
+
+    ok, _, _ = await service.finish_and_publish_giveaway(
+        giveaway["giveaway_id"]
+    )
+
+    assert ok is True
+    assert app_modules.app.pinned_messages == [(-100, 1)]
+    assert app_modules.app.deleted_messages == [(-100, 99)]
+    assert app_modules.app.unpinned_messages == []
+    assert "抽奖结果" in app_modules.app.sent_messages[0][1]
+
+
 async def test_participant_write_failure_creates_compensating_refund(
     app_modules, monkeypatch
 ):

+ 35 - 3
wbb/modules/admin.py

@@ -37,9 +37,14 @@ from pyrogram.types import (
     Message,
 )
 
-from wbb import BOT_ID, SUDOERS, app, log
+from wbb import BOT_ID, BOT_PERMISSIONS, SUDOERS, app, log
 from wbb.core.decorators.errors import capture_err
 from wbb.core.keyboard import ikb
+from wbb.services.bot_permissions import has_permission
+from wbb.services.member_shortcuts import (
+    MEMBER_ACTION_SHORTCUT_RE,
+    member_action_shortcut_command,
+)
 from wbb.utils.dbfunctions import (
     add_warn,
     get_warn,
@@ -75,8 +80,10 @@ __HELP__ = """/ban - 封禁成员。
 /unmute - 解除禁言。
 /ban_ghosts - 封禁已注销账号。
 /report、@admins 或 @admin - 向管理员举报消息。
-/invite - 获取群邀请链接。"""
-
+/invite - 获取群邀请链接。
+回复成员消息发送“封禁”“解封”“禁言”“禁言10分钟”“解除禁言”、
+“警告”“踢出”“踢”或“飞”,
+可快捷执行对应管理操作。"""
 
 async def member_permissions(chat_id: int, user_id: int):
     perms = []
@@ -625,6 +632,31 @@ async def mute(_, message: Message):
     await message.reply_text(msg, reply_markup=keyboard)
 
 
+@app.on_message(
+    filters.group
+    & filters.reply
+    & filters.regex(MEMBER_ACTION_SHORTCUT_RE)
+)
+async def member_action_shortcut(client, message: Message):
+    if not has_permission("chat.members", BOT_PERMISSIONS):
+        return None
+    command = member_action_shortcut_command(message.text or "")
+    if not command or not message.reply_to_message:
+        return None
+    message.text, message.command = command
+    if message.command[0] == "ban":
+        return await banFunc(client, message)
+    if message.command[0] in {"mute", "tmute"}:
+        return await mute(client, message)
+    if message.command[0] == "warn":
+        return await warn_user(client, message)
+    if message.command[0] == "unban":
+        return await unban_func(client, message)
+    if message.command[0] == "unmute":
+        return await unmute(client, message)
+    return await kickFunc(client, message)
+
+
 # Unmute members
 
 

+ 282 - 10
wbb/modules/giveaway.py

@@ -7,7 +7,12 @@ from typing import Any
 
 from pyrogram import filters
 from pyrogram.enums import ParseMode
-from pyrogram.types import CallbackQuery, Message
+from pyrogram.types import (
+    CallbackQuery,
+    InlineKeyboardButton,
+    InlineKeyboardMarkup,
+    Message,
+)
 
 from wbb import app, log
 from wbb.core.decorators.permissions import adminsOnly
@@ -17,6 +22,7 @@ from wbb.services.giveaways import (
     create_and_publish_giveaway,
     finish_and_publish_giveaway,
     format_giveaway_time,
+    giveaway_starts_at,
     join_giveaway,
     parse_giveaway_time,
     refresh_giveaway_message,
@@ -28,10 +34,12 @@ from wbb.services.giveaways import (
 from wbb.services.member_identity import display_name
 from wbb.utils.dbgiveaway import (
     add_giveaway_ban,
+    as_utc,
     count_participants,
     get_giveaway,
     list_due_giveaways,
-    list_participants,
+    list_giveaways_page,
+    list_participants_page,
     list_pending_cancellations,
     list_running_giveaways,
     list_unannounced_started_giveaways,
@@ -45,6 +53,8 @@ from wbb.utils.functions import extract_user_and_reason
 __MODULE__ = "抽奖管理"
 __HELP__ = """/giveaway <报名开始时间 ~ 开奖时间> | <奖项> | <标题> | [说明] | [最低积分] | [报名消耗] | [参与奖励]
 在当前群创建抽奖。
+群成员也可以直接发送“抽奖”查看当前抽奖、报名和参与者,
+或发送“开奖”“中奖名单”查看最近一次开奖结果。
 
 奖项格式:`1`、`一等奖:1, 二等奖:3`,或 `一等奖:1:50`;
 最后的 50 表示该奖项每位中奖者获得 50 积分。
@@ -61,7 +71,7 @@ __HELP__ = """/giveaway <报名开始时间 ~ 开奖时间> | <奖项> | <标题
 /gunban <成员> - 解除抽奖参与限制。"""
 
 MAX_TOTAL_WINNERS = 100
-PARTICIPANT_PREVIEW_LIMIT = 30
+PARTICIPANTS_PAGE_SIZE = 10
 SWEEP_INTERVAL_SECONDS = 30
 _sweeper_started = False
 
@@ -147,6 +157,152 @@ def _participant_mention(participant: dict[str, Any]) -> str:
     return f'<a href="tg://user?id={user_id}">{escape(str(label))}</a>'
 
 
+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"<b>抽奖 #{escape(giveaway_id)} 的参与者</b>",
+        f"第 {page}/{total_pages} 页 · 共 {total} 人",
+    ]
+    if participants:
+        offset = (page - 1) * PARTICIPANTS_PAGE_SIZE
+        lines.extend(
+            f"{offset + index}. {_participant_mention(item)}"
+            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"<b>抽奖结果 #{escape(giveaway['giveaway_id'])}</b>",
+        f"<b>标题:</b>{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) -> str:
     status, _ = await join_giveaway(
         giveaway_id,
@@ -240,6 +396,72 @@ async def giveaway_join_callback(_, query: CallbackQuery):
     await query.answer(await _join_user(giveaway_id, query.from_user), 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 message.from_user:
@@ -249,6 +471,58 @@ async def giveaway_join_command(_, message: Message):
     return await message.reply_text(await _join_user(message.command[1], message.from_user))
 
 
+@app.on_message(filters.regex(r"^抽奖$") & filters.group)
+async def giveaway_keyword_command(_, message: Message):
+    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 = ["<b>本群抽奖</b>"]
+    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):
+    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):
@@ -277,14 +551,12 @@ async def participants_command(_, message: Message):
     giveaway = await get_giveaway(giveaway_id)
     if not giveaway or giveaway["chat_id"] != message.chat.id:
         return await message.reply_text("本群中未找到该抽奖。")
-    total = await count_participants(giveaway_id)
-    participants = await list_participants(giveaway_id, limit=PARTICIPANT_PREVIEW_LIMIT)
-    lines = [f"<b>抽奖 #{escape(giveaway_id)} 的参与者</b>", f"共 {total} 人"]
-    lines.extend("- " + _participant_mention(item) for item in participants)
-    if total > len(participants):
-        lines.append(f"另有 {total - len(participants)} 人未显示。")
+    text, markup = await _render_participants_page(giveaway_id, 1)
     return await message.reply_text(
-        "\n".join(lines), parse_mode=ParseMode.HTML, disable_web_page_preview=True
+        text,
+        parse_mode=ParseMode.HTML,
+        reply_markup=markup,
+        disable_web_page_preview=True,
     )
 
 

+ 8 - 2
wbb/modules/karma.py

@@ -48,7 +48,7 @@ __HELP__ = """回复消息发送“+”“+1”“谢谢”或 👍 可点赞。
 回复消息发送“-”“-1”或 👎 可点踩。
 /karma_toggle [开启|关闭] - 开启或关闭本群声望系统。
 回复成员消息发送 /karma 可查看该成员声望。
-直接发送 /karma 可查看本群声望前 10 名。"""
+直接发送 /karma、“声望”或“声望排行”可查看本群声望前 10 名。"""
 
 regex_upvote = r"^(\++|\+1|thx|tnx|tq|ty|thankyou|thank you|thanx|thanks|pro|cool|good|agree|👍|\++ .+)$"
 regex_downvote = r"^(-+|-1|not cool|disagree|worst|bad|👎|-+ .+)$"
@@ -145,7 +145,13 @@ async def downvote(_, message):
     )
 
 
-@app.on_message(filters.command("karma") & filters.group)
+@app.on_message(
+    filters.group
+    & (
+        filters.command("karma")
+        | filters.regex(r"^(?:声望|声望排行)$")
+    )
+)
 @capture_err
 async def command_karma(_, message):
     chat_id = message.chat.id

+ 14 - 5
wbb/modules/points.py

@@ -41,13 +41,14 @@ from wbb.utils.i18n import point_source_label
 __MODULE__ = "积分管理"
 __HELP__ = """/points - 查看自己或被回复成员的积分余额。
 /checkin - 领取每日签到积分。
-/points_rank - 查看本群积分排行榜。
+/points_rank - 查看本群积分排行榜,也可直接发送“积分排行”。
 /points_history - 查看自己或被回复成员的最近积分流水。
 /points_add <成员> <数值> <原因> - 增加积分。
 /points_deduct <成员> <数值> <原因> - 扣减积分。
 /points_set <成员> <余额> <原因> - 通过新流水设置余额。
 /points_toggle <开启|关闭> - 开关本群积分系统。
-/checkin_button <开启|关闭> - 开关输入框底部的签到按钮。"""
+/checkin_button <开启|关闭> - 开关输入框底部的签到按钮。
+群成员也可以直接发送“签到”“积分”“流水”触发对应功能。"""
 
 _VOTE_RE = re.compile(
     r"^(\++|\+1|thx|tnx|tq|ty|thankyou|thank you|thanx|thanks|pro|cool|good|agree|👍|-+|-1|not cool|disagree|worst|bad|👎)$",
@@ -142,7 +143,9 @@ def _adjustment_parts(message: Message) -> tuple[str | None, str | None, str]:
     return target, amount, reason
 
 
-@app.on_message(filters.command("points") & filters.group)
+@app.on_message(
+    filters.group & (filters.command("points") | filters.regex(r"^积分$"))
+)
 async def points_balance_command(_, message: Message):
     target = await _resolve_target(message)
     if not target:
@@ -209,7 +212,10 @@ async def points_checkin_command(_, message: Message):
     )
 
 
-@app.on_message(filters.command("points_rank") & filters.group)
+@app.on_message(
+    filters.group
+    & (filters.command("points_rank") | filters.regex(r"^积分排行$"))
+)
 async def points_rank_command(_, message: Message):
     accounts, _ = await list_point_accounts(
         chat_id=message.chat.id, page=1, page_size=10
@@ -227,7 +233,10 @@ async def points_rank_command(_, message: Message):
     )
 
 
-@app.on_message(filters.command("points_history") & filters.group)
+@app.on_message(
+    filters.group
+    & (filters.command("points_history") | filters.regex(r"^流水$"))
+)
 async def points_history_command(_, message: Message):
     target = await _resolve_target(message)
     if not target:

+ 5 - 2
wbb/modules/rules.py

@@ -35,7 +35,7 @@ from wbb.utils.functions import check_format
 
 __MODULE__ = "群规"
 __HELP__ = """
- • /rules:查看本群群规。
+ • /rules:查看本群群规,也可直接发送“群规”。
 
 **仅管理员:**
  • /setrules:回复一条消息,将其设为群规。
@@ -43,7 +43,10 @@ __HELP__ = """
 """
 
 
-@app.on_message(filters.command("rules") & ~filters.private)
+@app.on_message(
+    (filters.command("rules") | filters.regex(r"^群规$"))
+    & ~filters.private
+)
 async def send_rules(_, message):
     chat_id = message.chat.id
     replied_message = message.reply_to_message

+ 15 - 6
wbb/modules/teacher_directory.py

@@ -48,9 +48,9 @@ from wbb.utils.dbdirectory import (
 )
 
 __MODULE__ = "技师咨询与评价"
-__HELP__ = """/directory - 打开技师咨询与评价菜单。
-/nearby - 浏览公开技师(兼容命令)。
-/teacher_rank - 浏览公开技师(兼容命令)。
+__HELP__ = """/directory - 打开技师咨询与评价菜单,也可在群内发送“技师”。
+/nearby - 浏览公开技师,也可发送“附近技师”。
+/teacher_rank - 浏览公开技师,也可发送“技师榜单”。
 /teacher_apply - 申请成为技师。
 /teacher_online - 技师上线。
 /teacher_offline - 技师下线。
@@ -317,7 +317,10 @@ async def _verify_group_member(message: Message) -> bool:
     return active
 
 
-@app.on_message(filters.command("directory"))
+@app.on_message(
+    filters.command("directory")
+    | (filters.regex(r"^技师$") & filters.group)
+)
 async def directory_command(_, message: Message):
     if message.chat.type.value == "private":
         return await send_directory_menu(message)
@@ -341,14 +344,20 @@ async def directory_command(_, message: Message):
     schedule_group_cleanup(message, response)
 
 
-@app.on_message(filters.command("nearby"))
+@app.on_message(
+    filters.command("nearby")
+    | (filters.regex(r"^附近技师$") & filters.group)
+)
 async def nearby_command(_, message: Message):
     if message.chat.type.value != "private" and not await _verify_group_member(message):
         return
     await _open_service_directory(message)
 
 
-@app.on_message(filters.command("teacher_rank"))
+@app.on_message(
+    filters.command("teacher_rank")
+    | (filters.regex(r"^技师榜单$") & filters.group)
+)
 async def teacher_rank_command(_, message: Message):
     if message.chat.type.value != "private" and not await _verify_group_member(message):
         return

+ 33 - 6
wbb/services/giveaways.py

@@ -145,7 +145,13 @@ def join_markup(giveaway_id: str) -> InlineKeyboardMarkup:
                 InlineKeyboardButton(
                     "立即报名", callback_data=f"giveaway_join:{giveaway_id}"
                 )
-            ]
+            ],
+            [
+                InlineKeyboardButton(
+                    "查看参与者",
+                    callback_data=f"giveaway_participants:{giveaway_id}",
+                )
+            ],
         ]
     )
 
@@ -549,7 +555,7 @@ async def publish_finished(
     chat_id = int(giveaway["chat_id"])
     message_id = giveaway.get("message_id")
     text = render_winners(giveaway, winners)
-    result_posted = False
+    result_message = None
     if message_id:
         with suppress(Exception):
             await app.edit_message_text(
@@ -560,16 +566,37 @@ async def publish_finished(
                 reply_markup=None,
             )
         with suppress(Exception):
-            await app.send_message(
+            result_message = await app.send_message(
                 chat_id,
                 text,
                 parse_mode=ParseMode.HTML,
                 reply_to_message_id=int(message_id),
             )
-            result_posted = True
-    if not result_posted:
+    if result_message is None:
         with suppress(Exception):
-            await app.send_message(chat_id, text, parse_mode=ParseMode.HTML)
+            result_message = await app.send_message(
+                chat_id, text, parse_mode=ParseMode.HTML
+            )
+    result_pinned = False
+    if result_message is not None:
+        try:
+            await app.pin_chat_message(
+                chat_id,
+                int(result_message.id),
+                disable_notification=True,
+            )
+            result_pinned = True
+        except Exception as exc:
+            log.error(
+                f"抽奖 {giveaway['giveaway_id']} 结果置顶失败:{exc}"
+            )
+    if result_pinned and message_id and int(message_id) != int(result_message.id):
+        try:
+            await app.delete_messages(chat_id, int(message_id))
+        except Exception:
+            # If Telegram refuses deletion, at least remove the stale pinned entry.
+            with suppress(Exception):
+                await app.unpin_chat_message(chat_id, int(message_id))
     for winner in winners:
         with suppress(Exception):
             await app.send_message(

+ 39 - 0
wbb/services/member_shortcuts.py

@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+import re
+
+MEMBER_ACTION_SHORTCUT_RE = (
+    r"^(?:封禁|解封|解禁|解除封禁|禁言|解除禁言|警告|"
+    r"禁言\s*[1-9]\d?\s*(?:分钟|分|小时|时|天)|踢出|踢|飞)$"
+)
+
+
+def member_action_shortcut_command(text: str) -> tuple[str, list[str]] | None:
+    normalized = text.strip()
+    if normalized == "封禁":
+        return "/ban", ["ban"]
+    if normalized == "禁言":
+        return "/mute", ["mute"]
+    if normalized == "警告":
+        return "/warn", ["warn"]
+    if normalized in {"解封", "解禁", "解除封禁"}:
+        return "/unban", ["unban"]
+    if normalized == "解除禁言":
+        return "/unmute", ["unmute"]
+    duration = re.fullmatch(
+        r"禁言\s*([1-9]\d?)\s*(分钟|分|小时|时|天)", normalized
+    )
+    if duration:
+        value, raw_unit = duration.groups()
+        unit = {
+            "分钟": "m",
+            "分": "m",
+            "小时": "h",
+            "时": "h",
+            "天": "d",
+        }[raw_unit]
+        time_value = f"{value}{unit}"
+        return f"/tmute {time_value}", ["tmute", time_value]
+    if normalized in {"踢出", "踢", "飞"}:
+        return "/kick", ["kick"]
+    return None