Procházet zdrojové kódy

feat: refine teacher directory interactions

AI-Co-Authored-By: Codex
chendeben před 2 měsíci
rodič
revize
71d48baa62

+ 1 - 1
admin-web/src/layouts/AdminLayout.tsx

@@ -32,7 +32,7 @@ const menuItems = [
   { path: '/chats', name: '群组管理', icon: <Users size={18} /> },
   { path: '/giveaways', name: '抽奖管理', icon: <Gift size={18} /> },
   { path: '/points', name: '积分管理', icon: <Star size={18} /> },
-  { path: '/directory', name: '师生与附近', icon: <MapPinned size={18} /> },
+  { path: '/directory', name: '老师与附近', icon: <MapPinned size={18} /> },
   { path: '/audit', name: '操作日志', icon: <ClipboardList size={18} /> },
   { path: '/settings', name: '系统设置', icon: <Settings size={18} /> },
 ];

+ 31 - 70
admin-web/src/pages/Directory.tsx

@@ -7,7 +7,6 @@ import {
   Modal,
   Select,
   Space,
-  Switch,
   Table,
   Tabs,
   Tag,
@@ -44,7 +43,7 @@ const emptyPage = <T,>(): Paged<T> => ({
 });
 
 const applicationLabels: Record<DirectoryApplicationStatus, string> = {
-  none: '学生',
+  none: '未申请',
   pending: '待审批',
   approved: '已批准',
   rejected: '已拒绝',
@@ -62,7 +61,6 @@ const applicationColors: Record<DirectoryApplicationStatus, string> = {
 const eventLabels: Record<string, string> = {
   location_updated: '更新位置',
   location_cleared: '清除位置',
-  geocode_consent_updated: '区域解析授权',
   teacher_application_submitted: '提交老师申请',
   teacher_application_approve: '批准老师',
   teacher_application_reject: '拒绝申请',
@@ -75,6 +73,12 @@ const eventLabels: Record<string, string> = {
   teacher_username_removed: '用户名移除',
 };
 
+const locationSourceLabels: Record<string, string> = {
+  telegram_location: 'Telegram 定位',
+  manual_coordinates: '手动选择',
+  web: '后台修正',
+};
+
 type ActionTarget = {
   kind: 'application' | 'profile' | 'location';
   action: string;
@@ -249,7 +253,9 @@ export default function DirectoryPage() {
       title: '位置',
       minWidth: 170,
       render: (_, item) =>
-        item.location ? item.location.region?.label || '区域暂不可用' : '尚未更新',
+        item.location
+          ? locationSourceLabels[item.location.source || ''] || '已更新'
+          : '尚未更新',
     },
     {
       title: '申请时间',
@@ -306,13 +312,20 @@ export default function DirectoryPage() {
     {
       title: '上下线',
       dataIndex: 'online',
-      width: 90,
-      render: (value) => <Tag color={value ? 'processing' : 'default'}>{value ? '在线' : '离线'}</Tag>,
+      width: 110,
+      render: (value) => (
+        <Tag color={value ? 'success' : 'error'}>
+          {value ? '🟢 在线' : '🔴 离线'}
+        </Tag>
+      ),
     },
     {
-      title: '区域',
-      minWidth: 160,
-      render: (_, item) => item.location?.region?.label || '区域暂不可用',
+      title: '位置',
+      minWidth: 150,
+      render: (_, item) =>
+        item.location
+          ? locationSourceLabels[item.location.source || ''] || '已更新'
+          : '尚未更新',
     },
     {
       title: '自动下线',
@@ -393,22 +406,9 @@ export default function DirectoryPage() {
       ),
     },
     {
-      title: '区域',
-      minWidth: 170,
-      render: (_, item) => item.region?.label || '区域暂不可用',
-    },
-    {
-      title: 'Nominatim',
-      width: 130,
-      render: (_, item) => (
-        <Tag color={item.geocode_consent === true ? 'success' : 'default'}>
-          {item.geocode_consent === true
-            ? '已授权'
-            : item.geocode_consent === false
-              ? '已关闭'
-              : '待选择'}
-        </Tag>
-      ),
+      title: '来源',
+      width: 140,
+      render: (_, item) => locationSourceLabels[item.source] || item.source || '-',
     },
     {
       title: '更新时间',
@@ -494,7 +494,7 @@ export default function DirectoryPage() {
   return (
     <>
       <PageHeader
-        title="师生与附近"
+        title="老师与附近"
         actions={
           <Button icon={<RefreshCw size={16} />} loading={loading} onClick={() => void refresh()}>
             刷新
@@ -505,7 +505,7 @@ export default function DirectoryPage() {
         showIcon
         type="warning"
         message="位置属于敏感信息"
-        description="精确坐标仅供 Web 超级管理员修正;Telegram 榜单只显示距离和省市区。所有状态操作均为全平台生效。"
+        description="精确坐标仅供 Web 超级管理员修正;Telegram 榜单只显示距离,不显示区域信息。所有状态操作均为全平台生效。"
         style={{ marginBottom: 16 }}
       />
       <Surface>
@@ -627,7 +627,7 @@ export default function DirectoryPage() {
                   onFinish={(values) => {
                     Modal.confirm({
                       title: '确认更新全平台目录设置?',
-                      content: '设置会影响所有具备“师生与附近”职责的机器人。',
+                      content: '设置会影响所有具备“老师与附近”职责的机器人。',
                       okText: '确认保存',
                       onOk: async () => {
                         const saved = await apiRequest<DirectorySettings>(
@@ -644,42 +644,10 @@ export default function DirectoryPage() {
                 >
                   <Alert
                     showIcon
-                    type={settings?.nominatim_ready ? 'success' : 'info'}
-                    message={
-                      settings?.nominatim_ready
-                        ? 'Nominatim 区域解析已就绪'
-                        : '未填写联系信息时只计算距离,不会发送坐标'
-                    }
+                    type="info"
+                    message="系统只使用经纬度计算距离,不进行区域解析。"
                     style={{ marginBottom: 20 }}
                   />
-                  <Form.Item
-                    name="nominatim_enabled"
-                    label="启用 OpenStreetMap Nominatim 区域解析"
-                    valuePropName="checked"
-                  >
-                    <Switch />
-                  </Form.Item>
-                  <Form.Item
-                    name="nominatim_contact"
-                    label="应用联系邮箱或 URL"
-                    extra="公共 Nominatim 要求 User-Agent 能识别应用;留空时禁止外发坐标。"
-                  >
-                    <Input placeholder="admin@example.com" />
-                  </Form.Item>
-                  <Form.Item
-                    name="nominatim_endpoint"
-                    label="Nominatim 服务地址"
-                    rules={[{ required: true }]}
-                  >
-                    <Input />
-                  </Form.Item>
-                  <Form.Item
-                    name="nominatim_user_agent"
-                    label="应用 User-Agent"
-                    rules={[{ required: true }]}
-                  >
-                    <Input />
-                  </Form.Item>
                   <Form.Item name="nearby_radius_km" label="默认附近范围">
                     <Select
                       options={(settings?.nearby_radius_options_km || [5, 10, 20, 50]).map(
@@ -691,13 +659,6 @@ export default function DirectoryPage() {
                     <Button type="primary" htmlType="submit" icon={<Settings2 size={16} />}>
                       保存设置
                     </Button>
-                    <Button
-                      type="link"
-                      href={settings?.attribution_url || 'https://www.openstreetmap.org/copyright'}
-                      target="_blank"
-                    >
-                      {settings?.attribution || '© OpenStreetMap contributors'}
-                    </Button>
                   </Space>
                 </Form>
               ),
@@ -747,7 +708,7 @@ export default function DirectoryPage() {
             if (!locationTarget) return;
             Modal.confirm({
               title: '确认保存精确坐标?',
-              content: '若该用户已授权区域解析,新坐标会进入 Nominatim 队列。',
+              content: '坐标只用于附近距离计算,不会解析或展示区域信息。',
               okText: '确认保存',
               onOk: async () => {
                 await apiRequest(

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

@@ -249,14 +249,6 @@ export type DirectoryApplicationStatus =
   | 'rejected'
   | 'revoked';
 
-export interface DirectoryRegion {
-  province?: string;
-  city?: string;
-  district?: string;
-  label: string;
-  provider: 'nominatim';
-}
-
 export interface DirectoryProfile {
   user_id: string;
   username?: string | null;
@@ -272,8 +264,7 @@ export interface DirectoryProfile {
   applied_at?: string | null;
   decided_at?: string | null;
   location?: {
-    region?: DirectoryRegion | null;
-    geocode_status?: string;
+    source?: string;
     updated_at?: string;
   } | null;
 }
@@ -285,9 +276,6 @@ export interface DirectoryLocation {
   application_status: DirectoryApplicationStatus;
   longitude: number;
   latitude: number;
-  region?: DirectoryRegion | null;
-  geocode_consent?: boolean | null;
-  geocode_status: string;
   source: string;
   updated_at: string;
 }
@@ -306,14 +294,7 @@ export interface DirectoryEvent {
 }
 
 export interface DirectorySettings {
-  nominatim_enabled: boolean;
-  nominatim_endpoint: string;
-  nominatim_contact: string;
-  nominatim_user_agent: string;
-  nominatim_ready: boolean;
   online_duration_hours: number;
   nearby_radius_km: number;
   nearby_radius_options_km: number[];
-  attribution: string;
-  attribution_url: string;
 }

+ 3 - 10
admin-web/tests/e2e/admin.spec.ts

@@ -190,8 +190,8 @@ test.beforeEach(async ({ page }) => {
           online: false,
           applied_at: '2026-07-24T08:00:00Z',
           location: {
-            region: { label: '上海市 黄浦区', provider: 'nominatim' },
-            geocode_status: 'completed',
+            source: 'telegram_location',
+            updated_at: '2026-07-24T08:00:00Z',
           },
         }],
         total: 1,
@@ -206,16 +206,9 @@ test.beforeEach(async ({ page }) => {
       data = { items: [], total: 0, page: 1, page_size: 20 };
     } else if (path.endsWith('/directory/settings')) {
       data = {
-        nominatim_enabled: true,
-        nominatim_endpoint: 'https://nominatim.openstreetmap.org/reverse',
-        nominatim_contact: '',
-        nominatim_user_agent: 'TelegramBotAdmin/1.0',
-        nominatim_ready: false,
         online_duration_hours: 24,
         nearby_radius_km: 20,
         nearby_radius_options_km: [5, 10, 20, 50],
-        attribution: '© OpenStreetMap contributors',
-        attribution_url: 'https://www.openstreetmap.org/copyright',
       };
     } else if (path.endsWith('/settings')) {
       data = {
@@ -265,7 +258,7 @@ test('登录与主要管理视图在不同视口无页面级横向滚动', async
 
   for (const [path, heading] of [
     ['/admin/points', '积分管理'],
-    ['/admin/directory', '师生与附近'],
+    ['/admin/directory', '老师与附近'],
     ['/admin/audit', '操作日志'],
     ['/admin/settings', '系统设置'],
   ] as const) {

+ 7 - 16
admin-web/tests/unit/Directory.test.tsx

@@ -23,8 +23,7 @@ beforeEach(() => {
             online: false,
             applied_at: '2026-07-24T08:00:00Z',
             location: {
-              region: { label: '上海市 黄浦区', provider: 'nominatim' },
-              geocode_status: 'completed',
+              source: 'telegram_location',
             },
           },
         ],
@@ -44,36 +43,28 @@ beforeEach(() => {
     }
     if (path === '/directory/settings') {
       return {
-        nominatim_enabled: true,
-        nominatim_endpoint: 'https://nominatim.openstreetmap.org/reverse',
-        nominatim_contact: '',
-        nominatim_user_agent: 'TelegramBotAdmin/1.0',
-        nominatim_ready: false,
         online_duration_hours: 24,
         nearby_radius_km: 20,
         nearby_radius_options_km: [5, 10, 20, 50],
-        attribution: '© OpenStreetMap contributors',
-        attribution_url: 'https://www.openstreetmap.org/copyright',
       };
     }
     throw new Error(`Unexpected request: ${path}`);
   });
 });
 
-test('展示老师申请、全平台风险提示和Nominatim设置', async () => {
+test('展示老师申请、定位来源并且不显示区域解析', async () => {
   render(<DirectoryPage />);
 
-  expect(screen.getByText('师生与附近')).toBeInTheDocument();
+  expect(screen.getByText('老师与附近')).toBeInTheDocument();
   expect(screen.getByText('位置属于敏感信息')).toBeInTheDocument();
   expect(await screen.findByText('王老师')).toBeInTheDocument();
   expect(screen.getByText('@teacher100 · 100')).toBeInTheDocument();
-  expect(screen.getByText('上海市 黄浦区')).toBeInTheDocument();
+  expect(screen.getByText('Telegram 定位')).toBeInTheDocument();
+  expect(screen.queryByText('上海市 黄浦区')).not.toBeInTheDocument();
 
   fireEvent.click(screen.getByRole('tab', { name: '目录设置' }));
   expect(
-    await screen.findByText('未填写联系信息时只计算距离,不会发送坐标'),
+    await screen.findByText('系统只使用经纬度计算距离,不进行区域解析。'),
   ).toBeInTheDocument();
-  expect(
-    screen.getByRole('link', { name: '© OpenStreetMap contributors' }),
-  ).toHaveAttribute('href', 'https://www.openstreetmap.org/copyright');
+  expect(screen.queryByText(/Nominatim/)).not.toBeInTheDocument();
 });

+ 77 - 41
tests/test_directory.py

@@ -23,13 +23,15 @@ async def _approved_teacher(directory_db, *, user_id: int = 200):
         source="test",
         actor_id=user_id,
         actor_name="王老师",
-        geocode_consent=False,
     )
-    await directory_db.submit_teacher_application(
+    _, created = await directory_db.submit_teacher_application(
         user_id=user_id,
         source="test",
         bot_id="primary",
+        chat_id=-100200,
+        chat_title="测试群",
     )
+    assert created is True
     await directory_db.decide_teacher_application(
         user_id=user_id,
         action="approve",
@@ -52,7 +54,9 @@ async def test_teacher_application_distance_listing_and_presence(app_modules):
     directory_service = app_modules.load("wbb.services.directory")
     await directory_db.ensure_directory_indexes()
 
-    await _approved_teacher(directory_db)
+    approved = await _approved_teacher(directory_db)
+    assert approved["application_source_chat_id"] == -100200
+    assert approved["application_source_chat_title"] == "测试群"
     profile, applied = await directory_db.set_teacher_state(
         user_id=200,
         action="online",
@@ -102,12 +106,11 @@ async def test_teacher_application_distance_listing_and_presence(app_modules):
     assert expired["listed"] is True
 
 
-async def test_location_consent_geocode_queue_and_cache(app_modules):
+async def test_location_only_keeps_coordinates_and_source(app_modules):
     directory_db = app_modules.load("wbb.utils.dbdirectory")
-    geocoding = app_modules.load("wbb.services.directory_geocoding")
     await directory_db.upsert_directory_identity(
         user_id=300,
-        username="student300",
+        username="member300",
         first_name="李",
         last_name="同学",
     )
@@ -119,48 +122,37 @@ async def test_location_consent_geocode_queue_and_cache(app_modules):
         actor_id=300,
         actor_name="李同学",
     )
-    assert location["geocode_consent"] is None
-    assert await directory_db.claim_geocode_job() is None
+    assert location["source"] == "test"
+    assert location["longitude"] == pytest.approx(121.4737)
+    assert location["latitude"] == pytest.approx(31.2304)
+    assert "region" not in location
+    assert "geocode_consent" not in location
+    assert "geocode_status" not in location
+    assert "coordinate_key" not in location
 
-    await directory_db.set_geocode_consent(
-        user_id=300,
-        consent=True,
-        source="test",
-        actor_id=300,
-        actor_name="李同学",
-    )
-    job = await directory_db.claim_geocode_job()
-    assert job["longitude"] == pytest.approx(121.4737)
-    region = geocoding.parse_nominatim_region(
+    await app_modules.wbb.control_db.directory_locations.update_one(
+        {"user_id": 300},
         {
-            "address": {
-                "state": "上海市",
-                "city": "上海市",
-                "city_district": "黄浦区",
-                "road": "测试道路",
+            "$set": {
+                "region": {"label": "旧区域"},
+                "geocode_consent": True,
+                "geocode_status": "completed",
+                "coordinate_key": "legacy",
             }
-        }
+        },
     )
-    assert region["label"] == "上海市 黄浦区"
-    assert "road" not in region
-    await directory_db.complete_geocode_job(job=job, region=region)
-
-    completed = await directory_db.get_directory_location(300)
-    assert completed["geocode_status"] == "completed"
-    assert completed["region"]["district"] == "黄浦区"
-    await directory_db.enqueue_geocode_job(300)
-    assert await directory_db.claim_geocode_job() is None
-
-    await directory_db.set_geocode_consent(
+    updated = await directory_db.save_directory_location(
         user_id=300,
-        consent=False,
+        longitude=121.48,
+        latitude=31.24,
         source="test",
         actor_id=300,
         actor_name="李同学",
     )
-    disabled = await directory_db.get_directory_location(300)
-    assert disabled["region"] is None
-    assert disabled["geocode_status"] == "disabled"
+    assert "region" not in updated
+    assert "geocode_consent" not in updated
+    assert "geocode_status" not in updated
+    assert "coordinate_key" not in updated
 
 
 async def test_cross_bot_membership_verification_uses_live_status(app_modules):
@@ -246,6 +238,9 @@ async def test_directory_admin_api_requires_role_and_manages_global_data(app_mod
         assert updated.status == 200
         payload = (await updated.json())["data"]
         assert payload["longitude"] == pytest.approx(113.2644)
+        assert payload["source"] == "web"
+        assert "region" not in payload
+        assert "geocode_status" not in payload
 
         listed = await client.get("/api/admin/v1/directory/locations")
         assert listed.status == 200
@@ -253,6 +248,14 @@ async def test_directory_admin_api_requires_role_and_manages_global_data(app_mod
         assert item["latitude"] == pytest.approx(23.1291)
         assert "point" not in item
         assert "coordinate_key" not in item
+        assert "region" not in item
+        assert "geocode_consent" not in item
+
+        settings = await client.get("/api/admin/v1/directory/settings")
+        assert settings.status == 200
+        settings_data = (await settings.json())["data"]
+        assert settings_data["nearby_radius_km"] == 20
+        assert "nominatim_enabled" not in settings_data
 
         unconfirmed = await client.delete(
             "/api/admin/v1/directory/locations/500",
@@ -289,9 +292,13 @@ async def test_role_specific_bottom_menus_are_chinese(app_modules):
         student_labels = [
             button.text for row in student.keyboard for button in row
         ]
-        assert "更新位置" in student_labels
+        assert "Telegram 定位" in student_labels
+        assert "手动选择位置" in student_labels
         assert "附近老师" in student_labels
         assert "申请成为老师" in student_labels
+        assert "我的状态" not in student_labels
+        assert "上线" not in student_labels
+        assert "下线" not in student_labels
         assert "老师审批" not in student_labels
 
         teacher_admin = module._directory_keyboard(
@@ -306,8 +313,37 @@ async def test_role_specific_bottom_menus_are_chinese(app_modules):
             button.text for row in teacher_admin.keyboard for button in row
         ]
         assert "下榜" in teacher_labels
-        assert "下线" in teacher_labels
+        assert "🔴 下线" in teacher_labels
         assert "老师审批" in teacher_labels
+
+        group_student = module._group_directory_keyboard(
+            {"application_status": "none"},
+            chat_id=-100123,
+            is_admin=False,
+        )
+        group_student_labels = [
+            button.text for row in group_student.inline_keyboard for button in row
+        ]
+        assert "申请成为老师" in group_student_labels
+        assert "我的状态" not in group_student_labels
+
+        teacher_rows = module._teacher_result_markup(
+            [
+                {
+                    "user_id": index,
+                    "username": f"teacher{index}",
+                    "display_name": f"老师 {index}",
+                    "online": index % 2 == 0,
+                }
+                for index in range(1, 5)
+            ],
+            radius_key="all",
+            page=1,
+            total=4,
+        ).inline_keyboard
+        assert [len(row) for row in teacher_rows[:2]] == [3, 1]
+        assert teacher_rows[0][0].text.startswith("🔴")
+        assert teacher_rows[0][1].text.startswith("🟢")
     finally:
         await directory_service.stop_presence_sweeper()
 

+ 158 - 0
tests/test_message_cleanup.py

@@ -0,0 +1,158 @@
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+
+import pytest
+
+
+class FakeMessage:
+    def __init__(self, chat_id: int, message_id: int, *, text: str = "") -> None:
+        self.chat = SimpleNamespace(
+            id=chat_id,
+            type=SimpleNamespace(value="supergroup" if chat_id < 0 else "private"),
+        )
+        self.id = message_id
+        self.text = text
+        self.caption = None
+        self.delete_calls: list[bool] = []
+
+    async def delete(self, *, revoke: bool = True) -> None:
+        self.delete_calls.append(revoke)
+
+
+class FakeClient:
+    def __init__(self) -> None:
+        self.next_message_id = 100
+
+    async def send_message(
+        self,
+        chat_id: int,
+        _text: str,
+        *,
+        reply_to_message_id: int | None = None,
+    ) -> FakeMessage:
+        self.next_message_id += 1
+        return FakeMessage(chat_id, self.next_message_id)
+
+
+@pytest.fixture
+def cleanup(app_modules):
+    module = app_modules.load("wbb.services.message_cleanup")
+    module._scheduled_messages.clear()
+    module._active_interactions.clear()
+    yield module
+    module._scheduled_messages.clear()
+    module._active_interactions.clear()
+
+
+async def test_group_cleanup_revokes_once_for_everyone(cleanup, monkeypatch):
+    real_sleep = asyncio.sleep
+
+    async def immediate_sleep(_delay: int) -> None:
+        return None
+
+    monkeypatch.setattr(cleanup.asyncio, "sleep", immediate_sleep)
+    message = FakeMessage(-1001, 10, text="/points")
+
+    cleanup.schedule_group_cleanup(message)
+    cleanup.schedule_group_cleanup(message)
+    await real_sleep(0)
+    await real_sleep(0)
+
+    assert message.delete_calls == [True]
+    assert (-1001, 10) not in cleanup._scheduled_messages
+
+
+async def test_private_messages_are_not_scheduled(cleanup):
+    message = FakeMessage(1001, 11, text="/points")
+
+    cleanup.schedule_group_cleanup(message)
+
+    assert not cleanup.is_group_cleanup_scheduled(message)
+    assert message.delete_calls == []
+
+
+async def test_command_context_tracks_only_responses_in_the_same_group(
+    cleanup, monkeypatch
+):
+    scheduled: list[FakeMessage] = []
+    monkeypatch.setattr(
+        cleanup,
+        "schedule_group_cleanup",
+        lambda *messages, **_kwargs: scheduled.extend(messages),
+    )
+    client = FakeClient()
+    cleanup.install_group_interaction_tracking(client)
+    command = FakeMessage(-1001, 12, text="/points")
+
+    assert cleanup.begin_group_message_interaction(command)
+    group_response = await client.send_message(
+        -1001, "积分余额", reply_to_message_id=command.id
+    )
+    await client.send_message(-1001, "抽奖主消息")
+    await client.send_message(-1002, "其他群通知", reply_to_message_id=command.id)
+    await client.send_message(1001, "私聊通知", reply_to_message_id=command.id)
+
+    assert scheduled == [command, group_response]
+
+
+async def test_plain_message_clears_previous_command_context(cleanup, monkeypatch):
+    scheduled: list[FakeMessage] = []
+    monkeypatch.setattr(
+        cleanup,
+        "schedule_group_cleanup",
+        lambda *messages, **_kwargs: scheduled.extend(messages),
+    )
+    client = FakeClient()
+    cleanup.install_group_interaction_tracking(client)
+    command = FakeMessage(-1001, 13, text="/points")
+    plain_message = FakeMessage(-1001, 14, text="大家好")
+
+    assert cleanup.begin_group_message_interaction(command)
+    assert not cleanup.begin_group_message_interaction(plain_message)
+    await client.send_message(
+        -1001, "普通自动回复", reply_to_message_id=plain_message.id
+    )
+
+    assert scheduled == [command]
+
+
+async def test_checkin_button_is_treated_as_group_interaction(cleanup, monkeypatch):
+    scheduled: list[FakeMessage] = []
+    monkeypatch.setattr(
+        cleanup,
+        "schedule_group_cleanup",
+        lambda *messages, **_kwargs: scheduled.extend(messages),
+    )
+    message = FakeMessage(-1001, 15, text="签到")
+
+    assert cleanup.begin_group_message_interaction(message, menu_texts={"签到"})
+    assert scheduled == [message]
+
+
+async def test_callback_only_continues_an_already_ephemeral_menu(
+    cleanup, monkeypatch
+):
+    scheduled: list[FakeMessage] = []
+    monkeypatch.setattr(
+        cleanup,
+        "schedule_group_cleanup",
+        lambda *messages, **_kwargs: scheduled.extend(messages),
+    )
+    client = FakeClient()
+    cleanup.install_group_interaction_tracking(client)
+    persistent_menu = FakeMessage(-1001, 16)
+    query = SimpleNamespace(message=persistent_menu)
+
+    assert not cleanup.begin_group_callback_interaction(query)
+    await client.send_message(
+        -1001, "不应自动删除", reply_to_message_id=persistent_menu.id
+    )
+    cleanup._scheduled_messages.add((-1001, 16))
+    assert cleanup.begin_group_callback_interaction(query)
+    callback_response = await client.send_message(
+        -1001, "临时菜单回复", reply_to_message_id=persistent_menu.id
+    )
+
+    assert scheduled == [callback_response]

+ 1 - 0
wbb/__init__.py

@@ -67,6 +67,7 @@ MOD_LOAD = [
     "flood",
     "giveaway",
     "greetings",
+    "interaction_cleanup",
     "karma",
     "locks",
     "notes",

+ 13 - 13
wbb/__main__.py

@@ -100,14 +100,9 @@ BOT_COMMANDS = [
     BotCommand("gcancel", "取消抽奖并退款"),
     BotCommand("greroll", "重新抽取中奖者"),
     BotCommand("rules", "查看本群群规"),
-    BotCommand("directory", "打开师生与附近菜单"),
+    BotCommand("directory", "打开附近老师菜单"),
     BotCommand("nearby", "查找附近老师"),
     BotCommand("teacher_rank", "查看老师榜单"),
-    BotCommand("teacher_apply", "申请成为老师"),
-    BotCommand("teacher_online", "切换老师为上线"),
-    BotCommand("teacher_offline", "切换老师为下线"),
-    BotCommand("teacher_list", "老师自助上榜"),
-    BotCommand("teacher_unlist", "老师自助下榜"),
 ]
 BOT_COMMAND_PERMISSIONS = {
     "manage": "__group_any__",
@@ -127,11 +122,6 @@ BOT_COMMAND_PERMISSIONS = {
     "directory": "teacher_directory.manage",
     "nearby": "teacher_directory.manage",
     "teacher_rank": "teacher_directory.manage",
-    "teacher_apply": "teacher_directory.manage",
-    "teacher_online": "teacher_directory.manage",
-    "teacher_offline": "teacher_directory.manage",
-    "teacher_list": "teacher_directory.manage",
-    "teacher_unlist": "teacher_directory.manage",
 }
 
 
@@ -280,7 +270,7 @@ if has_any_permission(GROUP_MANAGEMENT_PERMISSIONS, BOT_PERMISSIONS):
 if has_permission("teacher_directory.manage", BOT_PERMISSIONS):
     home_actions.append(
         InlineKeyboardButton(
-            text="师生与附近",
+            text="附近老师",
             url=f"https://t.me/{BOT_USERNAME}?start=directory",
         )
     )
@@ -317,7 +307,7 @@ assigned_features = [
         ("points.manage", "积分"),
         ("giveaways.manage", "抽奖"),
         ("karma.manage", "声望"),
-        ("teacher_directory.manage", "师生与附近"),
+        ("teacher_directory.manage", "老师与附近"),
     )
     if has_permission(permission, BOT_PERMISSIONS)
 ]
@@ -403,6 +393,16 @@ async def start(_, message):
                     "该群管理员尚未设置群规。",
                 )
             return
+        directory_match = re.fullmatch(r"directory_(-?\d+)", name)
+        if directory_match and has_permission(
+            "teacher_directory.manage", BOT_PERMISSIONS
+        ):
+            from wbb.modules.teacher_directory import send_directory_menu
+
+            return await send_directory_menu(
+                message,
+                source_chat_id=int(directory_match.group(1)),
+            )
         if name == "directory" and has_permission(
             "teacher_directory.manage", BOT_PERMISSIONS
         ):

+ 4 - 51
wbb/admin/api.py

@@ -57,10 +57,6 @@ from wbb.services.chat_management import (
     update_chat_profile,
 )
 from wbb.services.directory import DirectoryServiceError, verify_local_membership
-from wbb.services.directory_geocoding import (
-    start_directory_geocoder,
-    stop_directory_geocoder,
-)
 from wbb.services.giveaways import (
     GiveawayServiceError,
     cancel_and_refund_giveaway,
@@ -90,7 +86,6 @@ from wbb.utils.dbdirectory import (
     clear_directory_location,
     decide_teacher_application,
     ensure_directory_indexes,
-    get_directory_location,
     get_directory_profile,
     get_directory_settings,
     list_directory_events,
@@ -754,14 +749,7 @@ class AdminApi:
                 )
 
     async def directory_settings(self, _: web.Request) -> web.Response:
-        settings = await get_directory_settings()
-        settings["nominatim_ready"] = bool(
-            settings.get("nominatim_enabled")
-            and str(settings.get("nominatim_contact") or "").strip()
-        )
-        settings["attribution"] = "© OpenStreetMap contributors"
-        settings["attribution_url"] = "https://www.openstreetmap.org/copyright"
-        return success(settings)
+        return success(await get_directory_settings())
 
     async def directory_settings_update(self, request: web.Request) -> web.Response:
         body = await json_body(request)
@@ -769,10 +757,6 @@ class AdminApi:
         values = {
             key: body[key]
             for key in (
-                "nominatim_enabled",
-                "nominatim_endpoint",
-                "nominatim_contact",
-                "nominatim_user_agent",
                 "nearby_radius_km",
                 "nearby_radius_options_km",
             )
@@ -782,19 +766,9 @@ class AdminApi:
         set_audit(
             request,
             "directory.settings.update",
-            summary="更新师生目录和 Nominatim 设置",
-        )
-        return success(
-            {
-                **saved,
-                "nominatim_ready": bool(
-                    saved.get("nominatim_enabled")
-                    and str(saved.get("nominatim_contact") or "").strip()
-                ),
-                "attribution": "© OpenStreetMap contributors",
-                "attribution_url": "https://www.openstreetmap.org/copyright",
-            }
+            summary="更新老师目录设置",
         )
+        return success(saved)
 
     async def directory_applications(self, request: web.Request) -> web.Response:
         page, page_size = page_params(request)
@@ -917,9 +891,6 @@ class AdminApi:
                     "user_id": item["user_id"],
                     "longitude": item.get("longitude"),
                     "latitude": item.get("latitude"),
-                    "region": item.get("region"),
-                    "geocode_consent": item.get("geocode_consent"),
-                    "geocode_status": item.get("geocode_status"),
                     "source": item.get("source"),
                     "updated_at": item.get("updated_at"),
                     "display_name": profile.get("display_name"),
@@ -946,7 +917,6 @@ class AdminApi:
         profile = await get_directory_profile(user_id)
         if not profile:
             raise ApiProblem("profile_not_found", "未找到该成员资料。", status=404)
-        previous = await get_directory_location(user_id)
         saved = await save_directory_location(
             user_id=user_id,
             longitude=body.get("longitude"),
@@ -954,9 +924,6 @@ class AdminApi:
             source="web",
             actor_id=request["admin"]["username"],
             actor_name=request["admin"]["username"],
-            geocode_consent=(
-                previous.get("geocode_consent") if previous else None
-            ),
         )
         set_audit(
             request,
@@ -970,9 +937,7 @@ class AdminApi:
                 "user_id": user_id,
                 "longitude": saved["longitude"],
                 "latitude": saved["latitude"],
-                "region": saved.get("region"),
-                "geocode_status": saved.get("geocode_status"),
-                "geocode_consent": saved.get("geocode_consent"),
+                "source": saved.get("source"),
                 "updated_at": saved.get("updated_at"),
             }
         )
@@ -1846,16 +1811,6 @@ class AdminApi:
         )
 
 
-async def _start_global_admin_services(_: web.Application) -> None:
-    if not bool(getattr(wbb, "BOT_WORKER_MODE", False)):
-        await start_directory_geocoder()
-
-
-async def _stop_global_admin_services(_: web.Application) -> None:
-    if not bool(getattr(wbb, "BOT_WORKER_MODE", False)):
-        await stop_directory_geocoder()
-
-
 def build_admin_application() -> web.Application:
     max_upload_mb = int(getattr(wbb, "ADMIN_WEB_UPLOAD_MAX_MB", 20))
     application = web.Application(
@@ -1870,6 +1825,4 @@ def build_admin_application() -> web.Application:
     admin_api = AdminApi()
     application["admin_api"] = admin_api
     admin_api.register(application)
-    application.on_startup.append(_start_global_admin_services)
-    application.on_cleanup.append(_stop_global_admin_services)
     return application

+ 26 - 0
wbb/modules/interaction_cleanup.py

@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+from pyrogram.types import CallbackQuery, Message
+
+from wbb import app
+from wbb.services.message_cleanup import (
+    begin_group_callback_interaction,
+    begin_group_message_interaction,
+    install_group_interaction_tracking,
+)
+from wbb.services.point_settings import CHECKIN_BUTTON_TEXT
+
+__MODULE__ = "群聊交互清理"
+__HELP__ = "群内机器人指令和按钮交互消息会在 1 分钟后自动清理。"
+
+install_group_interaction_tracking(app)
+
+
+@app.on_message(group=-2000)
+async def begin_message_cleanup_context(_, message: Message):
+    begin_group_message_interaction(message, menu_texts={CHECKIN_BUTTON_TEXT})
+
+
+@app.on_callback_query(group=-2000)
+async def begin_callback_cleanup_context(_, query: CallbackQuery):
+    begin_group_callback_interaction(query)

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 518 - 185
wbb/modules/teacher_directory.py


+ 2 - 2
wbb/services/bot_permissions.py

@@ -39,7 +39,7 @@ PERMISSIONS = (
     BotPermission("karma.manage", "声望", "社区运营", "处理点赞、点踩和声望排行。"),
     BotPermission(
         "teacher_directory.manage",
-        "师生与附近",
+        "老师与附近",
         "社区运营",
         "提供老师申请、上下榜、在线状态、位置和附近老师目录。",
     ),
@@ -108,7 +108,7 @@ BUILTIN_ROLES = (
     ),
     BuiltinBotRole(
         "teacher_directory_manager",
-        "师生目录专员",
+        "老师目录专员",
         "负责老师申请、位置、上下榜、在线状态和附近老师目录。",
         ("teacher_directory.manage",),
     ),

+ 102 - 49
wbb/services/directory.py

@@ -16,10 +16,10 @@ from wbb.utils.dbdirectory import (
     decide_teacher_application,
     expire_teacher_presence,
     get_directory_location,
+    get_directory_profile,
     list_directory_teachers,
     list_membership_candidates,
     save_directory_location,
-    set_geocode_consent,
     set_teacher_state,
     submit_teacher_application,
     teacher_is_online,
@@ -238,39 +238,23 @@ async def update_user_location(
     user: Any,
     longitude: float,
     latitude: float,
-    source: str = "telegram_private",
-) -> tuple[dict[str, Any], bool | None]:
+    source: str = "telegram_location",
+) -> dict[str, Any]:
     await require_platform_membership(int(user.id))
     identity = user_identity(user)
     await observe_directory_user(user)
-    previous = await get_directory_location(int(user.id))
-    consent = previous.get("geocode_consent") if previous else None
-    location = await save_directory_location(
-        user_id=int(user.id),
-        longitude=longitude,
-        latitude=latitude,
-        source=source,
-        actor_id=int(user.id),
-        actor_name=identity["display_name"],
-        geocode_consent=consent,
-        bot_id=str(BOT_PROFILE_ID),
-    )
-    return location, consent
-
-
-async def update_user_geocode_consent(
-    *,
-    user: Any,
-    consent: bool,
-) -> dict[str, Any]:
-    identity = user_identity(user)
-    return await set_geocode_consent(
-        user_id=int(user.id),
-        consent=consent,
-        source="telegram_private",
-        actor_id=int(user.id),
-        actor_name=identity["display_name"],
-    )
+    try:
+        return await save_directory_location(
+            user_id=int(user.id),
+            longitude=longitude,
+            latitude=latitude,
+            source=source,
+            actor_id=int(user.id),
+            actor_name=identity["display_name"],
+            bot_id=str(BOT_PROFILE_ID),
+        )
+    except DirectoryDataError as exc:
+        raise DirectoryServiceError(exc.code, str(exc), status=409) from exc
 
 
 async def clear_user_location(*, user: Any) -> bool:
@@ -284,14 +268,34 @@ async def clear_user_location(*, user: Any) -> bool:
     )
 
 
-async def apply_as_teacher(*, user: Any) -> dict[str, Any]:
-    await require_platform_membership(int(user.id))
+async def apply_as_teacher(
+    *,
+    user: Any,
+    chat_id: int | None = None,
+    chat_title: str = "",
+    source: str = "telegram_private",
+) -> tuple[dict[str, Any], bool]:
+    authorization = await require_platform_membership(int(user.id))
     await observe_directory_user(user)
+    application_chat_id = int(chat_id or authorization.get("chat_id") or 0)
+    if not application_chat_id:
+        raise DirectoryServiceError(
+            "application_chat_required",
+            "请先在要申请老师的群里打开附近老师菜单。",
+            status=409,
+        )
+    application_chat_title = str(chat_title or "").strip()
+    if not application_chat_title:
+        with suppress(Exception):
+            chat = await app.get_chat(application_chat_id)
+            application_chat_title = str(getattr(chat, "title", "") or "").strip()
     try:
         return await submit_teacher_application(
             user_id=int(user.id),
-            source="telegram_private",
+            source=source,
             bot_id=str(BOT_PROFILE_ID),
+            chat_id=application_chat_id,
+            chat_title=application_chat_title,
         )
     except DirectoryDataError as exc:
         raise DirectoryServiceError(exc.code, str(exc), status=409) from exc
@@ -323,6 +327,65 @@ async def change_own_teacher_state(
         raise DirectoryServiceError(exc.code, str(exc), status=409) from exc
 
 
+async def admin_source_chat_ids(actor: Any) -> list[int] | None:
+    if is_sudoer(int(actor.id)):
+        return None
+    candidates = await list_membership_candidates(int(actor.id))
+    chat_ids: list[int] = []
+    for item in candidates:
+        if str(item.get("bot_id")) != str(BOT_PROFILE_ID):
+            continue
+        chat_id = int(item["chat_id"])
+        try:
+            member = await app.get_chat_member(chat_id, int(actor.id))
+        except Exception:
+            continue
+        if getattr(member, "status", None) in ADMIN_MEMBER_STATUSES:
+            chat_ids.append(chat_id)
+    values = list(dict.fromkeys(chat_ids))
+    if not values:
+        raise DirectoryServiceError(
+            "managed_group_admin_required",
+            "只有申请来源群的管理员可以审批老师。",
+            status=403,
+        )
+    return values
+
+
+async def require_teacher_admin(actor: Any, user_id: int) -> dict[str, Any]:
+    profile = await get_directory_profile(int(user_id))
+    if not profile:
+        raise DirectoryServiceError("profile_not_found", "未找到该成员资料。", status=404)
+    if is_sudoer(int(actor.id)):
+        return profile
+    source_chat_id = profile.get("application_source_chat_id")
+    source_bot_id = profile.get("application_source_bot_id")
+    if not source_chat_id:
+        await require_platform_membership(int(actor.id), require_admin=True)
+        return profile
+    if source_bot_id and str(source_bot_id) != str(BOT_PROFILE_ID):
+        raise DirectoryServiceError(
+            "source_bot_required",
+            "请使用接收老师申请提醒的机器人完成审批。",
+            status=403,
+        )
+    try:
+        member = await app.get_chat_member(int(source_chat_id), int(actor.id))
+    except Exception as exc:
+        raise DirectoryServiceError(
+            "admin_verification_unavailable",
+            "暂时无法核验申请来源群的管理员身份。",
+            status=503,
+        ) from exc
+    if getattr(member, "status", None) not in ADMIN_MEMBER_STATUSES:
+        raise DirectoryServiceError(
+            "source_chat_admin_required",
+            "只有申请来源群的管理员可以执行此操作。",
+            status=403,
+        )
+    return profile
+
+
 async def admin_decide_teacher(
     *,
     actor: Any,
@@ -330,10 +393,7 @@ async def admin_decide_teacher(
     action: str,
     reason: str = "",
 ) -> dict[str, Any]:
-    authorization = await require_platform_membership(
-        int(actor.id),
-        require_admin=True,
-    )
+    profile = await require_teacher_admin(actor, int(user_id))
     identity = user_identity(actor)
     try:
         return await decide_teacher_application(
@@ -343,7 +403,7 @@ async def admin_decide_teacher(
             actor_name=identity["display_name"],
             source="telegram_private",
             reason=reason,
-            authorization_chat_id=authorization.get("chat_id"),
+            authorization_chat_id=profile.get("application_source_chat_id"),
         )
     except DirectoryDataError as exc:
         raise DirectoryServiceError(exc.code, str(exc), status=409) from exc
@@ -356,7 +416,7 @@ async def admin_change_teacher_state(
     action: str,
     reason: str,
 ) -> dict[str, Any]:
-    await require_platform_membership(int(actor.id), require_admin=True)
+    await require_teacher_admin(actor, int(user_id))
     if not reason.strip():
         raise DirectoryServiceError("reason_required", "必须填写操作原因。")
     identity = user_identity(actor)
@@ -387,7 +447,7 @@ async def directory_for_user(
     if not location:
         raise DirectoryServiceError(
             "location_required",
-            "请先点击“更新位置”发送定位,再查看老师。",
+            "请先使用 Telegram 定位或手动选择位置,再查看老师。",
             status=409,
         )
     try:
@@ -405,14 +465,12 @@ async def directory_for_user(
 
 
 def public_teacher(profile: dict[str, Any]) -> dict[str, Any]:
-    region = profile.get("region") or {}
     return {
         "user_id": int(profile["user_id"]),
         "username": profile.get("username"),
         "display_name": profile.get("display_name") or f"用户 {profile['user_id']}",
         "online": teacher_is_online(profile),
         "online_until": aware_utc(profile.get("online_until")),
-        "region": str(region.get("label") or "区域暂不可用"),
         "distance_meters": float(profile.get("distance_meters") or 0),
         "location_updated_at": aware_utc(profile.get("location_updated_at")),
     }
@@ -428,12 +486,7 @@ def profile_summary(profile: dict[str, Any], location: dict[str, Any] | None) ->
         "online": teacher_is_online(profile),
         "online_until": aware_utc(profile.get("online_until")),
         "has_location": bool(location),
-        "region": (
-            str((location.get("region") or {}).get("label") or "区域暂不可用")
-            if location
-            else ""
-        ),
-        "geocode_consent": location.get("geocode_consent") if location else None,
+        "location_source": location.get("source") if location else None,
         "location_updated_at": aware_utc(location.get("updated_at")) if location else None,
     }
 

+ 0 - 161
wbb/services/directory_geocoding.py

@@ -1,161 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-from contextlib import suppress
-from typing import Any
-
-from aiohttp import ClientError, ClientSession, ClientTimeout
-
-from wbb import log
-from wbb.utils.dbdirectory import (
-    claim_geocode_job,
-    complete_geocode_job,
-    fail_geocode_job,
-    get_directory_settings,
-)
-
-NOMINATIM_ATTRIBUTION = "© OpenStreetMap contributors"
-NOMINATIM_ATTRIBUTION_URL = "https://www.openstreetmap.org/copyright"
-
-_worker_task: asyncio.Task[None] | None = None
-_stop_event: asyncio.Event | None = None
-
-
-def parse_nominatim_region(payload: dict[str, Any]) -> dict[str, str]:
-    address = payload.get("address") if isinstance(payload.get("address"), dict) else {}
-    province = str(address.get("state") or address.get("province") or "").strip()
-    city = str(
-        address.get("city")
-        or address.get("municipality")
-        or address.get("town")
-        or address.get("county")
-        or ""
-    ).strip()
-    district = str(
-        address.get("city_district")
-        or address.get("district")
-        or address.get("borough")
-        or address.get("suburb")
-        or address.get("county")
-        or ""
-    ).strip()
-    values = list(dict.fromkeys(value for value in (province, city, district) if value))
-    return {
-        "province": province,
-        "city": city,
-        "district": district,
-        "label": " ".join(values) if values else "区域暂不可用",
-        "provider": "nominatim",
-    }
-
-
-async def reverse_geocode(
-    *,
-    endpoint: str,
-    contact: str,
-    user_agent: str,
-    longitude: float,
-    latitude: float,
-) -> dict[str, str]:
-    headers = {
-        "User-Agent": f"{user_agent} ({contact})",
-        "Accept": "application/json",
-        "Accept-Language": "zh-CN,zh;q=0.9",
-    }
-    params = {
-        "format": "jsonv2",
-        "lat": repr(float(latitude)),
-        "lon": repr(float(longitude)),
-        "zoom": "12",
-        "addressdetails": "1",
-        "accept-language": "zh-CN",
-    }
-    timeout = ClientTimeout(total=12)
-    async with ClientSession(timeout=timeout, headers=headers) as session:
-        async with session.get(endpoint, params=params) as response:
-            if response.status == 429:
-                retry_after = int(response.headers.get("Retry-After") or 60)
-                raise NominatimRateLimited(retry_after)
-            if response.status >= 400:
-                text = await response.text()
-                raise RuntimeError(
-                    f"Nominatim 返回 HTTP {response.status}: {text[:200]}"
-                )
-            payload = await response.json()
-    if not isinstance(payload, dict):
-        raise RuntimeError("Nominatim 返回了无效数据。")
-    return parse_nominatim_region(payload)
-
-
-class NominatimRateLimited(RuntimeError):
-    def __init__(self, retry_after_seconds: int):
-        super().__init__("Nominatim 请求已被限流。")
-        self.retry_after_seconds = max(1, int(retry_after_seconds))
-
-
-async def _geocode_worker(stop_event: asyncio.Event) -> None:
-    last_request_at = 0.0
-    while not stop_event.is_set():
-        settings = await get_directory_settings()
-        enabled = bool(settings.get("nominatim_enabled"))
-        contact = str(settings.get("nominatim_contact") or "").strip()
-        if not enabled or not contact:
-            with suppress(TimeoutError):
-                await asyncio.wait_for(stop_event.wait(), timeout=2)
-            continue
-        job = await claim_geocode_job()
-        if not job:
-            with suppress(TimeoutError):
-                await asyncio.wait_for(stop_event.wait(), timeout=1)
-            continue
-        loop = asyncio.get_running_loop()
-        wait_seconds = 1.1 - (loop.time() - last_request_at)
-        if wait_seconds > 0:
-            with suppress(TimeoutError):
-                await asyncio.wait_for(stop_event.wait(), timeout=wait_seconds)
-            if stop_event.is_set():
-                return
-        try:
-            last_request_at = loop.time()
-            region = await reverse_geocode(
-                endpoint=str(settings["nominatim_endpoint"]),
-                contact=contact,
-                user_agent=str(settings["nominatim_user_agent"]),
-                longitude=float(job["longitude"]),
-                latitude=float(job["latitude"]),
-            )
-            await complete_geocode_job(job=job, region=region)
-        except NominatimRateLimited as exc:
-            await fail_geocode_job(
-                job=job,
-                error=str(exc),
-                retry_after_seconds=exc.retry_after_seconds,
-            )
-        except (ClientError, TimeoutError, ValueError, RuntimeError) as exc:
-            log.error(f"Nominatim 区域解析失败:{exc}")
-            await fail_geocode_job(job=job, error=str(exc), retry_after_seconds=60)
-        except Exception as exc:
-            log.error(f"Nominatim 区域解析出现未预期错误:{exc}")
-            await fail_geocode_job(job=job, error="区域解析服务异常。")
-
-
-async def start_directory_geocoder() -> None:
-    global _worker_task, _stop_event
-    if _worker_task and not _worker_task.done():
-        return
-    _stop_event = asyncio.Event()
-    _worker_task = asyncio.create_task(
-        _geocode_worker(_stop_event),
-        name="directory-geocoder",
-    )
-
-
-async def stop_directory_geocoder() -> None:
-    global _worker_task, _stop_event
-    if _stop_event:
-        _stop_event.set()
-    if _worker_task:
-        with suppress(asyncio.CancelledError):
-            await asyncio.wait_for(_worker_task, timeout=3)
-    _worker_task = None
-    _stop_event = None

+ 196 - 0
wbb/services/message_cleanup.py

@@ -0,0 +1,196 @@
+from __future__ import annotations
+
+import asyncio
+import logging
+from functools import wraps
+from typing import Any
+from weakref import WeakKeyDictionary
+
+GROUP_INTERACTION_TTL_SECONDS = 60
+
+_LOGGER = logging.getLogger(__name__)
+_scheduled_messages: set[tuple[int, int]] = set()
+_active_interactions: WeakKeyDictionary[asyncio.Task[Any], int] = WeakKeyDictionary()
+_TRACKED_SEND_METHODS = (
+    "send_message",
+    "send_photo",
+    "send_document",
+    "send_video",
+    "send_animation",
+    "send_audio",
+    "send_voice",
+    "send_video_note",
+    "send_sticker",
+    "send_contact",
+    "send_location",
+    "send_venue",
+    "send_poll",
+    "send_dice",
+    "send_media_group",
+    "send_cached_media",
+    "copy_message",
+    "copy_media_group",
+    "forward_messages",
+)
+
+
+def _message_key(message: Any) -> tuple[int, int] | None:
+    chat = getattr(message, "chat", None)
+    message_id = getattr(message, "id", None)
+    if not chat or not message_id:
+        return None
+    try:
+        return int(chat.id), int(message_id)
+    except (TypeError, ValueError):
+        return None
+
+
+def _is_group_message(message: Any) -> bool:
+    chat = getattr(message, "chat", None)
+    if not chat:
+        return False
+    chat_type = getattr(chat, "type", None)
+    chat_type_value = str(getattr(chat_type, "value", chat_type) or "").lower()
+    if chat_type_value:
+        return chat_type_value in {"group", "supergroup"}
+    try:
+        return int(chat.id) < 0
+    except (TypeError, ValueError):
+        return False
+
+
+def _current_task() -> asyncio.Task[Any] | None:
+    try:
+        return asyncio.current_task()
+    except RuntimeError:
+        return None
+
+
+async def _delete_later(message: Any, delay_seconds: int) -> None:
+    key = _message_key(message)
+    if key is None:
+        return
+    try:
+        await asyncio.sleep(max(1, int(delay_seconds)))
+        try:
+            await message.delete(revoke=True)
+        except asyncio.CancelledError:
+            raise
+        except Exception as exc:
+            _LOGGER.warning(
+                "群交互消息自动删除失败 chat_id=%s message_id=%s: %s",
+                key[0],
+                key[1],
+                exc,
+            )
+    finally:
+        _scheduled_messages.discard(key)
+
+
+def schedule_group_cleanup(
+    *messages: Any,
+    delay_seconds: int = GROUP_INTERACTION_TTL_SECONDS,
+) -> None:
+    for message in messages:
+        key = _message_key(message)
+        if key is None or not _is_group_message(message):
+            continue
+        if key in _scheduled_messages:
+            continue
+        _scheduled_messages.add(key)
+        asyncio.create_task(
+            _delete_later(message, delay_seconds),
+            name=f"group-interaction-cleanup:{key[0]}:{key[1]}",
+        )
+
+
+def is_group_cleanup_scheduled(message: Any) -> bool:
+    key = _message_key(message)
+    return bool(key and key in _scheduled_messages)
+
+
+def clear_current_group_interaction() -> None:
+    task = _current_task()
+    if task is not None:
+        _active_interactions.pop(task, None)
+
+
+def begin_group_message_interaction(
+    message: Any,
+    *,
+    menu_texts: set[str] | frozenset[str] = frozenset(),
+) -> bool:
+    clear_current_group_interaction()
+    if not _is_group_message(message):
+        return False
+    text = str(
+        getattr(message, "text", None) or getattr(message, "caption", None) or ""
+    ).strip()
+    folded = text.casefold()
+    is_command = text.startswith("/") or folded in {"@admin", "@admins"}
+    is_command = is_command or folded.startswith(("@admin ", "@admins "))
+    if not is_command and text not in menu_texts:
+        return False
+    task = _current_task()
+    if task is None:
+        return False
+    _active_interactions[task] = int(message.chat.id)
+    schedule_group_cleanup(message)
+    return True
+
+
+def begin_group_callback_interaction(query: Any) -> bool:
+    clear_current_group_interaction()
+    message = getattr(query, "message", None)
+    if (
+        not message
+        or not _is_group_message(message)
+        or not is_group_cleanup_scheduled(message)
+    ):
+        return False
+    task = _current_task()
+    if task is None:
+        return False
+    _active_interactions[task] = int(message.chat.id)
+    return True
+
+
+def track_current_group_interaction(
+    result: Any,
+    *,
+    reply_to_message_id: Any = None,
+) -> None:
+    if reply_to_message_id is None:
+        return
+    task = _current_task()
+    if task is None:
+        return
+    chat_id = _active_interactions.get(task)
+    if chat_id is None:
+        return
+    messages = result if isinstance(result, (list, tuple)) else (result,)
+    for message in messages:
+        key = _message_key(message)
+        if key and key[0] == chat_id and _is_group_message(message):
+            schedule_group_cleanup(message)
+
+
+def install_group_interaction_tracking(client: Any) -> None:
+    if getattr(client, "_wbb_group_interaction_tracking", False):
+        return
+    for method_name in _TRACKED_SEND_METHODS:
+        original = getattr(client, method_name, None)
+        if not callable(original):
+            continue
+
+        @wraps(original)
+        async def tracked_send(*args: Any, _original=original, **kwargs: Any):
+            result = await _original(*args, **kwargs)
+            track_current_group_interaction(
+                result,
+                reply_to_message_id=kwargs.get("reply_to_message_id"),
+            )
+            return result
+
+        setattr(client, method_name, tracked_send)
+    client._wbb_group_interaction_tracking = True

+ 38 - 259
wbb/utils/dbdirectory.py

@@ -1,8 +1,6 @@
 from __future__ import annotations
 
 import asyncio
-import hashlib
-import json
 import math
 import re
 from datetime import UTC, datetime, timedelta
@@ -19,8 +17,6 @@ locationsdb = control_db.directory_locations
 membershipsdb = control_db.directory_memberships
 eventsdb = control_db.directory_events
 settingsdb = control_db.directory_settings
-geocode_cachedb = control_db.directory_geocode_cache
-geocode_jobsdb = control_db.directory_geocode_jobs
 
 APPLICATION_NONE = "none"
 APPLICATION_PENDING = "pending"
@@ -39,10 +35,6 @@ PRESENCE_ONLINE = "online"
 PRESENCE_OFFLINE = "offline"
 
 DEFAULT_DIRECTORY_SETTINGS: dict[str, Any] = {
-    "nominatim_enabled": True,
-    "nominatim_endpoint": "https://nominatim.openstreetmap.org/reverse",
-    "nominatim_contact": "",
-    "nominatim_user_agent": "TelegramBotAdmin/1.0",
     "online_duration_hours": 24,
     "nearby_radius_km": 20,
     "nearby_radius_options_km": [5, 10, 20, 50],
@@ -129,11 +121,6 @@ async def ensure_directory_indexes() -> None:
         await eventsdb.create_index([("created_at", DESCENDING)])
         await eventsdb.create_index([("user_id", ASCENDING), ("created_at", DESCENDING)])
         await settingsdb.create_index([("settings_id", ASCENDING)], unique=True)
-        await geocode_cachedb.create_index([("coordinate_key", ASCENDING)], unique=True)
-        await geocode_jobsdb.create_index([("user_id", ASCENDING)], unique=True)
-        await geocode_jobsdb.create_index(
-            [("status", ASCENDING), ("next_attempt_at", ASCENDING)]
-        )
         _indexes_ready = True
 
 
@@ -143,9 +130,9 @@ async def get_directory_settings() -> dict[str, Any]:
     return {
         **DEFAULT_DIRECTORY_SETTINGS,
         **{
-            key: value
-            for key, value in stored.items()
-            if key not in {"_id", "settings_id"}
+            key: stored[key]
+            for key in DEFAULT_DIRECTORY_SETTINGS
+            if key in stored
         },
     }
 
@@ -154,12 +141,6 @@ async def set_directory_settings(values: dict[str, Any]) -> dict[str, Any]:
     await ensure_directory_indexes()
     current = await get_directory_settings()
     target = {**current, **values}
-    endpoint = str(target.get("nominatim_endpoint") or "").strip()
-    if not endpoint.startswith(("https://", "http://127.0.0.1", "http://localhost")):
-        raise DirectoryDataError(
-            "invalid_nominatim_endpoint",
-            "Nominatim 地址必须使用 HTTPS;仅本机自建服务可以使用 HTTP。",
-        )
     try:
         radius_options = sorted(
             {
@@ -179,26 +160,8 @@ async def set_directory_settings(values: dict[str, Any]) -> dict[str, Any]:
         raise DirectoryDataError(
             "invalid_default_radius", "默认附近范围必须属于可选范围。"
         )
-    contact = str(target.get("nominatim_contact") or "").strip()
-    user_agent = str(
-        target.get("nominatim_user_agent") or "TelegramBotAdmin/1.0"
-    ).strip()
-    if "\n" in contact or "\r" in contact or "\n" in user_agent or "\r" in user_agent:
-        raise DirectoryDataError(
-            "invalid_nominatim_identity",
-            "Nominatim 联系信息和 User-Agent 不能包含换行符。",
-        )
-    if contact and "@" not in contact and not contact.startswith(("https://", "http://")):
-        raise DirectoryDataError(
-            "invalid_nominatim_contact",
-            "Nominatim 联系信息必须是邮箱或 URL。",
-        )
     target.update(
         {
-            "nominatim_enabled": bool(target.get("nominatim_enabled")),
-            "nominatim_endpoint": endpoint,
-            "nominatim_contact": contact,
-            "nominatim_user_agent": user_agent,
             "online_duration_hours": 24,
             "nearby_radius_km": default_radius,
             "nearby_radius_options_km": radius_options,
@@ -330,15 +293,6 @@ def validate_coordinates(longitude: float, latitude: float) -> tuple[float, floa
     return lon, lat
 
 
-def coordinate_key(longitude: float, latitude: float) -> str:
-    raw = json.dumps(
-        [float(longitude), float(latitude)],
-        ensure_ascii=True,
-        separators=(",", ":"),
-    )
-    return hashlib.sha256(raw.encode("ascii")).hexdigest()
-
-
 async def save_directory_location(
     *,
     user_id: int,
@@ -347,20 +301,11 @@ async def save_directory_location(
     source: str,
     actor_id: int | str,
     actor_name: str,
-    geocode_consent: bool | None = None,
     bot_id: str | None = None,
 ) -> dict[str, Any]:
     await ensure_directory_indexes()
     lon, lat = validate_coordinates(longitude, latitude)
-    previous = await get_directory_location(int(user_id))
-    consent = (
-        geocode_consent
-        if geocode_consent is not None
-        else previous.get("geocode_consent") if previous else None
-    )
-    key = coordinate_key(lon, lat)
     now = utc_now()
-    status = "pending" if consent is True else "disabled" if consent is False else "consent_required"
     await locationsdb.update_one(
         {"user_id": int(user_id)},
         {
@@ -368,14 +313,18 @@ async def save_directory_location(
                 "point": {"type": "Point", "coordinates": [lon, lat]},
                 "longitude": lon,
                 "latitude": lat,
-                "coordinate_key": key,
-                "geocode_consent": consent,
-                "geocode_status": status,
-                "region": None,
                 "source": source,
                 "source_bot_id": bot_id or str(BOT_PROFILE_ID),
                 "updated_at": now,
             },
+            "$unset": {
+                "coordinate_key": "",
+                "geocode_consent": "",
+                "geocode_status": "",
+                "region": "",
+                "geocoded_at": "",
+                "geocode_error": "",
+            },
             "$setOnInsert": {"created_at": now},
         },
         upsert=True,
@@ -400,45 +349,9 @@ async def save_directory_location(
         actor_name=actor_name,
         source=source,
         reason="更新位置",
-        metadata={"geocode_consent": consent},
+        metadata={"location_source": source},
         bot_id=bot_id,
     )
-    if consent is True:
-        await enqueue_geocode_job(int(user_id))
-    return await get_directory_location(int(user_id)) or {}
-
-
-async def set_geocode_consent(
-    *,
-    user_id: int,
-    consent: bool,
-    source: str,
-    actor_id: int | str,
-    actor_name: str,
-) -> dict[str, Any]:
-    location = await get_directory_location(int(user_id))
-    if not location:
-        raise DirectoryDataError("location_required", "请先更新位置。")
-    updates: dict[str, Any] = {
-        "geocode_consent": bool(consent),
-        "geocode_status": "pending" if consent else "disabled",
-        "updated_at": utc_now(),
-    }
-    if not consent:
-        updates["region"] = None
-        await geocode_jobsdb.delete_one({"user_id": int(user_id)})
-    await locationsdb.update_one({"user_id": int(user_id)}, {"$set": updates})
-    await record_directory_event(
-        event_type="geocode_consent_updated",
-        user_id=int(user_id),
-        actor_id=actor_id,
-        actor_name=actor_name,
-        source=source,
-        reason="同意区域解析" if consent else "关闭区域解析",
-        metadata={"geocode_consent": bool(consent)},
-    )
-    if consent:
-        await enqueue_geocode_job(int(user_id))
     return await get_directory_location(int(user_id)) or {}
 
 
@@ -452,7 +365,6 @@ async def clear_directory_location(
 ) -> bool:
     await ensure_directory_indexes()
     result = await locationsdb.delete_one({"user_id": int(user_id)})
-    await geocode_jobsdb.delete_one({"user_id": int(user_id)})
     await profilesdb.update_one(
         {"user_id": int(user_id)},
         {
@@ -482,7 +394,9 @@ async def submit_teacher_application(
     user_id: int,
     source: str,
     bot_id: str,
-) -> dict[str, Any]:
+    chat_id: int,
+    chat_title: str = "",
+) -> tuple[dict[str, Any], bool]:
     await ensure_directory_indexes()
     profile = await get_directory_profile(int(user_id))
     location = await get_directory_location(int(user_id))
@@ -499,7 +413,7 @@ async def submit_teacher_application(
             "teacher_revoked", "老师资格已被撤销,只能由管理员重新批准。"
         )
     if profile.get("application_status") == APPLICATION_PENDING:
-        return profile
+        return profile, False
     now = utc_now()
     await profilesdb.update_one(
         {"user_id": int(user_id)},
@@ -508,6 +422,8 @@ async def submit_teacher_application(
                 "application_status": APPLICATION_PENDING,
                 "application_reason": "",
                 "application_source_bot_id": str(bot_id),
+                "application_source_chat_id": int(chat_id),
+                "application_source_chat_title": str(chat_title or "").strip(),
                 "applied_at": now,
                 "decided_at": None,
                 "decided_by": None,
@@ -523,8 +439,9 @@ async def submit_teacher_application(
         source=source,
         reason="申请成为老师",
         bot_id=bot_id,
+        chat_id=int(chat_id),
     )
-    return await get_directory_profile(int(user_id)) or {}
+    return await get_directory_profile(int(user_id)) or {}, True
 
 
 async def decide_teacher_application(
@@ -768,7 +685,6 @@ async def _list_teachers_fallback(
                 {
                     **profiles[int(location["user_id"])],
                     "distance_meters": distance,
-                    "region": location.get("region"),
                     "location_updated_at": location.get("updated_at"),
                 }
             )
@@ -839,7 +755,6 @@ async def list_directory_teachers(
             {
                 **item["profile"],
                 "distance_meters": item["distance_meters"],
-                "region": item.get("region"),
                 "location_updated_at": item.get("updated_at"),
             }
             for item in result.get("items", [])
@@ -861,6 +776,8 @@ async def list_teacher_applications(
     *,
     status: str = "",
     query: str = "",
+    source_bot_id: str = "",
+    source_chat_ids: list[int] | None = None,
     page: int = 1,
     page_size: int = 20,
 ) -> tuple[list[dict[str, Any]], int]:
@@ -872,6 +789,12 @@ async def list_teacher_applications(
         filters["application_status"] = status
     else:
         filters["application_status"] = {"$ne": APPLICATION_NONE}
+    if source_bot_id:
+        filters["application_source_bot_id"] = str(source_bot_id)
+    if source_chat_ids is not None:
+        filters["application_source_chat_id"] = {
+            "$in": [int(value) for value in source_chat_ids]
+        }
     if query:
         pattern = re.compile(re.escape(query.lstrip("@")), re.IGNORECASE)
         filters["$or"] = [
@@ -897,8 +820,7 @@ async def list_teacher_applications(
         location = location_map.get(int(item["user_id"]))
         item["location"] = (
             {
-                "region": location.get("region"),
-                "geocode_status": location.get("geocode_status"),
+                "source": location.get("source"),
                 "updated_at": location.get("updated_at"),
             }
             if location
@@ -913,6 +835,8 @@ async def list_directory_profiles(
     query: str = "",
     application_status: str = "",
     listed: bool | None = None,
+    source_bot_id: str = "",
+    source_chat_ids: list[int] | None = None,
     page: int = 1,
     page_size: int = 20,
 ) -> tuple[list[dict[str, Any]], int]:
@@ -921,6 +845,12 @@ async def list_directory_profiles(
         filters["application_status"] = application_status
     if listed is not None:
         filters["listed"] = listed
+    if source_bot_id:
+        filters["application_source_bot_id"] = str(source_bot_id)
+    if source_chat_ids is not None:
+        filters["application_source_chat_id"] = {
+            "$in": [int(value) for value in source_chat_ids]
+        }
     if query:
         pattern = re.compile(re.escape(query.lstrip("@")), re.IGNORECASE)
         filters["$or"] = [
@@ -946,8 +876,7 @@ async def list_directory_profiles(
         location = locations.get(int(item["user_id"]))
         item["location"] = (
             {
-                "region": location.get("region"),
-                "geocode_status": location.get("geocode_status"),
+                "source": location.get("source"),
                 "updated_at": location.get("updated_at"),
             }
             if location
@@ -1075,153 +1004,3 @@ async def list_membership_candidates(user_id: int) -> list[dict[str, Any]]:
     return await membershipsdb.find(
         {"user_id": int(user_id), "active": True}
     ).sort("verified_at", DESCENDING).to_list(length=200)
-
-
-async def enqueue_geocode_job(user_id: int) -> dict[str, Any] | None:
-    await ensure_directory_indexes()
-    location = await get_directory_location(int(user_id))
-    if not location or location.get("geocode_consent") is not True:
-        return None
-    key = str(location["coordinate_key"])
-    cached = await geocode_cachedb.find_one({"coordinate_key": key})
-    if cached:
-        await locationsdb.update_one(
-            {"user_id": int(user_id), "coordinate_key": key},
-            {
-                "$set": {
-                    "region": cached.get("region"),
-                    "geocode_status": "completed",
-                    "geocoded_at": cached.get("created_at"),
-                    "updated_at": utc_now(),
-                }
-            },
-        )
-        return None
-    now = utc_now()
-    await geocode_jobsdb.update_one(
-        {"user_id": int(user_id)},
-        {
-            "$set": {
-                "coordinate_key": key,
-                "longitude": float(location["longitude"]),
-                "latitude": float(location["latitude"]),
-                "status": "pending",
-                "attempts": 0,
-                "next_attempt_at": now,
-                "updated_at": now,
-            },
-            "$setOnInsert": {"created_at": now},
-        },
-        upsert=True,
-    )
-    return await geocode_jobsdb.find_one({"user_id": int(user_id)})
-
-
-async def claim_geocode_job() -> dict[str, Any] | None:
-    await ensure_directory_indexes()
-    now = utc_now()
-    return await geocode_jobsdb.find_one_and_update(
-        {
-            "status": "pending",
-            "$or": [
-                {"next_attempt_at": {"$lte": now}},
-                {"next_attempt_at": {"$exists": False}},
-            ],
-        },
-        {
-            "$set": {
-                "status": "processing",
-                "claimed_at": now,
-                "updated_at": now,
-            },
-            "$inc": {"attempts": 1},
-        },
-        sort=[("created_at", ASCENDING)],
-        return_document=ReturnDocument.AFTER,
-    )
-
-
-async def complete_geocode_job(
-    *,
-    job: dict[str, Any],
-    region: dict[str, Any],
-) -> None:
-    now = utc_now()
-    key = str(job["coordinate_key"])
-    await geocode_cachedb.update_one(
-        {"coordinate_key": key},
-        {
-            "$setOnInsert": {
-                "coordinate_key": key,
-                "region": region,
-                "provider": "nominatim",
-                "created_at": now,
-            }
-        },
-        upsert=True,
-    )
-    await locationsdb.update_one(
-        {
-            "user_id": int(job["user_id"]),
-            "coordinate_key": key,
-            "geocode_consent": True,
-        },
-        {
-            "$set": {
-                "region": region,
-                "geocode_status": "completed",
-                "geocoded_at": now,
-                "updated_at": now,
-            }
-        },
-    )
-    await geocode_jobsdb.delete_one(
-        {"user_id": int(job["user_id"]), "coordinate_key": key}
-    )
-
-
-async def fail_geocode_job(
-    *,
-    job: dict[str, Any],
-    error: str,
-    retry_after_seconds: int = 60,
-) -> None:
-    attempts = int(job.get("attempts") or 1)
-    key_filter = {
-        "user_id": int(job["user_id"]),
-        "coordinate_key": str(job["coordinate_key"]),
-    }
-    if attempts >= 3:
-        await geocode_jobsdb.update_one(
-            key_filter,
-            {
-                "$set": {
-                    "status": "failed",
-                    "error": error[:500],
-                    "updated_at": utc_now(),
-                }
-            },
-        )
-        await locationsdb.update_one(
-            key_filter,
-            {
-                "$set": {
-                    "geocode_status": "failed",
-                    "geocode_error": error[:500],
-                    "updated_at": utc_now(),
-                }
-            },
-        )
-        return
-    await geocode_jobsdb.update_one(
-        key_filter,
-        {
-            "$set": {
-                "status": "pending",
-                "error": error[:500],
-                "next_attempt_at": utc_now()
-                + timedelta(seconds=max(1, retry_after_seconds)),
-                "updated_at": utc_now(),
-            }
-        },
-    )

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů