diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index ba10c803ad4..7a1ffd6191e 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -10,6 +10,7 @@ import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } fro import type { DynamicAgentCreationConfig } from "./types.js"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; +import { tryRecordMessage } from "./dedup.js"; import { maybeCreateDynamicAgent } from "./dynamic-agent.js"; import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js"; import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js"; @@ -23,37 +24,6 @@ import { createFeishuReplyDispatcher } from "./reply-dispatcher.js"; import { getFeishuRuntime } from "./runtime.js"; import { getMessageFeishu, sendMessageFeishu } from "./send.js"; -// --- Message deduplication --- -// Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages. -const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes -const DEDUP_MAX_SIZE = 1_000; -const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes -const processedMessageIds = new Map(); // messageId -> timestamp -let lastCleanupTime = Date.now(); - -function tryRecordMessage(messageId: string): boolean { - const now = Date.now(); - - // Throttled cleanup: evict expired entries at most once per interval - if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) { - for (const [id, ts] of processedMessageIds) { - if (now - ts > DEDUP_TTL_MS) processedMessageIds.delete(id); - } - lastCleanupTime = now; - } - - if (processedMessageIds.has(messageId)) return false; - - // Evict oldest entries if cache is full - if (processedMessageIds.size >= DEDUP_MAX_SIZE) { - const first = processedMessageIds.keys().next().value!; - processedMessageIds.delete(first); - } - - processedMessageIds.set(messageId, now); - return true; -} - // --- Permission error extraction --- // Extract permission grant URL from Feishu API error response. type PermissionError = { diff --git a/extensions/feishu/src/dedup.ts b/extensions/feishu/src/dedup.ts new file mode 100644 index 00000000000..25677f628d5 --- /dev/null +++ b/extensions/feishu/src/dedup.ts @@ -0,0 +1,33 @@ +// Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages. +const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes +const DEDUP_MAX_SIZE = 1_000; +const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes +const processedMessageIds = new Map(); // messageId -> timestamp +let lastCleanupTime = Date.now(); + +export function tryRecordMessage(messageId: string): boolean { + const now = Date.now(); + + // Throttled cleanup: evict expired entries at most once per interval. + if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) { + for (const [id, ts] of processedMessageIds) { + if (now - ts > DEDUP_TTL_MS) { + processedMessageIds.delete(id); + } + } + lastCleanupTime = now; + } + + if (processedMessageIds.has(messageId)) { + return false; + } + + // Evict oldest entries if cache is full. + if (processedMessageIds.size >= DEDUP_MAX_SIZE) { + const first = processedMessageIds.keys().next().value!; + processedMessageIds.delete(first); + } + + processedMessageIds.set(messageId, now); + return true; +} diff --git a/extensions/mattermost/src/mattermost/monitor-onchar.ts b/extensions/mattermost/src/mattermost/monitor-onchar.ts new file mode 100644 index 00000000000..c23629fbee1 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-onchar.ts @@ -0,0 +1,25 @@ +const DEFAULT_ONCHAR_PREFIXES = [">", "!"]; + +export function resolveOncharPrefixes(prefixes: string[] | undefined): string[] { + const cleaned = prefixes?.map((entry) => entry.trim()).filter(Boolean) ?? DEFAULT_ONCHAR_PREFIXES; + return cleaned.length > 0 ? cleaned : DEFAULT_ONCHAR_PREFIXES; +} + +export function stripOncharPrefix( + text: string, + prefixes: string[], +): { triggered: boolean; stripped: string } { + const trimmed = text.trimStart(); + for (const prefix of prefixes) { + if (!prefix) { + continue; + } + if (trimmed.startsWith(prefix)) { + return { + triggered: true, + stripped: trimmed.slice(prefix.length).trimStart(), + }; + } + } + return { triggered: false, stripped: text }; +} diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index cce4d87b381..8d4f3d95e95 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -38,6 +38,7 @@ import { rawDataToString, resolveThreadSessionKeys, } from "./monitor-helpers.js"; +import { resolveOncharPrefixes, stripOncharPrefix } from "./monitor-onchar.js"; import { sendMessageMattermost } from "./send.js"; export type MonitorMattermostOpts = { @@ -75,7 +76,6 @@ const RECENT_MATTERMOST_MESSAGE_TTL_MS = 5 * 60_000; const RECENT_MATTERMOST_MESSAGE_MAX = 2000; const CHANNEL_CACHE_TTL_MS = 5 * 60_000; const USER_CACHE_TTL_MS = 10 * 60_000; -const DEFAULT_ONCHAR_PREFIXES = [">", "!"]; const recentInboundMessages = createDedupeCache({ ttlMs: RECENT_MATTERMOST_MESSAGE_TTL_MS, @@ -103,30 +103,6 @@ function normalizeMention(text: string, mention: string | undefined): string { return text.replace(re, " ").replace(/\s+/g, " ").trim(); } -function resolveOncharPrefixes(prefixes: string[] | undefined): string[] { - const cleaned = prefixes?.map((entry) => entry.trim()).filter(Boolean) ?? DEFAULT_ONCHAR_PREFIXES; - return cleaned.length > 0 ? cleaned : DEFAULT_ONCHAR_PREFIXES; -} - -function stripOncharPrefix( - text: string, - prefixes: string[], -): { triggered: boolean; stripped: string } { - const trimmed = text.trimStart(); - for (const prefix of prefixes) { - if (!prefix) { - continue; - } - if (trimmed.startsWith(prefix)) { - return { - triggered: true, - stripped: trimmed.slice(prefix.length).trimStart(), - }; - } - } - return { triggered: false, stripped: text }; -} - function isSystemPost(post: MattermostPost): boolean { const type = post.type?.trim(); return Boolean(type);