瀏覽代碼

feat: add role-based bot responsibilities

Prevent point rankings from notifying listed members.

AI-Co-Authored-By: Codex
chendeben 2 月之前
父節點
當前提交
0d343a173c

+ 19 - 0
admin-web/src/contexts/BotAccess.tsx

@@ -0,0 +1,19 @@
+import { createContext, useContext } from 'react';
+
+import type { BotProfile } from '@/types';
+
+interface BotAccessValue {
+  bot: BotProfile | null;
+  hasPermission: (permission: string) => boolean;
+  hasAnyPermission: (permissions: string[]) => boolean;
+}
+
+export const BotAccessContext = createContext<BotAccessValue>({
+  bot: null,
+  hasPermission: () => true,
+  hasAnyPermission: () => true,
+});
+
+export function useBotAccess(): BotAccessValue {
+  return useContext(BotAccessContext);
+}

+ 48 - 7
admin-web/src/layouts/AdminLayout.tsx

@@ -23,6 +23,7 @@ import {
   setCsrfToken,
   setSelectedBotId,
 } from '@/services/api';
+import { BotAccessContext } from '@/contexts/BotAccess';
 import type { AdminUser, BotProfile } from '@/types';
 
 const menuItems = [
@@ -75,6 +76,42 @@ export default function AdminLayout() {
     return () => window.removeEventListener('bot-profiles-changed', loadBots);
   }, []);
 
+  const selectedProfile = bots.find((item) => item.bot_id === selectedBot) || null;
+  const rolePermissions = selectedProfile?.permissions;
+  const hasPermission = (permission: string) =>
+    rolePermissions === undefined || rolePermissions.includes(permission);
+  const hasAnyPermission = (permissions: string[]) =>
+    rolePermissions === undefined ||
+    permissions.some((permission) => rolePermissions.includes(permission));
+  const chatPermissions = [
+    'chat.profile',
+    'chat.members',
+    'chat.permissions',
+    'chat.announcements',
+    'chat.invites',
+    'chat.rules',
+    'chat.notes',
+    'automation.manage',
+  ];
+  const visibleMenuItems = menuItems.filter((item) => {
+    if (item.path === '/chats') return hasAnyPermission(chatPermissions);
+    if (item.path === '/points') return hasPermission('points.manage');
+    if (item.path === '/giveaways') return hasPermission('giveaways.manage');
+    return true;
+  });
+
+  useEffect(() => {
+    if (!selectedProfile) return;
+    const denied =
+      (location.pathname.startsWith('/points') &&
+        !hasPermission('points.manage')) ||
+      (location.pathname.startsWith('/giveaways') &&
+        !hasPermission('giveaways.manage')) ||
+      (location.pathname.startsWith('/chats') &&
+        !hasAnyPermission(chatPermissions));
+    if (denied) history.replace('/dashboard');
+  }, [selectedBot, bots, location.pathname]);
+
   const accountMenu: MenuProps['items'] = [
     {
       key: 'settings',
@@ -100,11 +137,14 @@ export default function AdminLayout() {
   }
 
   return (
-    <ProLayout
+    <BotAccessContext.Provider
+      value={{ bot: selectedProfile, hasPermission, hasAnyPermission }}
+    >
+      <ProLayout
       title="Telegram 群管"
       logo={<Bot size={24} color="#fff" aria-hidden="true" />}
       location={{ pathname: location.pathname }}
-      menu={{ request: async () => menuItems }}
+      menu={{ request: async () => visibleMenuItems }}
       layout="mix"
       fixedHeader
       fixSiderbar
@@ -161,10 +201,11 @@ export default function AdminLayout() {
           }}
         />,
       ]}
-    >
-      <main className="page-content" id="main-content">
-        <Outlet />
-      </main>
-    </ProLayout>
+      >
+        <main className="page-content" id="main-content">
+          <Outlet />
+        </main>
+      </ProLayout>
+    </BotAccessContext.Provider>
   );
 }

+ 133 - 101
admin-web/src/pages/ChatDetail.tsx

@@ -42,6 +42,7 @@ import { useEffect, useMemo, useState } from 'react';
 
 import { PageHeader, Surface } from '@/components/Page';
 import { StatusTag } from '@/components/StatusTag';
+import { useBotAccess } from '@/contexts/BotAccess';
 import { apiRequest, jsonOptions } from '@/services/api';
 import type {
   AutoReply,
@@ -141,6 +142,7 @@ async function uploadMedia(file: File): Promise<UploadedMedia> {
 
 export default function ChatDetailPage() {
   const { chatId = '' } = useParams<{ chatId: string }>();
+  const { hasPermission } = useBotAccess();
   const [overview, setOverview] = useState<ChatOverview | null>(null);
   const [loading, setLoading] = useState(true);
   const [activeWorkspace, setActiveWorkspace] = useState('overview');
@@ -166,12 +168,24 @@ export default function ChatDetailPage() {
 
   const workspaceItems = [
     { key: 'overview', label: '概览与群规', children: <OverviewTab chatId={chatId} overview={overview} onChanged={load} /> },
-    { key: 'members', label: '成员与管理员', children: <MembersTab chatId={chatId} /> },
-    { key: 'permissions', label: '群权限', children: <PermissionsTab chatId={chatId} permissions={overview.permissions} onChanged={load} /> },
-    { key: 'announcement', label: '公告', children: <AnnouncementTab chatId={chatId} /> },
-    { key: 'automation', label: '自动化与风控', children: <AutomationTab chatId={chatId} initial={overview.automation} /> },
-    { key: 'points', label: '积分规则', children: <PointRulesTab chatId={chatId} /> },
-    { key: 'giveaways', label: '抽奖', children: <ChatGiveawaysTab chatId={chatId} /> },
+    ...(hasPermission('chat.members')
+      ? [{ key: 'members', label: '成员与管理员', children: <MembersTab chatId={chatId} /> }]
+      : []),
+    ...(hasPermission('chat.permissions')
+      ? [{ key: 'permissions', label: '群权限', children: <PermissionsTab chatId={chatId} permissions={overview.permissions} onChanged={load} /> }]
+      : []),
+    ...(hasPermission('chat.announcements')
+      ? [{ key: 'announcement', label: '公告', children: <AnnouncementTab chatId={chatId} /> }]
+      : []),
+    ...(hasPermission('automation.manage')
+      ? [{ key: 'automation', label: '自动化与风控', children: <AutomationTab chatId={chatId} initial={overview.automation} /> }]
+      : []),
+    ...(hasPermission('points.manage')
+      ? [{ key: 'points', label: '积分规则', children: <PointRulesTab chatId={chatId} /> }]
+      : []),
+    ...(hasPermission('giveaways.manage')
+      ? [{ key: 'giveaways', label: '抽奖', children: <ChatGiveawaysTab chatId={chatId} /> }]
+      : []),
   ];
 
   return (
@@ -230,14 +244,24 @@ function OverviewTab({
   const [inviteOpen, setInviteOpen] = useState(false);
   const [profileForm] = Form.useForm();
   const [inviteForm] = Form.useForm();
+  const { hasPermission } = useBotAccess();
+  const canEditProfile = hasPermission('chat.profile');
+  const canEditRules = hasPermission('chat.rules');
+  const canManageInvites = hasPermission('chat.invites');
 
   const loadSecondary = async () => {
-    const [rulesResult, inviteResult] = await Promise.all([
-      apiRequest<{ rules: string }>(`/chats/${chatId}/rules`),
-      apiRequest<{ items: InviteLink[] }>(`/chats/${chatId}/invites`),
+    await Promise.all([
+      canEditRules
+        ? apiRequest<{ rules: string }>(`/chats/${chatId}/rules`).then((result) =>
+            setRules(result.rules),
+          )
+        : Promise.resolve(),
+      canManageInvites
+        ? apiRequest<{ items: InviteLink[] }>(`/chats/${chatId}/invites`).then(
+            (result) => setInvites(result.items),
+          )
+        : Promise.resolve(),
     ]);
-    setRules(rulesResult.rules);
-    setInvites(inviteResult.items);
   };
 
   useEffect(() => {
@@ -248,60 +272,64 @@ function OverviewTab({
   return (
     <div className="split-grid">
       <div>
-        <Surface title="群资料">
-          <Form
-            form={profileForm}
-            layout="vertical"
-            onFinish={(values) => {
-              Modal.confirm({
-                title: '确认更新群资料?',
-                content: '群标题或描述会立即同步到 Telegram。',
-                okText: '确认更新',
-                onOk: async () => {
-                  await apiRequest(
-                    `/chats/${chatId}/profile`,
-                    jsonOptions('PATCH', { ...values, confirm: true }),
-                  );
-                  message.success('群资料已更新');
-                  await onChanged();
-                },
-              });
-            }}
-          >
-            <Form.Item name="title" label="群标题" rules={[{ required: true }, { max: 128 }]}>
-              <Input />
-            </Form.Item>
-            <Form.Item name="description" label="群描述" rules={[{ max: 255 }]}>
-              <Input.TextArea rows={3} showCount maxLength={255} />
-            </Form.Item>
-            <Button type="primary" htmlType="submit">
-              保存资料
+        {canEditProfile ? (
+          <Surface title="群资料">
+            <Form
+              form={profileForm}
+              layout="vertical"
+              onFinish={(values) => {
+                Modal.confirm({
+                  title: '确认更新群资料?',
+                  content: '群标题或描述会立即同步到 Telegram。',
+                  okText: '确认更新',
+                  onOk: async () => {
+                    await apiRequest(
+                      `/chats/${chatId}/profile`,
+                      jsonOptions('PATCH', { ...values, confirm: true }),
+                    );
+                    message.success('群资料已更新');
+                    await onChanged();
+                  },
+                });
+              }}
+            >
+              <Form.Item name="title" label="群标题" rules={[{ required: true }, { max: 128 }]}>
+                <Input />
+              </Form.Item>
+              <Form.Item name="description" label="群描述" rules={[{ max: 255 }]}>
+                <Input.TextArea rows={3} showCount maxLength={255} />
+              </Form.Item>
+              <Button type="primary" htmlType="submit">
+                保存资料
+              </Button>
+            </Form>
+          </Surface>
+        ) : null}
+        {canEditProfile && canEditRules ? <div style={{ height: 16 }} /> : null}
+        {canEditRules ? (
+          <Surface title="群规">
+            <Input.TextArea value={rules} onChange={(event) => setRules(event.target.value)} rows={7} maxLength={4000} showCount />
+            <Button
+              type="primary"
+              style={{ marginTop: 12 }}
+              onClick={() => {
+                Modal.confirm({
+                  title: '确认更新群规?',
+                  okText: '确认保存',
+                  onOk: async () => {
+                    await apiRequest(
+                      `/chats/${chatId}/rules`,
+                      jsonOptions('PUT', { rules, confirm: true }),
+                    );
+                    message.success('群规已保存');
+                  },
+                });
+              }}
+            >
+              保存群规
             </Button>
-          </Form>
-        </Surface>
-        <div style={{ height: 16 }} />
-        <Surface title="群规">
-          <Input.TextArea value={rules} onChange={(event) => setRules(event.target.value)} rows={7} maxLength={4000} showCount />
-          <Button
-            type="primary"
-            style={{ marginTop: 12 }}
-            onClick={() => {
-              Modal.confirm({
-                title: '确认更新群规?',
-                okText: '确认保存',
-                onOk: async () => {
-                  await apiRequest(
-                    `/chats/${chatId}/rules`,
-                    jsonOptions('PUT', { rules, confirm: true }),
-                  );
-                  message.success('群规已保存');
-                },
-              });
-            }}
-          >
-            保存群规
-          </Button>
-        </Surface>
+          </Surface>
+        ) : null}
       </div>
       <div>
         <Surface title="群状态">
@@ -315,44 +343,48 @@ function OverviewTab({
             </Descriptions.Item>
           </Descriptions>
         </Surface>
-        <div style={{ height: 16 }} />
-        <Surface
-          title="邀请链接"
-          actions={<Button icon={<Plus size={16} />} onClick={() => setInviteOpen(true)}>新建</Button>}
-        >
-          <List
-            dataSource={invites}
-            locale={{ emptyText: '暂无面板创建的邀请链接' }}
-            renderItem={(item) => (
-              <List.Item
-                actions={[
-                  <Popconfirm
-                    key="revoke"
-                    title="确认撤销该邀请链接?"
-                    onConfirm={async () => {
-                      await apiRequest(
-                        `/chats/${chatId}/invites`,
-                        jsonOptions('DELETE', { invite_link: item.invite_link, confirm: true }),
-                      );
-                      message.success('邀请链接已撤销');
-                      await loadSecondary();
-                    }}
+        {canManageInvites ? (
+          <>
+            <div style={{ height: 16 }} />
+            <Surface
+              title="邀请链接"
+              actions={<Button icon={<Plus size={16} />} onClick={() => setInviteOpen(true)}>新建</Button>}
+            >
+              <List
+                dataSource={invites}
+                locale={{ emptyText: '暂无面板创建的邀请链接' }}
+                renderItem={(item) => (
+                  <List.Item
+                    actions={[
+                      <Popconfirm
+                        key="revoke"
+                        title="确认撤销该邀请链接?"
+                        onConfirm={async () => {
+                          await apiRequest(
+                            `/chats/${chatId}/invites`,
+                            jsonOptions('DELETE', { invite_link: item.invite_link, confirm: true }),
+                          );
+                          message.success('邀请链接已撤销');
+                          await loadSecondary();
+                        }}
+                      >
+                        <Button danger type="link" disabled={item.revoked}>撤销</Button>
+                      </Popconfirm>,
+                    ]}
                   >
-                    <Button danger type="link" disabled={item.revoked}>撤销</Button>
-                  </Popconfirm>,
-                ]}
-              >
-                <List.Item.Meta
-                  avatar={<LinkIcon size={18} />}
-                  title={item.name || '邀请链接'}
-                  description={item.revoked ? '已撤销' : item.invite_link}
-                />
-              </List.Item>
-            )}
-          />
-        </Surface>
+                    <List.Item.Meta
+                      avatar={<LinkIcon size={18} />}
+                      title={item.name || '邀请链接'}
+                      description={item.revoked ? '已撤销' : item.invite_link}
+                    />
+                  </List.Item>
+                )}
+              />
+            </Surface>
+          </>
+        ) : null}
       </div>
-      <Modal
+      {canManageInvites ? <Modal
         title="新建邀请链接"
         open={inviteOpen}
         onCancel={() => setInviteOpen(false)}
@@ -398,7 +430,7 @@ function OverviewTab({
             <InputNumber min={1} max={365} style={{ width: '100%' }} />
           </Form.Item>
         </Form>
-      </Modal>
+      </Modal> : null}
     </div>
   );
 }

+ 256 - 2
admin-web/src/pages/Settings.tsx

@@ -1,6 +1,7 @@
 import {
   Alert,
   Button,
+  Checkbox,
   Col,
   Descriptions,
   Form,
@@ -8,11 +9,13 @@ import {
   InputNumber,
   Modal,
   Row,
+  Select,
   Space,
   Switch,
   Table,
   Tag,
   Tooltip,
+  Typography,
   message,
 } from 'antd';
 import {
@@ -30,7 +33,13 @@ import { useCallback, useEffect, useState } from 'react';
 
 import { PageHeader, Surface } from '@/components/Page';
 import { apiRequest, jsonOptions, setCsrfToken, setSelectedBotId } from '@/services/api';
-import type { AdminUser, BotProfile, TelegramSettings } from '@/types';
+import type {
+  AdminUser,
+  BotPermissionDefinition,
+  BotProfile,
+  BotRole,
+  TelegramSettings,
+} from '@/types';
 
 interface SystemSettings {
   web_address: string;
@@ -48,6 +57,13 @@ interface BotFormValues {
   log_group_id?: string;
   gban_log_group_id?: string;
   message_dump_chat?: string;
+  role_ids: string[];
+}
+
+interface RoleFormValues {
+  name: string;
+  description?: string;
+  permissions: string[];
 }
 
 const TELEGRAM_APP_URL = 'https://my.telegram.org/apps';
@@ -77,8 +93,11 @@ export default function SettingsPage() {
   const [telegram, setTelegram] = useState<TelegramSettings | null>(null);
   const [editingBot, setEditingBot] = useState<BotProfile | null>(null);
   const [botModalOpen, setBotModalOpen] = useState(false);
+  const [editingRole, setEditingRole] = useState<BotRole | null>(null);
+  const [roleModalOpen, setRoleModalOpen] = useState(false);
   const [globalForm] = Form.useForm();
   const [botForm] = Form.useForm<BotFormValues>();
+  const [roleForm] = Form.useForm<RoleFormValues>();
   const [passwordForm] = Form.useForm();
 
   const reloadTelegram = useCallback(async () => {
@@ -106,6 +125,7 @@ export default function SettingsPage() {
             log_group_id: target.log_group_id,
             gban_log_group_id: target.gban_log_group_id,
             message_dump_chat: target.message_dump_chat,
+            role_ids: target.role_ids || ['full_access'],
           }
         : {
             label: '',
@@ -115,6 +135,7 @@ export default function SettingsPage() {
             log_group_id: '0',
             gban_log_group_id: '0',
             message_dump_chat: '0',
+            role_ids: ['full_access'],
           },
     );
     setBotModalOpen(true);
@@ -141,6 +162,63 @@ export default function SettingsPage() {
     });
   };
 
+  const openRoleModal = (role?: BotRole) => {
+    const target = role || null;
+    setEditingRole(target);
+    roleForm.setFieldsValue(
+      target
+        ? {
+            name: target.name,
+            description: target.description,
+            permissions: target.permissions,
+          }
+        : {
+            name: '',
+            description: '',
+            permissions: [],
+          },
+    );
+    setRoleModalOpen(true);
+  };
+
+  const saveRole = (values: RoleFormValues) => {
+    Modal.confirm({
+      title: editingRole ? '保存角色权限?' : '创建职责角色?',
+      content: editingRole
+        ? '使用该角色的机器人会自动重启并应用新权限。'
+        : '创建后可将角色分配给一个或多个机器人。',
+      okText: '确认保存',
+      cancelText: '取消',
+      onOk: async () => {
+        const path = editingRole ? `/roles/${editingRole.role_id}` : '/roles';
+        const method = editingRole ? 'PUT' : 'POST';
+        await apiRequest<BotRole>(
+          path,
+          jsonOptions(method, { ...values, confirm: true }),
+        );
+        setRoleModalOpen(false);
+        message.success(editingRole ? '角色权限已更新' : '职责角色已创建');
+        await reloadTelegram();
+      },
+    });
+  };
+
+  const roleNames = new Map(
+    (telegram?.roles || []).map((role) => [role.role_id, role.name]),
+  );
+  const permissionNames = new Map(
+    (telegram?.permission_catalog || []).map((permission) => [
+      permission.key,
+      permission.name,
+    ]),
+  );
+  const permissionGroups = (telegram?.permission_catalog || []).reduce<
+    Record<string, BotPermissionDefinition[]>
+  >((groups, permission) => {
+    (groups[permission.group] ||= []).push(permission);
+    return groups;
+  }, {});
+
   const botColumns = [
     {
       title: '机器人',
@@ -168,6 +246,19 @@ export default function SettingsPage() {
       width: 120,
       render: (_: unknown, profile: BotProfile) => runtimeTag(profile),
     },
+    {
+      title: '职责角色',
+      dataIndex: 'role_ids',
+      minWidth: 180,
+      render: (roleIds: string[]) => (
+        <Space size={[4, 4]} wrap>
+          {(roleIds || []).map((roleId) => (
+            <Tag key={roleId}>{roleNames.get(roleId) || roleId}</Tag>
+          ))}
+          {!roleIds?.length ? <Tag color="warning">未分配职责</Tag> : null}
+        </Space>
+      ),
+    },
     {
       title: '媒体中转群',
       dataIndex: 'message_dump_chat',
@@ -261,6 +352,78 @@ export default function SettingsPage() {
     },
   ];
 
+  const roleColumns = [
+    {
+      title: '角色',
+      key: 'role',
+      minWidth: 180,
+      render: (_: unknown, role: BotRole) => (
+        <div>
+          <Space size={6}>
+            <strong>{role.name}</strong>
+            {role.builtin ? <Tag color="processing">内置</Tag> : <Tag>自定义</Tag>}
+          </Space>
+          <div style={{ color: '#52606d', marginTop: 4 }}>{role.description || '无说明'}</div>
+        </div>
+      ),
+    },
+    {
+      title: '权限',
+      dataIndex: 'permissions',
+      minWidth: 360,
+      render: (permissions: string[]) => (
+        <Space size={[4, 4]} wrap>
+          {permissions.map((permission) => (
+            <Tag key={permission}>{permissionNames.get(permission) || permission}</Tag>
+          ))}
+          {!permissions.length ? <Tag color="warning">无权限</Tag> : null}
+        </Space>
+      ),
+    },
+    {
+      title: '操作',
+      key: 'actions',
+      width: 110,
+      fixed: 'right' as const,
+      render: (_: unknown, role: BotRole) =>
+        role.builtin ? null : (
+          <Space size={4}>
+            <Tooltip title="编辑角色">
+              <Button
+                aria-label={`编辑角色 ${role.name}`}
+                icon={<Pencil size={16} />}
+                onClick={() => openRoleModal(role)}
+              />
+            </Tooltip>
+            <Tooltip title="删除角色">
+              <Button
+                danger
+                aria-label={`删除角色 ${role.name}`}
+                icon={<Trash2 size={16} />}
+                onClick={() =>
+                  Modal.confirm({
+                    title: `删除角色 ${role.name}?`,
+                    content: '已分配给机器人的角色不能删除。',
+                    okText: '确认删除',
+                    okButtonProps: { danger: true },
+                    cancelText: '取消',
+                    onOk: async () => {
+                      await apiRequest(
+                        `/roles/${role.role_id}`,
+                        jsonOptions('DELETE', { confirm: true }),
+                      );
+                      message.success('职责角色已删除');
+                      await reloadTelegram();
+                    },
+                  })
+                }
+              />
+            </Tooltip>
+          </Space>
+        ),
+    },
+  ];
+
   return (
     <>
       <PageHeader title="系统设置" />
@@ -358,12 +521,38 @@ export default function SettingsPage() {
             columns={botColumns}
             dataSource={telegram?.bots || []}
             pagination={false}
-            scroll={{ x: 780 }}
+            scroll={{ x: 980 }}
             locale={{ emptyText: '尚未添加机器人' }}
           />
         </div>
       </Surface>
 
+      <div style={{ height: 16 }} />
+      <Surface
+        title="职责角色"
+        actions={
+          <Button type="primary" icon={<CirclePlus size={16} />} onClick={() => openRoleModal()}>
+            新建角色
+          </Button>
+        }
+      >
+        <Alert
+          type="info"
+          showIcon
+          message="机器人可同时分配多个角色,最终权限为所有角色权限的并集。"
+          style={{ marginBottom: 16 }}
+        />
+        <div className="table-wrap">
+          <Table<BotRole>
+            rowKey="role_id"
+            columns={roleColumns}
+            dataSource={telegram?.roles || []}
+            pagination={false}
+            scroll={{ x: 760 }}
+          />
+        </div>
+      </Surface>
+
       <div style={{ height: 16 }} />
       <div className="split-grid">
         <Surface title="运行信息">
@@ -456,6 +645,21 @@ export default function SettingsPage() {
           <Form.Item name="enabled" label="启用" valuePropName="checked">
             <Switch />
           </Form.Item>
+          <Form.Item
+            name="role_ids"
+            label="职责角色"
+            extra="可多选;不选择角色时机器人只保持连接,不执行管理职责。"
+          >
+            <Select
+              mode="multiple"
+              allowClear
+              placeholder="选择一个或多个职责角色"
+              options={(telegram?.roles || []).map((role) => ({
+                value: role.role_id,
+                label: role.name,
+              }))}
+            />
+          </Form.Item>
           <Form.Item name="sudo_users_id" label="超级管理员用户 ID">
             <Input placeholder="多个 ID 使用空格分隔" />
           </Form.Item>
@@ -476,6 +680,56 @@ export default function SettingsPage() {
           </Form.Item>
         </Form>
       </Modal>
+
+      <Modal
+        title={editingRole ? `编辑角色 ${editingRole.name}` : '新建职责角色'}
+        open={roleModalOpen}
+        onCancel={() => setRoleModalOpen(false)}
+        onOk={() => roleForm.submit()}
+        okText="继续"
+        cancelText="取消"
+        width={720}
+        destroyOnHidden
+      >
+        <Form form={roleForm} layout="vertical" onFinish={saveRole} preserve={false}>
+          <Form.Item
+            name="name"
+            label="角色名称"
+            rules={[{ required: true, message: '请输入角色名称' }, { max: 60 }]}
+          >
+            <Input maxLength={60} />
+          </Form.Item>
+          <Form.Item name="description" label="角色说明" rules={[{ max: 200 }]}>
+            <Input.TextArea maxLength={200} rows={2} />
+          </Form.Item>
+          <Form.Item name="permissions" label="职责权限">
+            <Checkbox.Group style={{ width: '100%' }}>
+              <Space direction="vertical" size={14} style={{ width: '100%' }}>
+                {Object.entries(permissionGroups).map(([group, permissions]) => (
+                  <div key={group}>
+                    <Typography.Text strong>{group}</Typography.Text>
+                    <Row gutter={[12, 8]} style={{ marginTop: 8 }}>
+                      {permissions.map((permission) => (
+                        <Col xs={24} sm={12} key={permission.key}>
+                          <Checkbox value={permission.key}>
+                            <span>{permission.name}</span>
+                            <Typography.Text
+                              type="secondary"
+                              style={{ display: 'block', fontSize: 12 }}
+                            >
+                              {permission.description}
+                            </Typography.Text>
+                          </Checkbox>
+                        </Col>
+                      ))}
+                    </Row>
+                  </div>
+                ))}
+              </Space>
+            </Checkbox.Group>
+          </Form.Item>
+        </Form>
+      </Modal>
     </>
   );
 }

+ 21 - 0
admin-web/src/types.ts

@@ -14,6 +14,23 @@ export interface BotRuntime {
   error?: string;
 }
 
+export interface BotPermissionDefinition {
+  key: string;
+  name: string;
+  group: string;
+  description: string;
+}
+
+export interface BotRole {
+  role_id: string;
+  name: string;
+  description: string;
+  permissions: string[];
+  builtin: boolean;
+  created_at?: string;
+  updated_at?: string;
+}
+
 export interface BotProfile {
   bot_id: string;
   label: string;
@@ -23,6 +40,8 @@ export interface BotProfile {
   log_group_id: string;
   gban_log_group_id: string;
   message_dump_chat: string;
+  role_ids: string[];
+  permissions: string[];
   identity?: { id: string; username: string; name: string } | null;
   ready_to_connect: boolean;
   created_at?: string;
@@ -34,6 +53,8 @@ export interface TelegramSettings {
   api_id?: number | null;
   api_hash_configured: boolean;
   api_ready: boolean;
+  roles: BotRole[];
+  permission_catalog: BotPermissionDefinition[];
   bots: BotProfile[];
 }
 

+ 36 - 0
admin-web/tests/e2e/admin.spec.ts

@@ -25,6 +25,21 @@ const recentMember = {
   last_seen_at: '2026-07-24T08:00:00Z',
 };
 
+const permissions = [
+  'chat.profile',
+  'chat.members',
+  'chat.permissions',
+  'chat.announcements',
+  'chat.invites',
+  'chat.rules',
+  'chat.notes',
+  'automation.manage',
+  'points.manage',
+  'giveaways.manage',
+  'karma.manage',
+  'media.upload',
+];
+
 const bot = {
   bot_id: 'primary',
   label: 'Test Bot',
@@ -34,6 +49,8 @@ const bot = {
   log_group_id: '0',
   gban_log_group_id: '0',
   message_dump_chat: '-1001234567890',
+  role_ids: ['full_access'],
+  permissions,
   identity: { id: '999', username: 'test_bot', name: 'Test Bot' },
   ready_to_connect: true,
   runtime: { state: 'running' },
@@ -67,6 +84,21 @@ test.beforeEach(async ({ page }) => {
         api_id: 12345,
         api_hash_configured: true,
         api_ready: true,
+        permission_catalog: permissions.map((key) => ({
+          key,
+          name: key,
+          group: '职责权限',
+          description: key,
+        })),
+        roles: [
+          {
+            role_id: 'full_access',
+            name: '全部职责',
+            description: '拥有全部机器人职责',
+            permissions,
+            builtin: true,
+          },
+        ],
         bots: [bot],
       };
     } else if (path.endsWith('/dashboard')) {
@@ -199,6 +231,10 @@ test('登录与主要管理视图在不同视口无页面级横向滚动', async
   ] as const) {
     await page.goto(path);
     await expect(page.getByRole('heading', { name: heading })).toBeVisible();
+    if (path === '/admin/settings') {
+      await expect(page.getByText('全部职责').first()).toBeVisible();
+      await page.screenshot({ path: testInfo.outputPath('settings.png'), fullPage: true });
+    }
     expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
   }
 });

+ 35 - 1
admin-web/tests/unit/Settings.test.tsx

@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
 import { beforeEach, expect, test, vi } from 'vitest';
 
 import SettingsPage from '@/pages/Settings';
@@ -27,6 +27,30 @@ beforeEach(() => {
         api_id: 12345,
         api_hash_configured: true,
         api_ready: true,
+        permission_catalog: [
+          {
+            key: 'points.manage',
+            name: '积分',
+            group: '社区运营',
+            description: '管理积分',
+          },
+        ],
+        roles: [
+          {
+            role_id: 'full_access',
+            name: '全部职责',
+            description: '全部权限',
+            permissions: ['points.manage'],
+            builtin: true,
+          },
+          {
+            role_id: 'points',
+            name: '积分专员',
+            description: '负责积分',
+            permissions: ['points.manage'],
+            builtin: false,
+          },
+        ],
         bots: [
           {
             bot_id: 'one',
@@ -37,6 +61,8 @@ beforeEach(() => {
             log_group_id: '0',
             gban_log_group_id: '0',
             message_dump_chat: '-100123',
+            role_ids: ['points'],
+            permissions: ['points.manage'],
             identity: { id: '1', username: 'primary_bot', name: 'Primary' },
             ready_to_connect: true,
             runtime: { state: 'running' },
@@ -50,6 +76,8 @@ beforeEach(() => {
             log_group_id: '0',
             gban_log_group_id: '0',
             message_dump_chat: '0',
+            role_ids: [],
+            permissions: [],
             identity: null,
             ready_to_connect: true,
             runtime: { state: 'stopped' },
@@ -66,6 +94,8 @@ test('展示多个机器人、脱敏凭据状态和运行状态', async () => {
 
   expect(await screen.findByText('@primary_bot')).toBeInTheDocument();
   expect(screen.getByText('备用 Bot')).toBeInTheDocument();
+  expect(screen.getAllByText('积分专员').length).toBeGreaterThan(0);
+  expect(screen.getByText('未分配职责')).toBeInTheDocument();
   expect(screen.getByText('运行中')).toBeInTheDocument();
   expect(screen.getByText('已停用')).toBeInTheDocument();
   expect(screen.getAllByText('已配置')).toHaveLength(2);
@@ -74,4 +104,8 @@ test('展示多个机器人、脱敏凭据状态和运行状态', async () => {
   expect(credentialsLink.parentElement).toHaveTextContent('不是机器人编号。');
   expect(screen.getByText('32 位十六进制字符串,不是机器人令牌。')).toBeInTheDocument();
   expect(screen.queryByText(/AA[A-Za-z0-9_-]{20}/)).not.toBeInTheDocument();
+
+  fireEvent.click(screen.getByRole('button', { name: '新建角色' }));
+  expect(await screen.findByText('新建职责角色')).toBeInTheDocument();
+  expect(screen.getByRole('checkbox', { name: /积分/ })).toBeInTheDocument();
 });

+ 10 - 0
sample_config.py

@@ -60,3 +60,13 @@ ADMIN_BOOTSTRAP_MODE = os.environ.get("WBB_ADMIN_BOOTSTRAP", "0").lower() in ["t
 SUPERVISOR_MODE = os.environ.get("WBB_SUPERVISOR_MODE", "0").lower() in ["true", "1"]
 BOT_PROFILE_ID = os.environ.get("WBB_BOT_PROFILE_ID", "primary")
 BOT_PROFILES_PATH = os.environ.get("BOT_PROFILES_PATH", "runtime/bot_profiles.json")
+_BOT_PERMISSIONS_RAW = os.environ.get("WBB_BOT_PERMISSIONS")
+BOT_PERMISSIONS = (
+    {"*"}
+    if _BOT_PERMISSIONS_RAW is None
+    else {
+        item.strip()
+        for item in _BOT_PERMISSIONS_RAW.split(",")
+        if item.strip()
+    }
+)

+ 1 - 0
tests/conftest.py

@@ -69,6 +69,7 @@ def app_modules(tmp_path):
     fake_wbb.BOT_NAME = "Test Bot"
     fake_wbb.BOT_USERNAME = "test_bot"
     fake_wbb.BOT_PROFILE_ID = "primary"
+    fake_wbb.BOT_PERMISSIONS = {"*"}
     fake_wbb.LOG_GROUP_ID = 0
     fake_wbb.TELEGRAM_CONNECTED = True
     fake_wbb.SUPERVISOR_MODE = False

+ 127 - 0
tests/test_bot_config.py

@@ -32,6 +32,8 @@ def test_multiple_bot_profiles_are_atomic_and_redacted(app_modules, tmp_path):
     assert status["api_ready"] is True
     assert len(status["bots"]) == 2
     assert status["bots"][0]["sudo_users_id"] == ["100", "200"]
+    assert status["bots"][0]["role_ids"] == ["full_access"]
+    assert status["bots"][0]["permissions"]
     serialized = json.dumps(status)
     assert first_token not in serialized
     assert second_token not in serialized
@@ -47,6 +49,54 @@ def test_multiple_bot_profiles_are_atomic_and_redacted(app_modules, tmp_path):
     assert len(config.telegram_config_status(path)["bots"]) == 1
 
 
+def test_custom_roles_are_assignable_and_permissions_are_merged(app_modules, tmp_path):
+    config = app_modules.load("wbb.admin.bot_config")
+    path = tmp_path / "bots.json"
+    config.update_telegram_config(path, {"api_id": 12345, "api_hash": "a" * 32})
+    role = config.create_bot_role(
+        path,
+        {
+            "name": "积分与抽奖",
+            "description": "社区运营",
+            "permissions": ["points.manage", "giveaways.manage"],
+        },
+    )
+    profile = config.create_bot_profile(
+        path,
+        {
+            "label": "Operator",
+            "bot_token": "123456:" + "A" * 30,
+            "role_ids": [role["role_id"], "karma_manager"],
+        },
+    )
+
+    assert profile["role_ids"] == [role["role_id"], "karma_manager"]
+    assert set(profile["permissions"]) == {
+        "points.manage",
+        "giveaways.manage",
+        "karma.manage",
+    }
+
+    config.update_bot_role(
+        path,
+        role["role_id"],
+        {"permissions": ["automation.manage"]},
+    )
+    updated = config.telegram_config_status(path)["bots"][0]
+    assert set(updated["permissions"]) == {"automation.manage", "karma.manage"}
+
+    with pytest.raises(config.BotConfigError) as error:
+        config.delete_bot_role(path, role["role_id"])
+    assert error.value.code == "role_in_use"
+
+    config.update_bot_profile(path, profile["bot_id"], {"role_ids": []})
+    config.delete_bot_role(path, role["role_id"])
+    assert all(
+        item["role_id"] != role["role_id"]
+        for item in config.telegram_config_status(path)["roles"]
+    )
+
+
 def test_bot_profile_validation(app_modules, tmp_path):
     config = app_modules.load("wbb.admin.bot_config")
     path = tmp_path / "bots.json"
@@ -63,6 +113,20 @@ def test_bot_profile_validation(app_modules, tmp_path):
     assert "不能使用机器人令牌" in str(error.value)
 
 
+def test_role_permissions_gate_telegram_modules(app_modules):
+    permissions = app_modules.load("wbb.services.bot_permissions")
+
+    assert permissions.module_allowed("points", {"points.manage"}) is True
+    assert permissions.module_allowed("points", {"giveaways.manage"}) is False
+    assert permissions.module_allowed("admin", {"chat.members"}) is True
+    assert permissions.module_allowed("admin", {"chat.invites"}) is True
+    assert permissions.module_allowed("admin", set()) is False
+    assert permissions.module_allowed("chat_watcher", set()) is True
+    assert permissions.TELEGRAM_COMMAND_PERMISSIONS["ban"] == "chat.members"
+    assert permissions.TELEGRAM_COMMAND_PERMISSIONS["pin"] == "chat.announcements"
+    assert permissions.TELEGRAM_COMMAND_PERMISSIONS["invite"] == "chat.invites"
+
+
 def test_supervisor_assigns_each_worker_an_isolated_database(app_modules, tmp_path):
     supervisor_module = app_modules.load("wbb.admin.supervisor")
     supervisor = supervisor_module.BotSupervisor(tmp_path / "bots.json", project_root=tmp_path)
@@ -75,12 +139,14 @@ def test_supervisor_assigns_each_worker_an_isolated_database(app_modules, tmp_pa
         "log_group_id": 0,
         "gban_log_group_id": 0,
         "message_dump_chat": 0,
+        "permissions": ["points.manage", "giveaways.manage"],
     }
 
     environment = supervisor._worker_environment(profile, 18088)
 
     assert environment["WBB_BOT_PROFILE_ID"] == "bot.one/secondary"
     assert environment["WBB_BOT_DATABASE"] == "wbb_bot_bot_one_secondary"
+    assert environment["WBB_BOT_PERMISSIONS"] == "points.manage,giveaways.manage"
 
 
 async def test_bot_admin_api_crud_and_token_test_are_redacted(app_modules, monkeypatch):
@@ -115,6 +181,19 @@ async def test_bot_admin_api_crud_and_token_test_are_redacted(app_modules, monke
         assert telegram.status == 200
         assert (await telegram.json())["data"]["api_hash_configured"] is True
 
+        created_role = await client.post(
+            "/api/admin/v1/roles",
+            headers=headers,
+            json={
+                "name": "积分专员",
+                "description": "只负责积分",
+                "permissions": ["points.manage"],
+                "confirm": True,
+            },
+        )
+        assert created_role.status == 201
+        role = (await created_role.json())["data"]
+
         created = await client.post(
             "/api/admin/v1/bots",
             headers=headers,
@@ -122,6 +201,7 @@ async def test_bot_admin_api_crud_and_token_test_are_redacted(app_modules, monke
                 "label": "Test Bot",
                 "bot_token": token,
                 "enabled": False,
+                "role_ids": [role["role_id"]],
                 "confirm": True,
             },
         )
@@ -129,6 +209,7 @@ async def test_bot_admin_api_crud_and_token_test_are_redacted(app_modules, monke
         created_payload = await created.json()
         profile = created_payload["data"]
         assert profile["bot_token_configured"] is True
+        assert profile["permissions"] == ["points.manage"]
         assert token not in json.dumps(created_payload)
 
         async def fake_test_bot_token(_token: str):
@@ -155,5 +236,51 @@ async def test_bot_admin_api_crud_and_token_test_are_redacted(app_modules, monke
             json={"confirm": True},
         )
         assert deleted.status == 200
+        deleted_role = await client.delete(
+            f"/api/admin/v1/roles/{role['role_id']}",
+            headers=headers,
+            json={"confirm": True},
+        )
+        assert deleted_role.status == 200
+    finally:
+        await client.close()
+
+
+async def test_bot_role_permission_is_enforced_by_worker_api(app_modules):
+    admin_api = app_modules.load("wbb.admin.api")
+    app_modules.wbb.BOT_PERMISSIONS = set()
+    application = admin_api.build_admin_application()
+    await application["admin_api"].initialize()
+    client = TestClient(TestServer(application), cookie_jar=CookieJar(unsafe=True))
+    await client.start_server()
+    try:
+        login = await client.post(
+            "/api/admin/v1/auth/login",
+            json={"username": "admin", "password": "qwe0.123456"},
+        )
+        login_data = (await login.json())["data"]
+        changed = await client.put(
+            "/api/admin/v1/auth/password",
+            headers={"X-CSRF-Token": login_data["csrf_token"]},
+            json={
+                "current_password": "qwe0.123456",
+                "new_password": "changed-pass-123",
+            },
+        )
+        assert changed.status == 200
+
+        denied = await client.get(
+            "/api/admin/v1/points/accounts?chat_id=-100"
+        )
+        assert denied.status == 403
+        payload = await denied.json()
+        assert payload["error"]["code"] == "bot_role_permission_denied"
+        assert payload["error"]["details"]["required_permission"] == "points.manage"
+
+        app_modules.wbb.BOT_PERMISSIONS = {"points.manage"}
+        allowed = await client.get(
+            "/api/admin/v1/points/accounts?chat_id=-100"
+        )
+        assert allowed.status == 200
     finally:
         await client.close()

+ 28 - 1
tests/test_points.py

@@ -5,6 +5,33 @@ from datetime import UTC, datetime, timedelta
 import pytest
 
 
+def test_leaderboard_labels_never_mention_members(app_modules):
+    identity = app_modules.load("wbb.services.member_identity")
+
+    assert (
+        identity.non_mention_account_name(
+            {
+                "user_id": 10,
+                "username": "alice",
+                "first_name": "小明",
+                "display_name": "小明 同学",
+            }
+        )
+        == "小明 同学"
+    )
+    fallback = identity.non_mention_account_name(
+        {
+            "user_id": 11,
+            "username": "alice",
+            "first_name": "",
+            "display_name": "",
+        }
+    )
+    assert fallback == "用户 11"
+    assert "@" not in fallback
+    assert "tg://user" not in fallback
+
+
 async def test_adjustment_is_idempotent_and_balance_cannot_be_negative(app_modules):
     points = app_modules.load("wbb.utils.dbpoints")
 
@@ -134,7 +161,7 @@ async def test_activity_cooldown_duplicate_content_and_daily_cap(app_modules):
             "activity_daily_cap": 2,
         },
     )
-    now = datetime.now(UTC)
+    now = datetime(2099, 1, 1, 4, 0, tzinfo=UTC)
 
     _, first = await points.award_activity(
         chat_id=-100,

+ 1 - 0
wbb/__init__.py

@@ -52,6 +52,7 @@ GBAN_LOG_GROUP_ID = GBAN_LOG_GROUP_ID
 WELCOME_DELAY_KICK_SEC = WELCOME_DELAY_KICK_SEC
 LOG_GROUP_ID = LOG_GROUP_ID
 MESSAGE_DUMP_CHAT = MESSAGE_DUMP_CHAT
+BOT_PERMISSIONS = globals().get("BOT_PERMISSIONS", {"*"})
 MOD_LOAD = [
     "admin",
     "admin_misc",

+ 109 - 13
wbb/__main__.py

@@ -29,13 +29,14 @@ import time
 from contextlib import closing, suppress
 
 import psutil
-from pyrogram import filters, idle
+from pyrogram import StopPropagation, filters, idle
 from pyrogram.enums import ChatType, ParseMode
 from pyrogram.types import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup
 from uvloop import install
 
 from wbb import (
     BOT_NAME,
+    BOT_PERMISSIONS,
     BOT_USERNAME,
     LOG_GROUP_ID,
     USERBOT_CONNECTED,
@@ -48,6 +49,12 @@ from wbb import (
 )
 from wbb.core.keyboard import ikb
 from wbb.modules import ALL_MODULES
+from wbb.services.bot_permissions import (
+    PRIVATE_MANAGEMENT_PERMISSIONS,
+    TELEGRAM_COMMAND_PERMISSIONS,
+    has_any_permission,
+    has_permission,
+)
 from wbb.utils import paginate_modules
 from wbb.utils.constants import MARKDOWN
 from wbb.utils.dbfunctions import clean_restart_stage, get_rules
@@ -93,6 +100,68 @@ BOT_COMMANDS = [
     BotCommand("greroll", "重新抽取中奖者"),
     BotCommand("rules", "查看本群群规"),
 ]
+BOT_COMMAND_PERMISSIONS = {
+    "manage": "__any__",
+    "cancel": "__any__",
+    "points": "points.manage",
+    "checkin": "points.manage",
+    "points_rank": "points.manage",
+    "points_history": "points.manage",
+    "giveaway": "giveaways.manage",
+    "gjoin": "giveaways.manage",
+    "glist": "giveaways.manage",
+    "gparticipants": "giveaways.manage",
+    "gend": "giveaways.manage",
+    "gcancel": "giveaways.manage",
+    "greroll": "giveaways.manage",
+    "rules": "chat.rules",
+}
+
+
+def _visible_bot_commands() -> list[BotCommand]:
+    visible = []
+    for command in BOT_COMMANDS:
+        required = BOT_COMMAND_PERMISSIONS.get(command.command)
+        if required == "__any__" and not has_any_permission(
+            PRIVATE_MANAGEMENT_PERMISSIONS, BOT_PERMISSIONS
+        ):
+            continue
+        if required and required != "__any__" and not has_permission(
+            required, BOT_PERMISSIONS
+        ):
+            continue
+        visible.append(command)
+    return visible
+
+
+@app.on_message(
+    filters.command(list(TELEGRAM_COMMAND_PERMISSIONS)),
+    group=-1000,
+)
+async def bot_role_command_guard(_, message):
+    command = (
+        str(message.command[0]).lower().split("@", 1)[0]
+        if message.command
+        else ""
+    )
+    required = TELEGRAM_COMMAND_PERMISSIONS.get(command)
+    if required and not has_permission(required, BOT_PERMISSIONS):
+        raise StopPropagation
+
+
+@app.on_message(
+    filters.command(["admins", "admin"], prefixes="@"),
+    group=-1000,
+)
+async def bot_role_at_command_guard(_, __):
+    if not has_permission("chat.members", BOT_PERMISSIONS):
+        raise StopPropagation
+
+
+@app.on_callback_query(filters.regex(r"^unwarn_"), group=-1000)
+async def bot_role_callback_guard(_, __):
+    if not has_permission("chat.members", BOT_PERMISSIONS):
+        raise StopPropagation
 
 
 async def bot_sys_stats() -> str:
@@ -146,7 +215,7 @@ async def start_bot():
         log.info(f"用户客户端已启动:{USERBOT_NAME}")
 
     try:
-        await app.set_bot_commands(BOT_COMMANDS)
+        await app.set_bot_commands(_visible_bot_commands())
         log.info("已注册中文 Telegram 命令菜单")
     except Exception as exc:
         log.error(f"注册 Telegram 命令菜单失败:{exc}")
@@ -180,17 +249,17 @@ async def start_bot():
     log.info("机器人已停止")
 
 
+home_actions = [
+    InlineKeyboardButton(text="功能帮助", callback_data="bot_commands"),
+]
+if has_any_permission(PRIVATE_MANAGEMENT_PERMISSIONS, BOT_PERMISSIONS):
+    home_actions.append(
+        InlineKeyboardButton(text="群组管理", callback_data="manage_hint")
+    )
+
 home_keyboard_pm = InlineKeyboardMarkup(
     [
-        [
-            InlineKeyboardButton(
-                text="功能帮助", callback_data="bot_commands"
-            ),
-            InlineKeyboardButton(
-                text="群组管理",
-                callback_data="manage_hint",
-            ),
-        ],
+        home_actions,
         [
             InlineKeyboardButton(
                 text="运行状态",
@@ -206,10 +275,35 @@ home_keyboard_pm = InlineKeyboardMarkup(
     ]
 )
 
+assigned_features = [
+    label
+    for permission, label in (
+        ("chat.profile", "群资料"),
+        ("chat.members", "成员管理"),
+        ("chat.permissions", "群权限"),
+        ("chat.announcements", "公告"),
+        ("chat.invites", "邀请链接"),
+        ("chat.rules", "群规"),
+        ("chat.notes", "群笔记"),
+        ("automation.manage", "自动化与风控"),
+        ("points.manage", "积分"),
+        ("giveaways.manage", "抽奖"),
+        ("karma.manage", "声望"),
+    )
+    if has_permission(permission, BOT_PERMISSIONS)
+]
 home_text_pm = (
     f"你好,我是 {BOT_NAME}。\n\n"
-    "我可以协助管理群组、自动回复、风控、积分和抽奖。"
-    "群管理员可发送 /manage 打开私聊管理菜单。"
+    + (
+        f"我当前负责:{'、'.join(assigned_features)}。"
+        if assigned_features
+        else "我当前尚未分配管理职责。"
+    )
+    + (
+        "群管理员可发送 /manage 打开私聊管理菜单。"
+        if has_any_permission(PRIVATE_MANAGEMENT_PERMISSIONS, BOT_PERMISSIONS)
+        else ""
+    )
 )
 
 keyboard = InlineKeyboardMarkup(
@@ -406,6 +500,8 @@ async def commands_callbacc(_, CallbackQuery):
 
 @app.on_callback_query(filters.regex("^manage_hint$"))
 async def manage_hint_callback(_, query):
+    if not has_any_permission(PRIVATE_MANAGEMENT_PERMISSIONS, BOT_PERMISSIONS):
+        return await query.answer("当前机器人未分配私聊管理职责。", show_alert=True)
     await query.answer("请发送 /manage 打开群组管理菜单。", show_alert=True)
 
 

+ 112 - 14
wbb/admin/api.py

@@ -17,12 +17,15 @@ from wbb import app as telegram_app
 from wbb.admin.bot_config import (
     BotConfigError,
     create_bot_profile,
+    create_bot_role,
     delete_bot_profile,
+    delete_bot_role,
     get_bot_profile_secrets,
     store_bot_identity,
     telegram_config_status,
     test_bot_token,
     update_bot_profile,
+    update_bot_role,
     update_telegram_config,
 )
 from wbb.admin.security import (
@@ -33,6 +36,7 @@ from wbb.admin.security import (
     validate_new_password,
     verify_password,
 )
+from wbb.services.bot_permissions import api_permission, has_permission
 from wbb.services.chat_management import (
     ChatManagementError,
     apply_automation_settings,
@@ -256,7 +260,13 @@ async def api_error_middleware(request: web.Request, handler):
         await _record_request_audit(request, success_state=False, error=str(exc))
         return error_response(problem)
     except BotConfigError as exc:
-        problem = ApiProblem(exc.code, str(exc), status=404 if exc.code == "bot_not_found" else 400)
+        status = {
+            "bot_not_found": 404,
+            "role_not_found": 404,
+            "role_in_use": 409,
+            "builtin_role_immutable": 409,
+        }.get(exc.code, 400)
+        problem = ApiProblem(exc.code, str(exc), status=status)
         await _record_request_audit(request, success_state=False, error=str(exc))
         return error_response(problem)
     except web.HTTPException:
@@ -324,6 +334,49 @@ async def authentication_middleware(request: web.Request, handler):
     return await handler(request)
 
 
+def _selected_bot_id(request: web.Request) -> str:
+    bot_id = str(request.headers.get("X-Bot-Id") or "").strip()
+    if bot_id:
+        return bot_id
+    supervisor = getattr(wbb, "BOT_SUPERVISOR", None)
+    if supervisor is not None:
+        running = [
+            key
+            for key, value in supervisor.runtimes().items()
+            if value.get("state") == "running"
+        ]
+        if len(running) == 1:
+            return running[0]
+    raise ApiProblem(
+        "bot_selection_required",
+        "请先选择要管理的机器人。",
+        status=409,
+    )
+
+
+@web.middleware
+async def bot_role_middleware(request: web.Request, handler):
+    required = api_permission(request.method, request.path)
+    if not required:
+        return await handler(request)
+    if bool(getattr(wbb, "SUPERVISOR_MODE", False)):
+        profile = get_bot_profile_secrets(
+            getattr(wbb, "BOT_PROFILES_PATH", "runtime/bot_profiles.json"),
+            _selected_bot_id(request),
+        )
+        permissions = profile.get("permissions", [])
+    else:
+        permissions = getattr(wbb, "BOT_PERMISSIONS", {"*"})
+    if not has_permission(required, permissions):
+        raise ApiProblem(
+            "bot_role_permission_denied",
+            "所选机器人的职责角色不允许执行该操作。",
+            status=403,
+            details={"required_permission": required},
+        )
+    return await handler(request)
+
+
 @web.middleware
 async def bot_proxy_middleware(request: web.Request, handler):
     if not bool(getattr(wbb, "SUPERVISOR_MODE", False)) or not request.path.startswith(
@@ -337,19 +390,7 @@ async def bot_proxy_middleware(request: web.Request, handler):
             "机器人监管服务尚未就绪。",
             status=503,
         )
-    bot_id = str(request.headers.get("X-Bot-Id") or "").strip()
-    if not bot_id:
-        running = [
-            key for key, value in supervisor.runtimes().items() if value.get("state") == "running"
-        ]
-        if len(running) == 1:
-            bot_id = running[0]
-        else:
-            raise ApiProblem(
-                "bot_selection_required",
-                "请先选择要管理的机器人。",
-                status=409,
-            )
+    bot_id = _selected_bot_id(request)
     endpoint = supervisor.endpoint_for(bot_id)
     if endpoint is None:
         raise ApiProblem(
@@ -429,6 +470,10 @@ class AdminApi:
         router.add_delete(f"{API_PREFIX}/bots/{{bot_id}}", self.bot_delete)
         router.add_post(f"{API_PREFIX}/bots/{{bot_id}}/test", self.bot_test)
         router.add_post(f"{API_PREFIX}/bots/{{bot_id}}/restart", self.bot_restart)
+        router.add_get(f"{API_PREFIX}/roles", self.roles)
+        router.add_post(f"{API_PREFIX}/roles", self.role_create)
+        router.add_put(f"{API_PREFIX}/roles/{{role_id}}", self.role_update)
+        router.add_delete(f"{API_PREFIX}/roles/{{role_id}}", self.role_delete)
         router.add_get(f"{API_PREFIX}/audit-logs", self.audit_logs)
         router.add_post(f"{API_PREFIX}/media", self.upload_media)
 
@@ -714,6 +759,58 @@ class AdminApi:
             )
         return success(await supervisor.restart(bot_id))
 
+    async def roles(self, _: web.Request) -> web.Response:
+        status = self._telegram_status()
+        return success(
+            {
+                "items": status["roles"],
+                "permission_catalog": status["permission_catalog"],
+            }
+        )
+
+    async def role_create(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        set_audit(
+            request,
+            "bot.role.create",
+            summary=str(body.get("name") or ""),
+        )
+        body.pop("confirm", None)
+        return success(create_bot_role(self.bot_config_path, body), status=201)
+
+    async def role_update(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        role_id = request.match_info["role_id"]
+        set_audit(
+            request,
+            "bot.role.update",
+            target_id=role_id,
+            summary=str(body.get("name") or ""),
+        )
+        body.pop("confirm", None)
+        role = update_bot_role(self.bot_config_path, role_id, body)
+        supervisor = self._supervisor()
+        if supervisor is not None:
+            for profile in self._telegram_status()["bots"]:
+                if role_id in profile["role_ids"]:
+                    await supervisor.reconcile(profile["bot_id"])
+        return success(role)
+
+    async def role_delete(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        role_id = request.match_info["role_id"]
+        set_audit(
+            request,
+            "bot.role.delete",
+            target_id=role_id,
+            summary="Delete Bot role",
+        )
+        delete_bot_role(self.bot_config_path, role_id)
+        return success({"deleted": True})
+
     async def audit_logs(self, request: web.Request) -> web.Response:
         page, page_size = page_params(request)
         chat_raw = request.query.get("chat_id")
@@ -1272,6 +1369,7 @@ def build_admin_application() -> web.Application:
         middlewares=[
             api_error_middleware,
             authentication_middleware,
+            bot_role_middleware,
             bot_proxy_middleware,
         ],
         client_max_size=(max_upload_mb + 1) * 1024 * 1024,

+ 222 - 18
wbb/admin/bot_config.py

@@ -11,6 +11,13 @@ from uuid import uuid4
 
 from aiohttp import ClientError, ClientSession, ClientTimeout
 
+from wbb.services.bot_permissions import (
+    ALL_BOT_PERMISSIONS,
+    builtin_roles,
+    normalize_permissions,
+    permission_catalog,
+)
+
 BOT_TOKEN_PATTERN = re.compile(r"^\d{5,}:[A-Za-z0-9_-]{20,}$")
 API_HASH_PATTERN = re.compile(r"^[A-Fa-f0-9]{32}$")
 PROFILE_FIELDS = {
@@ -30,8 +37,9 @@ class BotConfigError(ValueError):
 
 def _empty_document() -> dict[str, Any]:
     return {
-        "version": 1,
+        "version": 2,
         "telegram": {"api_id": 0, "api_hash": ""},
+        "roles": [],
         "bots": [],
     }
 
@@ -53,11 +61,12 @@ def _read_document(path: str | Path) -> dict[str, Any]:
     if not isinstance(telegram, dict):
         telegram = {}
     return {
-        "version": 1,
+        "version": 2,
         "telegram": {
             "api_id": int(telegram.get("api_id") or 0),
             "api_hash": str(telegram.get("api_hash") or ""),
         },
+        "roles": [item for item in data.get("roles", []) if isinstance(item, dict)],
         "bots": [item for item in data.get("bots", []) if isinstance(item, dict)],
     }
 
@@ -108,9 +117,91 @@ def _find_profile(data: dict[str, Any], bot_id: str) -> dict[str, Any]:
     return profile
 
 
+def _all_roles(data: dict[str, Any]) -> list[dict[str, Any]]:
+    roles = builtin_roles()
+    builtin_ids = {str(item["role_id"]) for item in roles}
+    roles.extend(
+        {
+            "role_id": str(item.get("role_id") or ""),
+            "name": str(item.get("name") or ""),
+            "description": str(item.get("description") or ""),
+            "permissions": [
+                permission
+                for permission in item.get("permissions", [])
+                if permission in ALL_BOT_PERMISSIONS
+            ],
+            "builtin": False,
+            "created_at": item.get("created_at"),
+            "updated_at": item.get("updated_at"),
+        }
+        for item in data.get("roles", [])
+        if str(item.get("role_id") or "") not in builtin_ids
+    )
+    return roles
+
+
+def _find_role(data: dict[str, Any], role_id: str) -> dict[str, Any]:
+    role = next(
+        (item for item in _all_roles(data) if item["role_id"] == str(role_id)),
+        None,
+    )
+    if role is None:
+        raise BotConfigError("role_not_found", "未找到该机器人角色。")
+    return role
+
+
+def _normalize_role_ids(
+    value: Any,
+    data: dict[str, Any],
+    *,
+    legacy_default: bool = False,
+) -> list[str]:
+    if value is None and legacy_default:
+        return ["full_access"]
+    items = value if isinstance(value, list) else str(value or "").replace(",", " ").split()
+    normalized = list(dict.fromkeys(str(item).strip() for item in items if str(item).strip()))
+    known = {str(item["role_id"]) for item in _all_roles(data)}
+    unknown = sorted(set(normalized) - known)
+    if unknown:
+        raise BotConfigError(
+            "invalid_bot_roles",
+            f"包含不存在的机器人角色:{', '.join(unknown)}。",
+        )
+    return normalized
+
+
+def _profile_role_ids(profile: dict[str, Any], data: dict[str, Any]) -> list[str]:
+    raw = profile.get("role_ids")
+    if raw is None:
+        return ["full_access"]
+    values = raw if isinstance(raw, list) else str(raw or "").replace(",", " ").split()
+    known = {str(item["role_id"]) for item in _all_roles(data)}
+    return [
+        item
+        for item in dict.fromkeys(str(value) for value in values)
+        if item in known
+    ]
+
+
+def _effective_permissions(
+    profile: dict[str, Any],
+    data: dict[str, Any],
+) -> list[str]:
+    roles = {str(item["role_id"]): item for item in _all_roles(data)}
+    granted: set[str] = set()
+    for role_id in _profile_role_ids(profile, data):
+        granted.update(roles[role_id].get("permissions", []))
+    return [
+        item["key"]
+        for item in permission_catalog()
+        if item["key"] in granted
+    ]
+
+
 def _public_profile(
     profile: dict[str, Any],
     *,
+    data: dict[str, Any],
     api_ready: bool,
     runtime: dict[str, Any] | None = None,
 ) -> dict[str, Any]:
@@ -125,6 +216,8 @@ def _public_profile(
         "log_group_id": str(profile.get("log_group_id") or 0),
         "gban_log_group_id": str(profile.get("gban_log_group_id") or 0),
         "message_dump_chat": str(profile.get("message_dump_chat") or 0),
+        "role_ids": _profile_role_ids(profile, data),
+        "permissions": _effective_permissions(profile, data),
         "identity": identity,
         "ready_to_connect": api_ready and token_configured,
         "created_at": profile.get("created_at"),
@@ -149,9 +242,12 @@ def telegram_config_status(
         "api_id": api_id or None,
         "api_hash_configured": api_hash_configured,
         "api_ready": api_ready,
+        "roles": _all_roles(data),
+        "permission_catalog": permission_catalog(),
         "bots": [
             _public_profile(
                 profile,
+                data=data,
                 api_ready=api_ready,
                 runtime=runtime_map.get(str(profile.get("bot_id"))),
             )
@@ -199,26 +295,35 @@ def create_bot_profile(path: str | Path, body: dict[str, Any]) -> dict[str, Any]
         )
     if not BOT_TOKEN_PATTERN.fullmatch(token):
         raise BotConfigError("invalid_bot_token", "机器人令牌格式无效。")
-    now = datetime.now(UTC).isoformat().replace("+00:00", "Z")
-    profile = {
-        "bot_id": uuid4().hex,
-        "label": label,
-        "bot_token": token,
-        "enabled": bool(body.get("enabled", True)),
-        "sudo_users_id": _normalize_sudoers(body.get("sudo_users_id", [])),
-        "log_group_id": _integer(body.get("log_group_id", 0), "日志群 ID"),
-        "gban_log_group_id": _integer(body.get("gban_log_group_id", 0), "全局封禁日志群 ID"),
-        "message_dump_chat": _integer(body.get("message_dump_chat", 0), "媒体中转群 ID"),
-        "identity": None,
-        "created_at": now,
-        "updated_at": now,
-    }
     with _STORE_LOCK:
         data = _read_document(path)
+        now = datetime.now(UTC).isoformat().replace("+00:00", "Z")
+        profile = {
+            "bot_id": uuid4().hex,
+            "label": label,
+            "bot_token": token,
+            "enabled": bool(body.get("enabled", True)),
+            "sudo_users_id": _normalize_sudoers(body.get("sudo_users_id", [])),
+            "log_group_id": _integer(body.get("log_group_id", 0), "日志群 ID"),
+            "gban_log_group_id": _integer(
+                body.get("gban_log_group_id", 0), "全局封禁日志群 ID"
+            ),
+            "message_dump_chat": _integer(
+                body.get("message_dump_chat", 0), "媒体中转群 ID"
+            ),
+            "role_ids": _normalize_role_ids(
+                body.get("role_ids"),
+                data,
+                legacy_default="role_ids" not in body,
+            ),
+            "identity": None,
+            "created_at": now,
+            "updated_at": now,
+        }
         data["bots"].append(profile)
         _write_document(path, data)
         api_ready = bool(data["telegram"]["api_id"] and data["telegram"]["api_hash"])
-    return _public_profile(profile, api_ready=api_ready)
+    return _public_profile(profile, data=data, api_ready=api_ready)
 
 
 def update_bot_profile(path: str | Path, bot_id: str, body: dict[str, Any]) -> dict[str, Any]:
@@ -243,13 +348,15 @@ def update_bot_profile(path: str | Path, bot_id: str, body: dict[str, Any]) -> d
             profile["enabled"] = bool(body.get("enabled"))
         if "sudo_users_id" in body:
             profile["sudo_users_id"] = _normalize_sudoers(body.get("sudo_users_id"))
+        if "role_ids" in body:
+            profile["role_ids"] = _normalize_role_ids(body.get("role_ids"), data)
         for field in PROFILE_FIELDS - {"sudo_users_id"}:
             if field in body:
                 profile[field] = _integer(body.get(field), field)
         profile["updated_at"] = datetime.now(UTC).isoformat().replace("+00:00", "Z")
         _write_document(path, data)
         api_ready = bool(data["telegram"]["api_id"] and data["telegram"]["api_hash"])
-    return _public_profile(profile, api_ready=api_ready)
+    return _public_profile(profile, data=data, api_ready=api_ready)
 
 
 def delete_bot_profile(path: str | Path, bot_id: str) -> None:
@@ -266,10 +373,107 @@ def get_bot_profile_secrets(path: str | Path, bot_id: str) -> dict[str, Any]:
     with _STORE_LOCK:
         data = _read_document(path)
         profile = dict(_find_profile(data, bot_id))
+        profile["role_ids"] = _profile_role_ids(profile, data)
+        profile["permissions"] = _effective_permissions(profile, data)
         telegram = dict(data["telegram"])
     return {**profile, **telegram}
 
 
+def create_bot_role(path: str | Path, body: dict[str, Any]) -> dict[str, Any]:
+    name = str(body.get("name") or "").strip()
+    description = str(body.get("description") or "").strip()
+    if not name or len(name) > 60:
+        raise BotConfigError(
+            "invalid_role_name",
+            "角色名称长度需要在 1 到 60 个字符之间。",
+        )
+    if len(description) > 200:
+        raise BotConfigError("invalid_role_description", "角色说明不能超过 200 个字符。")
+    try:
+        permissions = normalize_permissions(body.get("permissions", []))
+    except ValueError as exc:
+        raise BotConfigError("invalid_role_permissions", str(exc)) from exc
+    now = datetime.now(UTC).isoformat().replace("+00:00", "Z")
+    role = {
+        "role_id": uuid4().hex,
+        "name": name,
+        "description": description,
+        "permissions": permissions,
+        "created_at": now,
+        "updated_at": now,
+    }
+    with _STORE_LOCK:
+        data = _read_document(path)
+        data["roles"].append(role)
+        _write_document(path, data)
+    return {**role, "builtin": False}
+
+
+def update_bot_role(
+    path: str | Path,
+    role_id: str,
+    body: dict[str, Any],
+) -> dict[str, Any]:
+    with _STORE_LOCK:
+        data = _read_document(path)
+        current = _find_role(data, role_id)
+        if current.get("builtin"):
+            raise BotConfigError("builtin_role_immutable", "内置角色不能修改。")
+        role = next(
+            item
+            for item in data["roles"]
+            if str(item.get("role_id")) == str(role_id)
+        )
+        if "name" in body:
+            name = str(body.get("name") or "").strip()
+            if not name or len(name) > 60:
+                raise BotConfigError(
+                    "invalid_role_name",
+                    "角色名称长度需要在 1 到 60 个字符之间。",
+                )
+            role["name"] = name
+        if "description" in body:
+            description = str(body.get("description") or "").strip()
+            if len(description) > 200:
+                raise BotConfigError(
+                    "invalid_role_description",
+                    "角色说明不能超过 200 个字符。",
+                )
+            role["description"] = description
+        if "permissions" in body:
+            try:
+                role["permissions"] = normalize_permissions(body.get("permissions"))
+            except ValueError as exc:
+                raise BotConfigError("invalid_role_permissions", str(exc)) from exc
+        role["updated_at"] = datetime.now(UTC).isoformat().replace("+00:00", "Z")
+        _write_document(path, data)
+    return {**role, "builtin": False}
+
+
+def delete_bot_role(path: str | Path, role_id: str) -> None:
+    with _STORE_LOCK:
+        data = _read_document(path)
+        role = _find_role(data, role_id)
+        if role.get("builtin"):
+            raise BotConfigError("builtin_role_immutable", "内置角色不能删除。")
+        assigned = [
+            str(profile.get("label") or profile.get("bot_id"))
+            for profile in data["bots"]
+            if str(role_id) in _profile_role_ids(profile, data)
+        ]
+        if assigned:
+            raise BotConfigError(
+                "role_in_use",
+                f"角色仍分配给以下机器人:{', '.join(assigned)}。",
+            )
+        data["roles"] = [
+            item
+            for item in data["roles"]
+            if str(item.get("role_id")) != str(role_id)
+        ]
+        _write_document(path, data)
+
+
 def store_bot_identity(path: str | Path, bot_id: str, identity: dict[str, Any]) -> None:
     with _STORE_LOCK:
         data = _read_document(path)

+ 1 - 0
wbb/admin/supervisor.py

@@ -172,6 +172,7 @@ class BotSupervisor:
                 "WBB_BOT_WORKER": "1",
                 "WBB_BOT_PROFILE_ID": str(profile["bot_id"]),
                 "WBB_BOT_DATABASE": self._worker_database_name(profile["bot_id"]),
+                "WBB_BOT_PERMISSIONS": ",".join(profile.get("permissions", [])),
                 "BOT_TOKEN": str(profile["bot_token"]),
                 "API_ID": str(profile["api_id"]),
                 "API_HASH": str(profile["api_hash"]),

+ 6 - 1
wbb/modules/__init__.py

@@ -26,7 +26,8 @@ import importlib
 import sys
 from os.path import basename, dirname, isfile
 
-from wbb import MOD_LOAD, MOD_NOLOAD
+from wbb import BOT_PERMISSIONS, MOD_LOAD, MOD_NOLOAD
+from wbb.services.bot_permissions import module_allowed
 
 
 def __list_all_modules():
@@ -54,6 +55,10 @@ def __list_all_modules():
         else:
             to_load = all_modules
 
+        to_load = [
+            item for item in to_load if module_allowed(item, BOT_PERMISSIONS)
+        ]
+
         if MOD_NOLOAD:
             return [item for item in to_load if item not in MOD_NOLOAD]
 

+ 95 - 17
wbb/modules/admin_panel.py

@@ -10,8 +10,9 @@ from pyrogram import filters
 from pyrogram.enums import ParseMode
 from pyrogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
 
-from wbb import app, log
+from wbb import BOT_PERMISSIONS, app, log
 from wbb.services.blacklist_enforcement import add_risk_keyword, remove_risk_keyword
+from wbb.services.bot_permissions import has_permission
 from wbb.services.chat_management import (
     ChatManagementError,
     apply_automation_settings,
@@ -29,7 +30,7 @@ from wbb.services.giveaways import (
     create_and_publish_giveaway,
     finish_and_publish_giveaway,
 )
-from wbb.services.member_identity import display_name
+from wbb.services.member_identity import display_name, non_mention_account_name
 from wbb.services.point_settings import apply_point_rules
 from wbb.utils.dbadmin import record_audit
 from wbb.utils.dbgiveaway import get_giveaway, list_running_giveaways
@@ -57,6 +58,28 @@ __HELP__ = """私聊管理员菜单:
 
 FLOW_TTL = timedelta(minutes=10)
 PAGE_SIZE = 6
+SECTION_ROLE_PERMISSIONS = {
+    "announce": "chat.announcements",
+    "members": "chat.members",
+    "recent_members": "chat.members",
+    "permissions": "chat.permissions",
+    "autoreply": "automation.manage",
+    "risk": "automation.manage",
+    "identity": "automation.manage",
+    "points": "points.manage",
+    "point_history": "points.manage",
+    "giveaways": "giveaways.manage",
+}
+MEMBER_ACTIONS = {
+    "warn",
+    "ban",
+    "unban",
+    "kick",
+    "mute",
+    "unmute",
+    "promote",
+    "demote",
+}
 
 
 @dataclass
@@ -82,6 +105,48 @@ def _back(chat_id: int) -> list[InlineKeyboardButton]:
     return [_button("返回群菜单", f"mg:c:{chat_id}")]
 
 
+def _require_bot_role(permission: str | None) -> None:
+    if permission and not has_permission(permission, BOT_PERMISSIONS):
+        raise ChatManagementError(
+            "bot_role_permission_denied",
+            "当前机器人未分配执行该操作的职责角色。",
+            status=403,
+        )
+
+
+def _flow_role_permission(action: str) -> str | None:
+    if action in MEMBER_ACTIONS:
+        return "chat.members"
+    if action.startswith("points_"):
+        return "points.manage"
+    if action == "announcement":
+        return "chat.announcements"
+    if action == "giveaway_create":
+        return "giveaways.manage"
+    if action == "autoreply" or action.startswith("risk_"):
+        return "automation.manage"
+    return None
+
+
+def _direct_role_permission(action: str) -> str | None:
+    if action == "permissions":
+        return "chat.permissions"
+    if action in {
+        "toggle",
+        "identity_monitor_toggle",
+        "risk_rule_toggle",
+        "risk_rule_action",
+        "risk_rule_delete",
+        "autoreply_clear",
+    }:
+        return "automation.manage"
+    if action == "point_rule_toggle":
+        return "points.manage"
+    if action in {"gfinish", "gcancel"}:
+        return "giveaways.manage"
+    return None
+
+
 async def _edit(query: CallbackQuery, text: str, keyboard: InlineKeyboardMarkup) -> None:
     await query.message.edit_text(
         text,
@@ -149,16 +214,25 @@ async def _show_chat_menu(query: CallbackQuery, chat_id: int) -> None:
         f"成员数:{overview.get('member_count') or '-'}\n"
         f"机器人权限:{escape(privileges)}"
     )
-    keyboard = InlineKeyboardMarkup(
-        [
-            [_button("概览", f"mg:s:{chat_id}:overview"), _button("公告", f"mg:s:{chat_id}:announce")],
-            [_button("成员管理", f"mg:s:{chat_id}:members"), _button("权限", f"mg:s:{chat_id}:permissions")],
-            [_button("自动回复", f"mg:s:{chat_id}:autoreply"), _button("风控", f"mg:s:{chat_id}:risk")],
-            [_button("资料监控", f"mg:s:{chat_id}:identity"), _button("积分", f"mg:s:{chat_id}:points")],
-            [_button("抽奖", f"mg:s:{chat_id}:giveaways")],
-            [_button("返回群列表", "mg:p:1")],
-        ]
+    rows = [[_button("概览", f"mg:s:{chat_id}:overview")]]
+    options = (
+        ("chat.announcements", "公告", "announce"),
+        ("chat.members", "成员管理", "members"),
+        ("chat.permissions", "权限", "permissions"),
+        ("automation.manage", "自动回复", "autoreply"),
+        ("automation.manage", "风控", "risk"),
+        ("automation.manage", "资料监控", "identity"),
+        ("points.manage", "积分", "points"),
+        ("giveaways.manage", "抽奖", "giveaways"),
     )
+    available = [
+        _button(label, f"mg:s:{chat_id}:{section}")
+        for permission, label, section in options
+        if has_permission(permission, BOT_PERMISSIONS)
+    ]
+    rows.extend(available[index : index + 2] for index in range(0, len(available), 2))
+    rows.append([_button("返回群列表", "mg:p:1")])
+    keyboard = InlineKeyboardMarkup(rows)
     await _edit(query, text, keyboard)
 
 
@@ -181,6 +255,7 @@ def _duration_label(seconds: int) -> str:
 async def _show_risk_rule(
     query: CallbackQuery, chat_id: int, rule_id: str
 ) -> None:
+    _require_bot_role("automation.manage")
     await get_chat_overview(chat_id, actor_id=query.from_user.id)
     settings = await get_automation_settings(chat_id)
     rule = next(
@@ -292,6 +367,7 @@ async def _show_risk_rule(
 
 
 async def _show_section(query: CallbackQuery, chat_id: int, section: str) -> None:
+    _require_bot_role(SECTION_ROLE_PERMISSIONS.get(section))
     await get_chat_overview(chat_id, actor_id=query.from_user.id)
     if section == "overview":
         return await _show_chat_menu(query, chat_id)
@@ -429,12 +505,7 @@ async def _show_section(query: CallbackQuery, chat_id: int, section: str) -> Non
             f"底部签到按钮:{'开' if rules['checkin_button_enabled'] else '关'}",
         ]
         for index, account in enumerate(accounts, 1):
-            label = (
-                account.get("display_name")
-                or account.get("first_name")
-                or (f"@{account['username']}" if account.get("username") else None)
-                or account["user_id"]
-            )
+            label = non_mention_account_name(account)
             lines.append(f"{index}. {escape(str(label))}:{account['balance']}")
         return await _edit(
             query,
@@ -494,6 +565,7 @@ async def _show_section(query: CallbackQuery, chat_id: int, section: str) -> Non
 
 
 async def _start_flow(query: CallbackQuery, chat_id: int, action: str) -> None:
+    _require_bot_role(_flow_role_permission(action))
     if action == "points_query":
         await get_chat_overview(chat_id, actor_id=query.from_user.id)
     else:
@@ -540,6 +612,7 @@ async def _start_risk_rule_flow(
     action: str,
     rule_id: str | None = None,
 ) -> None:
+    _require_bot_role("automation.manage")
     await ensure_permission(chat_id, "can_change_info", actor_id=query.from_user.id)
     data: dict[str, Any] = {}
     if rule_id:
@@ -615,6 +688,7 @@ async def _start_flow_for_member(
     user_id: int,
     action: str,
 ) -> None:
+    _require_bot_role("chat.members")
     permission = "can_promote_members" if action in {"promote", "demote"} else "can_restrict_members"
     await ensure_permission(chat_id, permission, actor_id=query.from_user.id)
     flow = PendingFlow(action, chat_id, "reason", {"user_id": user_id})
@@ -858,6 +932,7 @@ async def _execute_confirmation(query: CallbackQuery, token: str) -> None:
     flow: PendingFlow = confirmation["flow"]
     actor_id = query.from_user.id
     action = flow.action
+    _require_bot_role(_flow_role_permission(action))
     data = flow.data
     if action.startswith("points_") or action.startswith("risk_rule_") or action in {
         "announcement",
@@ -1020,6 +1095,7 @@ async def _danger_action(query: CallbackQuery, parts: list[str]) -> None:
         payload = {"action": "autoreply_clear", "chat_id": chat_id}
     else:
         return
+    _require_bot_role(_direct_role_permission(payload["action"]))
     token = secrets.token_urlsafe(8)
     _confirmations[token] = {
         "actor_id": query.from_user.id,
@@ -1038,6 +1114,7 @@ async def _danger_action(query: CallbackQuery, parts: list[str]) -> None:
 async def _start_risk_rule_direct(
     query: CallbackQuery, chat_id: int, rule_id: str, operation: str
 ) -> None:
+    _require_bot_role("automation.manage")
     await ensure_permission(chat_id, "can_change_info", actor_id=query.from_user.id)
     settings = await get_automation_settings(chat_id)
     rule = next(
@@ -1105,6 +1182,7 @@ async def _execute_direct_confirmation(query: CallbackQuery, token: str) -> None
     payload = confirmation["direct"]
     chat_id = int(payload["chat_id"])
     action = payload["action"]
+    _require_bot_role(_direct_role_permission(action))
     if action == "permissions":
         readonly = payload["mode"] == "readonly"
         await update_chat_permissions(

+ 4 - 2
wbb/modules/chat_watcher.py

@@ -21,7 +21,8 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 """
-from wbb import app
+from wbb import BOT_PERMISSIONS, app
+from wbb.services.bot_permissions import has_permission
 from wbb.services.member_identity import observe_message_member_identities
 from wbb.utils.dbadmin import upsert_managed_chat
 from wbb.utils.dbfunctions import (
@@ -49,7 +50,8 @@ async def chat_watcher_func(_, message):
 
     await add_served_chat(chat_id)
     if chat_id < 0:
-        await observe_message_member_identities(chat_id=chat_id, message=message)
+        if has_permission("automation.manage", BOT_PERMISSIONS):
+            await observe_message_member_identities(chat_id=chat_id, message=message)
         await upsert_managed_chat(
             chat_id=chat_id,
             title=message.chat.title,

+ 2 - 7
wbb/modules/points.py

@@ -10,6 +10,7 @@ from pyrogram.types import Message, User
 
 from wbb import app, log
 from wbb.core.decorators.permissions import adminsOnly
+from wbb.services.member_identity import non_mention_account_name
 from wbb.services.point_settings import (
     CHECKIN_BUTTON_TEXT,
     apply_point_rules,
@@ -161,14 +162,8 @@ async def points_rank_command(_, message: Message):
         return await message.reply_text("本群尚无积分记录。")
     lines = ["<b>本群积分排行榜</b>"]
     for index, account in enumerate(accounts, 1):
-        label = (
-            account.get("display_name")
-            or account.get("first_name")
-            or (f"@{account['username']}" if account.get("username") else None)
-            or str(account["user_id"])
-        )
         lines.append(
-            f"{index}. {_mention(int(account['user_id']), str(label))} - "
+            f"{index}. {escape(non_mention_account_name(account))} - "
             f"<b>{int(account['balance'])}</b> 积分"
         )
     return await message.reply_text(

+ 227 - 0
wbb/services/bot_permissions.py

@@ -0,0 +1,227 @@
+from __future__ import annotations
+
+from collections.abc import Iterable
+from dataclasses import asdict, dataclass
+
+
+@dataclass(frozen=True)
+class BotPermission:
+    key: str
+    name: str
+    group: str
+    description: str
+
+
+@dataclass(frozen=True)
+class BuiltinBotRole:
+    role_id: str
+    name: str
+    description: str
+    permissions: tuple[str, ...]
+
+
+PERMISSIONS = (
+    BotPermission("chat.profile", "群资料", "群组管理", "修改群标题和描述。"),
+    BotPermission("chat.members", "成员与管理员", "群组管理", "查询、处罚及调整管理员。"),
+    BotPermission("chat.permissions", "群权限", "群组管理", "修改普通成员默认权限。"),
+    BotPermission("chat.announcements", "公告", "群组管理", "发送并置顶群公告。"),
+    BotPermission("chat.invites", "邀请链接", "群组管理", "创建和撤销邀请链接。"),
+    BotPermission("chat.rules", "群规", "群组管理", "查看和维护群规。"),
+    BotPermission("chat.notes", "群笔记", "群组管理", "维护群内笔记功能。"),
+    BotPermission(
+        "automation.manage",
+        "自动化与风控",
+        "自动化",
+        "管理欢迎语、自动回复、验证码、聊天机器人、内容风控和资料监控。",
+    ),
+    BotPermission("points.manage", "积分", "社区运营", "运行积分规则、排行和人工调整。"),
+    BotPermission("giveaways.manage", "抽奖", "社区运营", "创建、开奖、退款和管理参与者。"),
+    BotPermission("karma.manage", "声望", "社区运营", "处理点赞、点踩和声望排行。"),
+    BotPermission("media.upload", "媒体中转", "系统能力", "向媒体中转群上传文件。"),
+)
+
+ALL_BOT_PERMISSIONS = frozenset(item.key for item in PERMISSIONS)
+PRIVATE_MANAGEMENT_PERMISSIONS = frozenset(
+    {
+        "chat.announcements",
+        "chat.members",
+        "chat.permissions",
+        "automation.manage",
+        "points.manage",
+        "giveaways.manage",
+    }
+)
+
+BUILTIN_ROLES = (
+    BuiltinBotRole(
+        "full_access",
+        "全部职责",
+        "拥有当前系统的全部机器人职责。",
+        tuple(item.key for item in PERMISSIONS),
+    ),
+    BuiltinBotRole(
+        "group_manager",
+        "群组管理员",
+        "负责群资料、成员、权限、公告、邀请链接、群规和群笔记。",
+        (
+            "chat.profile",
+            "chat.members",
+            "chat.permissions",
+            "chat.announcements",
+            "chat.invites",
+            "chat.rules",
+            "chat.notes",
+        ),
+    ),
+    BuiltinBotRole(
+        "automation_manager",
+        "自动化与风控专员",
+        "负责自动回复、欢迎语、验证码、聊天机器人、风控和资料监控。",
+        ("automation.manage", "media.upload"),
+    ),
+    BuiltinBotRole(
+        "points_manager",
+        "积分专员",
+        "负责积分规则、签到、排行榜和积分调整。",
+        ("points.manage",),
+    ),
+    BuiltinBotRole(
+        "giveaway_manager",
+        "抽奖专员",
+        "负责抽奖全流程,并可处理抽奖关联积分。",
+        ("giveaways.manage", "points.manage"),
+    ),
+    BuiltinBotRole(
+        "karma_manager",
+        "声望专员",
+        "负责点赞、点踩和声望排行。",
+        ("karma.manage",),
+    ),
+)
+
+MODULE_PERMISSIONS: dict[str, frozenset[str]] = {
+    "admin": frozenset({"chat.members", "chat.announcements", "chat.invites"}),
+    "admin_misc": frozenset({"chat.profile", "chat.members"}),
+    "admin_panel": PRIVATE_MANAGEMENT_PERMISSIONS,
+    "antiservice": frozenset({"automation.manage"}),
+    "blacklist": frozenset({"automation.manage"}),
+    "blacklist_chat": frozenset({"automation.manage"}),
+    "chatbot": frozenset({"automation.manage"}),
+    "filters": frozenset({"automation.manage"}),
+    "flood": frozenset({"automation.manage"}),
+    "greetings": frozenset({"automation.manage"}),
+    "giveaway": frozenset({"giveaways.manage"}),
+    "karma": frozenset({"karma.manage"}),
+    "locks": frozenset({"chat.permissions"}),
+    "notes": frozenset({"chat.notes"}),
+    "points": frozenset({"points.manage"}),
+    "rules": frozenset({"chat.rules"}),
+}
+
+TELEGRAM_COMMAND_PERMISSIONS = {
+    "purge": "chat.members",
+    "kick": "chat.members",
+    "dkick": "chat.members",
+    "ban": "chat.members",
+    "dban": "chat.members",
+    "tban": "chat.members",
+    "unban": "chat.members",
+    "listban": "chat.members",
+    "listunban": "chat.members",
+    "del": "chat.members",
+    "promote": "chat.members",
+    "fullpromote": "chat.members",
+    "demote": "chat.members",
+    "mute": "chat.members",
+    "tmute": "chat.members",
+    "unmute": "chat.members",
+    "ban_ghosts": "chat.members",
+    "warn": "chat.members",
+    "dwarn": "chat.members",
+    "rmwarns": "chat.members",
+    "warns": "chat.members",
+    "report": "chat.members",
+    "admins": "chat.members",
+    "admin": "chat.members",
+    "set_user_title": "chat.members",
+    "pin": "chat.announcements",
+    "unpin": "chat.announcements",
+    "invite": "chat.invites",
+    "set_chat_title": "chat.profile",
+    "set_chat_photo": "chat.profile",
+}
+
+
+def permission_catalog() -> list[dict[str, str]]:
+    return [asdict(item) for item in PERMISSIONS]
+
+
+def builtin_roles() -> list[dict[str, object]]:
+    return [
+        {
+            "role_id": item.role_id,
+            "name": item.name,
+            "description": item.description,
+            "permissions": list(item.permissions),
+            "builtin": True,
+        }
+        for item in BUILTIN_ROLES
+    ]
+
+
+def normalize_permissions(values: object) -> list[str]:
+    if not isinstance(values, (list, tuple, set, frozenset)):
+        values = str(values or "").replace(",", " ").split()
+    normalized = list(dict.fromkeys(str(item).strip() for item in values if str(item).strip()))
+    unknown = sorted(set(normalized) - ALL_BOT_PERMISSIONS)
+    if unknown:
+        raise ValueError(f"未知机器人权限:{', '.join(unknown)}")
+    return normalized
+
+
+def has_permission(permission: str, permissions: Iterable[str] | None) -> bool:
+    current = set(permissions or ())
+    return "*" in current or permission in current
+
+
+def has_any_permission(
+    required: Iterable[str],
+    permissions: Iterable[str] | None,
+) -> bool:
+    current = set(permissions or ())
+    return "*" in current or bool(set(required) & current)
+
+
+def module_allowed(module: str, permissions: Iterable[str] | None) -> bool:
+    required = MODULE_PERMISSIONS.get(module)
+    return not required or has_any_permission(required, permissions)
+
+
+def api_permission(method: str, path: str) -> str | None:
+    if path.startswith("/api/admin/v1/media"):
+        return "media.upload"
+    if path.startswith("/api/admin/v1/points"):
+        return "points.manage"
+    if path.startswith("/api/admin/v1/giveaways"):
+        return "giveaways.manage"
+    if "/giveaway-bans" in path:
+        return "giveaways.manage"
+    if not path.startswith("/api/admin/v1/chats/"):
+        return None
+    if path.endswith("/profile"):
+        return "chat.profile"
+    if path.endswith("/permissions"):
+        return "chat.permissions"
+    if path.endswith("/announcements"):
+        return "chat.announcements"
+    if "/members" in path or path.endswith("/admins"):
+        return "chat.members"
+    if path.endswith("/invites"):
+        return "chat.invites"
+    if path.endswith("/rules"):
+        return "chat.rules"
+    if path.endswith("/automation"):
+        return "automation.manage"
+    if "/points/" in path:
+        return "points.manage"
+    return None

+ 7 - 0
wbb/services/member_identity.py

@@ -31,6 +31,13 @@ def user_display_name(user: Any) -> str:
     )
 
 
+def non_mention_account_name(account: dict[str, Any]) -> str:
+    return (
+        str(account.get("display_name") or account.get("first_name") or "").strip()
+        or f"用户 {account['user_id']}"
+    )
+
+
 def _snapshot(*, username: str | None, first_name: str | None, last_name: str | None) -> dict[str, Any]:
     return {
         "username": username,