Procházet zdrojové kódy

fix: restore private chat navigation

Replace the persistent private directory keyboard with inline actions and actively remove stale reply keyboards on entry and exit paths. Add regression coverage and record the project deployment authorization convention.\n\nAI-Co-Authored-By: Codex
chendeben před 2 měsíci
rodič
revize
0e56b008cc

+ 6 - 0
AGENTS.md

@@ -0,0 +1,6 @@
+# 项目协作约束
+
+- 功能修改完成后,如果用户明确要求“提交部署”或同等含义的操作,视为已经授权提交、推送和按本项目既有流程部署,无需再次要求二次确认。
+- 提交信息必须包含 `AI-Co-Authored-By: Codex`。
+- 部署只操作本项目服务;数据库、数据迁移或其他基础设施变更仍需按实际影响单独评估。
+- 本文件不能覆盖系统、平台或更高优先级指令中的强制安全要求。

+ 2 - 2
tests/test_directory.py

@@ -330,7 +330,7 @@ async def test_role_specific_bottom_menus_are_chinese(app_modules):
             is_admin=False,
         )
         student_labels = [
-            button.text for row in student.keyboard for button in row
+            button.text for row in student.inline_keyboard for button in row
         ]
         assert "浏览技师" in student_labels
         assert "Telegram 定位" not in student_labels
@@ -351,7 +351,7 @@ async def test_role_specific_bottom_menus_are_chinese(app_modules):
             is_admin=True,
         )
         teacher_labels = [
-            button.text for row in teacher_admin.keyboard for button in row
+            button.text for row in teacher_admin.inline_keyboard for button in row
         ]
         assert "下榜" in teacher_labels
         assert "🔴 下线" in teacher_labels

+ 76 - 0
tests/test_private_keyboard.py

@@ -0,0 +1,76 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+
+from pyrogram.types import Message, ReplyKeyboardRemove
+
+
+async def test_remove_reply_keyboard_sends_removal_and_cleans_notice(app_modules):
+    keyboard = app_modules.load("wbb.core.reply_keyboard")
+    notice = SimpleNamespace(delete=AsyncMock())
+    message = SimpleNamespace(reply_text=AsyncMock(return_value=notice))
+
+    await keyboard.remove_reply_keyboard(message)
+
+    call = message.reply_text.await_args
+    assert "消息内菜单" in call.args[0]
+    assert isinstance(call.kwargs["reply_markup"], ReplyKeyboardRemove)
+    assert call.kwargs["reply_markup"].selective is False
+    notice.delete.assert_awaited_once_with()
+
+
+async def test_directory_menu_uses_inline_buttons_and_removes_old_keyboard(
+    app_modules, monkeypatch
+):
+    module = app_modules.load("wbb.modules.teacher_directory")
+    remove_reply_keyboard = AsyncMock()
+    monkeypatch.setattr(module, "remove_reply_keyboard", remove_reply_keyboard)
+    monkeypatch.setattr(
+        module,
+        "observe_directory_user",
+        AsyncMock(
+            return_value={
+                "user_id": 123,
+                "application_status": "none",
+            }
+        ),
+    )
+    monkeypatch.setattr(
+        module,
+        "verify_platform_membership",
+        AsyncMock(return_value={"allowed": False}),
+    )
+    sent = SimpleNamespace()
+    message = SimpleNamespace(
+        from_user=SimpleNamespace(id=123),
+        reply_text=AsyncMock(return_value=sent),
+    )
+
+    await module.send_directory_menu(message)
+
+    remove_reply_keyboard.assert_awaited_once_with(message)
+    markup = message.reply_text.await_args.kwargs["reply_markup"]
+    labels = [
+        button.text for row in markup.inline_keyboard for button in row
+    ]
+    assert "浏览技师" in labels
+    assert "申请成为技师" in labels
+
+
+async def test_service_home_removes_old_keyboard_for_message_entry(
+    app_modules, monkeypatch
+):
+    module = app_modules.load("wbb.modules.technician_services")
+    remove_reply_keyboard = AsyncMock()
+    monkeypatch.setattr(module, "remove_reply_keyboard", remove_reply_keyboard)
+    monkeypatch.setattr(module, "_require_terms", AsyncMock(return_value=True))
+    message = MagicMock(spec=Message)
+    message.from_user = SimpleNamespace(id=123)
+    message.reply_text = AsyncMock()
+
+    await module.send_service_home(message)
+
+    remove_reply_keyboard.assert_awaited_once_with(message)
+    markup = message.reply_text.await_args.kwargs["reply_markup"]
+    assert markup.inline_keyboard[0][0].text == "浏览技师"

+ 16 - 0
wbb/core/reply_keyboard.py

@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+from contextlib import suppress
+from typing import Any
+
+from pyrogram.types import ReplyKeyboardRemove
+
+
+async def remove_reply_keyboard(message: Any) -> None:
+    """Remove a stale Telegram reply keyboard without leaving a chat message."""
+    notice = await message.reply_text(
+        "正在打开消息内菜单…",
+        reply_markup=ReplyKeyboardRemove(selective=False),
+    )
+    with suppress(Exception):
+        await notice.delete()

+ 40 - 18
wbb/modules/teacher_directory.py

@@ -12,12 +12,11 @@ from pyrogram.types import (
     ChatMemberUpdated,
     InlineKeyboardButton,
     InlineKeyboardMarkup,
-    KeyboardButton,
     Message,
-    ReplyKeyboardMarkup,
 )
 
 from wbb import BOT_PROFILE_ID, BOT_USERNAME, app, log
+from wbb.core.reply_keyboard import remove_reply_keyboard
 from wbb.services.directory import (
     DirectoryServiceError,
     admin_change_teacher_state,
@@ -96,45 +95,63 @@ def _directory_keyboard(
     profile: dict[str, Any] | None,
     *,
     is_admin: bool,
-) -> ReplyKeyboardMarkup:
+) -> InlineKeyboardMarkup:
     status = str((profile or {}).get("application_status") or "none")
-    rows: list[list[KeyboardButton]] = [
-        [KeyboardButton(BUTTON_BROWSE)],
+    rows: list[list[InlineKeyboardButton]] = [
+        [InlineKeyboardButton(BUTTON_BROWSE, callback_data="dir:browse")],
     ]
     if status == APPLICATION_APPROVED:
         rows.extend(
             [
                 [
-                    KeyboardButton(
-                        BUTTON_UNLIST if profile and profile.get("listed") else BUTTON_LIST
+                    InlineKeyboardButton(
+                        BUTTON_UNLIST if profile and profile.get("listed") else BUTTON_LIST,
+                        callback_data=(
+                            "dir:self:unlist"
+                            if profile and profile.get("listed")
+                            else "dir:self:list"
+                        ),
                     ),
-                    KeyboardButton(
+                    InlineKeyboardButton(
                         BUTTON_OFFLINE
                         if profile and profile.get("online")
-                        else BUTTON_ONLINE
+                        else BUTTON_ONLINE,
+                        callback_data=(
+                            "dir:self:offline"
+                            if profile and profile.get("online")
+                            else "dir:self:online"
+                        ),
                     ),
                 ],
-                [KeyboardButton(BUTTON_STATUS)],
+                [InlineKeyboardButton(BUTTON_STATUS, callback_data="dir:status")],
             ]
         )
     else:
         rows.append(
             [
-                KeyboardButton(
+                InlineKeyboardButton(
                     "技师申请审核中"
                     if status == APPLICATION_PENDING
-                    else BUTTON_APPLY
+                    else BUTTON_APPLY,
+                    callback_data=(
+                        "dir:status"
+                        if status == APPLICATION_PENDING
+                        else "dir:apply"
+                    ),
                 )
             ]
         )
     if is_admin:
-        rows.append([KeyboardButton(BUTTON_APPROVAL)])
-    return ReplyKeyboardMarkup(
+        rows.append(
+            [
+                InlineKeyboardButton(
+                    BUTTON_APPROVAL,
+                    callback_data="dir:admin:pending:1",
+                )
+            ]
+        )
+    return InlineKeyboardMarkup(
         rows,
-        resize_keyboard=True,
-        is_persistent=True,
-        selective=True,
-        placeholder="浏览技师、咨询或评价",
     )
 
 
@@ -225,6 +242,7 @@ async def send_directory_menu(
 ) -> None:
     if not message.from_user:
         return
+    await remove_reply_keyboard(message)
     if source_chat_id is not None:
         try:
             member = await app.get_chat_member(source_chat_id, message.from_user.id)
@@ -445,6 +463,7 @@ async def _refresh_keyboard(
 ) -> None:
     if not message.from_user:
         return
+    await remove_reply_keyboard(message)
     admin_result = await verify_platform_membership(
         user_id=int(message.from_user.id),
         require_admin=True,
@@ -1078,6 +1097,8 @@ async def _confirm_admin_action(target: CallbackQuery, token: str) -> None:
 async def directory_callback(_, query: CallbackQuery):
     parts = str(query.data).split(":")
     try:
+        if parts[1] == "browse":
+            return await _open_service_directory(query)
         if parts[1] == "apply":
             return await _apply_teacher(query)
         if parts[1] == "status":
@@ -1176,6 +1197,7 @@ async def cancel_directory_flow(_, message: Message):
     admin_flow = _admin_flows.pop(message.from_user.id, None)
     if not admin_flow:
         return
+    await remove_reply_keyboard(message)
     await message.reply_text("已取消当前操作。")
     raise StopPropagation
 

+ 6 - 0
wbb/modules/technician_services.py

@@ -23,6 +23,7 @@ from pyrogram.types import (
 
 import wbb
 from wbb import BOT_USERNAME, app, log
+from wbb.core.reply_keyboard import remove_reply_keyboard
 from wbb.services.technician_reviews import ensure_technician_review_topic
 from wbb.utils.dbservice import (
     ServiceDataError,
@@ -276,6 +277,8 @@ async def _require_terms(target: Message | CallbackQuery) -> bool:
 async def send_service_home(target: Message | CallbackQuery) -> None:
     if not target.from_user:
         return
+    if isinstance(target, Message):
+        await remove_reply_keyboard(target)
     if not await _require_terms(target):
         return
     await _send_message(
@@ -1251,6 +1254,7 @@ async def service_location_message(_, message: Message):
         return
     longitude = message.location.longitude
     latitude = message.location.latitude
+    await remove_reply_keyboard(message)
     if flow["stage"] == "search_location":
         await _show_nearby(message, longitude=longitude, latitude=latitude)
     else:
@@ -1269,6 +1273,7 @@ async def service_location_message(_, message: Message):
 async def cancel_service_flow(_, message: Message):
     if not message.from_user or not _flows.pop(message.from_user.id, None):
         return
+    await remove_reply_keyboard(message)
     await message.reply_text("已取消当前技师服务操作。")
     raise StopPropagation
 
@@ -1643,6 +1648,7 @@ async def service_callback(_, query: CallbackQuery):
             return await query.answer("已提交。")
         if action == "flow_cancel":
             _flows.pop(query.from_user.id, None)
+            await remove_reply_keyboard(query.message)
             await query.message.reply_text("已取消当前操作。")
             return await query.answer()
         if action == "orders":