diff --git a/frontend/components/common/settings/bot-binding-card.tsx b/frontend/components/common/settings/bot-binding-card.tsx
new file mode 100644
index 00000000..5adaf3e3
--- /dev/null
+++ b/frontend/components/common/settings/bot-binding-card.tsx
@@ -0,0 +1,202 @@
+// Copyright 2026 Arctel.net
+// SPDX-License-Identifier: Apache-2.0
+
+'use client';
+
+import * as React from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Bot, Loader2, Unlink } from 'lucide-react';
+import { useTranslations } from 'next-intl';
+import { toast } from 'sonner';
+
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { UserMessageGatewayService } from '@/lib/services/message-gateway';
+
+export function BotBindingCard() {
+ const t = useTranslations('settings.botBinding');
+ const queryClient = useQueryClient();
+ const [open, setOpen] = React.useState(false);
+ const [channelId, setChannelId] = React.useState('');
+ const [code, setCode] = React.useState('');
+
+ const bindingsQuery = useQuery({
+ queryKey: ['message-gateway', 'bindings'],
+ queryFn: () => UserMessageGatewayService.listBindings(),
+ });
+ const channelsQuery = useQuery({
+ queryKey: ['message-gateway', 'channels'],
+ queryFn: () => UserMessageGatewayService.listChannels(),
+ enabled: open,
+ });
+
+ const bindMutation = useMutation({
+ mutationFn: () =>
+ UserMessageGatewayService.bind({ channel_id: channelId, code }),
+ onSuccess: () => {
+ toast.success(t('bindSuccess'));
+ queryClient.invalidateQueries({ queryKey: ['message-gateway', 'bindings'] });
+ setOpen(false);
+ setChannelId('');
+ setCode('');
+ },
+ onError: (err: unknown) => {
+ toast.error(t('bindFailed') + ': ' + (err as Error).message);
+ },
+ });
+
+ const unbindMutation = useMutation({
+ mutationFn: (id: string) => UserMessageGatewayService.unbind(id),
+ onSuccess: () => {
+ toast.success(t('unbindSuccess'));
+ queryClient.invalidateQueries({ queryKey: ['message-gateway', 'bindings'] });
+ },
+ onError: (err: unknown) => {
+ toast.error(t('unbindFailed') + ': ' + (err as Error).message);
+ },
+ });
+
+ const typeLabel = (typ: string) =>
+ typ === 'qq' ? t('typeQQ') : t('typeTelegram');
+
+ return (
+
+
+
+
+
+
+
{t('title')}
+
{t('description')}
+
+
+
+
+
+ {t('bound')}
+
+ {bindingsQuery.isPending ? (
+
+
+
+ ) : bindingsQuery.isError ? (
+
{t('loadFailed')}
+ ) : (bindingsQuery.data ?? []).length > 0 ? (
+
+ {(bindingsQuery.data ?? []).map((binding) => (
+
+
+
+ {binding.channel_name || typeLabel(binding.channel_type)}
+
+
+ {t('platformUserId')}: {binding.platform_user_id}
+
+
+
+
+ ))}
+
+ ) : (
+
+ {t('empty')}
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/components/common/settings/profile.tsx b/frontend/components/common/settings/profile.tsx
index df52d8db..9f2a00fa 100644
--- a/frontend/components/common/settings/profile.tsx
+++ b/frontend/components/common/settings/profile.tsx
@@ -65,6 +65,7 @@ import {
} from '@/components/ui/image-crop';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
+import { BotBindingCard } from '@/components/common/settings/bot-binding-card';
export function ProfileMain() {
const { user, loading, refetch } = useUser();
@@ -651,6 +652,7 @@ export function ProfileMain() {
+
{/* 编辑资料 Dialog */}
diff --git a/frontend/lib/services/index.ts b/frontend/lib/services/index.ts
index c1956438..3720d1c1 100644
--- a/frontend/lib/services/index.ts
+++ b/frontend/lib/services/index.ts
@@ -23,7 +23,10 @@ import {
import { AuthService } from './auth';
import { ConfigService } from './config';
import { DbManageService } from './db-manage';
-import { AdminMessageGatewayService } from './message-gateway';
+import {
+ AdminMessageGatewayService,
+ UserMessageGatewayService,
+} from './message-gateway';
import { PushService } from './push';
import { AdminUploadService, UploadService } from './upload';
import { UserService } from './user';
@@ -45,6 +48,7 @@ const services = {
dbManage: DbManageService,
push: PushService,
adminMessageGateway: AdminMessageGatewayService,
+ userMessageGateway: UserMessageGatewayService,
} as const;
export default services;
@@ -169,7 +173,10 @@ export type {
} from './db-manage';
export { PushService } from './push';
-export { AdminMessageGatewayService } from './message-gateway';
+export {
+ AdminMessageGatewayService,
+ UserMessageGatewayService,
+} from './message-gateway';
export type {
MessageChannel,
MessageChannelDefinition,
diff --git a/frontend/lib/services/message-gateway/index.ts b/frontend/lib/services/message-gateway/index.ts
index a72eb34b..871e2d3e 100644
--- a/frontend/lib/services/message-gateway/index.ts
+++ b/frontend/lib/services/message-gateway/index.ts
@@ -3,3 +3,4 @@
export * from './types';
export * from './admin.service';
+export * from './user.service';
diff --git a/frontend/lib/services/message-gateway/user.service.ts b/frontend/lib/services/message-gateway/user.service.ts
new file mode 100644
index 00000000..bedfbe82
--- /dev/null
+++ b/frontend/lib/services/message-gateway/user.service.ts
@@ -0,0 +1,29 @@
+// Copyright 2026 Arctel.net
+// SPDX-License-Identifier: Apache-2.0
+
+import { BaseService } from '@/lib/services/core';
+import type {
+ BindMessageChannelRequest,
+ MessageBinding,
+ PublicMessageChannel,
+} from './types';
+
+export class UserMessageGatewayService extends BaseService {
+ protected static readonly basePath = '/api/v1/message-gateway';
+
+ static async listBindings(): Promise {
+ return this.get('/bindings');
+ }
+
+ static async listChannels(): Promise {
+ return this.get('/channels');
+ }
+
+ static async bind(data: BindMessageChannelRequest): Promise {
+ return this.post('/bindings', data);
+ }
+
+ static async unbind(id: string): Promise {
+ return this.delete(`/bindings/${id}`);
+ }
+}
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index cfccee50..65145ad2 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -338,6 +338,30 @@
"cropAvatar": "Crop avatar",
"cropAvatarDesc": "Adjust the avatar position and size to fit within the centered circular area."
},
+ "botBinding": {
+ "title": "Bind a bot",
+ "description": "Link your Telegram or QQ private-chat identity to this account so messages you send the bot can be saved.",
+ "bound": "Bound",
+ "empty": "No bots bound yet",
+ "unbind": "Unbind",
+ "bind": "Enter pairing code",
+ "bindDialogTitle": "Bind a bot",
+ "bindDialogDescription": "Choose an enabled channel and enter the pairing code the bot sent you.",
+ "selectChannel": "Select a channel",
+ "pairingCode": "Pairing code",
+ "pairingCodePlaceholder": "ABCD-EFGH",
+ "submit": "Bind",
+ "cancel": "Cancel",
+ "bindSuccess": "Bound successfully",
+ "bindFailed": "Failed to bind",
+ "unbindSuccess": "Unbound",
+ "unbindFailed": "Failed to unbind",
+ "platformUserId": "Platform user ID",
+ "noChannels": "No channels available. Ask an admin to add one.",
+ "typeTelegram": "Telegram",
+ "typeQQ": "QQ",
+ "loadFailed": "Failed to load bindings"
+ },
"security": {
"passwordLoginEnabled": "Allow password login",
"passwordLoginEnabledDesc": "When disabled, only third-party OIDC authentication sources are available for login.",
diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json
index ba9401a9..c936f0b8 100644
--- a/frontend/messages/zh-CN.json
+++ b/frontend/messages/zh-CN.json
@@ -338,6 +338,30 @@
"cropAvatar": "裁剪头像",
"cropAvatarDesc": "调整头像位置和大小,使其处于居中圆形区域内。"
},
+ "botBinding": {
+ "title": "绑定机器人",
+ "description": "把 Telegram / QQ 私聊身份绑定到此账号,向机器人发消息即可入库。",
+ "bound": "已绑定",
+ "empty": "尚未绑定任何机器人",
+ "unbind": "解绑",
+ "bind": "绑定配对码",
+ "bindDialogTitle": "绑定机器人",
+ "bindDialogDescription": "选择一个已启用的通道,并输入机器人发给你的配对码。",
+ "selectChannel": "选择通道",
+ "pairingCode": "配对码",
+ "pairingCodePlaceholder": "ABCD-EFGH",
+ "submit": "绑定",
+ "cancel": "取消",
+ "bindSuccess": "绑定成功",
+ "bindFailed": "绑定失败",
+ "unbindSuccess": "已解绑",
+ "unbindFailed": "解绑失败",
+ "platformUserId": "平台用户 ID",
+ "noChannels": "暂无可用通道,请联系管理员添加。",
+ "typeTelegram": "Telegram",
+ "typeQQ": "QQ",
+ "loadFailed": "加载绑定失败"
+ },
"security": {
"passwordLoginEnabled": "允许密码登录",
"passwordLoginEnabledDesc": "关闭后仅保留第三方 OIDC 认证源进行系统登录。",