From d58922c3c1860b51422b70fedadf5fb7e835f302 Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Fri, 17 Jul 2026 15:19:59 +0800 Subject: [PATCH] fix(remote): alert on Feishu delivery errors --- src/main/app/composition.ts | 3 + src/main/remote/channels/feishu/adapter.ts | 6 ++ .../remote/channels/feishu/feishuRuntime.ts | 18 ++++ src/main/remote/index.ts | 46 +++++++++ src/main/remote/ports.ts | 10 ++ test/main/remote/feishuAdapter.test.ts | 29 ++++++ test/main/remote/feishuRuntime.test.ts | 94 ++++++++++++++++++- test/main/remote/remoteService.test.ts | 67 +++++++++++++ 8 files changed, 272 insertions(+), 1 deletion(-) diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index fc74d0499d..c2eef8f0ab 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -1263,6 +1263,9 @@ export async function createMainProcessControl(dependencies: { projection: sessionQuery, desktop: { openSession: (sessionId) => openRemoteSession(sessionId) + }, + notifications: { + showNotification: (options) => notificationService.showNotification(options) } }) cronJobs = new SchedulerService({ diff --git a/src/main/remote/channels/feishu/adapter.ts b/src/main/remote/channels/feishu/adapter.ts index b2e64c088f..a99aa5c703 100644 --- a/src/main/remote/channels/feishu/adapter.ts +++ b/src/main/remote/channels/feishu/adapter.ts @@ -21,6 +21,7 @@ type FeishuAdapterDeps = { bindingStore: RemoteBindingStore createConversationRunner: () => RemoteConversationRunner onFatalError?: (message: string) => Promise | void + onDeliveryError?: (message: string) => void configSignature?: string } @@ -28,6 +29,7 @@ export class FeishuAdapter extends ChannelAdapter { private readonly bindingStore: RemoteBindingStore private readonly createConversationRunner: () => RemoteConversationRunner private readonly onFatalError?: (message: string) => Promise | void + private readonly onDeliveryError?: (message: string) => void private readonly credentials: { brand: FeishuBrand appId: string @@ -47,6 +49,7 @@ export class FeishuAdapter extends ChannelAdapter { this.bindingStore = deps.bindingStore this.createConversationRunner = deps.createConversationRunner this.onFatalError = deps.onFatalError + this.onDeliveryError = deps.onDeliveryError this.credentials = { brand: config.channelConfig.brand === 'lark' ? 'lark' : 'feishu', appId: String(config.channelConfig.appId ?? '').trim(), @@ -80,6 +83,9 @@ export class FeishuAdapter extends ChannelAdapter { }, onFatalError: (message) => { void this.onFatalError?.(message) + }, + onDeliveryError: (message) => { + this.onDeliveryError?.(message) } }) diff --git a/src/main/remote/channels/feishu/feishuRuntime.ts b/src/main/remote/channels/feishu/feishuRuntime.ts index f80ba91cd5..3b89414396 100644 --- a/src/main/remote/channels/feishu/feishuRuntime.ts +++ b/src/main/remote/channels/feishu/feishuRuntime.ts @@ -31,6 +31,8 @@ const safeErrorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error) const FEISHU_INTERNAL_ERROR_REPLY = 'An internal error occurred while processing your request.' +const FEISHU_INBOUND_MESSAGE_ERROR = 'Failed to handle inbound message.' +const FEISHU_REPLY_DELIVERY_ERROR = 'Failed to deliver reply to Feishu.' const FEISHU_STREAMING_CARD_FALLBACK_NOTICE = 'Feishu CardKit streaming failed. Falling back to normal message updates. Check that the app has im:message and cardkit:card:write permissions.' @@ -45,6 +47,7 @@ type FeishuRuntimeDeps = { } onStatusChange?: (snapshot: FeishuRuntimeStatusSnapshot) => void onFatalError?: (message: string) => void + onDeliveryError?: (message: string) => void } type FeishuProcessedInboundEntry = { @@ -321,6 +324,10 @@ export class FeishuRuntime { } } } + + if (this.isCurrentRun(runId) && this.statusSnapshot.lastError) { + this.setStatus({ lastError: null }) + } } catch (error) { const diagnostics = { runId, @@ -345,6 +352,10 @@ export class FeishuRuntime { return } + // Keep the runtime running but surface the failure in the status snapshot + // (settings page and /status), since the user otherwise sees nothing. + this.setStatus({ lastError: FEISHU_INBOUND_MESSAGE_ERROR }) + try { if (!this.isCurrentRun(runId)) { return @@ -358,6 +369,13 @@ export class FeishuRuntime { eventId: message.eventId, error: sendError }) + if (this.isCurrentRun(runId)) { + const deliveryError = FEISHU_REPLY_DELIVERY_ERROR + this.setStatus({ + lastError: deliveryError + }) + this.deps.onDeliveryError?.(deliveryError) + } } } } diff --git a/src/main/remote/index.ts b/src/main/remote/index.ts index 0f25b31e44..0a732b7540 100644 --- a/src/main/remote/index.ts +++ b/src/main/remote/index.ts @@ -87,6 +87,7 @@ import { const DEFAULT_CHANNEL_ID = 'default' const WEIXIN_TRACE_LOG_ENABLED = process.env.DEEPCHAT_WEIXIN_TRACE === '1' +const REMOTE_DELIVERY_ERROR_NOTIFY_INTERVAL_MS = 60 * 1000 const FEISHU_AUTH_SESSION_TTL_MS = 5 * 60 * 1000 const FEISHU_AUTH_DEFAULT_WAIT_TIMEOUT_MS = 5 * 60 * 1000 const FEISHU_INSTALL_DEFAULT_WAIT_TIMEOUT_MS = 5 * 60 * 1000 @@ -222,6 +223,8 @@ export class RemoteService { private weixinIlinkLoginWindow: BrowserWindow | null = null private weixinIlinkLoginWindowUrl: string | null = null private readonly weixinIlinkLoginWaits = new Map>() + private readonly deliveryErrorNotifiedAt = new Map() + private readonly deliveryErrorNotificationInFlight = new Set() constructor(private readonly deps: RemoteServiceDeps) { this.bindingStore = new RemoteBindingStore(this.deps.settings) @@ -1255,6 +1258,9 @@ export class RemoteService { await this.disableFeishuRuntimeForFatalError(config.configSignature ?? '', message) }) }, + onDeliveryError: (message) => { + this.notifyChannelDeliveryError('feishu', 'Feishu', message) + }, configSignature: config.configSignature }) }) @@ -2560,6 +2566,46 @@ export class RemoteService { this.weixinIlinkLoginWindowUrl = null } + private notifyChannelDeliveryError( + channel: RemoteChannel, + channelLabel: string, + message: string + ): void { + const notifications = this.deps.notifications + if (!notifications || this.deliveryErrorNotificationInFlight.has(channel)) { + return + } + + // Throttled: a dead proxy fails every message; one notification per minute is enough. + const now = Date.now() + const lastNotifiedAt = this.deliveryErrorNotifiedAt.get(channel) ?? 0 + if (now - lastNotifiedAt < REMOTE_DELIVERY_ERROR_NOTIFY_INTERVAL_MS) { + return + } + + this.deliveryErrorNotificationInFlight.add(channel) + void notifications + .showNotification({ + id: `remote-delivery-error:${channel}`, + title: `DeepChat ${channelLabel} Remote`, + body: message + }) + .then((notificationId) => { + if (notificationId) { + this.deliveryErrorNotifiedAt.set(channel, now) + } + }) + .catch((error) => { + logger.warn('[RemoteService] Failed to show delivery error notification.', { + channel, + error + }) + }) + .finally(() => { + this.deliveryErrorNotificationInFlight.delete(channel) + }) + } + private createConversationRunner(channel: RemoteChannel): RemoteConversationRunner { return new RemoteConversationRunner( { diff --git a/src/main/remote/ports.ts b/src/main/remote/ports.ts index 55a05f4fb4..1b551fedd5 100644 --- a/src/main/remote/ports.ts +++ b/src/main/remote/ports.ts @@ -64,6 +64,15 @@ export interface RemoteDesktopPort { openSession(sessionId: string): Promise } +export interface RemoteNotificationPort { + showNotification(options: { + id: string + title: string + body: string + silent?: boolean + }): Promise +} + export interface RemoteServiceDeps { settings: Pick catalog: RemoteCatalogPort @@ -73,6 +82,7 @@ export interface RemoteServiceDeps { assignment: RemoteSessionAssignmentPort projection: RemoteSessionProjectionPort desktop: RemoteDesktopPort + notifications?: RemoteNotificationPort } export interface RemoteRuntimeLifecycle { diff --git a/test/main/remote/feishuAdapter.test.ts b/test/main/remote/feishuAdapter.test.ts index 60419382a1..f80f6208e9 100644 --- a/test/main/remote/feishuAdapter.test.ts +++ b/test/main/remote/feishuAdapter.test.ts @@ -4,6 +4,7 @@ import type { FeishuRuntimeStatusSnapshot } from '@/remote/types' type MockRuntimeDeps = { onStatusChange?: (snapshot: FeishuRuntimeStatusSnapshot) => void onFatalError?: (message: string) => void + onDeliveryError?: (message: string) => void } const runtimeInstances: Array<{ @@ -121,6 +122,34 @@ describe('FeishuAdapter', () => { expect(onFatalError).toHaveBeenCalledWith('fatal feishu error') }) + it('forwards delivery errors from the wrapped runtime', async () => { + const onDeliveryError = vi.fn() + const adapter = new FeishuAdapter( + { + channelId: 'default', + channelType: 'feishu', + agentId: 'deepchat', + channelConfig: { + appId: 'cli_a', + appSecret: 'secret', + verificationToken: 'verify', + encryptKey: 'encrypt' + }, + configSignature: 'feishu:test' + }, + { + bindingStore: {} as any, + createConversationRunner: () => ({}) as any, + onDeliveryError + } + ) + + await adapter.connect() + runtimeInstances[0].deps.onDeliveryError?.('Failed to deliver reply to Feishu.') + + expect(onDeliveryError).toHaveBeenCalledWith('Failed to deliver reply to Feishu.') + }) + it('returns Feishu image message ids from sendImage', async () => { const adapter = new FeishuAdapter( { diff --git a/test/main/remote/feishuRuntime.test.ts b/test/main/remote/feishuRuntime.test.ts index 1e8004a6de..cfd421272a 100644 --- a/test/main/remote/feishuRuntime.test.ts +++ b/test/main/remote/feishuRuntime.test.ts @@ -42,6 +42,7 @@ const createDeferred = () => { const createHarness = async (options?: { logger?: { error: (...params: unknown[]) => void } enableStreamingCards?: boolean + onDeliveryError?: (message: string) => void }) => { let onMessage: ((event: unknown) => Promise) | null = null const streamHandlers: Array<(event: unknown) => Promise> = [] @@ -109,7 +110,8 @@ const createHarness = async (options?: { router: router as any, bindingStore: bindingStore as any, enableStreamingCards: options?.enableStreamingCards, - logger: options?.logger + logger: options?.logger, + onDeliveryError: options?.onDeliveryError }) await runtime.start() @@ -1285,6 +1287,96 @@ describe('FeishuRuntime', () => { messageId: 'om-error', eventId: 'evt-error' }) + expect(harness.runtime.getStatusSnapshot().lastError).toBe('Failed to handle inbound message.') + + await harness.runtime.stop() + }) + + it('records handling failures in the status snapshot without stopping the runtime', async () => { + const harness = await createHarness() + harness.router.handleMessage.mockRejectedValueOnce(new Error('Insufficient Balance')) + + await harness.emitMessage({ + parsed: createParsedMessage({ + eventId: 'evt-error', + messageId: 'om-error' + }) + }) + + await vi.waitFor(() => { + expect(harness.runtime.getStatusSnapshot()).toMatchObject({ + state: 'running', + lastError: 'Failed to handle inbound message.' + }) + }) + + harness.router.handleMessage.mockResolvedValueOnce({ + replies: ['ok'] + }) + await harness.emitMessage({ + parsed: createParsedMessage({ + eventId: 'evt-recovered', + messageId: 'om-recovered' + }) + }) + + await vi.waitFor(() => { + expect(harness.runtime.getStatusSnapshot()).toMatchObject({ + state: 'running', + lastError: null + }) + }) + + await harness.runtime.stop() + }) + + it('records reply delivery failures when even the error reply cannot be sent', async () => { + const onDeliveryError = vi.fn() + const harness = await createHarness({ onDeliveryError }) + harness.router.handleMessage.mockRejectedValueOnce(new Error('Insufficient Balance')) + harness.client.sendText.mockRejectedValueOnce( + new Error('Connect Timeout Error (attempted address: 127.0.0.1:7890)') + ) + + await harness.emitMessage({ + parsed: createParsedMessage({ + eventId: 'evt-error', + messageId: 'om-error' + }) + }) + + const expectedDeliveryError = 'Failed to deliver reply to Feishu.' + await vi.waitFor(() => { + expect(harness.runtime.getStatusSnapshot()).toMatchObject({ + state: 'running', + lastError: expectedDeliveryError + }) + }) + expect(onDeliveryError).toHaveBeenCalledWith(expectedDeliveryError) + + await harness.runtime.stop() + }) + + it('does not raise the delivery fallback when the error reply is sent successfully', async () => { + const onDeliveryError = vi.fn() + const harness = await createHarness({ onDeliveryError }) + harness.router.handleMessage.mockRejectedValueOnce(new Error('Insufficient Balance')) + + await harness.emitMessage({ + parsed: createParsedMessage({ + eventId: 'evt-error', + messageId: 'om-error' + }) + }) + + await vi.waitFor(() => { + expect(harness.client.sendText).toHaveBeenCalled() + expect(harness.runtime.getStatusSnapshot()).toMatchObject({ + state: 'running', + lastError: 'Failed to handle inbound message.' + }) + }) + expect(onDeliveryError).not.toHaveBeenCalled() await harness.runtime.stop() }) diff --git a/test/main/remote/remoteService.test.ts b/test/main/remote/remoteService.test.ts index 0ac7b2f465..b9ddf2b22a 100644 --- a/test/main/remote/remoteService.test.ts +++ b/test/main/remote/remoteService.test.ts @@ -66,6 +66,7 @@ import type { RemoteSessionLifecyclePort, RemoteSessionProjectionPort, RemoteSessionTurnPort, + RemoteNotificationPort, RemoteServiceDeps, RemoteWorkspacePort } from '@/remote/ports' @@ -188,6 +189,12 @@ const createWorkspace = (): RemoteWorkspacePort => ({ }) }) +const createNotifications = (): RemoteNotificationPort & { + showNotification: ReturnType +} => ({ + showNotification: vi.fn().mockResolvedValue('remote-delivery-error:feishu') +}) + const createRemoteService = (settings: TestSettings, overrides: Partial = {}) => new RemoteService({ settings, @@ -212,6 +219,66 @@ describe('RemoteService', () => { vi.restoreAllMocks() }) + it('notifies once for a Feishu delivery failure and throttles later failures', async () => { + const notifications = createNotifications() + const presenter = createRemoteService(createProviderSettings(), { notifications }) + const notifyDeliveryError = (presenter as any).notifyChannelDeliveryError.bind(presenter) + + notifyDeliveryError('feishu', 'Feishu', 'Failed to deliver reply to Feishu.') + await vi.waitFor(() => { + expect(notifications.showNotification).toHaveBeenCalledWith({ + id: 'remote-delivery-error:feishu', + title: 'DeepChat Feishu Remote', + body: 'Failed to deliver reply to Feishu.' + }) + }) + + notifyDeliveryError('feishu', 'Feishu', 'Failed to deliver reply to Feishu.') + await Promise.resolve() + + expect(notifications.showNotification).toHaveBeenCalledTimes(1) + }) + + it('retries a Feishu delivery notification after the previous attempt fails', async () => { + const notifications = createNotifications() + notifications.showNotification.mockRejectedValueOnce(new Error('notification unavailable')) + const presenter = createRemoteService(createProviderSettings(), { notifications }) + const notifyDeliveryError = (presenter as any).notifyChannelDeliveryError.bind(presenter) + + notifyDeliveryError('feishu', 'Feishu', 'Failed to deliver reply to Feishu.') + await vi.waitFor(() => { + expect(notifications.showNotification).toHaveBeenCalledTimes(1) + }) + await vi.waitFor(() => { + expect((presenter as any).deliveryErrorNotificationInFlight.has('feishu')).toBe(false) + }) + + notifyDeliveryError('feishu', 'Feishu', 'Failed to deliver reply to Feishu.') + await vi.waitFor(() => { + expect(notifications.showNotification).toHaveBeenCalledTimes(2) + }) + }) + + it('does not throttle a Feishu delivery notification when notifications are disabled', async () => { + const notifications = createNotifications() + notifications.showNotification.mockResolvedValue(undefined) + const presenter = createRemoteService(createProviderSettings(), { notifications }) + const notifyDeliveryError = (presenter as any).notifyChannelDeliveryError.bind(presenter) + + notifyDeliveryError('feishu', 'Feishu', 'Failed to deliver reply to Feishu.') + await vi.waitFor(() => { + expect(notifications.showNotification).toHaveBeenCalledTimes(1) + }) + await vi.waitFor(() => { + expect((presenter as any).deliveryErrorNotificationInFlight.has('feishu')).toBe(false) + }) + + notifyDeliveryError('feishu', 'Feishu', 'Failed to deliver reply to Feishu.') + await vi.waitFor(() => { + expect(notifications.showNotification).toHaveBeenCalledTimes(2) + }) + }) + it('serializes runtime rebuilds so only one poller starts per token', async () => { const providerSettings = createProviderSettings()