🐛 fix: message gateway (#13979)

* fix: local webhook typing

* feat: add dormant status

* feat: add bot status tag

* feat: add bot connection status and refresh status

* feat: support bot status list refresh

* fix: bot status

* chore: add test timeout
This commit is contained in:
Rdmclin2 2026-04-20 00:17:57 +08:00 committed by GitHub
parent 8240e8685d
commit 0213656565
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 710 additions and 342 deletions

View file

@ -79,6 +79,7 @@
"channel.qq.description": "Connect this assistant to QQ for group chats and direct messages.",
"channel.qq.webhookMigrationDesc": "WebSocket mode provides real-time event delivery and automatic reconnection without needing a callback URL. To migrate, create a new bot on QQ Open Platform without configuring a callback URL, then switch the Connection Mode to WebSocket in Advanced Settings.",
"channel.qq.webhookMigrationTitle": "Consider migrating to WebSocket mode",
"channel.refreshStatus": "Refresh status",
"channel.removeChannel": "Remove Channel",
"channel.removeFailed": "Failed to remove channel",
"channel.removed": "Channel removed",
@ -106,6 +107,12 @@
"channel.slack.description": "Connect this assistant to Slack for channel conversations and direct messages.",
"channel.slack.webhookMigrationDesc": "Socket Mode provides real-time event delivery via WebSocket without exposing a public HTTP endpoint. To migrate, enable Socket Mode in your Slack app settings, generate an App-Level Token, then switch the Connection Mode to WebSocket in Advanced Settings.",
"channel.slack.webhookMigrationTitle": "Consider migrating to Socket Mode (WebSocket)",
"channel.statusConnected": "Connected",
"channel.statusDisconnected": "Disconnected",
"channel.statusDormant": "Dormant",
"channel.statusFailed": "Failed",
"channel.statusQueued": "Queued",
"channel.statusStarting": "Starting",
"channel.telegram.description": "Connect this assistant to Telegram for private and group chats.",
"channel.testConnection": "Test Connection",
"channel.testFailed": "Connection test failed",

View file

@ -79,6 +79,7 @@
"channel.qq.description": "将助手连接到 QQ支持群聊和私聊。",
"channel.qq.webhookMigrationDesc": "WebSocket 模式无需回调 URL即可提供实时事件传递和自动重连。要进行迁移请在 QQ 开放平台创建一个新的机器人且不要配置回调 URL然后在高级设置中将连接模式切换为 WebSocket。",
"channel.qq.webhookMigrationTitle": "建议迁移至 WebSocket 模式",
"channel.refreshStatus": "刷新状态",
"channel.removeChannel": "移除频道",
"channel.removeFailed": "移除频道失败",
"channel.removed": "频道已移除",
@ -106,6 +107,12 @@
"channel.slack.description": "将助手连接到 Slack支持频道对话和私信。",
"channel.slack.webhookMigrationDesc": "Socket Mode 通过 WebSocket 提供实时事件推送,无需暴露公网 HTTP 端点。如需迁移,请在 Slack 应用设置中启用 Socket Mode生成应用级别 Token然后在高级设置中将连接模式切换为 WebSocket。",
"channel.slack.webhookMigrationTitle": "建议迁移到 Socket ModeWebSocket",
"channel.statusConnected": "已连接",
"channel.statusDisconnected": "未连接",
"channel.statusDormant": "休眠中",
"channel.statusFailed": "连接失败",
"channel.statusQueued": "排队中",
"channel.statusStarting": "启动中",
"channel.telegram.description": "将助手连接到 Telegram支持私聊和群聊。",
"channel.testConnection": "测试连接",
"channel.testFailed": "连接测试失败",
@ -121,6 +128,7 @@
"channel.wechatBotId": "机器人 ID",
"channel.wechatBotIdHint": "通过二维码授权后分配的机器人标识符。",
"channel.wechatConnectedInfo": "已连接的微信账号",
"channel.wechatIdleNotice": "若超过 7 天没有用户发消息,连接将被自动暂停。如需恢复,请点击「通过二维码重新绑定」。",
"channel.wechatManagedCredentials": "此频道已通过二维码授权连接。凭据由系统自动管理。",
"channel.wechatQrExpired": "二维码已过期,请刷新获取新的二维码。",
"channel.wechatQrRefresh": "刷新二维码",

View file

@ -17,7 +17,7 @@ const StateChangeSchema = z.object({
platform: z.string(),
state: z.object({
error: z.string().optional(),
status: z.enum(['connected', 'connecting', 'disconnected', 'error']),
status: z.enum(['connected', 'connecting', 'disconnected', 'dormant', 'error']),
}),
});
@ -71,6 +71,7 @@ export async function POST(request: NextRequest) {
const statusMap: Partial<Record<string, BotRuntimeStatus>> = {
connected: BOT_RUNTIME_STATUSES.connected,
disconnected: BOT_RUNTIME_STATUSES.disconnected,
dormant: BOT_RUNTIME_STATUSES.dormant,
error: BOT_RUNTIME_STATUSES.failed,
};

View file

@ -81,22 +81,31 @@ afterEach(() => {
mockConfig.serverConfigInit = true;
});
// Each test does vi.resetModules() + dynamic import of the component, which
// re-parses antd + @lobehub/ui fresh. On cold CI runs this can blow past the
// default 5s timeout even though the test is doing nothing slow itself.
const TEST_TIMEOUT_MS = 15_000;
describe('ModeSwitch', () => {
it('renders both onboarding variants when agent onboarding is enabled', () => {
renderModeSwitch({ enabled: true, showLabel: true });
expect(screen.getByText('Choose your onboarding mode')).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Conversational' })).toBeChecked();
expect(screen.getByRole('radio', { name: 'Classic' })).not.toBeChecked();
});
expect(screen.getByText('Choose your onboarding mode')).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Conversational' })).toBeChecked();
expect(screen.getByRole('radio', { name: 'Classic' })).not.toBeChecked();
},
TEST_TIMEOUT_MS,
);
it('hides the onboarding switch entirely when agent onboarding is disabled', () => {
renderModeSwitch({ enabled: false });
expect(screen.queryByRole('radio', { name: 'Conversational' })).not.toBeInTheDocument();
expect(screen.queryByRole('radio', { name: 'Classic' })).not.toBeInTheDocument();
expect(screen.queryByText('Choose your onboarding mode')).not.toBeInTheDocument();
});
expect(screen.queryByRole('radio', { name: 'Conversational' })).not.toBeInTheDocument();
expect(screen.queryByRole('radio', { name: 'Classic' })).not.toBeInTheDocument();
expect(screen.queryByText('Choose your onboarding mode')).not.toBeInTheDocument();
},
TEST_TIMEOUT_MS,
);
it('hides the onboarding switch until server config is initialized', () => {
renderModeSwitch({ enabled: true, serverConfigInit: false });

View file

@ -68,6 +68,8 @@ export default {
'channel.wechatQrWait': 'Open WeChat and scan the QR code to connect.',
'channel.wechatBotId': 'Bot ID',
'channel.wechatConnectedInfo': 'Connected WeChat Account',
'channel.wechatIdleNotice':
'If no users send messages for over 7 days, this connection will be automatically paused. To resume, click "Rebind via QR Code".',
'channel.wechatManagedCredentials':
'This channel is already connected through QR code authorization. Credentials are managed automatically.',
'channel.wechatRebind': 'Rebind via QR Code',
@ -152,5 +154,12 @@ export default {
'channel.userId': 'Your Platform User ID',
'channel.userIdHint':
'Your user ID on this platform. The AI can use it to send you direct messages.',
'channel.refreshStatus': 'Refresh status',
'channel.runtimeDisconnected': 'Bot disconnected',
'channel.statusConnected': 'Connected',
'channel.statusDisconnected': 'Disconnected',
'channel.statusDormant': 'Dormant',
'channel.statusFailed': 'Failed',
'channel.statusQueued': 'Queued',
'channel.statusStarting': 'Starting',
} as const;

View file

@ -1,32 +1,80 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { ActionIcon, Flexbox, Tag } from '@lobehub/ui';
import { Button, Switch } from 'antd';
import { ExternalLink } from 'lucide-react';
import { ExternalLink, RefreshCw } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import InfoTooltip from '@/components/InfoTooltip';
import type { SerializedPlatformDefinition } from '@/server/services/bot/platforms/types';
import { BOT_RUNTIME_STATUSES, type BotRuntimeStatus } from '../../../../../types/botRuntimeStatus';
import { getPlatformIcon } from '../const';
interface HeaderProps {
currentConfig?: { enabled: boolean };
enabledValue?: boolean;
onRefreshStatus?: () => void;
onToggleEnable: (enabled: boolean) => void;
platformDef: SerializedPlatformDefinition;
refreshingStatus?: boolean;
runtimeStatus?: BotRuntimeStatus;
toggleLoading?: boolean;
}
const STATUS_TAG_COLORS: Partial<Record<BotRuntimeStatus, string>> = {
[BOT_RUNTIME_STATUSES.connected]: 'success',
[BOT_RUNTIME_STATUSES.dormant]: 'warning',
[BOT_RUNTIME_STATUSES.failed]: 'error',
[BOT_RUNTIME_STATUSES.queued]: 'processing',
[BOT_RUNTIME_STATUSES.starting]: 'processing',
};
const Header = memo<HeaderProps>(
({ platformDef, currentConfig, enabledValue, onToggleEnable, toggleLoading }) => {
({
platformDef,
currentConfig,
enabledValue,
onRefreshStatus,
onToggleEnable,
refreshingStatus,
runtimeStatus,
toggleLoading,
}) => {
const { t } = useTranslation('agent');
const PlatformIcon = getPlatformIcon(platformDef.name);
const ColorIcon =
PlatformIcon && 'Color' in PlatformIcon ? (PlatformIcon as any).Color : PlatformIcon;
const effectiveEnabled = enabledValue ?? currentConfig?.enabled;
const statusLabel = (() => {
switch (runtimeStatus) {
case BOT_RUNTIME_STATUSES.connected: {
return t('channel.statusConnected');
}
case BOT_RUNTIME_STATUSES.failed: {
return t('channel.statusFailed');
}
case BOT_RUNTIME_STATUSES.queued: {
return t('channel.statusQueued');
}
case BOT_RUNTIME_STATUSES.starting: {
return t('channel.statusStarting');
}
case BOT_RUNTIME_STATUSES.dormant: {
return t('channel.statusDormant');
}
case BOT_RUNTIME_STATUSES.disconnected: {
return t('channel.statusDisconnected');
}
default: {
return undefined;
}
}
})();
const statusColor = runtimeStatus ? STATUS_TAG_COLORS[runtimeStatus] : undefined;
return (
<Flexbox
horizontal
@ -42,6 +90,20 @@ const Header = memo<HeaderProps>(
<Flexbox horizontal align="center" gap={8}>
{ColorIcon && <ColorIcon size={32} />}
{platformDef.name}
{statusLabel && (
<Tag color={statusColor} size={'small'}>
{statusLabel}
</Tag>
)}
{onRefreshStatus && currentConfig?.enabled && (
<ActionIcon
icon={RefreshCw}
loading={refreshingStatus}
size={'small'}
title={t('channel.refreshStatus')}
onClick={onRefreshStatus}
/>
)}
{platformDef.documentation?.setupGuideUrl && (
<a
href={platformDef.documentation.setupGuideUrl}

View file

@ -11,6 +11,7 @@ import { useAgentStore } from '@/store/agent';
import {
BOT_RUNTIME_STATUSES,
type BotRuntimeStatus,
type BotRuntimeStatusSnapshot,
} from '../../../../../types/botRuntimeStatus';
import Body from './Body';
@ -62,249 +63,247 @@ interface PlatformDetailProps {
agentId: string;
currentConfig?: CurrentConfig;
platformDef: SerializedPlatformDefinition;
runtimeStatus?: BotRuntimeStatus;
}
const PlatformDetail = memo<PlatformDetailProps>(({ platformDef, agentId, currentConfig }) => {
const { t } = useTranslation('agent');
const { message: msg, modal } = App.useApp();
const [form] = Form.useForm<ChannelFormValues>();
const PlatformDetail = memo<PlatformDetailProps>(
({ platformDef, agentId, currentConfig, runtimeStatus }) => {
const { t } = useTranslation('agent');
const { message: msg, modal } = App.useApp();
const [form] = Form.useForm<ChannelFormValues>();
const [createBotProvider, deleteBotProvider, updateBotProvider, connectBot, testConnection] =
useAgentStore((s) => [
const [
createBotProvider,
deleteBotProvider,
updateBotProvider,
connectBot,
testConnection,
refreshBotRuntimeStatus,
] = useAgentStore((s) => [
s.createBotProvider,
s.deleteBotProvider,
s.updateBotProvider,
s.connectBot,
s.testConnection,
s.refreshBotRuntimeStatus,
]);
const [saving, setSaving] = useState(false);
const [connecting, setConnecting] = useState(false);
const [pendingEnabled, setPendingEnabled] = useState<boolean>();
const [saveResult, setSaveResult] = useState<TestResult>();
const [connectResult, setConnectResult] = useState<TestResult>();
const [toggleLoading, setToggleLoading] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<TestResult>();
const connectPollingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [saving, setSaving] = useState(false);
const [connecting, setConnecting] = useState(false);
const [pendingEnabled, setPendingEnabled] = useState<boolean>();
const [saveResult, setSaveResult] = useState<TestResult>();
const [connectResult, setConnectResult] = useState<TestResult>();
const [toggleLoading, setToggleLoading] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<TestResult>();
const [observedStatus, setObservedStatus] = useState<BotRuntimeStatus | undefined>(
runtimeStatus,
);
const [refreshingStatus, setRefreshingStatus] = useState(false);
const connectPollingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const stopConnectPolling = useCallback(() => {
if (!connectPollingTimerRef.current) return;
clearTimeout(connectPollingTimerRef.current);
connectPollingTimerRef.current = null;
}, []);
const stopConnectPolling = useCallback(() => {
if (!connectPollingTimerRef.current) return;
clearTimeout(connectPollingTimerRef.current);
connectPollingTimerRef.current = null;
}, []);
const mapRuntimeStatusToResult = useCallback(
(
runtimeStatus: BotRuntimeStatusSnapshot,
options?: { showConnected?: boolean },
): TestResult | undefined => {
switch (runtimeStatus.status) {
case BOT_RUNTIME_STATUSES.connected: {
if (!options?.showConnected) return undefined;
return { title: t('channel.connectSuccess'), type: 'success' };
const mapRuntimeStatusToResult = useCallback(
(
runtimeStatus: BotRuntimeStatusSnapshot,
options?: { showConnected?: boolean },
): TestResult | undefined => {
switch (runtimeStatus.status) {
case BOT_RUNTIME_STATUSES.connected: {
if (!options?.showConnected) return undefined;
return { title: t('channel.connectSuccess'), type: 'success' };
}
case BOT_RUNTIME_STATUSES.failed: {
return {
errorDetail: runtimeStatus.errorMessage,
title: t('channel.connectFailed'),
type: 'error',
};
}
case BOT_RUNTIME_STATUSES.queued: {
return { title: t('channel.connectQueued'), type: 'info' };
}
case BOT_RUNTIME_STATUSES.starting: {
return { title: t('channel.connectStarting'), type: 'info' };
}
default: {
return undefined;
}
}
case BOT_RUNTIME_STATUSES.failed: {
return {
errorDetail: runtimeStatus.errorMessage,
title: t('channel.connectFailed'),
type: 'error',
};
}
case BOT_RUNTIME_STATUSES.queued: {
return { title: t('channel.connectQueued'), type: 'info' };
}
case BOT_RUNTIME_STATUSES.starting: {
return { title: t('channel.connectStarting'), type: 'info' };
}
default: {
return undefined;
}
}
},
[t],
);
const syncRuntimeStatus = useCallback(
async (
params: {
applicationId: string;
platform: string;
},
options?: { poll?: boolean; showConnected?: boolean },
) => {
stopConnectPolling();
const runtimeStatus = await agentBotProviderService.getRuntimeStatus(params);
const nextResult = mapRuntimeStatusToResult(runtimeStatus, {
showConnected: options?.showConnected,
});
if (nextResult) {
setConnectResult(nextResult);
} else if (runtimeStatus.status === BOT_RUNTIME_STATUSES.disconnected) {
setConnectResult(undefined);
}
if (
options?.poll &&
(runtimeStatus.status === BOT_RUNTIME_STATUSES.queued ||
runtimeStatus.status === BOT_RUNTIME_STATUSES.starting)
) {
connectPollingTimerRef.current = setTimeout(() => {
void syncRuntimeStatus(params, options);
}, 2000);
}
},
[mapRuntimeStatusToResult, stopConnectPolling],
);
const connectCurrentBot = useCallback(
async (applicationId: string) => {
setConnecting(true);
try {
const { status } = await connectBot({ agentId, applicationId, platform: platformDef.id });
setConnectResult({
title: status === 'queued' ? t('channel.connectQueued') : t('channel.connectStarting'),
type: 'info',
});
await syncRuntimeStatus(
{ applicationId, platform: platformDef.id },
{ poll: true, showConnected: true },
);
} catch (e: any) {
setConnectResult({ errorDetail: e?.message || String(e), type: 'error' });
} finally {
setConnecting(false);
}
},
[agentId, connectBot, platformDef.id, syncRuntimeStatus, t],
);
// Reset form and status when switching platforms
useEffect(() => {
form.resetFields();
setSaveResult(undefined);
setConnectResult(undefined);
setTestResult(undefined);
stopConnectPolling();
}, [platformDef.id, form, stopConnectPolling]);
// Sync form with saved config
useEffect(() => {
if (currentConfig) {
form.setFieldsValue(getChannelFormValues(currentConfig));
}
}, [currentConfig, form]);
useEffect(() => {
if (!currentConfig) {
setPendingEnabled(undefined);
setToggleLoading(false);
return;
}
if (pendingEnabled === currentConfig.enabled) {
setPendingEnabled(undefined);
}
}, [currentConfig, pendingEnabled]);
useEffect(() => {
if (!currentConfig?.enabled) {
stopConnectPolling();
setConnectResult(undefined);
return;
}
void syncRuntimeStatus(
{
applicationId: currentConfig.applicationId,
platform: currentConfig.platform,
},
{ poll: true, showConnected: false },
[t],
);
return () => {
stopConnectPolling();
};
}, [currentConfig, stopConnectPolling, syncRuntimeStatus]);
const syncRuntimeStatus = useCallback(
async (
params: {
applicationId: string;
platform: string;
},
options?: { poll?: boolean; showConnected?: boolean },
) => {
stopConnectPolling();
const handleSave = useCallback(async () => {
try {
await form.validateFields();
const values = form.getFieldsValue(true) as ChannelFormValues;
setSaving(true);
setSaveResult(undefined);
setConnectResult(undefined);
const {
applicationId: formAppId,
credentials: rawCredentials = {},
settings: rawSettings = {},
} = values as ChannelFormValues;
// Strip undefined values from credentials (optional fields left empty by antd form)
const credentials = Object.fromEntries(
Object.entries(rawCredentials).filter(([, v]) => v !== undefined && v !== ''),
);
const settings = omitUndefinedValues(rawSettings);
// Use explicit applicationId from form; fall back to deriving from botToken (Telegram)
let applicationId = formAppId || '';
if (!applicationId && (credentials as Record<string, string>).botToken) {
const colonIdx = (credentials as Record<string, string>).botToken.indexOf(':');
if (colonIdx !== -1)
applicationId = (credentials as Record<string, string>).botToken.slice(0, colonIdx);
}
if (currentConfig) {
await updateBotProvider(currentConfig.id, agentId, {
applicationId,
credentials,
settings,
const snapshot = await agentBotProviderService.getRuntimeStatus(params);
setObservedStatus(snapshot.status);
const nextResult = mapRuntimeStatusToResult(snapshot, {
showConnected: options?.showConnected,
});
} else {
await createBotProvider({
agentId,
applicationId,
credentials,
platform: platformDef.id,
settings,
});
}
setSaveResult({ type: 'success' });
setTimeout(() => setSaveResult(undefined), 3000);
setSaving(false);
if (nextResult) {
setConnectResult(nextResult);
} else if (snapshot.status === BOT_RUNTIME_STATUSES.disconnected) {
setConnectResult(undefined);
}
// Auto-connect bot after save
await connectCurrentBot(applicationId);
} catch (e: any) {
if (e?.errorFields) return;
console.error(e);
setSaveResult({ errorDetail: e?.message || String(e), type: 'error' });
setSaving(false);
}
}, [
agentId,
platformDef,
form,
currentConfig,
createBotProvider,
updateBotProvider,
connectCurrentBot,
]);
if (
options?.poll &&
(snapshot.status === BOT_RUNTIME_STATUSES.queued ||
snapshot.status === BOT_RUNTIME_STATUSES.starting)
) {
connectPollingTimerRef.current = setTimeout(() => {
void syncRuntimeStatus(params, options);
}, 2000);
}
},
[mapRuntimeStatusToResult, stopConnectPolling],
);
const handleExternalAuth = useCallback(
async (params: { applicationId: string; credentials: Record<string, string> }) => {
setSaving(true);
setSaveResult(undefined);
setConnectResult(undefined);
const connectCurrentBot = useCallback(
async (applicationId: string) => {
setConnecting(true);
try {
const { status } = await connectBot({ agentId, applicationId, platform: platformDef.id });
setConnectResult({
title: status === 'queued' ? t('channel.connectQueued') : t('channel.connectStarting'),
type: 'info',
});
await syncRuntimeStatus(
{ applicationId, platform: platformDef.id },
{ poll: true, showConnected: true },
);
} catch (e: any) {
setConnectResult({ errorDetail: e?.message || String(e), type: 'error' });
} finally {
setConnecting(false);
}
},
[agentId, connectBot, platformDef.id, syncRuntimeStatus, t],
);
const handleRefreshStatus = useCallback(async () => {
if (!currentConfig?.enabled) return;
setRefreshingStatus(true);
try {
const { applicationId, credentials } = params;
const settings = omitUndefinedValues(form.getFieldValue('settings') || {});
const snapshot = await refreshBotRuntimeStatus({
agentId,
applicationId: currentConfig.applicationId,
platform: currentConfig.platform,
});
setObservedStatus(snapshot.status);
const nextResult = mapRuntimeStatusToResult(snapshot, { showConnected: true });
if (nextResult) {
setConnectResult(nextResult);
} else if (snapshot.status === BOT_RUNTIME_STATUSES.disconnected) {
setConnectResult(undefined);
}
} catch (e: any) {
msg.error(e?.message || String(e));
} finally {
setRefreshingStatus(false);
}
}, [agentId, currentConfig, mapRuntimeStatusToResult, msg, refreshBotRuntimeStatus]);
// Reset form and status when switching platforms. Must NOT depend on
// runtimeStatus — otherwise background status refreshes would wipe
// in-progress form edits and cancel the connect-status polling loop.
useEffect(() => {
form.resetFields();
setSaveResult(undefined);
setConnectResult(undefined);
setTestResult(undefined);
stopConnectPolling();
}, [platformDef.id, form, stopConnectPolling]);
// Keep the displayed status in sync with the latest snapshot from the
// parent (initial load, bulk refresh, SWR revalidation).
useEffect(() => {
setObservedStatus(runtimeStatus);
}, [runtimeStatus]);
// Sync form with saved config
useEffect(() => {
if (currentConfig) {
form.setFieldsValue(getChannelFormValues(currentConfig));
}
}, [currentConfig, form]);
useEffect(() => {
if (!currentConfig) {
setPendingEnabled(undefined);
setToggleLoading(false);
return;
}
if (pendingEnabled === currentConfig.enabled) {
setPendingEnabled(undefined);
}
}, [currentConfig, pendingEnabled]);
useEffect(() => {
if (!currentConfig?.enabled) {
stopConnectPolling();
setConnectResult(undefined);
setObservedStatus(undefined);
return;
}
void syncRuntimeStatus(
{
applicationId: currentConfig.applicationId,
platform: currentConfig.platform,
},
{ poll: true, showConnected: false },
);
return () => {
stopConnectPolling();
};
}, [currentConfig, stopConnectPolling, syncRuntimeStatus]);
const handleSave = useCallback(async () => {
try {
await form.validateFields();
const values = form.getFieldsValue(true) as ChannelFormValues;
setSaving(true);
setSaveResult(undefined);
setConnectResult(undefined);
const {
applicationId: formAppId,
credentials: rawCredentials = {},
settings: rawSettings = {},
} = values as ChannelFormValues;
// Strip undefined values from credentials (optional fields left empty by antd form)
const credentials = Object.fromEntries(
Object.entries(rawCredentials).filter(([, v]) => v !== undefined && v !== ''),
);
const settings = omitUndefinedValues(rawSettings);
// Use explicit applicationId from form; fall back to deriving from botToken (Telegram)
let applicationId = formAppId || '';
if (!applicationId && (credentials as Record<string, string>).botToken) {
const colonIdx = (credentials as Record<string, string>).botToken.indexOf(':');
if (colonIdx !== -1)
applicationId = (credentials as Record<string, string>).botToken.slice(0, colonIdx);
}
if (currentConfig) {
await updateBotProvider(currentConfig.id, agentId, {
@ -323,17 +322,18 @@ const PlatformDetail = memo<PlatformDetailProps>(({ platformDef, agentId, curren
}
setSaveResult({ type: 'success' });
msg.success(t('channel.saved'));
setTimeout(() => setSaveResult(undefined), 3000);
setSaving(false);
// Auto-connect
// Auto-connect bot after save
await connectCurrentBot(applicationId);
} catch (e: any) {
if (e?.errorFields) return;
console.error(e);
setSaveResult({ errorDetail: e?.message || String(e), type: 'error' });
} finally {
setSaving(false);
}
},
[
}, [
agentId,
platformDef,
form,
@ -341,107 +341,158 @@ const PlatformDetail = memo<PlatformDetailProps>(({ platformDef, agentId, curren
createBotProvider,
updateBotProvider,
connectCurrentBot,
msg,
t,
],
);
]);
const handleDelete = useCallback(async () => {
if (!currentConfig) return;
const handleExternalAuth = useCallback(
async (params: { applicationId: string; credentials: Record<string, string> }) => {
setSaving(true);
setSaveResult(undefined);
setConnectResult(undefined);
modal.confirm({
content: t('channel.deleteConfirmDesc'),
okButtonProps: { danger: true },
onOk: async () => {
try {
await deleteBotProvider(currentConfig.id, agentId);
msg.success(t('channel.removed'));
form.resetFields();
} catch {
msg.error(t('channel.removeFailed'));
const { applicationId, credentials } = params;
const settings = omitUndefinedValues(form.getFieldValue('settings') || {});
if (currentConfig) {
await updateBotProvider(currentConfig.id, agentId, {
applicationId,
credentials,
settings,
});
} else {
await createBotProvider({
agentId,
applicationId,
credentials,
platform: platformDef.id,
settings,
});
}
setSaveResult({ type: 'success' });
msg.success(t('channel.saved'));
// Auto-connect
await connectCurrentBot(applicationId);
} catch (e: any) {
setSaveResult({ errorDetail: e?.message || String(e), type: 'error' });
} finally {
setSaving(false);
}
},
title: t('channel.deleteConfirm'),
});
}, [currentConfig, agentId, deleteBotProvider, msg, t, modal, form]);
[
agentId,
platformDef,
form,
currentConfig,
createBotProvider,
updateBotProvider,
connectCurrentBot,
msg,
t,
],
);
const handleToggleEnable = useCallback(
async (enabled: boolean) => {
const handleDelete = useCallback(async () => {
if (!currentConfig) return;
try {
setPendingEnabled(enabled);
setToggleLoading(true);
await updateBotProvider(currentConfig.id, agentId, { enabled });
setToggleLoading(false);
if (enabled) {
await connectCurrentBot(currentConfig.applicationId);
modal.confirm({
content: t('channel.deleteConfirmDesc'),
okButtonProps: { danger: true },
onOk: async () => {
try {
await deleteBotProvider(currentConfig.id, agentId);
msg.success(t('channel.removed'));
form.resetFields();
} catch {
msg.error(t('channel.removeFailed'));
}
},
title: t('channel.deleteConfirm'),
});
}, [currentConfig, agentId, deleteBotProvider, msg, t, modal, form]);
const handleToggleEnable = useCallback(
async (enabled: boolean) => {
if (!currentConfig) return;
try {
setPendingEnabled(enabled);
setToggleLoading(true);
await updateBotProvider(currentConfig.id, agentId, { enabled });
setToggleLoading(false);
if (enabled) {
await connectCurrentBot(currentConfig.applicationId);
}
} catch {
setPendingEnabled(undefined);
setToggleLoading(false);
msg.error(t('channel.updateFailed'));
}
} catch {
setPendingEnabled(undefined);
setToggleLoading(false);
msg.error(t('channel.updateFailed'));
},
[currentConfig, agentId, updateBotProvider, connectCurrentBot, msg, t],
);
const handleTestConnection = useCallback(async () => {
if (!currentConfig) {
msg.warning(t('channel.saveFirstWarning'));
return;
}
},
[currentConfig, agentId, updateBotProvider, connectCurrentBot, msg, t],
);
const handleTestConnection = useCallback(async () => {
if (!currentConfig) {
msg.warning(t('channel.saveFirstWarning'));
return;
}
setTesting(true);
setTestResult(undefined);
try {
await testConnection({
applicationId: currentConfig.applicationId,
platform: platformDef.id,
});
setTestResult({ type: 'success' });
} catch (e: any) {
setTestResult({
errorDetail: e?.message || String(e),
type: 'error',
});
} finally {
setTesting(false);
}
}, [currentConfig, platformDef.id, testConnection, msg, t]);
setTesting(true);
setTestResult(undefined);
try {
await testConnection({
applicationId: currentConfig.applicationId,
platform: platformDef.id,
});
setTestResult({ type: 'success' });
} catch (e: any) {
setTestResult({
errorDetail: e?.message || String(e),
type: 'error',
});
} finally {
setTesting(false);
}
}, [currentConfig, platformDef.id, testConnection, msg, t]);
return (
<main className={styles.main}>
<Header
currentConfig={currentConfig}
enabledValue={pendingEnabled}
platformDef={platformDef}
toggleLoading={toggleLoading}
onToggleEnable={handleToggleEnable}
/>
<Body
currentConfig={currentConfig}
form={form}
hasConfig={!!currentConfig}
platformDef={platformDef}
onAuthenticated={handleExternalAuth}
/>
<Footer
connectResult={connectResult}
connecting={connecting}
form={form}
hasConfig={!!currentConfig}
platformDef={platformDef}
saveResult={saveResult}
saving={saving}
testResult={testResult}
testing={testing}
onCopied={() => msg.success(t('channel.copied'))}
onDelete={handleDelete}
onSave={handleSave}
onTestConnection={handleTestConnection}
/>
</main>
);
});
return (
<main className={styles.main}>
<Header
currentConfig={currentConfig}
enabledValue={pendingEnabled}
platformDef={platformDef}
refreshingStatus={refreshingStatus}
runtimeStatus={observedStatus}
toggleLoading={toggleLoading}
onRefreshStatus={handleRefreshStatus}
onToggleEnable={handleToggleEnable}
/>
<Body
currentConfig={currentConfig}
form={form}
hasConfig={!!currentConfig}
platformDef={platformDef}
onAuthenticated={handleExternalAuth}
/>
<Footer
connectResult={connectResult}
connecting={connecting}
form={form}
hasConfig={!!currentConfig}
platformDef={platformDef}
saveResult={saveResult}
saving={saving}
testResult={testResult}
testing={testing}
onCopied={() => msg.success(t('channel.copied'))}
onDelete={handleDelete}
onSave={handleSave}
onTestConnection={handleTestConnection}
/>
</main>
);
},
);
export default PlatformDetail;

View file

@ -2,7 +2,7 @@
import { Flexbox } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo, useMemo, useState } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import Loading from '@/components/Loading/BrandTextLoading';
@ -34,6 +34,14 @@ const ChannelPage = memo(() => {
const { data: providers, isLoading: providersLoading } = useAgentStore((s) =>
s.useFetchBotProviders(aid),
);
const triggerRefreshAllBotStatuses = useAgentStore((s) => s.triggerRefreshAllBotStatuses);
// Fire-and-forget a live gateway status refresh on entry. The list renders
// from cached statuses immediately; SWR revalidates once Redis is updated.
useEffect(() => {
if (!aid) return;
triggerRefreshAllBotStatuses(aid);
}, [aid, triggerRefreshAllBotStatuses]);
const isLoading = platformsLoading || providersLoading;
@ -86,6 +94,7 @@ const ChannelPage = memo(() => {
agentId={aid}
currentConfig={currentConfig}
platformDef={activePlatformDef}
runtimeStatus={platformRuntimeStatuses.get(activePlatformDef.id)}
/>
</div>
)}

View file

@ -209,6 +209,9 @@ const PlatformList = memo<PlatformListProps>(
case BOT_RUNTIME_STATUSES.starting: {
return theme.colorInfo;
}
case BOT_RUNTIME_STATUSES.dormant: {
return theme.colorWarning;
}
case BOT_RUNTIME_STATUSES.disconnected: {
return theme.colorTextQuaternary;
}
@ -232,6 +235,9 @@ const PlatformList = memo<PlatformListProps>(
case BOT_RUNTIME_STATUSES.starting: {
return t('channel.connectStarting');
}
case BOT_RUNTIME_STATUSES.dormant: {
return t('channel.statusDormant');
}
case BOT_RUNTIME_STATUSES.disconnected: {
return t('channel.runtimeDisconnected');
}

View file

@ -1,6 +1,6 @@
'use client';
import { Flexbox, FormItem } from '@lobehub/ui';
import { Alert, Flexbox, FormItem } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@ -77,6 +77,12 @@ const WechatConnectedInfo = memo<WechatConnectedInfoProps>(
/>
)}
</div>
<Alert
showIcon
message={t('channel.wechatIdleNotice')}
style={{ marginBlockEnd: 16 }}
type="info"
/>
{shouldShowApplicationId && (
<ReadOnlyField
description={t('channel.applicationIdHint')}

View file

@ -91,6 +91,21 @@ export const agentBotProviderRouter = router({
return getBotRuntimeStatus(input.platform, input.applicationId);
}),
refreshRuntimeStatus: authedProcedure
.input(z.object({ applicationId: z.string(), platform: z.string() }))
.mutation(async ({ input, ctx }) => {
const service = new GatewayService();
return service.refreshBotRuntimeStatus(input.platform, input.applicationId, ctx.userId);
}),
refreshRuntimeStatusesByAgent: authedProcedure
.input(z.object({ agentId: z.string() }))
.mutation(async ({ input, ctx }) => {
const service = new GatewayService();
await service.refreshBotRuntimeStatusesByAgent(input.agentId, ctx.userId);
return { ok: true as const };
}),
list: agentBotProviderProcedure
.input(
z

View file

@ -549,6 +549,7 @@ export class AgentBridgeService {
const useGatewayTyping = gwClient.isEnabled && platformSupportsTyping;
let progressMessage: SentMessage | undefined;
let gatewayConnectionId: string | undefined;
if (useGatewayTyping) {
log('executeWithWebhooks: using gateway typing, skipping ack message');
@ -559,15 +560,21 @@ export class AgentBridgeService {
// the entire AI generation (platform typing expires after ~10s).
if (botContext?.platformThreadId && botContext?.applicationId) {
const platform = botContext.platformThreadId.split(':')[0];
AgentBotProviderModel.findByPlatformAndAppId(this.db, platform, botContext.applicationId)
.then((row) => {
if (row?.id) {
return gwClient.startTyping(row.id, botContext.platformThreadId!);
}
})
.catch((err) => {
log('executeWithWebhooks: gateway startTyping failed: %O', err);
});
try {
const row = await AgentBotProviderModel.findByPlatformAndAppId(
this.db,
platform,
botContext.applicationId,
);
if (row?.id) {
gatewayConnectionId = row.id;
gwClient.startTyping(row.id, botContext.platformThreadId!).catch((err) => {
log('executeWithWebhooks: gateway startTyping failed: %O', err);
});
}
} catch (err) {
log('executeWithWebhooks: gateway provider lookup failed: %O', err);
}
}
} else {
await safeSideEffect(() => thread.startTyping(), 'startTyping (executeWithWebhooks)');
@ -638,6 +645,7 @@ export class AgentBridgeService {
client,
displayToolCalls,
files,
gatewayConnectionId,
progressMessage,
prompt,
topicId,
@ -800,6 +808,7 @@ export class AgentBridgeService {
client?: PlatformClient;
displayToolCalls?: boolean;
files?: any;
gatewayConnectionId?: string;
progressMessage?: SentMessage;
prompt: string;
topicId?: string;
@ -817,6 +826,7 @@ export class AgentBridgeService {
client,
displayToolCalls,
files,
gatewayConnectionId,
prompt,
topicId,
trigger,
@ -826,8 +836,18 @@ export class AgentBridgeService {
let { progressMessage } = opts;
let operationStartTime = 0;
const stopGatewayTyping = () => {
if (gatewayConnectionId && botContext?.platformThreadId) {
const gwClient = getMessageGatewayClient();
gwClient.stopTyping(gatewayConnectionId, botContext.platformThreadId).catch((err) => {
log('executeWithCallback[local]: gateway stopTyping failed: %O', err);
});
}
};
return new Promise<{ reply: string; topicId: string }>((resolve, reject) => {
const timeout = setTimeout(() => {
stopGatewayTyping();
reject(new Error(`Agent execution timed out`));
}, EXECUTION_TIMEOUT);
@ -899,6 +919,7 @@ export class AgentBridgeService {
{
handler: async (event) => {
clearTimeout(timeout);
stopGatewayTyping();
const reason = event.reason;
log('onComplete: reason=%s', reason);

View file

@ -24,7 +24,7 @@ export interface MessageGatewayConnectionStatus {
connectedAt?: number;
error?: string;
platform: string;
status: 'connected' | 'connecting' | 'disconnected' | 'error';
status: 'connected' | 'connecting' | 'disconnected' | 'dormant' | 'error';
};
}

View file

@ -4,12 +4,41 @@ import { getServerDB } from '@/database/core/db-adaptor';
import { AgentBotProviderModel } from '@/database/models/agentBotProvider';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import {
type BotRuntimeStatus,
type BotRuntimeStatusSnapshot,
} from '../../../types/botRuntimeStatus';
import type { ConnectionMode } from '../bot/platforms';
import { getEffectiveConnectionMode, platformRegistry } from '../bot/platforms';
import { BOT_CONNECT_QUEUE_EXPIRE_MS, BotConnectQueue } from './botConnectQueue';
import { createGatewayManager, getGatewayManager } from './GatewayManager';
import { getMessageGatewayClient } from './MessageGatewayClient';
import { BOT_RUNTIME_STATUSES, updateBotRuntimeStatus } from './runtimeStatus';
import {
getMessageGatewayClient,
type MessageGatewayConnectionStatus,
} from './MessageGatewayClient';
import { BOT_RUNTIME_STATUSES, getBotRuntimeStatus, updateBotRuntimeStatus } from './runtimeStatus';
function mapGatewayStatusToRuntimeStatus(
status: MessageGatewayConnectionStatus['state']['status'],
): BotRuntimeStatus {
switch (status) {
case 'connected': {
return BOT_RUNTIME_STATUSES.connected;
}
case 'connecting': {
return BOT_RUNTIME_STATUSES.starting;
}
case 'disconnected': {
return BOT_RUNTIME_STATUSES.disconnected;
}
case 'dormant': {
return BOT_RUNTIME_STATUSES.dormant;
}
case 'error': {
return BOT_RUNTIME_STATUSES.failed;
}
}
}
const log = debug('lobe-server:service:gateway');
@ -111,6 +140,14 @@ export class GatewayService {
log('Gateway sync: %s already %s, skipping', provider.id, status.state.status);
continue;
}
// Dormant: gateway is running sparse alarm-driven polling and will
// self-wake when a message arrives. Reconnecting here would defeat
// the purpose — only manual reconnect (startClient) should override.
if (status.state.status === 'dormant') {
skippedConnected++;
log('Gateway sync: %s dormant, skipping (DO is sparse-polling)', provider.id);
continue;
}
// "error" means credential/config issue (e.g. session expired, unauthorized).
// Auto-retry is pointless — only user action (saving new credentials) can fix it.
if (status.state.status === 'error') {
@ -247,6 +284,93 @@ export class GatewayService {
return 'started';
}
/**
* Pull live status from the gateway for every enabled provider under an
* agent and persist each result to Redis. No-op when the gateway is
* disabled; webhook-mode providers are skipped (they have no persistent
* gateway connection to query).
*/
async refreshBotRuntimeStatusesByAgent(agentId: string, userId: string): Promise<void> {
if (!this.useMessageGateway) return;
const serverDB = await getServerDB();
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
const model = new AgentBotProviderModel(serverDB, userId, gateKeeper);
const providers = await model.findByAgentId(agentId);
const client = getMessageGatewayClient();
await Promise.all(
providers.map(async (provider) => {
if (!provider.enabled) return;
const definition = platformRegistry.getPlatform(provider.platform);
const connectionMode = getEffectiveConnectionMode(definition, provider.settings);
if (connectionMode === 'webhook') return;
try {
const { state } = await client.getStatus(provider.id);
await updateBotRuntimeStatus({
applicationId: provider.applicationId,
errorMessage: state.error,
platform: provider.platform,
status: mapGatewayStatusToRuntimeStatus(state.status),
});
} catch (err) {
log(
'Bulk refresh: gateway status failed %s:%s: %O',
provider.platform,
provider.applicationId,
err,
);
}
}),
);
}
/**
* Pull the live connection status from the external message-gateway and
* persist it to the local Redis snapshot. When the gateway is disabled or
* the provider runs in webhook mode, returns the cached snapshot as-is.
*/
async refreshBotRuntimeStatus(
platform: string,
applicationId: string,
userId: string,
): Promise<BotRuntimeStatusSnapshot> {
const cached = await getBotRuntimeStatus(platform, applicationId);
if (!this.useMessageGateway) return cached;
const serverDB = await getServerDB();
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
const model = new AgentBotProviderModel(serverDB, userId, gateKeeper);
const provider = await model.findEnabledByApplicationId(platform, applicationId);
if (!provider) return cached;
const definition = platformRegistry.getPlatform(platform);
const connectionMode = getEffectiveConnectionMode(definition, provider.settings);
// Webhook-mode bots have no persistent gateway connection to query — the
// gateway only holds the webhook URL registration, so the local snapshot
// is already the source of truth.
if (connectionMode === 'webhook') return cached;
const client = getMessageGatewayClient();
try {
const { state } = await client.getStatus(provider.id);
return await updateBotRuntimeStatus({
applicationId,
errorMessage: state.error,
platform,
status: mapGatewayStatusToRuntimeStatus(state.status),
});
} catch (err) {
log('Refresh runtime status via gateway failed %s:%s: %O', platform, applicationId, err);
return cached;
}
}
async stopClient(platform: string, applicationId: string, userId?: string): Promise<void> {
if (this.useMessageGateway) {
return this.stopClientViaGateway(platform, applicationId);

View file

@ -18,6 +18,17 @@ class AgentBotProviderService {
return lambdaClient.agentBotProvider.getRuntimeStatus.query(params);
};
refreshRuntimeStatus = async (params: {
applicationId: string;
platform: string;
}): Promise<BotRuntimeStatusSnapshot> => {
return lambdaClient.agentBotProvider.refreshRuntimeStatus.mutate(params);
};
refreshRuntimeStatusesByAgent = async (agentId: string): Promise<void> => {
await lambdaClient.agentBotProvider.refreshRuntimeStatusesByAgent.mutate({ agentId });
};
create = async (params: {
agentId: string;
applicationId: string;

View file

@ -4,6 +4,7 @@ import { mutate, useClientDataSWR } from '@/libs/swr';
import type { SerializedPlatformDefinition } from '@/server/services/bot/platforms/types';
import { agentBotProviderService } from '@/services/agentBotProvider';
import { type StoreSetter } from '@/store/types';
import type { BotRuntimeStatusSnapshot } from '@/types/botRuntimeStatus';
import { type AgentStore } from '../../store';
@ -67,6 +68,31 @@ export class BotSliceActionImpl {
await this.internal_refreshBotProviders(agentId);
};
refreshBotRuntimeStatus = async (params: {
agentId?: string;
applicationId: string;
platform: string;
}): Promise<BotRuntimeStatusSnapshot> => {
const { agentId, ...rest } = params;
const snapshot = await agentBotProviderService.refreshRuntimeStatus(rest);
await this.internal_refreshBotProviders(agentId);
return snapshot;
};
/**
* Kick off a background refresh of every provider's live gateway status.
* Fire-and-forget: the list can render from cached statuses immediately,
* and we revalidate SWR once the server finishes updating Redis.
*/
triggerRefreshAllBotStatuses = (agentId: string) => {
agentBotProviderService
.refreshRuntimeStatusesByAgent(agentId)
.then(() => this.internal_refreshBotProviders(agentId))
.catch(() => {
// Non-critical: cached statuses remain visible.
});
};
internal_refreshBotProviders = async (agentId?: string) => {
const id = agentId || this.#get().activeAgentId;
if (!id) return;

View file

@ -1,6 +1,9 @@
export const BOT_RUNTIME_STATUSES = {
connected: 'connected',
disconnected: 'disconnected',
// Polling-mode bots silent for >7d enter dormant: continuous polling stops
// and sparse alarm-driven probes detect new messages. See LOBE-7320.
dormant: 'dormant',
failed: 'failed',
queued: 'queued',
starting: 'starting',