diff --git a/frontend/app/(main)/admin/message-gateway/channels/qq/form.tsx b/frontend/app/(main)/admin/message-gateway/channels/qq/form.tsx
new file mode 100644
index 00000000..eec64dc5
--- /dev/null
+++ b/frontend/app/(main)/admin/message-gateway/channels/qq/form.tsx
@@ -0,0 +1,57 @@
+// Copyright 2026 Arctel.net
+// SPDX-License-Identifier: Apache-2.0
+
+'use client';
+
+import { useTranslations } from 'next-intl';
+
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+
+export interface QQFormValue {
+ app_id: string;
+ app_secret: string;
+ portal_host: string;
+}
+
+interface QQFormProps {
+ value: QQFormValue;
+ onChange: (value: QQFormValue) => void;
+ keepSecretHint?: boolean;
+}
+
+export function QQForm({ value, onChange, keepSecretHint }: QQFormProps) {
+ const t = useTranslations('admin.messageGateway');
+ return (
+
+ );
+}
diff --git a/frontend/app/(main)/admin/message-gateway/channels/telegram/form.tsx b/frontend/app/(main)/admin/message-gateway/channels/telegram/form.tsx
new file mode 100644
index 00000000..802cf65a
--- /dev/null
+++ b/frontend/app/(main)/admin/message-gateway/channels/telegram/form.tsx
@@ -0,0 +1,54 @@
+// Copyright 2026 Arctel.net
+// SPDX-License-Identifier: Apache-2.0
+
+'use client';
+
+import { useTranslations } from 'next-intl';
+
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+
+export interface TelegramFormValue {
+ bot_token: string;
+ base_url: string;
+}
+
+interface TelegramFormProps {
+ value: TelegramFormValue;
+ onChange: (value: TelegramFormValue) => void;
+ keepSecretHint?: boolean;
+}
+
+export function TelegramForm({
+ value,
+ onChange,
+ keepSecretHint,
+}: TelegramFormProps) {
+ const t = useTranslations('admin.messageGateway');
+ return (
+
+ );
+}
diff --git a/frontend/app/(main)/admin/message-gateway/components/add-channel-dialog.tsx b/frontend/app/(main)/admin/message-gateway/components/add-channel-dialog.tsx
new file mode 100644
index 00000000..e1742606
--- /dev/null
+++ b/frontend/app/(main)/admin/message-gateway/components/add-channel-dialog.tsx
@@ -0,0 +1,137 @@
+// Copyright 2026 Arctel.net
+// SPDX-License-Identifier: Apache-2.0
+
+'use client';
+
+import * as React from 'react';
+import { useTranslations } from 'next-intl';
+import { Loader2 } from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { TelegramForm, type TelegramFormValue } from '../channels/telegram/form';
+import { QQForm, type QQFormValue } from '../channels/qq/form';
+import type { CreateMessageChannelRequest } from '@/lib/services/message-gateway';
+
+interface AddChannelDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSubmit: (data: CreateMessageChannelRequest) => void;
+ submitting?: boolean;
+}
+
+export function AddChannelDialog({
+ open,
+ onOpenChange,
+ onSubmit,
+ submitting,
+}: AddChannelDialogProps) {
+ const t = useTranslations('admin.messageGateway');
+ const [name, setName] = React.useState('');
+ const [type, setType] = React.useState<'telegram' | 'qq' | ''>('');
+ const [telegram, setTelegram] = React.useState({
+ bot_token: '',
+ base_url: '',
+ });
+ const [qq, setQQ] = React.useState({
+ app_id: '',
+ app_secret: '',
+ portal_host: '',
+ });
+
+ React.useEffect(() => {
+ if (!open) {
+ setName('');
+ setType('');
+ setTelegram({ bot_token: '', base_url: '' });
+ setQQ({ app_id: '', app_secret: '', portal_host: '' });
+ }
+ }, [open]);
+
+ const canSubmit =
+ name.trim() !== '' &&
+ ((type === 'telegram' && telegram.bot_token.trim() !== '') ||
+ (type === 'qq' && qq.app_id.trim() !== '' && qq.app_secret.trim() !== ''));
+
+ const handleSubmit = () => {
+ if (!canSubmit || !type) return;
+ const data: CreateMessageChannelRequest = {
+ name: name.trim(),
+ type,
+ enabled: true,
+ };
+ if (type === 'telegram') {
+ data.bot_token = telegram.bot_token.trim();
+ if (telegram.base_url.trim()) data.base_url = telegram.base_url.trim();
+ } else {
+ data.app_id = qq.app_id.trim();
+ data.app_secret = qq.app_secret.trim();
+ if (qq.portal_host.trim()) data.portal_host = qq.portal_host.trim();
+ }
+ onSubmit(data);
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/app/(main)/admin/message-gateway/components/channel-card.tsx b/frontend/app/(main)/admin/message-gateway/components/channel-card.tsx
new file mode 100644
index 00000000..62652942
--- /dev/null
+++ b/frontend/app/(main)/admin/message-gateway/components/channel-card.tsx
@@ -0,0 +1,104 @@
+// Copyright 2026 Arctel.net
+// SPDX-License-Identifier: Apache-2.0
+
+'use client';
+
+import * as React from 'react';
+import { useTranslations } from 'next-intl';
+import { Loader2, Trash2 } from 'lucide-react';
+
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Switch } from '@/components/ui/switch';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+import type { MessageChannel } from '@/lib/services/message-gateway';
+
+interface ChannelCardProps {
+ channel: MessageChannel;
+ onToggle: (enabled: boolean) => void;
+ onDelete: () => void;
+ toggling?: boolean;
+ deleting?: boolean;
+}
+
+export function ChannelCard({
+ channel,
+ onToggle,
+ onDelete,
+ toggling,
+ deleting,
+}: ChannelCardProps) {
+ const t = useTranslations('admin.messageGateway');
+ const [confirmOpen, setConfirmOpen] = React.useState(false);
+ const typeLabel =
+ channel.type === 'qq' ? t('typeQQ') : t('typeTelegram');
+
+ return (
+
+
+
+
{channel.name}
+
+ {typeLabel}
+
+
+
+
+ {channel.enabled ? t('enabled') : t('disabled')}
+
+
+
+
+
+
+
+
+
+
+ {t('deleteConfirmTitle')}
+
+ {t('deleteConfirmDescription')}
+
+
+
+ {t('cancel')}
+ {
+ setConfirmOpen(false);
+ onDelete();
+ }}
+ >
+ {t('delete')}
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/(main)/admin/message-gateway/page.tsx b/frontend/app/(main)/admin/message-gateway/page.tsx
new file mode 100644
index 00000000..fbb95093
--- /dev/null
+++ b/frontend/app/(main)/admin/message-gateway/page.tsx
@@ -0,0 +1,140 @@
+// Copyright 2026 Arctel.net
+// SPDX-License-Identifier: Apache-2.0
+
+'use client';
+
+import * as React from 'react';
+import { motion } from 'motion/react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { MessagesSquare, Plus } from 'lucide-react';
+import { useTranslations } from 'next-intl';
+import { toast } from 'sonner';
+
+import { Button } from '@/components/ui/button';
+import { ErrorInline } from '@/components/layout/error';
+import { LoadingStateWithBorder } from '@/components/layout/loading';
+import { AdminMessageGatewayService } from '@/lib/services/message-gateway';
+import type { CreateMessageChannelRequest } from '@/lib/services/message-gateway';
+import { AddChannelDialog } from './components/add-channel-dialog';
+import { ChannelCard } from './components/channel-card';
+
+export default function MessageGatewayAdminPage() {
+ const t = useTranslations('admin.messageGateway');
+ const queryClient = useQueryClient();
+ const [addOpen, setAddOpen] = React.useState(false);
+
+ const channelsQuery = useQuery({
+ queryKey: ['admin', 'message-gateway-channels'],
+ queryFn: () => AdminMessageGatewayService.list(),
+ });
+
+ const createMutation = useMutation({
+ mutationFn: (data: CreateMessageChannelRequest) =>
+ AdminMessageGatewayService.create(data),
+ onSuccess: () => {
+ toast.success(t('createSuccess'));
+ queryClient.invalidateQueries({
+ queryKey: ['admin', 'message-gateway-channels'],
+ });
+ setAddOpen(false);
+ },
+ onError: (err: unknown) => {
+ toast.error(t('createFailed') + ': ' + (err as Error).message);
+ },
+ });
+
+ const toggleMutation = useMutation({
+ mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
+ AdminMessageGatewayService.update(id, { enabled }),
+ onSuccess: () => {
+ toast.success(t('updateSuccess'));
+ queryClient.invalidateQueries({
+ queryKey: ['admin', 'message-gateway-channels'],
+ });
+ },
+ onError: (err: unknown) => {
+ toast.error(t('updateFailed') + ': ' + (err as Error).message);
+ },
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => AdminMessageGatewayService.remove(id),
+ onSuccess: () => {
+ toast.success(t('deleteSuccess'));
+ queryClient.invalidateQueries({
+ queryKey: ['admin', 'message-gateway-channels'],
+ });
+ },
+ onError: (err: unknown) => {
+ toast.error(t('deleteFailed') + ': ' + (err as Error).message);
+ },
+ });
+
+ const channels = channelsQuery.data ?? [];
+
+ return (
+
+
+
+
+
+
+ {t('pageTitle')}
+
+
+ {t('pageDescription')}
+
+
+
+
+
+
+ {channelsQuery.isLoading ? (
+
+ ) : channelsQuery.isError ? (
+
+ ) : channels.length === 0 ? (
+
+
{t('emptyTitle')}
+
+ {t('emptyDescription')}
+
+
+
+ ) : (
+
+ {channels.map((ch) => (
+
+ toggleMutation.mutate({ id: ch.id, enabled })
+ }
+ onDelete={() => deleteMutation.mutate(ch.id)}
+ />
+ ))}
+
+ )}
+
+ createMutation.mutate(data)}
+ />
+
+ );
+}
diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx
index 2afbdf05..7c8c6293 100644
--- a/frontend/components/layout/sidebar.tsx
+++ b/frontend/components/layout/sidebar.tsx
@@ -56,6 +56,7 @@ import {
Home,
Layers,
LogOut,
+ MessagesSquare,
Settings,
ShieldCheck,
Terminal,
@@ -84,6 +85,7 @@ const adminItems: NavItem[] = [
{ titleKey: 'storage', url: '/admin/files', icon: FolderOpen },
{ titleKey: 'database', url: '/admin/database', icon: Database },
{ titleKey: 'push', url: '/admin/push', icon: Bell },
+ { titleKey: 'messageGateway', url: '/admin/message-gateway', icon: MessagesSquare },
{ titleKey: 'logs', url: '/admin/logs', icon: Terminal },
{ titleKey: 'system', url: '/admin/system', icon: ShieldCheck },
{ titleKey: 'adminSettings', url: '/admin/settings', icon: Settings },
diff --git a/frontend/lib/services/index.ts b/frontend/lib/services/index.ts
index fd2f0240..c1956438 100644
--- a/frontend/lib/services/index.ts
+++ b/frontend/lib/services/index.ts
@@ -23,6 +23,7 @@ import {
import { AuthService } from './auth';
import { ConfigService } from './config';
import { DbManageService } from './db-manage';
+import { AdminMessageGatewayService } from './message-gateway';
import { PushService } from './push';
import { AdminUploadService, UploadService } from './upload';
import { UserService } from './user';
@@ -43,6 +44,7 @@ const services = {
adminUpload: AdminUploadService,
dbManage: DbManageService,
push: PushService,
+ adminMessageGateway: AdminMessageGatewayService,
} as const;
export default services;
@@ -167,6 +169,16 @@ export type {
} from './db-manage';
export { PushService } from './push';
+export { AdminMessageGatewayService } from './message-gateway';
+export type {
+ MessageChannel,
+ MessageChannelDefinition,
+ CreateMessageChannelRequest,
+ UpdateMessageChannelRequest,
+ PublicMessageChannel,
+ MessageBinding,
+ BindMessageChannelRequest,
+} from './message-gateway';
export type {
PushEvent,
PushHistory,
diff --git a/frontend/lib/services/message-gateway/admin.service.ts b/frontend/lib/services/message-gateway/admin.service.ts
new file mode 100644
index 00000000..9852dbf8
--- /dev/null
+++ b/frontend/lib/services/message-gateway/admin.service.ts
@@ -0,0 +1,43 @@
+// Copyright 2026 Arctel.net
+// SPDX-License-Identifier: Apache-2.0
+
+import { BaseService } from '@/lib/services/core';
+import type {
+ CreateMessageChannelRequest,
+ MessageChannel,
+ MessageChannelDefinition,
+ UpdateMessageChannelRequest,
+} from './types';
+
+export class AdminMessageGatewayService extends BaseService {
+ protected static readonly basePath = '/api/v1/admin/message-gateway';
+
+ static async list(): Promise {
+ return this.get('/channels');
+ }
+
+ static async definitions(): Promise {
+ return this.get('/channels/definitions');
+ }
+
+ static async create(
+ data: CreateMessageChannelRequest,
+ ): Promise {
+ return this.post('/channels', data);
+ }
+
+ static async update(
+ id: string,
+ data: UpdateMessageChannelRequest,
+ ): Promise {
+ return this.patch(`/channels/${id}`, data);
+ }
+
+ static async remove(id: string): Promise {
+ return this.delete(`/channels/${id}`);
+ }
+
+ static async test(id: string): Promise {
+ return this.post(`/channels/${id}/test`);
+ }
+}
diff --git a/frontend/lib/services/message-gateway/index.ts b/frontend/lib/services/message-gateway/index.ts
new file mode 100644
index 00000000..a72eb34b
--- /dev/null
+++ b/frontend/lib/services/message-gateway/index.ts
@@ -0,0 +1,5 @@
+// Copyright 2026 Arctel.net
+// SPDX-License-Identifier: Apache-2.0
+
+export * from './types';
+export * from './admin.service';
diff --git a/frontend/lib/services/message-gateway/types.ts b/frontend/lib/services/message-gateway/types.ts
new file mode 100644
index 00000000..02a9cd9c
--- /dev/null
+++ b/frontend/lib/services/message-gateway/types.ts
@@ -0,0 +1,73 @@
+// Copyright 2026 Arctel.net
+// SPDX-License-Identifier: Apache-2.0
+
+export type MessageChannelType = 'telegram' | 'qq';
+
+export interface MessageChannelField {
+ key: string;
+ type?: string;
+ required?: boolean;
+}
+
+export interface MessageChannelDefinition {
+ type: MessageChannelType | string;
+ name: string;
+ fields: MessageChannelField[];
+}
+
+export interface MessageChannel {
+ id: string;
+ name: string;
+ type: MessageChannelType | string;
+ owner_scope: string;
+ enabled: boolean;
+ bot_token?: string;
+ app_id?: string;
+ app_secret?: string;
+ base_url?: string;
+ portal_host?: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface CreateMessageChannelRequest {
+ name: string;
+ type: string;
+ enabled?: boolean;
+ bot_token?: string;
+ app_id?: string;
+ app_secret?: string;
+ base_url?: string;
+ portal_host?: string;
+}
+
+export interface UpdateMessageChannelRequest {
+ name?: string;
+ enabled?: boolean;
+ bot_token?: string;
+ app_id?: string;
+ app_secret?: string;
+ base_url?: string;
+ portal_host?: string;
+}
+
+export interface PublicMessageChannel {
+ id: string;
+ name: string;
+ type: MessageChannelType | string;
+}
+
+export interface MessageBinding {
+ id: string;
+ user_id: string;
+ channel_id: string;
+ channel_name: string;
+ channel_type: string;
+ platform_user_id: string;
+ created_at: string;
+}
+
+export interface BindMessageChannelRequest {
+ channel_id: string;
+ code: string;
+}
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 8cdb2f2e..cfccee50 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -21,6 +21,7 @@
"storage": "Storage",
"database": "Database",
"push": "Notifications",
+ "messageGateway": "Messaging",
"logs": "System Logs",
"system": "System Config",
"adminSettings": "System Settings",
@@ -1021,6 +1022,45 @@
"close": "Close"
}
},
+ "messageGateway": {
+ "pageTitle": "Messaging channels",
+ "pageDescription": "Configure instance-level Telegram and QQ bots. Users bind their private-chat identity with a pairing code.",
+ "addChannel": "Add channel",
+ "emptyTitle": "No messaging channels yet",
+ "emptyDescription": "Add a shared bot so users can bind it on their profile and send private messages.",
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "delete": "Delete",
+ "deleteConfirmTitle": "Delete this channel?",
+ "deleteConfirmDescription": "Bindings and unused pairing codes for this channel will also be removed. This cannot be undone.",
+ "createSuccess": "Channel created",
+ "createFailed": "Failed to create channel",
+ "updateSuccess": "Channel updated",
+ "updateFailed": "Failed to update channel",
+ "deleteSuccess": "Channel deleted",
+ "deleteFailed": "Failed to delete channel",
+ "testSuccess": "Connectivity test passed",
+ "testFailed": "Connectivity test failed",
+ "name": "Name",
+ "namePlaceholder": "e.g. Ops bot",
+ "type": "Type",
+ "typeTelegram": "Telegram",
+ "typeQQ": "QQ",
+ "selectType": "Select a channel type",
+ "botToken": "Bot token",
+ "botTokenPlaceholder": "From BotFather",
+ "baseUrl": "API base URL",
+ "baseUrlPlaceholder": "https://api.telegram.org",
+ "appId": "App ID",
+ "appSecret": "App Secret",
+ "portalHost": "Portal host",
+ "portalHostPlaceholder": "q.qq.com",
+ "keepSecretHint": "Leave blank to keep the saved secret",
+ "cancel": "Cancel",
+ "create": "Create",
+ "testConnection": "Test connection",
+ "loadFailed": "Failed to load channels"
+ },
"database": {
"pageTitle": "Database",
"refreshData": "Refresh data",
diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json
index 19fc88be..ba9401a9 100644
--- a/frontend/messages/zh-CN.json
+++ b/frontend/messages/zh-CN.json
@@ -21,6 +21,7 @@
"storage": "存储管理",
"database": "数据管理",
"push": "通知推送",
+ "messageGateway": "消息通道",
"logs": "系统日志",
"system": "系统配置",
"adminSettings": "系统设置",
@@ -1021,6 +1022,45 @@
"close": "关闭"
}
},
+ "messageGateway": {
+ "pageTitle": "消息通道",
+ "pageDescription": "配置实例级 Telegram / QQ 机器人,用户通过配对码绑定私聊身份。",
+ "addChannel": "添加通道",
+ "emptyTitle": "还没有消息通道",
+ "emptyDescription": "添加一个共享机器人,用户即可在资料页绑定并发送私聊消息。",
+ "enabled": "已启用",
+ "disabled": "已禁用",
+ "delete": "删除",
+ "deleteConfirmTitle": "删除通道?",
+ "deleteConfirmDescription": "将同时删除该通道下的绑定和未使用的配对码。此操作不可撤销。",
+ "createSuccess": "通道已创建",
+ "createFailed": "创建通道失败",
+ "updateSuccess": "通道已更新",
+ "updateFailed": "更新通道失败",
+ "deleteSuccess": "通道已删除",
+ "deleteFailed": "删除通道失败",
+ "testSuccess": "连通性测试通过",
+ "testFailed": "连通性测试失败",
+ "name": "名称",
+ "namePlaceholder": "例如:运营机器人",
+ "type": "类型",
+ "typeTelegram": "Telegram",
+ "typeQQ": "QQ",
+ "selectType": "选择通道类型",
+ "botToken": "Bot Token",
+ "botTokenPlaceholder": "从 BotFather 获取",
+ "baseUrl": "API 地址",
+ "baseUrlPlaceholder": "https://api.telegram.org",
+ "appId": "App ID",
+ "appSecret": "App Secret",
+ "portalHost": "开放平台域名",
+ "portalHostPlaceholder": "q.qq.com",
+ "keepSecretHint": "留空则保留已保存的密钥",
+ "cancel": "取消",
+ "create": "创建",
+ "testConnection": "测试连通性",
+ "loadFailed": "加载通道失败"
+ },
"database": {
"pageTitle": "数据管理",
"refreshData": "刷新数据",