Browse Source

feat: select visible channel subscribers for admin promotion

AI-Co-Authored-By: Codex
chendeben 5 days ago
parent
commit
80a37f163f

+ 3 - 2
README.md

@@ -60,8 +60,9 @@ Bot 自己发送的消息,并在官方 24 小时回复窗口外停止发送、
 公开频道若尚未进入 Bot 的 Telegram 本地缓存,手动接入会通过频道用户名补全;
 私有频道没有用户名时,需等待 Bot 收到该频道的新帖后再通过 ID 接入。
 
-管理员可以维护频道名称、简介、头像、邀请链接和管理员,通过后台发布文字或
-单个媒体帖子,或设置一次性定时发布。发布前可修改或取消定时任务。帖子页记录
+管理员可以维护频道名称、简介、头像、邀请链接和管理员。添加管理员时可搜索
+Telegram 当前可见的频道订阅者,或输入用户数字 ID;查询结果不保证是完整名册。
+后台可发布文字或单个媒体帖子,或设置一次性定时发布。发布前可修改或取消定时任务。帖子页记录
 Bot 接入后观察到的新帖;编辑和删除会同步到 Telegram。定时发送若结果不明,
 任务会停在“待核实”,不会自动重发。旧帖和 Bot 离线期间的消息无法保证补齐。
 

+ 67 - 7
admin-web/src/pages/ChannelDetail.tsx

@@ -6,7 +6,7 @@ import type { ColumnsType } from 'antd/es/table';
 import dayjs, { type Dayjs } from 'dayjs';
 import { history, useParams } from '@umijs/max';
 import { ArrowLeft, FileUp, Plus, RefreshCw } from 'lucide-react';
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
 
 import { PageHeader, Surface } from '@/components/Page';
 import { useBotAccess } from '@/contexts/BotAccess';
@@ -183,6 +183,11 @@ function ChannelProfile({ chatId, overview, onChanged }: { chatId: string; overv
 
 function ChannelAdmins({ chatId }: { chatId: string }) {
   const [admins, setAdmins] = useState<ChatMember[]>([]);
+  const [candidates, setCandidates] = useState<ChatMember[]>([]);
+  const [candidateQuery, setCandidateQuery] = useState('');
+  const [candidatesLoading, setCandidatesLoading] = useState(false);
+  const [selectedCandidate, setSelectedCandidate] = useState<ChatMember | null>(null);
+  const candidateRequest = useRef(0);
   const [loading, setLoading] = useState(false);
   const [open, setOpen] = useState(false);
   const [form] = Form.useForm();
@@ -193,11 +198,42 @@ function ChannelAdmins({ chatId }: { chatId: string }) {
     finally { setLoading(false); }
   };
   useEffect(() => { void load(); }, [chatId]);
+  const searchCandidates = async (query = '') => {
+    const request = ++candidateRequest.current;
+    setCandidatesLoading(true);
+    try {
+      const params = new URLSearchParams({ limit: '30' });
+      if (query.trim()) params.set('query', query.trim());
+      const result = await apiRequest<{ items: ChatMember[] }>(`/channels/${chatId}/admins/candidates?${params}`);
+      if (request === candidateRequest.current) setCandidates(result.items);
+    } catch (error) {
+      if (request === candidateRequest.current) {
+        setCandidates([]);
+        message.error(errorText(error));
+      }
+    } finally {
+      if (request === candidateRequest.current) setCandidatesLoading(false);
+    }
+  };
+  const openEditor = (admin?: ChatMember) => {
+    form.resetFields();
+    form.setFieldsValue({ user_id: admin?.user.id, privileges: admin?.privileges || [] });
+    setSelectedCandidate(null);
+    setCandidateQuery('');
+    setCandidates([]);
+    setOpen(true);
+    void searchCandidates();
+  };
+  const closeEditor = () => {
+    candidateRequest.current += 1;
+    setCandidatesLoading(false);
+    setOpen(false);
+  };
   const submit = async (values: { user_id: string; privileges: string[] }) => {
     try {
       await apiRequest(`/channels/${chatId}/admins/${values.user_id}`, jsonOptions('PUT', { privileges: values.privileges, confirm: true }));
       message.success('管理员已更新');
-      setOpen(false);
+      closeEditor();
       form.resetFields();
       await load();
     } catch (error) { message.error(errorText(error)); }
@@ -208,16 +244,40 @@ function ChannelAdmins({ chatId }: { chatId: string }) {
     { title: '身份', render: (_, item) => item.status === 'owner' ? '所有者' : '管理员' },
     { title: '权限', render: (_, item) => item.privileges.join('、') || '-' },
     { title: '操作', render: (_, item) => item.status === 'owner' ? null : <Space>
-      <Button type="link" onClick={() => { form.setFieldsValue({ user_id: item.user.id, privileges: item.privileges }); setOpen(true); }}>调整</Button>
+      <Button type="link" onClick={() => openEditor(item)}>调整</Button>
       <Popconfirm title="移除该频道管理员?" onConfirm={async () => { try { await apiRequest(`/channels/${chatId}/admins/${item.user.id}`, jsonOptions('DELETE', { confirm: true })); message.success('已移除管理员'); await load(); } catch (error) { message.error(errorText(error)); } }}><Button danger type="link">移除</Button></Popconfirm>
     </Space> },
   ];
-  return <Surface title="频道管理员" actions={<Space><Button icon={<RefreshCw size={16} />} loading={loading} onClick={() => void load()}>刷新</Button><Button type="primary" icon={<Plus size={16} />} onClick={() => { form.resetFields(); setOpen(true); }}>添加管理员</Button></Space>}>
-    <Alert type="info" showIcon message="只能通过用户 ID 操作已订阅频道的用户,无法浏览全部订阅者。" style={{ marginBottom: 16 }} />
+  return <Surface title="频道管理员" actions={<Space><Button icon={<RefreshCw size={16} />} loading={loading} onClick={() => void load()}>刷新</Button><Button type="primary" icon={<Plus size={16} />} onClick={() => openEditor()}>添加管理员</Button></Space>}>
+    <Alert type="info" showIcon message="可搜索 Telegram 当前可见的订阅者,也可输入用户数字 ID;查询结果不保证是完整名册。" style={{ marginBottom: 16 }} />
     <Table rowKey={(item) => item.user.id} columns={columns} dataSource={admins} loading={loading} pagination={false} scroll={{ x: 800 }} />
-    <Modal title="添加或调整管理员" open={open} okText="确认调整" onOk={() => form.submit()} onCancel={() => setOpen(false)}>
+    <Modal title="添加或调整管理员" open={open} width={680} okText="确认调整" onOk={() => form.submit()} onCancel={closeEditor}>
+      <Input.Search
+        value={candidateQuery}
+        onChange={(event) => setCandidateQuery(event.target.value)}
+        onSearch={(query) => void searchCandidates(query)}
+        placeholder="按昵称或 @用户名搜索订阅者"
+        enterButton="搜索"
+        maxLength={64}
+        style={{ marginBottom: 12 }}
+      />
+      <Table
+        size="small"
+        rowKey={(item) => item.user.id}
+        loading={candidatesLoading}
+        dataSource={candidates}
+        pagination={false}
+        scroll={{ y: 220 }}
+        locale={{ emptyText: '没有匹配的可见订阅者,可使用下方用户 ID' }}
+        columns={[
+          { title: candidateQuery ? '搜索结果' : '最近可见的订阅者', render: (_, item: ChatMember) => <Space direction="vertical" size={0}><Typography.Text>{userDisplayName(item.user)}</Typography.Text><Typography.Text type="secondary">{item.user.username ? `@${item.user.username} · ` : ''}{item.user.id}</Typography.Text></Space> },
+          { title: '操作', width: 80, render: (_, item: ChatMember) => <Button type="link" onClick={() => { form.setFieldValue('user_id', item.user.id); setSelectedCandidate(item); }}>选择</Button> },
+        ]}
+        style={{ marginBottom: 16 }}
+      />
       <Form form={form} layout="vertical" onFinish={(values) => Modal.confirm({ title: '确认修改频道管理员权限?', okText: '确认', onOk: () => submit(values) })}>
-        <Form.Item name="user_id" label="用户 ID" rules={[{ required: true, pattern: /^\d+$/, message: '请输入用户数字 ID' }]}><Input /></Form.Item>
+        <Form.Item name="user_id" label="用户 ID(也可手动输入)" rules={[{ required: true, pattern: /^\d+$/, message: '请输入用户数字 ID' }]}><Input onChange={() => setSelectedCandidate(null)} /></Form.Item>
+        {selectedCandidate && <Typography.Text type="secondary">已选择:{userDisplayName(selectedCandidate.user)}</Typography.Text>}
         <Form.Item name="privileges" label="管理员权限" rules={[{ required: true, message: '至少选择一项权限' }]}><Checkbox.Group options={privilegeOptions} /></Form.Item>
       </Form>
     </Modal>

+ 22 - 0
admin-web/tests/unit/Channels.test.tsx

@@ -44,3 +44,25 @@ it('频道详情展示频道资料、管理员与帖子记录', async () => {
   fireEvent.click(screen.getByText('帖子与定时'));
   expect(await screen.findByText('频道新帖')).toBeInTheDocument();
 });
+
+it('添加管理员可从频道可见订阅者中选择并填入用户 ID', async () => {
+  vi.mocked(apiRequest).mockImplementation(async (path) => {
+    if (path === '/channels/-100') return {
+      chat_id: '-100', title: '运营频道', type: 'channel',
+      accessible: true, bot_status: 'administrator', bot_privileges: ['can_promote_members'],
+    };
+    if (path.endsWith('/invites') || path.endsWith('/admins')) return { items: [] };
+    if (path.includes('/admins/candidates?')) return { items: [{
+      user: { id: '8960210159', username: 'uesd1515', first_name: 'Keven', is_bot: false, is_deleted: false },
+      status: 'member', privileges: [],
+    }] };
+    return {};
+  });
+  render(<ChannelDetailPage />);
+  fireEvent.click(await screen.findByText('管理员'));
+  fireEvent.click(await screen.findByRole('button', { name: '添加管理员' }));
+  expect(await screen.findByText('@uesd1515 · 8960210159')).toBeInTheDocument();
+  fireEvent.click(screen.getByRole('button', { name: '选择' }));
+  expect(screen.getByRole('textbox', { name: '用户 ID(也可手动输入)' })).toHaveValue('8960210159');
+  expect(apiRequest).toHaveBeenCalledWith('/channels/-100/admins/candidates?limit=30');
+});

+ 23 - 1
tests/test_channel_api.py

@@ -51,7 +51,7 @@ async def test_gif_upload_uses_telegram_returned_media_type(app_modules, monkeyp
     assert result["mime_type"] == "image/gif"
 
 
-async def test_channel_api_onboarding_schedule_and_role_gate(app_modules):
+async def test_channel_api_onboarding_schedule_and_role_gate(app_modules, monkeypatch):
     chat_id = -100123
     app_modules.app.chats[chat_id] = SimpleNamespace(
         id=chat_id, type=ChatType.CHANNEL, title="运营频道", username="ops_channel",
@@ -62,6 +62,7 @@ async def test_channel_api_onboarding_schedule_and_role_gate(app_modules):
         privileges=SimpleNamespace(
             can_manage_chat=True, can_post_messages=True, can_pin_messages=True,
             can_change_info=True, can_edit_messages=True, can_delete_messages=True,
+            can_promote_members=True,
         ),
     )
     api = app_modules.load("wbb.admin.api")
@@ -94,6 +95,23 @@ async def test_channel_api_onboarding_schedule_and_role_gate(app_modules):
         groups = await client.get("/api/admin/v1/chats")
         assert (await groups.json())["data"]["total"] == 0
 
+        async def get_chat_members(_chat_id, *, query, limit, filter):
+            assert query == "Keven" and limit == 20
+            yield SimpleNamespace(
+                status=ChatMemberStatus.MEMBER, privileges=None, custom_title=None, until_date=None,
+                user=SimpleNamespace(
+                    id=42, username="keven", first_name="Keven", last_name=None,
+                    is_bot=False, is_deleted=False,
+                ),
+            )
+
+        monkeypatch.setattr(app_modules.app, "get_chat_members", get_chat_members, raising=False)
+        candidates = await client.get(
+            f"/api/admin/v1/channels/{chat_id}/admins/candidates?query=Keven&limit=20"
+        )
+        assert candidates.status == 200
+        assert (await candidates.json())["data"]["items"][0]["user"]["id"] == "42"
+
         scheduled = await client.post(
             f"/api/admin/v1/channels/{chat_id}/posts", headers=headers,
             json={
@@ -120,5 +138,9 @@ async def test_channel_api_onboarding_schedule_and_role_gate(app_modules):
         denied = await client.get(f"/api/admin/v1/channels/{chat_id}/posts")
         assert denied.status == 403
         assert (await denied.json())["error"]["code"] == "bot_role_permission_denied"
+        denied_candidates = await client.get(
+            f"/api/admin/v1/channels/{chat_id}/admins/candidates"
+        )
+        assert denied_candidates.status == 403
     finally:
         await client.close()

+ 58 - 1
tests/test_channel_management.py

@@ -6,7 +6,7 @@ from types import SimpleNamespace
 from unittest.mock import AsyncMock
 
 import pytest
-from pyrogram.enums import ChatMemberStatus, ChatType
+from pyrogram.enums import ChatMembersFilter, ChatMemberStatus, ChatType
 from pyrogram.errors import MessageEmpty
 
 
@@ -53,6 +53,63 @@ async def test_channel_connect_rejects_group_and_non_admin(app_modules):
     assert caught.value.code == "not_channel"
 
 
+async def test_channel_admin_candidates_use_telegram_recent_and_search(app_modules, monkeypatch):
+    service = app_modules.load("wbb.services.channel_management")
+    prepare_channel(app_modules)
+    calls = []
+
+    def member(user_id: int, name: str, *, status=ChatMemberStatus.MEMBER, is_bot=False):
+        return SimpleNamespace(
+            status=status, privileges=None, custom_title=None, until_date=None,
+            user=SimpleNamespace(
+                id=user_id, username=None, first_name=name, last_name=None,
+                is_bot=is_bot, is_deleted=False,
+            ),
+        )
+
+    async def get_chat_members(chat_id, *, query, limit, filter):
+        calls.append((chat_id, query, limit, filter))
+        for item in [
+            member(42, "Keven"),
+            member(999, "Bot", status=ChatMemberStatus.ADMINISTRATOR, is_bot=True),
+            member(43, "Owner", status=ChatMemberStatus.OWNER),
+        ]:
+            yield item
+
+    monkeypatch.setattr(app_modules.app, "get_chat_members", get_chat_members, raising=False)
+    recent = await service.list_channel_admin_candidates(-100123)
+    searched = await service.list_channel_admin_candidates(-100123, query="@keven", limit=80)
+    assert [item["user"]["id"] for item in recent] == ["42"]
+    assert [item["user"]["id"] for item in searched] == ["42"]
+    assert calls == [
+        (-100123, "", 30, ChatMembersFilter.RECENT),
+        (-100123, "keven", 30, ChatMembersFilter.SEARCH),
+    ]
+
+    app_modules.app.members[(-100123, 999)].privileges.can_promote_members = False
+    with pytest.raises(service.ChatManagementError) as caught:
+        await service.list_channel_admin_candidates(-100123)
+    assert caught.value.code == "bot_permission_missing"
+
+
+async def test_channel_admin_candidates_lookup_numeric_member(app_modules, monkeypatch):
+    service = app_modules.load("wbb.services.channel_management")
+    prepare_channel(app_modules)
+    calls = []
+
+    async def bot_api(method, **params):
+        calls.append((method, params))
+        return {
+            "status": "member",
+            "user": {"id": 8960210159, "first_name": "Keven", "username": "uesd1515", "is_bot": False},
+        }
+
+    monkeypatch.setattr(service, "channel_bot_api", bot_api)
+    result = await service.list_channel_admin_candidates(-100123, query="8960210159")
+    assert result[0]["user"]["id"] == "8960210159"
+    assert calls == [("getChatMember", {"chat_id": -100123, "user_id": 8960210159})]
+
+
 async def test_manual_channel_connect_resolves_uncached_public_id(app_modules, monkeypatch):
     service = app_modules.load("wbb.services.channel_management")
     prepare_channel(app_modules)

+ 8 - 0
wbb/admin/api.py

@@ -52,6 +52,7 @@ from wbb.services.channel_management import (
     delete_channel_post,
     edit_channel_post,
     ensure_channel,
+    list_channel_admin_candidates,
     list_channel_admins,
     list_channels,
     public_post,
@@ -789,6 +790,7 @@ class AdminApi:
         router.add_patch(f"{API_PREFIX}/channels/{{chat_id}}/profile", self.channel_profile)
         router.add_post(f"{API_PREFIX}/channels/{{chat_id}}/photo", self.channel_photo)
         router.add_get(f"{API_PREFIX}/channels/{{chat_id}}/admins", self.channel_admins)
+        router.add_get(f"{API_PREFIX}/channels/{{chat_id}}/admins/candidates", self.channel_admin_candidates)
         router.add_put(f"{API_PREFIX}/channels/{{chat_id}}/admins/{{user_id}}", self.channel_admin_update)
         router.add_delete(f"{API_PREFIX}/channels/{{chat_id}}/admins/{{user_id}}", self.channel_admin_remove)
         router.add_get(f"{API_PREFIX}/channels/{{chat_id}}/invites", self.channel_invites)
@@ -2274,6 +2276,12 @@ class AdminApi:
     async def channel_admins(self, request: web.Request) -> web.Response:
         return success({"items": await list_channel_admins(chat_id_param(request))})
 
+    async def channel_admin_candidates(self, request: web.Request) -> web.Response:
+        limit = parse_int(request.query.get("limit", "30"), "limit", minimum=1)
+        return success({"items": await list_channel_admin_candidates(
+            chat_id_param(request), query=request.query.get("query", ""), limit=limit,
+        )})
+
     async def channel_admin_update(self, request: web.Request) -> web.Response:
         chat_id = chat_id_param(request)
         user_id = parse_int(request.match_info["user_id"], "user_id", minimum=1)

+ 36 - 0
wbb/services/channel_management.py

@@ -205,6 +205,42 @@ async def list_channel_admins(chat_id: int) -> list[dict]:
         raise ChatManagementError("admin_list_failed", "Telegram 未能读取频道管理员。", status=502) from exc
 
 
+async def list_channel_admin_candidates(chat_id: int, *, query: str = "", limit: int = 30) -> list[dict]:
+    await ensure_channel(chat_id)
+    await ensure_permission(chat_id, "can_promote_members")
+    from pyrogram.enums import ChatMembersFilter
+
+    query = query.strip()
+    if len(query) > 64:
+        raise ChatManagementError("member_query_too_long", "搜索内容不能超过 64 个字符。")
+    limit = max(1, min(limit, 30))
+    if query.isdigit():
+        member = await channel_bot_api("getChatMember", chat_id=chat_id, user_id=int(query))
+        if member.get("status") != "member" or (member.get("user") or {}).get("is_bot"):
+            return []
+        return [serialize_bot_api_member(member)]
+
+    results: list[dict] = []
+    try:
+        members = app.get_chat_members(
+            chat_id,
+            query=query.lstrip("@"),
+            limit=limit,
+            filter=ChatMembersFilter.SEARCH if query else ChatMembersFilter.RECENT,
+        )
+        async for member in members:
+            if member.status != ChatMemberStatus.MEMBER or member.user.is_bot:
+                continue
+            results.append(serialize_member(member))
+    except RPCError as exc:
+        raise ChatManagementError(
+            "member_list_failed", f"Telegram 拒绝读取频道订阅者:{telegram_error_detail(exc)}", status=502
+        ) from exc
+    except Exception as exc:
+        raise ChatManagementError("member_list_failed", "Telegram 未能读取频道订阅者。", status=502) from exc
+    return results
+
+
 async def set_channel_admin(chat_id: int, user_id: int, privileges: list[str]) -> dict:
     await ensure_channel(chat_id)
     await ensure_permission(chat_id, "can_promote_members")