mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
🐛 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:
parent
8240e8685d
commit
0213656565
17 changed files with 710 additions and 342 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 Mode(WebSocket)",
|
||||
"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": "刷新二维码",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export interface MessageGatewayConnectionStatus {
|
|||
connectedAt?: number;
|
||||
error?: string;
|
||||
platform: string;
|
||||
status: 'connected' | 'connecting' | 'disconnected' | 'error';
|
||||
status: 'connected' | 'connecting' | 'disconnected' | 'dormant' | 'error';
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue