diff --git a/src/langbot/pkg/api/http/controller/groups/platform/bots.py b/src/langbot/pkg/api/http/controller/groups/platform/bots.py index ac580b1a3..ab234fa05 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/bots.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/bots.py @@ -18,7 +18,6 @@ class BotsRouterGroup(group.RouterGroup): @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(bot_uuid: str) -> str: if quart.request.method == 'GET': - # 返回运行时信息,包括webhook地址等 bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid) if bot is None: return self.http_status(404, -1, 'bot not found') @@ -37,30 +36,21 @@ class BotsRouterGroup(group.RouterGroup): from_index = json_data.get('from_index', -1) max_count = json_data.get('max_count', 10) logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count) - return self.success( - data={ - 'logs': logs, - 'total_count': total_count, - } - ) + return self.success(data={'logs': logs, 'total_count': total_count}) @self.route('//send_message', methods=['POST'], auth_type=group.AuthType.API_KEY) async def _(bot_uuid: str) -> str: - """Send message to a specific target via bot""" json_data = await quart.request.json target_type = json_data.get('target_type') target_id = json_data.get('target_id') message_chain_data = json_data.get('message_chain') - # Validate required fields if not target_type: return self.http_status(400, -1, 'target_type is required') if not target_id: return self.http_status(400, -1, 'target_id is required') if not message_chain_data: return self.http_status(400, -1, 'message_chain is required') - - # Validate target_type if target_type not in ['person', 'group']: return self.http_status(400, -1, 'target_type must be either "person" or "group"') @@ -69,6 +59,29 @@ class BotsRouterGroup(group.RouterGroup): return self.success(data={'sent': True}) except Exception as e: import traceback - traceback.print_exc() return self.http_status(500, -1, f'Failed to send message: {str(e)}') + + # ============ Bot Admins ============ + + @self.route('//admins', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _(bot_uuid: str) -> str: + if quart.request.method == 'GET': + admins = await self.ap.bot_service.get_bot_admins(bot_uuid) + return self.success(data={'admins': admins}) + elif quart.request.method == 'POST': + json_data = await quart.request.json + launcher_type = json_data.get('launcher_type', '').strip() + launcher_id = str(json_data.get('launcher_id', '')).strip() + if not launcher_type or not launcher_id: + return self.http_status(400, -1, 'launcher_type and launcher_id are required') + try: + admin_id = await self.ap.bot_service.add_bot_admin(bot_uuid, launcher_type, launcher_id) + return self.success(data={'id': admin_id}) + except Exception as e: + return self.http_status(409, -1, str(e)) + + @self.route('//admins/', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def _(bot_uuid: str, admin_id: int) -> str: + await self.ap.bot_service.delete_bot_admin(bot_uuid, admin_id) + return self.success() diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index b8af08613..f31efcf7f 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -199,3 +199,37 @@ class BotService: # Send message via adapter await runtime_bot.adapter.send_message(target_type, str(target_id), message_chain) + + # ============ Bot Admins ============ + + async def get_bot_admins(self, bot_uuid: str) -> list[dict]: + from ....entity.persistence import bot as persistence_bot + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.select(persistence_bot.BotAdmin).where( + persistence_bot.BotAdmin.bot_uuid == bot_uuid + ) + ) + return [ + {'id': r.id, 'launcher_type': r.launcher_type, 'launcher_id': r.launcher_id} + for r in result.all() + ] + + async def add_bot_admin(self, bot_uuid: str, launcher_type: str, launcher_id: str) -> int: + from ....entity.persistence import bot as persistence_bot + result = await self.ap.persistence_mgr.execute_async( + sqlalchemy.insert(persistence_bot.BotAdmin).values( + bot_uuid=bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + ) + ) + return result.inserted_primary_key[0] + + async def delete_bot_admin(self, bot_uuid: str, admin_id: int) -> None: + from ....entity.persistence import bot as persistence_bot + await self.ap.persistence_mgr.execute_async( + sqlalchemy.delete(persistence_bot.BotAdmin).where( + persistence_bot.BotAdmin.bot_uuid == bot_uuid, + persistence_bot.BotAdmin.id == admin_id, + ) + ) diff --git a/src/langbot/pkg/command/cmdmgr.py b/src/langbot/pkg/command/cmdmgr.py index a1d7e009c..e955b6eac 100644 --- a/src/langbot/pkg/command/cmdmgr.py +++ b/src/langbot/pkg/command/cmdmgr.py @@ -84,7 +84,16 @@ class CommandManager: privilege = 1 - if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']: + import sqlalchemy as _sa + from ..entity.persistence.bot import BotAdmin as _BotAdmin + _admins = await self.ap.persistence_mgr.execute_async( + _sa.select(_BotAdmin).where( + _BotAdmin.bot_uuid == (query.bot_uuid or ''), + _BotAdmin.launcher_type == query.launcher_type.value, + _BotAdmin.launcher_id == str(query.launcher_id), + ) + ) + if _admins.first() is not None: privilege = 2 ctx = command_context.ExecuteContext( diff --git a/src/langbot/pkg/entity/persistence/bot.py b/src/langbot/pkg/entity/persistence/bot.py index c3fa295f7..6ff8512f9 100644 --- a/src/langbot/pkg/entity/persistence/bot.py +++ b/src/langbot/pkg/entity/persistence/bot.py @@ -3,6 +3,22 @@ import sqlalchemy from .base import Base +class BotAdmin(Base): + """Bot admin — a launcher that has admin privilege for a specific bot's commands""" + + __tablename__ = 'bot_admins' + + id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True) + bot_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + launcher_type = sqlalchemy.Column(sqlalchemy.String(64), nullable=False) + launcher_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) + created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) + + __table_args__ = ( + sqlalchemy.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'), + ) + + class Bot(Base): """Bot""" diff --git a/src/langbot/pkg/persistence/alembic/versions/0007_add_bot_admins.py b/src/langbot/pkg/persistence/alembic/versions/0007_add_bot_admins.py new file mode 100644 index 000000000..2f6c96944 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0007_add_bot_admins.py @@ -0,0 +1,86 @@ +"""add bot_admins table and migrate config admins + +Revision ID: 0007_add_bot_admins +Revises: 0006_normalize_mcp_remote_mode +Create Date: 2026-06-26 +""" + +import sqlalchemy as sa +from alembic import op + +revision = '0007_add_bot_admins' +down_revision = '0006_normalize_mcp_remote_mode' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + if 'bot_admins' in sa.inspect(conn).get_table_names(): + return + op.create_table( + 'bot_admins', + sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), + sa.Column('bot_uuid', sa.String(255), nullable=False), + sa.Column('launcher_type', sa.String(64), nullable=False), + sa.Column('launcher_id', sa.String(255), nullable=False), + sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'), + ) + + # Migrate old config-based admins into the first bot (best-effort) + inspector = sa.inspect(conn) + tables = inspector.get_table_names() + + if 'bots' not in tables: + return + + # Read the first bot uuid + row = conn.execute(sa.text('SELECT uuid FROM bots ORDER BY created_at LIMIT 1')).first() + if row is None: + return + first_bot_uuid = row[0] + + # Read instance_config metadata key that holds the admins list + if 'metadata' not in tables: + return + meta_row = conn.execute( + sa.text("SELECT value FROM metadata WHERE key = 'instance_config'") + ).first() + if meta_row is None: + return + + import json + try: + cfg = json.loads(meta_row[0]) + except Exception: + return + + admins = cfg.get('admins', []) + for entry in admins: + parts = entry.split('_', 1) + if len(parts) != 2: + continue + launcher_type, launcher_id = parts + try: + conn.execute( + sa.text( + 'INSERT OR IGNORE INTO bot_admins (bot_uuid, launcher_type, launcher_id)' + ' VALUES (:bu, :lt, :li)' + ), + {'bu': first_bot_uuid, 'lt': launcher_type, 'li': launcher_id}, + ) + except Exception: + pass + + # Remove admins key from stored config + if 'admins' in cfg: + del cfg['admins'] + conn.execute( + sa.text("UPDATE metadata SET value = :v WHERE key = 'instance_config'"), + {'v': json.dumps(cfg)}, + ) + + +def downgrade() -> None: + op.drop_table('bot_admins') diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 2f1c97a7e..85999ae11 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -1,4 +1,3 @@ -admins: [] api: port: 5300 webhook_prefix: 'http://127.0.0.1:5300' diff --git a/web/src/app/home/bots/BotDetailContent.tsx b/web/src/app/home/bots/BotDetailContent.tsx index 8440e47ac..118b3f8d0 100644 --- a/web/src/app/home/bots/BotDetailContent.tsx +++ b/web/src/app/home/bots/BotDetailContent.tsx @@ -22,11 +22,12 @@ import { import BotForm from '@/app/home/bots/components/bot-form/BotForm'; import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent'; import BotSessionMonitor from '@/app/home/bots/components/bot-session/BotSessionMonitor'; +import BotAdminsPanel from '@/app/home/bots/components/bot-admins/BotAdminsPanel'; import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-session/BotSessionMonitor'; import { httpClient } from '@/app/infra/http/HttpClient'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; import { useTranslation } from 'react-i18next'; -import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react'; +import { Settings, FileText, Users, RefreshCw, Trash2, ShieldCheck } from 'lucide-react'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; @@ -229,6 +230,10 @@ export default function BotDetailContent({ id }: { id: string }) { )} + + + {t('bots.admins.title')} + {/* Tab: Configuration */} @@ -291,6 +296,13 @@ export default function BotDetailContent({ id }: { id: string }) { + + {/* Tab: Admins */} + +
+ +
+
diff --git a/web/src/app/home/bots/components/bot-admins/BotAdminsPanel.tsx b/web/src/app/home/bots/components/bot-admins/BotAdminsPanel.tsx new file mode 100644 index 000000000..4d7f3e85b --- /dev/null +++ b/web/src/app/home/bots/components/bot-admins/BotAdminsPanel.tsx @@ -0,0 +1,132 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Trash2, Plus } from 'lucide-react'; +import { toast } from 'sonner'; + +interface BotAdmin { + id: number; + launcher_type: string; + launcher_id: string; +} + +export default function BotAdminsPanel({ botId }: { botId: string }) { + const { t } = useTranslation(); + const [admins, setAdmins] = useState([]); + const [loading, setLoading] = useState(false); + const [newType, setNewType] = useState('person'); + const [newId, setNewId] = useState(''); + const [adding, setAdding] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await httpClient.getBotAdmins(botId); + setAdmins(res.admins ?? []); + } finally { + setLoading(false); + } + }, [botId]); + + useEffect(() => { load(); }, [load]); + + async function handleAdd() { + if (!newId.trim()) return; + setAdding(true); + try { + await httpClient.addBotAdmin(botId, newType, newId.trim()); + toast.success(t('bots.admins.addSuccess')); + setNewId(''); + await load(); + } catch (e: any) { + toast.error(t('bots.admins.addError') + (e?.msg ?? e?.message ?? '')); + } finally { + setAdding(false); + } + } + + async function handleDelete(id: number) { + try { + await httpClient.deleteBotAdmin(botId, id); + toast.success(t('bots.admins.deleteSuccess')); + setAdmins((prev) => prev.filter((a) => a.id !== id)); + } catch (e: any) { + toast.error(t('bots.admins.deleteError') + (e?.msg ?? e?.message ?? '')); + } + } + + return ( +
+
+ + setNewId(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAdd()} + /> + +
+ + {loading ? ( +
{t('bots.sessionMonitor.loading')}
+ ) : admins.length === 0 ? ( +
{t('bots.admins.noAdmins')}
+ ) : ( +
+ + + + + + + + + {admins.map((admin) => ( + + + + + + ))} + +
{t('bots.admins.launcherType')}{t('bots.admins.launcherId')} +
+ + {admin.launcher_type === 'person' ? t('bots.admins.typePerson') : t('bots.admins.typeGroup')} + + {admin.launcher_id} + +
+
+ )} +
+ ); +} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index bff964ad9..ad125f974 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -394,6 +394,18 @@ export class BackendClient extends BaseHttpClient { return this.delete(`/api/v1/platform/bots/${uuid}`); } + public getBotAdmins(botId: string): Promise<{ admins: Array<{ id: number; launcher_type: string; launcher_id: string }> }> { + return this.get(`/api/v1/platform/bots/${botId}/admins`); + } + + public addBotAdmin(botId: string, launcher_type: string, launcher_id: string): Promise<{ id: number }> { + return this.post(`/api/v1/platform/bots/${botId}/admins`, { launcher_type, launcher_id }); + } + + public deleteBotAdmin(botId: string, adminId: number): Promise { + return this.delete(`/api/v1/platform/bots/${botId}/admins/${adminId}`); + } + public getBotLogs( botId: string, request: GetBotLogsRequest, diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 8881c6bf7..1803086c0 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -444,6 +444,21 @@ const enUS = { userMessage: 'User', botMessage: 'Assistant', }, + admins: { + title: 'Admins', + description: 'Launchers (person/group IDs) that have admin privilege for this bot\'s commands', + addAdmin: 'Add Admin', + launcherType: 'Type', + launcherId: 'ID', + typePerson: 'Person', + typeGroup: 'Group', + placeholderId: 'User or group ID', + addSuccess: 'Admin added', + addError: 'Failed to add admin: ', + deleteSuccess: 'Admin removed', + deleteError: 'Failed to remove admin: ', + noAdmins: 'No admins configured', + }, }, plugins: { title: 'Extensions', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 4c951ebc8..dd32f0dae 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -426,6 +426,21 @@ const zhHans = { userMessage: '用户', botMessage: '助手', }, + admins: { + title: '管理员', + description: '拥有此机器人命令管理员权限的会话(用户/群组 ID)', + addAdmin: '添加管理员', + launcherType: '类型', + launcherId: 'ID', + typePerson: '私聊', + typeGroup: '群聊', + placeholderId: '用户或群组 ID', + addSuccess: '添加成功', + addError: '添加失败:', + deleteSuccess: '已移除', + deleteError: '移除失败:', + noAdmins: '暂无管理员', + }, }, plugins: { title: '插件扩展',