mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-26 23:44:19 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 592b8902a0 | |||
| ff71c1cede |
@@ -18,6 +18,7 @@ class BotsRouterGroup(group.RouterGroup):
|
||||
@self.route('/<bot_uuid>', 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')
|
||||
@@ -36,21 +37,30 @@ 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('/<bot_uuid>/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"')
|
||||
|
||||
@@ -62,29 +72,3 @@ class BotsRouterGroup(group.RouterGroup):
|
||||
|
||||
traceback.print_exc()
|
||||
return self.http_status(500, -1, f'Failed to send message: {str(e)}')
|
||||
|
||||
# ============ Bot Admins ============
|
||||
|
||||
@self.route('/<bot_uuid>/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(
|
||||
'/<bot_uuid>/admins/<int:admin_id>', 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()
|
||||
|
||||
@@ -195,13 +195,6 @@ class UserRouterGroup(group.RouterGroup):
|
||||
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Set password for Space account (first time) or change password"""
|
||||
# Check if modifying login info is allowed
|
||||
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
||||
'allow_modify_login_info', True
|
||||
)
|
||||
if not allow_modify_login_info:
|
||||
return self.http_status(403, -1, 'Modifying login info is disabled')
|
||||
|
||||
json_data = await quart.request.json
|
||||
new_password = json_data.get('new_password')
|
||||
current_password = json_data.get('current_password')
|
||||
|
||||
@@ -199,35 +199,3 @@ 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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -84,17 +84,7 @@ class CommandManager:
|
||||
|
||||
privilege = 1
|
||||
|
||||
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:
|
||||
if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']:
|
||||
privilege = 2
|
||||
|
||||
ctx = command_context.ExecuteContext(
|
||||
|
||||
@@ -3,20 +3,6 @@ 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"""
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
"""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')
|
||||
@@ -1,3 +1,4 @@
|
||||
admins: []
|
||||
api:
|
||||
port: 5300
|
||||
webhook_prefix: 'http://127.0.0.1:5300'
|
||||
|
||||
@@ -138,7 +138,7 @@ class TestReadResourceFile:
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
content = importutil.read_resource_file('templates/config.yaml')
|
||||
assert 'api:' in content
|
||||
assert 'admins:' in content
|
||||
assert 'edition: community' in content
|
||||
|
||||
def test_raises_for_nonexistent_file(self):
|
||||
@@ -157,7 +157,7 @@ class TestReadResourceFileBytes:
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
content = importutil.read_resource_file_bytes('templates/config.yaml')
|
||||
assert b'api:' in content
|
||||
assert b'admins:' in content
|
||||
assert b'edition: community' in content
|
||||
|
||||
def test_raises_for_nonexistent_file_bytes(self):
|
||||
|
||||
@@ -22,19 +22,11 @@ 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,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -237,10 +229,6 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
</button>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="admins" className="gap-1.5">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
{t('bots.admins.title')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Tab: Configuration */}
|
||||
@@ -303,16 +291,6 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
<TabsContent value="sessions" className="flex-1 min-h-0 mt-4">
|
||||
<BotSessionMonitor ref={sessionMonitorRef} botId={id} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab: Admins */}
|
||||
<TabsContent
|
||||
value="admins"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<div className="mx-auto max-w-3xl pb-8">
|
||||
<BotAdminsPanel botId={id} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
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<BotAdmin[]>([]);
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Select value={newType} onValueChange={setNewType}>
|
||||
<SelectTrigger className="w-28 shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="person">
|
||||
{t('bots.admins.typePerson')}
|
||||
</SelectItem>
|
||||
<SelectItem value="group">{t('bots.admins.typeGroup')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder={t('bots.admins.placeholderId')}
|
||||
value={newId}
|
||||
onChange={(e) => setNewId(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
disabled={adding || !newId.trim()}
|
||||
>
|
||||
<Plus className="size-4 mr-1" />
|
||||
{t('bots.admins.addAdmin')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t('bots.sessionMonitor.loading')}
|
||||
</div>
|
||||
) : admins.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t('bots.admins.noAdmins')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/40">
|
||||
<th className="text-left px-3 py-2 font-medium text-muted-foreground w-28">
|
||||
{t('bots.admins.launcherType')}
|
||||
</th>
|
||||
<th className="text-left px-3 py-2 font-medium text-muted-foreground">
|
||||
{t('bots.admins.launcherId')}
|
||||
</th>
|
||||
<th className="w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{admins.map((admin) => (
|
||||
<tr
|
||||
key={admin.id}
|
||||
className="border-b last:border-0 hover:bg-muted/30"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<span className="px-1.5 py-0.5 rounded bg-muted text-xs">
|
||||
{admin.launcher_type === 'person'
|
||||
? t('bots.admins.typePerson')
|
||||
: t('bots.admins.typeGroup')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono">{admin.launcher_id}</td>
|
||||
<td className="px-2 py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
onClick={() => handleDelete(admin.id)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -394,27 +394,6 @@ 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<object> {
|
||||
return this.delete(`/api/v1/platform/bots/${botId}/admins/${adminId}`);
|
||||
}
|
||||
|
||||
public getBotLogs(
|
||||
botId: string,
|
||||
request: GetBotLogsRequest,
|
||||
|
||||
@@ -444,22 +444,6 @@ 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',
|
||||
|
||||
@@ -426,21 +426,6 @@ const zhHans = {
|
||||
userMessage: '用户',
|
||||
botMessage: '助手',
|
||||
},
|
||||
admins: {
|
||||
title: '管理员',
|
||||
description: '拥有此机器人命令管理员权限的会话(用户/群组 ID)',
|
||||
addAdmin: '添加管理员',
|
||||
launcherType: '类型',
|
||||
launcherId: 'ID',
|
||||
typePerson: '私聊',
|
||||
typeGroup: '群聊',
|
||||
placeholderId: '用户或群组 ID',
|
||||
addSuccess: '添加成功',
|
||||
addError: '添加失败:',
|
||||
deleteSuccess: '已移除',
|
||||
deleteError: '移除失败:',
|
||||
noAdmins: '暂无管理员',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
title: '插件扩展',
|
||||
|
||||
Reference in New Issue
Block a user