Kaynağa Gözat

feat: add role-based bot responsibilities

Prevent point rankings from notifying listed members.

AI-Co-Authored-By: Codex
chendeben 2 ay önce
ebeveyn
işleme
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,
   setCsrfToken,
   setSelectedBotId,
   setSelectedBotId,
 } from '@/services/api';
 } from '@/services/api';
+import { BotAccessContext } from '@/contexts/BotAccess';
 import type { AdminUser, BotProfile } from '@/types';
 import type { AdminUser, BotProfile } from '@/types';
 
 
 const menuItems = [
 const menuItems = [
@@ -75,6 +76,42 @@ export default function AdminLayout() {
     return () => window.removeEventListener('bot-profiles-changed', loadBots);
     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'] = [
   const accountMenu: MenuProps['items'] = [
     {
     {
       key: 'settings',
       key: 'settings',
@@ -100,11 +137,14 @@ export default function AdminLayout() {
   }
   }
 
 
   return (
   return (
-    <ProLayout
+    <BotAccessContext.Provider
+      value={{ bot: selectedProfile, hasPermission, hasAnyPermission }}
+    >
+      <ProLayout
       title="Telegram 群管"
       title="Telegram 群管"
       logo={<Bot size={24} color="#fff" aria-hidden="true" />}
       logo={<Bot size={24} color="#fff" aria-hidden="true" />}
       location={{ pathname: location.pathname }}
       location={{ pathname: location.pathname }}
-      menu={{ request: async () => menuItems }}
+      menu={{ request: async () => visibleMenuItems }}
       layout="mix"
       layout="mix"
       fixedHeader
       fixedHeader
       fixSiderbar
       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 { PageHeader, Surface } from '@/components/Page';
 import { StatusTag } from '@/components/StatusTag';
 import { StatusTag } from '@/components/StatusTag';
+import { useBotAccess } from '@/contexts/BotAccess';
 import { apiRequest, jsonOptions } from '@/services/api';
 import { apiRequest, jsonOptions } from '@/services/api';
 import type {
 import type {
   AutoReply,
   AutoReply,
@@ -141,6 +142,7 @@ async function uploadMedia(file: File): Promise<UploadedMedia> {
 
 
 export default function ChatDetailPage() {
 export default function ChatDetailPage() {
   const { chatId = '' } = useParams<{ chatId: string }>();
   const { chatId = '' } = useParams<{ chatId: string }>();
+  const { hasPermission } = useBotAccess();
   const [overview, setOverview] = useState<ChatOverview | null>(null);
   const [overview, setOverview] = useState<ChatOverview | null>(null);
   const [loading, setLoading] = useState(true);
   const [loading, setLoading] = useState(true);
   const [activeWorkspace, setActiveWorkspace] = useState('overview');
   const [activeWorkspace, setActiveWorkspace] = useState('overview');
@@ -166,12 +168,24 @@ export default function ChatDetailPage() {
 
 
   const workspaceItems = [
   const workspaceItems = [
     { key: 'overview', label: '概览与群规', children: <OverviewTab chatId={chatId} overview={overview} onChanged={load} /> },
     { 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 (
   return (
@@ -230,14 +244,24 @@ function OverviewTab({
   const [inviteOpen, setInviteOpen] = useState(false);
   const [inviteOpen, setInviteOpen] = useState(false);
   const [profileForm] = Form.useForm();
   const [profileForm] = Form.useForm();
   const [inviteForm] = 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 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(() => {
   useEffect(() => {
@@ -248,60 +272,64 @@ function OverviewTab({
   return (
   return (
     <div className="split-grid">
     <div className="split-grid">
       <div>
       <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>
             </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>
       <div>
       <div>
         <Surface title="群状态">
         <Surface title="群状态">
@@ -315,44 +343,48 @@ function OverviewTab({
             </Descriptions.Item>
             </Descriptions.Item>
           </Descriptions>
           </Descriptions>
         </Surface>
         </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>
       </div>
-      <Modal
+      {canManageInvites ? <Modal
         title="新建邀请链接"
         title="新建邀请链接"
         open={inviteOpen}
         open={inviteOpen}
         onCancel={() => setInviteOpen(false)}
         onCancel={() => setInviteOpen(false)}
@@ -398,7 +430,7 @@ function OverviewTab({
             <InputNumber min={1} max={365} style={{ width: '100%' }} />
             <InputNumber min={1} max={365} style={{ width: '100%' }} />
           </Form.Item>
           </Form.Item>
         </Form>
         </Form>
-      </Modal>
+      </Modal> : null}
     </div>
     </div>
   );
   );
 }
 }

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

@@ -1,6 +1,7 @@
 import {
 import {
   Alert,
   Alert,
   Button,
   Button,
+  Checkbox,
   Col,
   Col,
   Descriptions,
   Descriptions,
   Form,
   Form,
@@ -8,11 +9,13 @@ import {
   InputNumber,
   InputNumber,
   Modal,
   Modal,
   Row,
   Row,
+  Select,
   Space,
   Space,
   Switch,
   Switch,
   Table,
   Table,
   Tag,
   Tag,
   Tooltip,
   Tooltip,
+  Typography,
   message,
   message,
 } from 'antd';
 } from 'antd';
 import {
 import {
@@ -30,7 +33,13 @@ import { useCallback, useEffect, useState } from 'react';
 
 
 import { PageHeader, Surface } from '@/components/Page';
 import { PageHeader, Surface } from '@/components/Page';
 import { apiRequest, jsonOptions, setCsrfToken, setSelectedBotId } from '@/services/api';
 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 {
 interface SystemSettings {
   web_address: string;
   web_address: string;
@@ -48,6 +57,13 @@ interface BotFormValues {
   log_group_id?: string;
   log_group_id?: string;
   gban_log_group_id?: string;
   gban_log_group_id?: string;
   message_dump_chat?: 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';
 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 [telegram, setTelegram] = useState<TelegramSettings | null>(null);
   const [editingBot, setEditingBot] = useState<BotProfile | null>(null);
   const [editingBot, setEditingBot] = useState<BotProfile | null>(null);
   const [botModalOpen, setBotModalOpen] = useState(false);
   const [botModalOpen, setBotModalOpen] = useState(false);
+  const [editingRole, setEditingRole] = useState<BotRole | null>(null);
+  const [roleModalOpen, setRoleModalOpen] = useState(false);
   const [globalForm] = Form.useForm();
   const [globalForm] = Form.useForm();
   const [botForm] = Form.useForm<BotFormValues>();
   const [botForm] = Form.useForm<BotFormValues>();
+  const [roleForm] = Form.useForm<RoleFormValues>();
   const [passwordForm] = Form.useForm();
   const [passwordForm] = Form.useForm();
 
 
   const reloadTelegram = useCallback(async () => {
   const reloadTelegram = useCallback(async () => {
@@ -106,6 +125,7 @@ export default function SettingsPage() {
             log_group_id: target.log_group_id,
             log_group_id: target.log_group_id,
             gban_log_group_id: target.gban_log_group_id,
             gban_log_group_id: target.gban_log_group_id,
             message_dump_chat: target.message_dump_chat,
             message_dump_chat: target.message_dump_chat,
+            role_ids: target.role_ids || ['full_access'],
           }
           }
         : {
         : {
             label: '',
             label: '',
@@ -115,6 +135,7 @@ export default function SettingsPage() {
             log_group_id: '0',
             log_group_id: '0',
             gban_log_group_id: '0',
             gban_log_group_id: '0',
             message_dump_chat: '0',
             message_dump_chat: '0',
+            role_ids: ['full_access'],
           },
           },
     );
     );
     setBotModalOpen(true);
     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 = [
   const botColumns = [
     {
     {
       title: '机器人',
       title: '机器人',
@@ -168,6 +246,19 @@ export default function SettingsPage() {
       width: 120,
       width: 120,
       render: (_: unknown, profile: BotProfile) => runtimeTag(profile),
       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: '媒体中转群',
       title: '媒体中转群',
       dataIndex: 'message_dump_chat',
       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 (
   return (
     <>
     <>
       <PageHeader title="系统设置" />
       <PageHeader title="系统设置" />
@@ -358,12 +521,38 @@ export default function SettingsPage() {
             columns={botColumns}
             columns={botColumns}
             dataSource={telegram?.bots || []}
             dataSource={telegram?.bots || []}
             pagination={false}
             pagination={false}
-            scroll={{ x: 780 }}
+            scroll={{ x: 980 }}
             locale={{ emptyText: '尚未添加机器人' }}
             locale={{ emptyText: '尚未添加机器人' }}
           />
           />
         </div>
         </div>
       </Surface>
       </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 style={{ height: 16 }} />
       <div className="split-grid">
       <div className="split-grid">
         <Surface title="运行信息">
         <Surface title="运行信息">
@@ -456,6 +645,21 @@ export default function SettingsPage() {
           <Form.Item name="enabled" label="启用" valuePropName="checked">
           <Form.Item name="enabled" label="启用" valuePropName="checked">
             <Switch />
             <Switch />
           </Form.Item>
           </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">
           <Form.Item name="sudo_users_id" label="超级管理员用户 ID">
             <Input placeholder="多个 ID 使用空格分隔" />
             <Input placeholder="多个 ID 使用空格分隔" />
           </Form.Item>
           </Form.Item>
@@ -476,6 +680,56 @@ export default function SettingsPage() {
           </Form.Item>
           </Form.Item>
         </Form>
         </Form>
       </Modal>
       </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;
   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 {
 export interface BotProfile {
   bot_id: string;
   bot_id: string;
   label: string;
   label: string;
@@ -23,6 +40,8 @@ export interface BotProfile {
   log_group_id: string;
   log_group_id: string;
   gban_log_group_id: string;
   gban_log_group_id: string;
   message_dump_chat: string;
   message_dump_chat: string;
+  role_ids: string[];
+  permissions: string[];
   identity?: { id: string; username: string; name: string } | null;
   identity?: { id: string; username: string; name: string } | null;
   ready_to_connect: boolean;
   ready_to_connect: boolean;
   created_at?: string;
   created_at?: string;
@@ -34,6 +53,8 @@ export interface TelegramSettings {
   api_id?: number | null;
   api_id?: number | null;
   api_hash_configured: boolean;
   api_hash_configured: boolean;
   api_ready: boolean;
   api_ready: boolean;
+  roles: BotRole[];
+  permission_catalog: BotPermissionDefinition[];
   bots: BotProfile[];
   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',
   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 = {
 const bot = {
   bot_id: 'primary',
   bot_id: 'primary',
   label: 'Test Bot',
   label: 'Test Bot',
@@ -34,6 +49,8 @@ const bot = {
   log_group_id: '0',
   log_group_id: '0',
   gban_log_group_id: '0',
   gban_log_group_id: '0',
   message_dump_chat: '-1001234567890',
   message_dump_chat: '-1001234567890',
+  role_ids: ['full_access'],
+  permissions,
   identity: { id: '999', username: 'test_bot', name: 'Test Bot' },
   identity: { id: '999', username: 'test_bot', name: 'Test Bot' },
   ready_to_connect: true,
   ready_to_connect: true,
   runtime: { state: 'running' },
   runtime: { state: 'running' },
@@ -67,6 +84,21 @@ test.beforeEach(async ({ page }) => {
         api_id: 12345,
         api_id: 12345,
         api_hash_configured: true,
         api_hash_configured: true,
         api_ready: 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],
         bots: [bot],
       };
       };
     } else if (path.endsWith('/dashboard')) {
     } else if (path.endsWith('/dashboard')) {
@@ -199,6 +231,10 @@ test('登录与主要管理视图在不同视口无页面级横向滚动', async
   ] as const) {
   ] as const) {
     await page.goto(path);
     await page.goto(path);
     await expect(page.getByRole('heading', { name: heading })).toBeVisible();
     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);
     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 { beforeEach, expect, test, vi } from 'vitest';
 
 
 import SettingsPage from '@/pages/Settings';
 import SettingsPage from '@/pages/Settings';
@@ -27,6 +27,30 @@ beforeEach(() => {
         api_id: 12345,
         api_id: 12345,
         api_hash_configured: true,
         api_hash_configured: true,
         api_ready: 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: [
         bots: [
           {
           {
             bot_id: 'one',
             bot_id: 'one',
@@ -37,6 +61,8 @@ beforeEach(() => {
             log_group_id: '0',
             log_group_id: '0',
             gban_log_group_id: '0',
             gban_log_group_id: '0',
             message_dump_chat: '-100123',
             message_dump_chat: '-100123',
+            role_ids: ['points'],
+            permissions: ['points.manage'],
             identity: { id: '1', username: 'primary_bot', name: 'Primary' },
             identity: { id: '1', username: 'primary_bot', name: 'Primary' },
             ready_to_connect: true,
             ready_to_connect: true,
             runtime: { state: 'running' },
             runtime: { state: 'running' },
@@ -50,6 +76,8 @@ beforeEach(() => {
             log_group_id: '0',
             log_group_id: '0',
             gban_log_group_id: '0',
             gban_log_group_id: '0',
             message_dump_chat: '0',
             message_dump_chat: '0',
+            role_ids: [],
+            permissions: [],
             identity: null,
             identity: null,
             ready_to_connect: true,
             ready_to_connect: true,
             runtime: { state: 'stopped' },
             runtime: { state: 'stopped' },
@@ -66,6 +94,8 @@ test('展示多个机器人、脱敏凭据状态和运行状态', async () => {
 
 
   expect(await screen.findByText('@primary_bot')).toBeInTheDocument();
   expect(await screen.findByText('@primary_bot')).toBeInTheDocument();
   expect(screen.getByText('备用 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.getByText('已停用')).toBeInTheDocument();
   expect(screen.getByText('已停用')).toBeInTheDocument();
   expect(screen.getAllByText('已配置')).toHaveLength(2);
   expect(screen.getAllByText('已配置')).toHaveLength(2);
@@ -74,4 +104,8 @@ test('展示多个机器人、脱敏凭据状态和运行状态', async () => {
   expect(credentialsLink.parentElement).toHaveTextContent('不是机器人编号。');
   expect(credentialsLink.parentElement).toHaveTextContent('不是机器人编号。');
   expect(screen.getByText('32 位十六进制字符串,不是机器人令牌。')).toBeInTheDocument();
   expect(screen.getByText('32 位十六进制字符串,不是机器人令牌。')).toBeInTheDocument();
   expect(screen.queryByText(/AA[A-Za-z0-9_-]{20}/)).not.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"]
 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_PROFILE_ID = os.environ.get("WBB_BOT_PROFILE_ID", "primary")
 BOT_PROFILES_PATH = os.environ.get("BOT_PROFILES_PATH", "runtime/bot_profiles.json")
 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_NAME = "Test Bot"
     fake_wbb.BOT_USERNAME = "test_bot"
     fake_wbb.BOT_USERNAME = "test_bot"
     fake_wbb.BOT_PROFILE_ID = "primary"
     fake_wbb.BOT_PROFILE_ID = "primary"
+    fake_wbb.BOT_PERMISSIONS = {"*"}
     fake_wbb.LOG_GROUP_ID = 0
     fake_wbb.LOG_GROUP_ID = 0
     fake_wbb.TELEGRAM_CONNECTED = True
     fake_wbb.TELEGRAM_CONNECTED = True
     fake_wbb.SUPERVISOR_MODE = False
     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 status["api_ready"] is True
     assert len(status["bots"]) == 2
     assert len(status["bots"]) == 2
     assert status["bots"][0]["sudo_users_id"] == ["100", "200"]
     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)
     serialized = json.dumps(status)
     assert first_token not in serialized
     assert first_token not in serialized
     assert second_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
     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):
 def test_bot_profile_validation(app_modules, tmp_path):
     config = app_modules.load("wbb.admin.bot_config")
     config = app_modules.load("wbb.admin.bot_config")
     path = tmp_path / "bots.json"
     path = tmp_path / "bots.json"
@@ -63,6 +113,20 @@ def test_bot_profile_validation(app_modules, tmp_path):
     assert "不能使用机器人令牌" in str(error.value)
     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):
 def test_supervisor_assigns_each_worker_an_isolated_database(app_modules, tmp_path):
     supervisor_module = app_modules.load("wbb.admin.supervisor")
     supervisor_module = app_modules.load("wbb.admin.supervisor")
     supervisor = supervisor_module.BotSupervisor(tmp_path / "bots.json", project_root=tmp_path)
     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,
         "log_group_id": 0,
         "gban_log_group_id": 0,
         "gban_log_group_id": 0,
         "message_dump_chat": 0,
         "message_dump_chat": 0,
+        "permissions": ["points.manage", "giveaways.manage"],
     }
     }
 
 
     environment = supervisor._worker_environment(profile, 18088)
     environment = supervisor._worker_environment(profile, 18088)
 
 
     assert environment["WBB_BOT_PROFILE_ID"] == "bot.one/secondary"
     assert environment["WBB_BOT_PROFILE_ID"] == "bot.one/secondary"
     assert environment["WBB_BOT_DATABASE"] == "wbb_bot_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):
 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 telegram.status == 200
         assert (await telegram.json())["data"]["api_hash_configured"] is True
         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(
         created = await client.post(
             "/api/admin/v1/bots",
             "/api/admin/v1/bots",
             headers=headers,
             headers=headers,
@@ -122,6 +201,7 @@ async def test_bot_admin_api_crud_and_token_test_are_redacted(app_modules, monke
                 "label": "Test Bot",
                 "label": "Test Bot",
                 "bot_token": token,
                 "bot_token": token,
                 "enabled": False,
                 "enabled": False,
+                "role_ids": [role["role_id"]],
                 "confirm": True,
                 "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()
         created_payload = await created.json()
         profile = created_payload["data"]
         profile = created_payload["data"]
         assert profile["bot_token_configured"] is True
         assert profile["bot_token_configured"] is True
+        assert profile["permissions"] == ["points.manage"]
         assert token not in json.dumps(created_payload)
         assert token not in json.dumps(created_payload)
 
 
         async def fake_test_bot_token(_token: str):
         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},
             json={"confirm": True},
         )
         )
         assert deleted.status == 200
         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:
     finally:
         await client.close()
         await client.close()

+ 28 - 1
tests/test_points.py

@@ -5,6 +5,33 @@ from datetime import UTC, datetime, timedelta
 import pytest
 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):
 async def test_adjustment_is_idempotent_and_balance_cannot_be_negative(app_modules):
     points = app_modules.load("wbb.utils.dbpoints")
     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,
             "activity_daily_cap": 2,
         },
         },
     )
     )
-    now = datetime.now(UTC)
+    now = datetime(2099, 1, 1, 4, 0, tzinfo=UTC)
 
 
     _, first = await points.award_activity(
     _, first = await points.award_activity(
         chat_id=-100,
         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
 WELCOME_DELAY_KICK_SEC = WELCOME_DELAY_KICK_SEC
 LOG_GROUP_ID = LOG_GROUP_ID
 LOG_GROUP_ID = LOG_GROUP_ID
 MESSAGE_DUMP_CHAT = MESSAGE_DUMP_CHAT
 MESSAGE_DUMP_CHAT = MESSAGE_DUMP_CHAT
+BOT_PERMISSIONS = globals().get("BOT_PERMISSIONS", {"*"})
 MOD_LOAD = [
 MOD_LOAD = [
     "admin",
     "admin",
     "admin_misc",
     "admin_misc",

+ 109 - 13
wbb/__main__.py

@@ -29,13 +29,14 @@ import time
 from contextlib import closing, suppress
 from contextlib import closing, suppress
 
 
 import psutil
 import psutil
-from pyrogram import filters, idle
+from pyrogram import StopPropagation, filters, idle
 from pyrogram.enums import ChatType, ParseMode
 from pyrogram.enums import ChatType, ParseMode
 from pyrogram.types import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup
 from pyrogram.types import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup
 from uvloop import install
 from uvloop import install
 
 
 from wbb import (
 from wbb import (
     BOT_NAME,
     BOT_NAME,
+    BOT_PERMISSIONS,
     BOT_USERNAME,
     BOT_USERNAME,
     LOG_GROUP_ID,
     LOG_GROUP_ID,
     USERBOT_CONNECTED,
     USERBOT_CONNECTED,
@@ -48,6 +49,12 @@ from wbb import (
 )
 )
 from wbb.core.keyboard import ikb
 from wbb.core.keyboard import ikb
 from wbb.modules import ALL_MODULES
 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 import paginate_modules
 from wbb.utils.constants import MARKDOWN
 from wbb.utils.constants import MARKDOWN
 from wbb.utils.dbfunctions import clean_restart_stage, get_rules
 from wbb.utils.dbfunctions import clean_restart_stage, get_rules
@@ -93,6 +100,68 @@ BOT_COMMANDS = [
     BotCommand("greroll", "重新抽取中奖者"),
     BotCommand("greroll", "重新抽取中奖者"),
     BotCommand("rules", "查看本群群规"),
     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:
 async def bot_sys_stats() -> str:
@@ -146,7 +215,7 @@ async def start_bot():
         log.info(f"用户客户端已启动:{USERBOT_NAME}")
         log.info(f"用户客户端已启动:{USERBOT_NAME}")
 
 
     try:
     try:
-        await app.set_bot_commands(BOT_COMMANDS)
+        await app.set_bot_commands(_visible_bot_commands())
         log.info("已注册中文 Telegram 命令菜单")
         log.info("已注册中文 Telegram 命令菜单")
     except Exception as exc:
     except Exception as exc:
         log.error(f"注册 Telegram 命令菜单失败:{exc}")
         log.error(f"注册 Telegram 命令菜单失败:{exc}")
@@ -180,17 +249,17 @@ async def start_bot():
     log.info("机器人已停止")
     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(
 home_keyboard_pm = InlineKeyboardMarkup(
     [
     [
-        [
-            InlineKeyboardButton(
-                text="功能帮助", callback_data="bot_commands"
-            ),
-            InlineKeyboardButton(
-                text="群组管理",
-                callback_data="manage_hint",
-            ),
-        ],
+        home_actions,
         [
         [
             InlineKeyboardButton(
             InlineKeyboardButton(
                 text="运行状态",
                 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 = (
 home_text_pm = (
     f"你好,我是 {BOT_NAME}。\n\n"
     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(
 keyboard = InlineKeyboardMarkup(
@@ -406,6 +500,8 @@ async def commands_callbacc(_, CallbackQuery):
 
 
 @app.on_callback_query(filters.regex("^manage_hint$"))
 @app.on_callback_query(filters.regex("^manage_hint$"))
 async def manage_hint_callback(_, query):
 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)
     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 (
 from wbb.admin.bot_config import (
     BotConfigError,
     BotConfigError,
     create_bot_profile,
     create_bot_profile,
+    create_bot_role,
     delete_bot_profile,
     delete_bot_profile,
+    delete_bot_role,
     get_bot_profile_secrets,
     get_bot_profile_secrets,
     store_bot_identity,
     store_bot_identity,
     telegram_config_status,
     telegram_config_status,
     test_bot_token,
     test_bot_token,
     update_bot_profile,
     update_bot_profile,
+    update_bot_role,
     update_telegram_config,
     update_telegram_config,
 )
 )
 from wbb.admin.security import (
 from wbb.admin.security import (
@@ -33,6 +36,7 @@ from wbb.admin.security import (
     validate_new_password,
     validate_new_password,
     verify_password,
     verify_password,
 )
 )
+from wbb.services.bot_permissions import api_permission, has_permission
 from wbb.services.chat_management import (
 from wbb.services.chat_management import (
     ChatManagementError,
     ChatManagementError,
     apply_automation_settings,
     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))
         await _record_request_audit(request, success_state=False, error=str(exc))
         return error_response(problem)
         return error_response(problem)
     except BotConfigError as exc:
     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))
         await _record_request_audit(request, success_state=False, error=str(exc))
         return error_response(problem)
         return error_response(problem)
     except web.HTTPException:
     except web.HTTPException:
@@ -324,6 +334,49 @@ async def authentication_middleware(request: web.Request, handler):
     return await handler(request)
     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
 @web.middleware
 async def bot_proxy_middleware(request: web.Request, handler):
 async def bot_proxy_middleware(request: web.Request, handler):
     if not bool(getattr(wbb, "SUPERVISOR_MODE", False)) or not request.path.startswith(
     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,
             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)
     endpoint = supervisor.endpoint_for(bot_id)
     if endpoint is None:
     if endpoint is None:
         raise ApiProblem(
         raise ApiProblem(
@@ -429,6 +470,10 @@ class AdminApi:
         router.add_delete(f"{API_PREFIX}/bots/{{bot_id}}", self.bot_delete)
         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}}/test", self.bot_test)
         router.add_post(f"{API_PREFIX}/bots/{{bot_id}}/restart", self.bot_restart)
         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_get(f"{API_PREFIX}/audit-logs", self.audit_logs)
         router.add_post(f"{API_PREFIX}/media", self.upload_media)
         router.add_post(f"{API_PREFIX}/media", self.upload_media)
 
 
@@ -714,6 +759,58 @@ class AdminApi:
             )
             )
         return success(await supervisor.restart(bot_id))
         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:
     async def audit_logs(self, request: web.Request) -> web.Response:
         page, page_size = page_params(request)
         page, page_size = page_params(request)
         chat_raw = request.query.get("chat_id")
         chat_raw = request.query.get("chat_id")
@@ -1272,6 +1369,7 @@ def build_admin_application() -> web.Application:
         middlewares=[
         middlewares=[
             api_error_middleware,
             api_error_middleware,
             authentication_middleware,
             authentication_middleware,
+            bot_role_middleware,
             bot_proxy_middleware,
             bot_proxy_middleware,
         ],
         ],
         client_max_size=(max_upload_mb + 1) * 1024 * 1024,
         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 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,}$")
 BOT_TOKEN_PATTERN = re.compile(r"^\d{5,}:[A-Za-z0-9_-]{20,}$")
 API_HASH_PATTERN = re.compile(r"^[A-Fa-f0-9]{32}$")
 API_HASH_PATTERN = re.compile(r"^[A-Fa-f0-9]{32}$")
 PROFILE_FIELDS = {
 PROFILE_FIELDS = {
@@ -30,8 +37,9 @@ class BotConfigError(ValueError):
 
 
 def _empty_document() -> dict[str, Any]:
 def _empty_document() -> dict[str, Any]:
     return {
     return {
-        "version": 1,
+        "version": 2,
         "telegram": {"api_id": 0, "api_hash": ""},
         "telegram": {"api_id": 0, "api_hash": ""},
+        "roles": [],
         "bots": [],
         "bots": [],
     }
     }
 
 
@@ -53,11 +61,12 @@ def _read_document(path: str | Path) -> dict[str, Any]:
     if not isinstance(telegram, dict):
     if not isinstance(telegram, dict):
         telegram = {}
         telegram = {}
     return {
     return {
-        "version": 1,
+        "version": 2,
         "telegram": {
         "telegram": {
             "api_id": int(telegram.get("api_id") or 0),
             "api_id": int(telegram.get("api_id") or 0),
             "api_hash": str(telegram.get("api_hash") or ""),
             "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)],
         "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
     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(
 def _public_profile(
     profile: dict[str, Any],
     profile: dict[str, Any],
     *,
     *,
+    data: dict[str, Any],
     api_ready: bool,
     api_ready: bool,
     runtime: dict[str, Any] | None = None,
     runtime: dict[str, Any] | None = None,
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
@@ -125,6 +216,8 @@ def _public_profile(
         "log_group_id": str(profile.get("log_group_id") or 0),
         "log_group_id": str(profile.get("log_group_id") or 0),
         "gban_log_group_id": str(profile.get("gban_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),
         "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,
         "identity": identity,
         "ready_to_connect": api_ready and token_configured,
         "ready_to_connect": api_ready and token_configured,
         "created_at": profile.get("created_at"),
         "created_at": profile.get("created_at"),
@@ -149,9 +242,12 @@ def telegram_config_status(
         "api_id": api_id or None,
         "api_id": api_id or None,
         "api_hash_configured": api_hash_configured,
         "api_hash_configured": api_hash_configured,
         "api_ready": api_ready,
         "api_ready": api_ready,
+        "roles": _all_roles(data),
+        "permission_catalog": permission_catalog(),
         "bots": [
         "bots": [
             _public_profile(
             _public_profile(
                 profile,
                 profile,
+                data=data,
                 api_ready=api_ready,
                 api_ready=api_ready,
                 runtime=runtime_map.get(str(profile.get("bot_id"))),
                 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):
     if not BOT_TOKEN_PATTERN.fullmatch(token):
         raise BotConfigError("invalid_bot_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:
     with _STORE_LOCK:
         data = _read_document(path)
         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)
         data["bots"].append(profile)
         _write_document(path, data)
         _write_document(path, data)
         api_ready = bool(data["telegram"]["api_id"] and data["telegram"]["api_hash"])
         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]:
 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"))
             profile["enabled"] = bool(body.get("enabled"))
         if "sudo_users_id" in body:
         if "sudo_users_id" in body:
             profile["sudo_users_id"] = _normalize_sudoers(body.get("sudo_users_id"))
             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"}:
         for field in PROFILE_FIELDS - {"sudo_users_id"}:
             if field in body:
             if field in body:
                 profile[field] = _integer(body.get(field), field)
                 profile[field] = _integer(body.get(field), field)
         profile["updated_at"] = datetime.now(UTC).isoformat().replace("+00:00", "Z")
         profile["updated_at"] = datetime.now(UTC).isoformat().replace("+00:00", "Z")
         _write_document(path, data)
         _write_document(path, data)
         api_ready = bool(data["telegram"]["api_id"] and data["telegram"]["api_hash"])
         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:
 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:
     with _STORE_LOCK:
         data = _read_document(path)
         data = _read_document(path)
         profile = dict(_find_profile(data, bot_id))
         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"])
         telegram = dict(data["telegram"])
     return {**profile, **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:
 def store_bot_identity(path: str | Path, bot_id: str, identity: dict[str, Any]) -> None:
     with _STORE_LOCK:
     with _STORE_LOCK:
         data = _read_document(path)
         data = _read_document(path)

+ 1 - 0
wbb/admin/supervisor.py

@@ -172,6 +172,7 @@ class BotSupervisor:
                 "WBB_BOT_WORKER": "1",
                 "WBB_BOT_WORKER": "1",
                 "WBB_BOT_PROFILE_ID": str(profile["bot_id"]),
                 "WBB_BOT_PROFILE_ID": str(profile["bot_id"]),
                 "WBB_BOT_DATABASE": self._worker_database_name(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"]),
                 "BOT_TOKEN": str(profile["bot_token"]),
                 "API_ID": str(profile["api_id"]),
                 "API_ID": str(profile["api_id"]),
                 "API_HASH": str(profile["api_hash"]),
                 "API_HASH": str(profile["api_hash"]),

+ 6 - 1
wbb/modules/__init__.py

@@ -26,7 +26,8 @@ import importlib
 import sys
 import sys
 from os.path import basename, dirname, isfile
 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():
 def __list_all_modules():
@@ -54,6 +55,10 @@ def __list_all_modules():
         else:
         else:
             to_load = all_modules
             to_load = all_modules
 
 
+        to_load = [
+            item for item in to_load if module_allowed(item, BOT_PERMISSIONS)
+        ]
+
         if MOD_NOLOAD:
         if MOD_NOLOAD:
             return [item for item in to_load if item not in 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.enums import ParseMode
 from pyrogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
 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.blacklist_enforcement import add_risk_keyword, remove_risk_keyword
+from wbb.services.bot_permissions import has_permission
 from wbb.services.chat_management import (
 from wbb.services.chat_management import (
     ChatManagementError,
     ChatManagementError,
     apply_automation_settings,
     apply_automation_settings,
@@ -29,7 +30,7 @@ from wbb.services.giveaways import (
     create_and_publish_giveaway,
     create_and_publish_giveaway,
     finish_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.services.point_settings import apply_point_rules
 from wbb.utils.dbadmin import record_audit
 from wbb.utils.dbadmin import record_audit
 from wbb.utils.dbgiveaway import get_giveaway, list_running_giveaways
 from wbb.utils.dbgiveaway import get_giveaway, list_running_giveaways
@@ -57,6 +58,28 @@ __HELP__ = """私聊管理员菜单:
 
 
 FLOW_TTL = timedelta(minutes=10)
 FLOW_TTL = timedelta(minutes=10)
 PAGE_SIZE = 6
 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
 @dataclass
@@ -82,6 +105,48 @@ def _back(chat_id: int) -> list[InlineKeyboardButton]:
     return [_button("返回群菜单", f"mg:c:{chat_id}")]
     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:
 async def _edit(query: CallbackQuery, text: str, keyboard: InlineKeyboardMarkup) -> None:
     await query.message.edit_text(
     await query.message.edit_text(
         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"成员数:{overview.get('member_count') or '-'}\n"
         f"机器人权限:{escape(privileges)}"
         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)
     await _edit(query, text, keyboard)
 
 
 
 
@@ -181,6 +255,7 @@ def _duration_label(seconds: int) -> str:
 async def _show_risk_rule(
 async def _show_risk_rule(
     query: CallbackQuery, chat_id: int, rule_id: str
     query: CallbackQuery, chat_id: int, rule_id: str
 ) -> None:
 ) -> None:
+    _require_bot_role("automation.manage")
     await get_chat_overview(chat_id, actor_id=query.from_user.id)
     await get_chat_overview(chat_id, actor_id=query.from_user.id)
     settings = await get_automation_settings(chat_id)
     settings = await get_automation_settings(chat_id)
     rule = next(
     rule = next(
@@ -292,6 +367,7 @@ async def _show_risk_rule(
 
 
 
 
 async def _show_section(query: CallbackQuery, chat_id: int, section: str) -> None:
 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)
     await get_chat_overview(chat_id, actor_id=query.from_user.id)
     if section == "overview":
     if section == "overview":
         return await _show_chat_menu(query, chat_id)
         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 '关'}",
             f"底部签到按钮:{'开' if rules['checkin_button_enabled'] else '关'}",
         ]
         ]
         for index, account in enumerate(accounts, 1):
         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']}")
             lines.append(f"{index}. {escape(str(label))}:{account['balance']}")
         return await _edit(
         return await _edit(
             query,
             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:
 async def _start_flow(query: CallbackQuery, chat_id: int, action: str) -> None:
+    _require_bot_role(_flow_role_permission(action))
     if action == "points_query":
     if action == "points_query":
         await get_chat_overview(chat_id, actor_id=query.from_user.id)
         await get_chat_overview(chat_id, actor_id=query.from_user.id)
     else:
     else:
@@ -540,6 +612,7 @@ async def _start_risk_rule_flow(
     action: str,
     action: str,
     rule_id: str | None = None,
     rule_id: str | None = None,
 ) -> None:
 ) -> None:
+    _require_bot_role("automation.manage")
     await ensure_permission(chat_id, "can_change_info", actor_id=query.from_user.id)
     await ensure_permission(chat_id, "can_change_info", actor_id=query.from_user.id)
     data: dict[str, Any] = {}
     data: dict[str, Any] = {}
     if rule_id:
     if rule_id:
@@ -615,6 +688,7 @@ async def _start_flow_for_member(
     user_id: int,
     user_id: int,
     action: str,
     action: str,
 ) -> None:
 ) -> None:
+    _require_bot_role("chat.members")
     permission = "can_promote_members" if action in {"promote", "demote"} else "can_restrict_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)
     await ensure_permission(chat_id, permission, actor_id=query.from_user.id)
     flow = PendingFlow(action, chat_id, "reason", {"user_id": 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"]
     flow: PendingFlow = confirmation["flow"]
     actor_id = query.from_user.id
     actor_id = query.from_user.id
     action = flow.action
     action = flow.action
+    _require_bot_role(_flow_role_permission(action))
     data = flow.data
     data = flow.data
     if action.startswith("points_") or action.startswith("risk_rule_") or action in {
     if action.startswith("points_") or action.startswith("risk_rule_") or action in {
         "announcement",
         "announcement",
@@ -1020,6 +1095,7 @@ async def _danger_action(query: CallbackQuery, parts: list[str]) -> None:
         payload = {"action": "autoreply_clear", "chat_id": chat_id}
         payload = {"action": "autoreply_clear", "chat_id": chat_id}
     else:
     else:
         return
         return
+    _require_bot_role(_direct_role_permission(payload["action"]))
     token = secrets.token_urlsafe(8)
     token = secrets.token_urlsafe(8)
     _confirmations[token] = {
     _confirmations[token] = {
         "actor_id": query.from_user.id,
         "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(
 async def _start_risk_rule_direct(
     query: CallbackQuery, chat_id: int, rule_id: str, operation: str
     query: CallbackQuery, chat_id: int, rule_id: str, operation: str
 ) -> None:
 ) -> None:
+    _require_bot_role("automation.manage")
     await ensure_permission(chat_id, "can_change_info", actor_id=query.from_user.id)
     await ensure_permission(chat_id, "can_change_info", actor_id=query.from_user.id)
     settings = await get_automation_settings(chat_id)
     settings = await get_automation_settings(chat_id)
     rule = next(
     rule = next(
@@ -1105,6 +1182,7 @@ async def _execute_direct_confirmation(query: CallbackQuery, token: str) -> None
     payload = confirmation["direct"]
     payload = confirmation["direct"]
     chat_id = int(payload["chat_id"])
     chat_id = int(payload["chat_id"])
     action = payload["action"]
     action = payload["action"]
+    _require_bot_role(_direct_role_permission(action))
     if action == "permissions":
     if action == "permissions":
         readonly = payload["mode"] == "readonly"
         readonly = payload["mode"] == "readonly"
         await update_chat_permissions(
         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
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 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.services.member_identity import observe_message_member_identities
 from wbb.utils.dbadmin import upsert_managed_chat
 from wbb.utils.dbadmin import upsert_managed_chat
 from wbb.utils.dbfunctions import (
 from wbb.utils.dbfunctions import (
@@ -49,7 +50,8 @@ async def chat_watcher_func(_, message):
 
 
     await add_served_chat(chat_id)
     await add_served_chat(chat_id)
     if chat_id < 0:
     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(
         await upsert_managed_chat(
             chat_id=chat_id,
             chat_id=chat_id,
             title=message.chat.title,
             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 import app, log
 from wbb.core.decorators.permissions import adminsOnly
 from wbb.core.decorators.permissions import adminsOnly
+from wbb.services.member_identity import non_mention_account_name
 from wbb.services.point_settings import (
 from wbb.services.point_settings import (
     CHECKIN_BUTTON_TEXT,
     CHECKIN_BUTTON_TEXT,
     apply_point_rules,
     apply_point_rules,
@@ -161,14 +162,8 @@ async def points_rank_command(_, message: Message):
         return await message.reply_text("本群尚无积分记录。")
         return await message.reply_text("本群尚无积分记录。")
     lines = ["<b>本群积分排行榜</b>"]
     lines = ["<b>本群积分排行榜</b>"]
     for index, account in enumerate(accounts, 1):
     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(
         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> 积分"
             f"<b>{int(account['balance'])}</b> 积分"
         )
         )
     return await message.reply_text(
     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]:
 def _snapshot(*, username: str | None, first_name: str | None, last_name: str | None) -> dict[str, Any]:
     return {
     return {
         "username": username,
         "username": username,