From 464bd1e6c8b3603da9bbd5520f560450dc3f03f3 Mon Sep 17 00:00:00 2001 From: Sekiyama Date: Fri, 3 Jul 2026 08:55:09 +0800 Subject: [PATCH 1/2] fix: feishu message dedup + gateway proper close - Add seenMessageIds Set to FeishuChannelPlugin for message dedup - FeishuGateway.stop() calls wsClient.close({ force: true }) to properly terminate WebSocket and prevent ghost connections after restart - Do not clear dedup set on stop to prevent in-flight events from bypassing --- src/lib/bridge/bridge-manager.ts | 10 ++++++++++ src/lib/channels/feishu/index.ts | 31 ++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/lib/bridge/bridge-manager.ts b/src/lib/bridge/bridge-manager.ts index e40c34e85..a34071e3e 100644 --- a/src/lib/bridge/bridge-manager.ts +++ b/src/lib/bridge/bridge-manager.ts @@ -203,6 +203,8 @@ interface BridgeManagerState { adapters: Map; adapterMeta: Map; running: boolean; + /** Guard against concurrent start() calls — set immediately on entry, cleared on completion or error */ + starting: boolean; startedAt: string | null; loopAborts: Map; activeTasks: Map; @@ -218,6 +220,7 @@ function getState(): BridgeManagerState { adapters: new Map(), adapterMeta: new Map(), running: false, + starting: false, startedAt: null, loopAborts: new Map(), activeTasks: new Map(), @@ -263,6 +266,10 @@ export interface StartResult { export async function start(): Promise { const state = getState(); if (state.running) return { started: true }; + if (state.starting) return { started: false, reason: 'starting_in_progress' }; + + // Set starting flag immediately to prevent concurrent start() calls + state.starting = true; const bridgeEnabled = getSetting('remote_bridge_enabled') === 'true'; if (!bridgeEnabled) { @@ -311,6 +318,7 @@ export async function start(): Promise { console.warn('[bridge-manager] No adapters started successfully, bridge not activated'); state.adapters.clear(); state.adapterMeta.clear(); + state.starting = false; const reason = configErrors.length > 0 ? `adapter_config_invalid: ${configErrors.join('; ')}` : 'no_adapters_started'; @@ -320,6 +328,7 @@ export async function start(): Promise { // Mark running BEFORE starting consumer loops — runAdapterLoop checks // state.running in its while-condition, so it must be true first. state.running = true; + state.starting = false; state.startedAt = new Date().toISOString(); // Suppress notification bot polling to avoid conflicts @@ -344,6 +353,7 @@ export async function stop(): Promise { if (!state.running) return; state.running = false; + state.starting = false; // Abort all active tool/stream tasks so in-flight Claude sessions stop // writing to DB and release session locks cleanly. Without this, `/stop` diff --git a/src/lib/channels/feishu/index.ts b/src/lib/channels/feishu/index.ts index 00a3bf269..2d2209f80 100644 --- a/src/lib/channels/feishu/index.ts +++ b/src/lib/channels/feishu/index.ts @@ -28,6 +28,9 @@ interface CardActionEvent { open_message_id?: string; } import { loadFeishuConfig, validateFeishuConfig } from './config'; + +/** Max number of message IDs to keep for dedup. */ +const DEDUP_MAX = 1000; import { FeishuGateway } from './gateway'; import { parseMessageWithResources } from './inbound'; import { getBotInfo } from './identity'; @@ -60,6 +63,8 @@ export class FeishuChannelPlugin implements ChannelPlugin { * scheduling new timers. */ private identityGeneration = 0; + /** Dedup: set of recently seen message IDs to prevent duplicate processing. */ + private seenMessageIds = new Set(); loadConfig(): FeishuConfig | null { this.config = loadFeishuConfig(); @@ -347,6 +352,11 @@ export class FeishuChannelPlugin implements ChannelPlugin { this.waitResolve(null); this.waitResolve = null; } + // NOTE: Do NOT clear seenMessageIds here. + // After gateway.stop() closes the WSClient, in-flight events from the old + // connection may still arrive briefly. Keeping the dedup set prevents them + // from being re-processed if a new gateway starts immediately after. + // The set is bounded by DEDUP_MAX and will naturally age out. } isRunning(): boolean { @@ -381,10 +391,29 @@ export class FeishuChannelPlugin implements ChannelPlugin { } private enqueueMessage(msg: InboundMessage): void { - // Track messageId for reaction acknowledgment (skip callback messages) + // Dedup: skip messages already seen (prevents duplicates from WS reconnect/retry) if (msg.messageId && !msg.callbackData) { + if (this.seenMessageIds.has(msg.messageId)) { + console.log('[feishu/plugin]', 'Duplicate message skipped:', msg.messageId); + return; + } + this.seenMessageIds.add(msg.messageId); + + // Track messageId for reaction acknowledgment this.lastMessageIdByChat.set(msg.address.chatId, msg.messageId); + + // Prune dedup set when it exceeds capacity + if (this.seenMessageIds.size > DEDUP_MAX) { + const excess = this.seenMessageIds.size - DEDUP_MAX; + let removed = 0; + for (const id of this.seenMessageIds) { + if (removed >= excess) break; + this.seenMessageIds.delete(id); + removed++; + } + } } + if (this.waitResolve) { const resolve = this.waitResolve; this.waitResolve = null; From 1d7ab4f3527049ced2f98542c01e38b8706c6f19 Mon Sep 17 00:00:00 2001 From: Sekiyama Date: Fri, 3 Jul 2026 09:04:08 +0800 Subject: [PATCH 2/2] fix: drop stale messages on Feishu WS reconnect The Lark SDK replays unprocessed historical events when the WSClient reconnects or the bridge restarts. Without age filtering, the bot would respond to messages from hours or days ago, appearing to reply to old conversations at random times (e.g. 4:50 AM). Add a 5-minute stale message filter in enqueueMessage(): messages with create_time older than 5 minutes are dropped with a log message before being enqueued or dedup-checked. --- src/lib/channels/feishu/index.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/lib/channels/feishu/index.ts b/src/lib/channels/feishu/index.ts index 2d2209f80..7ab62c5c8 100644 --- a/src/lib/channels/feishu/index.ts +++ b/src/lib/channels/feishu/index.ts @@ -31,6 +31,14 @@ import { loadFeishuConfig, validateFeishuConfig } from './config'; /** Max number of message IDs to keep for dedup. */ const DEDUP_MAX = 1000; + +/** + * Max age for inbound messages in milliseconds. + * Messages older than this are dropped as stale — they are likely historical + * events replayed by the Lark SDK after a WSClient reconnect or bridge restart. + * Default: 5 minutes. + */ +const STALE_MESSAGE_MAX_AGE_MS = 5 * 60 * 1000; import { FeishuGateway } from './gateway'; import { parseMessageWithResources } from './inbound'; import { getBotInfo } from './identity'; @@ -391,6 +399,17 @@ export class FeishuChannelPlugin implements ChannelPlugin { } private enqueueMessage(msg: InboundMessage): void { + // Stale message filter: drop messages older than STALE_MESSAGE_MAX_AGE_MS. + // The Lark SDK replays unprocessed historical events on WSClient reconnect, + // which can cause the bot to respond to messages from hours or days ago. + if (msg.timestamp && !msg.callbackData) { + const age = Date.now() - msg.timestamp; + if (age > STALE_MESSAGE_MAX_AGE_MS) { + console.log('[feishu/plugin]', `Stale message dropped (${Math.round(age / 1000)}s old):`, msg.messageId); + return; + } + } + // Dedup: skip messages already seen (prevents duplicates from WS reconnect/retry) if (msg.messageId && !msg.callbackData) { if (this.seenMessageIds.has(msg.messageId)) {