Feishu: wire inbound message debounce (openclaw#31548) thanks @bertonhan
Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm check (fails on unrelated pre-existing lint in ui/src/ui/views/agents-utils.ts and src/pairing/pairing-store.ts) - pnpm test:macmini (previous run passed before rebase) Co-authored-by: bertonhan <60309291+bertonhan@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -143,6 +143,7 @@ Docs: https://docs.openclaw.ai
|
||||
- macOS/PeekabooBridge: add compatibility socket symlinks for legacy `clawdbot`, `clawdis`, and `moltbot` Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc.
|
||||
- Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (`provider: "message"`) and normalize `lark`/`feishu` provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526)
|
||||
- Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older `openclaw/plugin-sdk` builds omit webhook default constants. (#31606)
|
||||
- Feishu/Inbound debounce: debounce rapid same-chat sender bursts into one ordered dispatch turn, skip already-processed retries when composing merged text, and preserve bot-mention intent across merged entries to reduce duplicate or late inbound handling. (#31548)
|
||||
- BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound `message_id` selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.
|
||||
- Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
|
||||
- Docker/Sandbox bootstrap hardening: make `OPENCLAW_SANDBOX` opt-in parsing explicit (`1|true|yes|on`), support custom Docker socket paths via `OPENCLAW_DOCKER_SOCKET`, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to `off` when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createDedupeCache, createPersistentDedupe } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
createDedupeCache,
|
||||
createPersistentDedupe,
|
||||
readJsonFileWithFallback,
|
||||
} from "openclaw/plugin-sdk";
|
||||
|
||||
// Persistent TTL: 24 hours — survives restarts & WebSocket reconnects.
|
||||
const DEDUP_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const MEMORY_MAX_SIZE = 1_000;
|
||||
const FILE_MAX_ENTRIES = 10_000;
|
||||
type PersistentDedupeData = Record<string, number>;
|
||||
|
||||
const memoryDedupe = createDedupeCache({ ttlMs: DEDUP_TTL_MS, maxSize: MEMORY_MAX_SIZE });
|
||||
|
||||
@@ -40,6 +45,14 @@ export function tryRecordMessage(messageId: string): boolean {
|
||||
return !memoryDedupe.check(messageId);
|
||||
}
|
||||
|
||||
export function hasRecordedMessage(messageId: string): boolean {
|
||||
const trimmed = messageId.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return memoryDedupe.peek(trimmed);
|
||||
}
|
||||
|
||||
export async function tryRecordMessagePersistent(
|
||||
messageId: string,
|
||||
namespace = "global",
|
||||
@@ -52,3 +65,27 @@ export async function tryRecordMessagePersistent(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function hasRecordedMessagePersistent(
|
||||
messageId: string,
|
||||
namespace = "global",
|
||||
log?: (...args: unknown[]) => void,
|
||||
): Promise<boolean> {
|
||||
const trimmed = messageId.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const now = Date.now();
|
||||
const filePath = resolveNamespaceFilePath(namespace);
|
||||
try {
|
||||
const { value } = await readJsonFileWithFallback<PersistentDedupeData>(filePath, {});
|
||||
const seenAt = value[trimmed];
|
||||
if (typeof seenAt !== "number" || !Number.isFinite(seenAt)) {
|
||||
return false;
|
||||
}
|
||||
return DEDUP_TTL_MS <= 0 || now - seenAt < DEDUP_TTL_MS;
|
||||
} catch (error) {
|
||||
log?.(`feishu-dedup: persistent peek failed: ${String(error)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,25 @@ import * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { raceWithTimeoutAndAbort } from "./async.js";
|
||||
import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js";
|
||||
import {
|
||||
handleFeishuMessage,
|
||||
parseFeishuMessageEvent,
|
||||
type FeishuMessageEvent,
|
||||
type FeishuBotAddedEvent,
|
||||
} from "./bot.js";
|
||||
import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js";
|
||||
import { createEventDispatcher } from "./client.js";
|
||||
import {
|
||||
hasRecordedMessage,
|
||||
hasRecordedMessagePersistent,
|
||||
tryRecordMessage,
|
||||
tryRecordMessagePersistent,
|
||||
} from "./dedup.js";
|
||||
import { isMentionForwardRequest } from "./mention.js";
|
||||
import { fetchBotOpenIdForMonitor } from "./monitor.startup.js";
|
||||
import { botOpenIds } from "./monitor.state.js";
|
||||
import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { getMessageFeishu } from "./send.js";
|
||||
import type { ResolvedFeishuAccount } from "./types.js";
|
||||
|
||||
@@ -120,33 +133,238 @@ type RegisterEventHandlersContext = {
|
||||
fireAndForget?: boolean;
|
||||
};
|
||||
|
||||
function mergeFeishuDebounceMentions(
|
||||
entries: FeishuMessageEvent[],
|
||||
): FeishuMessageEvent["message"]["mentions"] | undefined {
|
||||
const merged = new Map<string, NonNullable<FeishuMessageEvent["message"]["mentions"]>[number]>();
|
||||
for (const entry of entries) {
|
||||
for (const mention of entry.message.mentions ?? []) {
|
||||
const stableId =
|
||||
mention.id.open_id?.trim() || mention.id.user_id?.trim() || mention.id.union_id?.trim();
|
||||
const mentionName = mention.name?.trim();
|
||||
const mentionKey = mention.key?.trim();
|
||||
const fallback =
|
||||
mentionName && mentionKey ? `${mentionName}|${mentionKey}` : mentionName || mentionKey;
|
||||
const key = stableId || fallback;
|
||||
if (!key || merged.has(key)) {
|
||||
continue;
|
||||
}
|
||||
merged.set(key, mention);
|
||||
}
|
||||
}
|
||||
if (merged.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
|
||||
function dedupeFeishuDebounceEntriesByMessageId(
|
||||
entries: FeishuMessageEvent[],
|
||||
): FeishuMessageEvent[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: FeishuMessageEvent[] = [];
|
||||
for (const entry of entries) {
|
||||
const messageId = entry.message.message_id?.trim();
|
||||
if (!messageId) {
|
||||
deduped.push(entry);
|
||||
continue;
|
||||
}
|
||||
if (seen.has(messageId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(messageId);
|
||||
deduped.push(entry);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function resolveFeishuDebounceMentions(params: {
|
||||
entries: FeishuMessageEvent[];
|
||||
botOpenId?: string;
|
||||
}): FeishuMessageEvent["message"]["mentions"] | undefined {
|
||||
const { entries, botOpenId } = params;
|
||||
if (entries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = entries[index];
|
||||
if (isMentionForwardRequest(entry, botOpenId)) {
|
||||
// Keep mention-forward semantics scoped to a single source message.
|
||||
return mergeFeishuDebounceMentions([entry]);
|
||||
}
|
||||
}
|
||||
const merged = mergeFeishuDebounceMentions(entries);
|
||||
if (!merged) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedBotOpenId = botOpenId?.trim();
|
||||
if (!normalizedBotOpenId) {
|
||||
return undefined;
|
||||
}
|
||||
const botMentions = merged.filter(
|
||||
(mention) => mention.id.open_id?.trim() === normalizedBotOpenId,
|
||||
);
|
||||
return botMentions.length > 0 ? botMentions : undefined;
|
||||
}
|
||||
|
||||
function registerEventHandlers(
|
||||
eventDispatcher: Lark.EventDispatcher,
|
||||
context: RegisterEventHandlersContext,
|
||||
): void {
|
||||
const { cfg, accountId, runtime, chatHistories, fireAndForget } = context;
|
||||
const core = getFeishuRuntime();
|
||||
const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
});
|
||||
const log = runtime?.log ?? console.log;
|
||||
const error = runtime?.error ?? console.error;
|
||||
const dispatchFeishuMessage = async (event: FeishuMessageEvent) => {
|
||||
await handleFeishuMessage({
|
||||
cfg,
|
||||
event,
|
||||
botOpenId: botOpenIds.get(accountId),
|
||||
runtime,
|
||||
chatHistories,
|
||||
accountId,
|
||||
});
|
||||
};
|
||||
const resolveSenderDebounceId = (event: FeishuMessageEvent): string | undefined => {
|
||||
const senderId =
|
||||
event.sender.sender_id.open_id?.trim() || event.sender.sender_id.user_id?.trim();
|
||||
return senderId || undefined;
|
||||
};
|
||||
const resolveDebounceText = (event: FeishuMessageEvent): string => {
|
||||
const botOpenId = botOpenIds.get(accountId);
|
||||
const parsed = parseFeishuMessageEvent(event, botOpenId);
|
||||
return parsed.content.trim();
|
||||
};
|
||||
const recordSuppressedMessageIds = async (
|
||||
entries: FeishuMessageEvent[],
|
||||
dispatchMessageId?: string,
|
||||
) => {
|
||||
const keepMessageId = dispatchMessageId?.trim();
|
||||
const suppressedIds = new Set(
|
||||
entries
|
||||
.map((entry) => entry.message.message_id?.trim())
|
||||
.filter((id): id is string => Boolean(id) && (!keepMessageId || id !== keepMessageId)),
|
||||
);
|
||||
if (suppressedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
for (const messageId of suppressedIds) {
|
||||
// Keep in-memory dedupe in sync with handleFeishuMessage's keying.
|
||||
tryRecordMessage(`${accountId}:${messageId}`);
|
||||
try {
|
||||
await tryRecordMessagePersistent(messageId, accountId, log);
|
||||
} catch (err) {
|
||||
error(
|
||||
`feishu[${accountId}]: failed to record merged dedupe id ${messageId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
const isMessageAlreadyProcessed = async (entry: FeishuMessageEvent): Promise<boolean> => {
|
||||
const messageId = entry.message.message_id?.trim();
|
||||
if (!messageId) {
|
||||
return false;
|
||||
}
|
||||
const memoryKey = `${accountId}:${messageId}`;
|
||||
if (hasRecordedMessage(memoryKey)) {
|
||||
return true;
|
||||
}
|
||||
return hasRecordedMessagePersistent(messageId, accountId, log);
|
||||
};
|
||||
const inboundDebouncer = core.channel.debounce.createInboundDebouncer<FeishuMessageEvent>({
|
||||
debounceMs: inboundDebounceMs,
|
||||
buildKey: (event) => {
|
||||
const chatId = event.message.chat_id?.trim();
|
||||
const senderId = resolveSenderDebounceId(event);
|
||||
if (!chatId || !senderId) {
|
||||
return null;
|
||||
}
|
||||
const rootId = event.message.root_id?.trim();
|
||||
const threadKey = rootId ? `thread:${rootId}` : "chat";
|
||||
return `feishu:${accountId}:${chatId}:${threadKey}:${senderId}`;
|
||||
},
|
||||
shouldDebounce: (event) => {
|
||||
if (event.message.message_type !== "text") {
|
||||
return false;
|
||||
}
|
||||
const text = resolveDebounceText(event);
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
return !core.channel.text.hasControlCommand(text, cfg);
|
||||
},
|
||||
onFlush: async (entries) => {
|
||||
const last = entries.at(-1);
|
||||
if (!last) {
|
||||
return;
|
||||
}
|
||||
if (entries.length === 1) {
|
||||
await dispatchFeishuMessage(last);
|
||||
return;
|
||||
}
|
||||
const dedupedEntries = dedupeFeishuDebounceEntriesByMessageId(entries);
|
||||
const freshEntries: FeishuMessageEvent[] = [];
|
||||
for (const entry of dedupedEntries) {
|
||||
if (!(await isMessageAlreadyProcessed(entry))) {
|
||||
freshEntries.push(entry);
|
||||
}
|
||||
}
|
||||
const dispatchEntry = freshEntries.at(-1);
|
||||
if (!dispatchEntry) {
|
||||
return;
|
||||
}
|
||||
await recordSuppressedMessageIds(dedupedEntries, dispatchEntry.message.message_id);
|
||||
const combinedText = freshEntries
|
||||
.map((entry) => resolveDebounceText(entry))
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const mergedMentions = resolveFeishuDebounceMentions({
|
||||
entries: freshEntries,
|
||||
botOpenId: botOpenIds.get(accountId),
|
||||
});
|
||||
if (!combinedText.trim()) {
|
||||
await dispatchFeishuMessage({
|
||||
...dispatchEntry,
|
||||
message: {
|
||||
...dispatchEntry.message,
|
||||
mentions: mergedMentions ?? dispatchEntry.message.mentions,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await dispatchFeishuMessage({
|
||||
...dispatchEntry,
|
||||
message: {
|
||||
...dispatchEntry.message,
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: combinedText }),
|
||||
mentions: mergedMentions ?? dispatchEntry.message.mentions,
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
error(`feishu[${accountId}]: inbound debounce flush failed: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
eventDispatcher.register({
|
||||
"im.message.receive_v1": async (data) => {
|
||||
try {
|
||||
const processMessage = async () => {
|
||||
const event = data as unknown as FeishuMessageEvent;
|
||||
const promise = handleFeishuMessage({
|
||||
cfg,
|
||||
event,
|
||||
botOpenId: botOpenIds.get(accountId),
|
||||
runtime,
|
||||
chatHistories,
|
||||
accountId,
|
||||
await inboundDebouncer.enqueue(event);
|
||||
};
|
||||
if (fireAndForget) {
|
||||
void processMessage().catch((err) => {
|
||||
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
||||
});
|
||||
if (fireAndForget) {
|
||||
promise.catch((err) => {
|
||||
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
||||
});
|
||||
} else {
|
||||
await promise;
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await processMessage();
|
||||
} catch (err) {
|
||||
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { hasControlCommand } from "../../../src/auto-reply/command-detection.js";
|
||||
import {
|
||||
createInboundDebouncer,
|
||||
resolveInboundDebounceMs,
|
||||
} from "../../../src/auto-reply/inbound-debounce.js";
|
||||
import { parseFeishuMessageEvent, type FeishuMessageEvent } from "./bot.js";
|
||||
import * as dedup from "./dedup.js";
|
||||
import { monitorSingleAccount } from "./monitor.account.js";
|
||||
import { resolveReactionSyntheticEvent, type FeishuReactionCreatedEvent } from "./monitor.js";
|
||||
import { setFeishuRuntime } from "./runtime.js";
|
||||
import type { ResolvedFeishuAccount } from "./types.js";
|
||||
|
||||
const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async (_params: { event?: unknown }) => {}));
|
||||
const createEventDispatcherMock = vi.hoisted(() => vi.fn());
|
||||
const monitorWebSocketMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const monitorWebhookMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
let handlers: Record<string, (data: unknown) => Promise<void>> = {};
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createEventDispatcher: createEventDispatcherMock,
|
||||
}));
|
||||
|
||||
vi.mock("./bot.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./bot.js")>("./bot.js");
|
||||
return {
|
||||
...actual,
|
||||
handleFeishuMessage: handleFeishuMessageMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./monitor.transport.js", () => ({
|
||||
monitorWebSocket: monitorWebSocketMock,
|
||||
monitorWebhook: monitorWebhookMock,
|
||||
}));
|
||||
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
|
||||
@@ -16,6 +50,100 @@ function makeReactionEvent(
|
||||
};
|
||||
}
|
||||
|
||||
type FeishuMention = NonNullable<FeishuMessageEvent["message"]["mentions"]>[number];
|
||||
|
||||
function buildDebounceConfig(): ClawdbotConfig {
|
||||
return {
|
||||
messages: {
|
||||
inbound: {
|
||||
debounceMs: 0,
|
||||
byChannel: {
|
||||
feishu: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
|
||||
function buildDebounceAccount(): ResolvedFeishuAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
appId: "cli_test",
|
||||
appSecret: "secret_test",
|
||||
domain: "feishu",
|
||||
config: {
|
||||
enabled: true,
|
||||
connectionMode: "websocket",
|
||||
},
|
||||
} as ResolvedFeishuAccount;
|
||||
}
|
||||
|
||||
function createTextEvent(params: {
|
||||
messageId: string;
|
||||
text: string;
|
||||
senderId?: string;
|
||||
mentions?: FeishuMention[];
|
||||
}): FeishuMessageEvent {
|
||||
const senderId = params.senderId ?? "ou_sender";
|
||||
return {
|
||||
sender: {
|
||||
sender_id: { open_id: senderId },
|
||||
sender_type: "user",
|
||||
},
|
||||
message: {
|
||||
message_id: params.messageId,
|
||||
chat_id: "oc_group_1",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: params.text }),
|
||||
mentions: params.mentions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function setupDebounceMonitor(): Promise<(data: unknown) => Promise<void>> {
|
||||
const register = vi.fn((registered: Record<string, (data: unknown) => Promise<void>>) => {
|
||||
handlers = registered;
|
||||
});
|
||||
createEventDispatcherMock.mockReturnValue({ register });
|
||||
|
||||
await monitorSingleAccount({
|
||||
cfg: buildDebounceConfig(),
|
||||
account: buildDebounceAccount(),
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
} as RuntimeEnv,
|
||||
botOpenIdSource: { kind: "prefetched", botOpenId: "ou_bot" },
|
||||
});
|
||||
|
||||
const onMessage = handlers["im.message.receive_v1"];
|
||||
if (!onMessage) {
|
||||
throw new Error("missing im.message.receive_v1 handler");
|
||||
}
|
||||
return onMessage;
|
||||
}
|
||||
|
||||
function getFirstDispatchedEvent(): FeishuMessageEvent {
|
||||
const firstCall = handleFeishuMessageMock.mock.calls[0];
|
||||
if (!firstCall) {
|
||||
throw new Error("missing dispatch call");
|
||||
}
|
||||
const firstParams = firstCall[0] as { event?: FeishuMessageEvent } | undefined;
|
||||
if (!firstParams?.event) {
|
||||
throw new Error("missing dispatched event payload");
|
||||
}
|
||||
return firstParams.event;
|
||||
}
|
||||
|
||||
describe("resolveReactionSyntheticEvent", () => {
|
||||
it("filters app self-reactions", async () => {
|
||||
const event = makeReactionEvent({ operator_type: "app" });
|
||||
@@ -233,3 +361,215 @@ describe("resolveReactionSyntheticEvent", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feishu inbound debounce regressions", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
handlers = {};
|
||||
handleFeishuMessageMock.mockClear();
|
||||
setFeishuRuntime({
|
||||
channel: {
|
||||
debounce: {
|
||||
createInboundDebouncer,
|
||||
resolveInboundDebounceMs,
|
||||
},
|
||||
text: {
|
||||
hasControlCommand,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("keeps bot mention when per-message mention keys collide across non-forward messages", async () => {
|
||||
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
||||
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
||||
vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
|
||||
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
|
||||
const onMessage = await setupDebounceMonitor();
|
||||
|
||||
await onMessage(
|
||||
createTextEvent({
|
||||
messageId: "om_1",
|
||||
text: "first",
|
||||
mentions: [
|
||||
{
|
||||
key: "@_user_1",
|
||||
id: { open_id: "ou_user_a" },
|
||||
name: "user-a",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await onMessage(
|
||||
createTextEvent({
|
||||
messageId: "om_2",
|
||||
text: "@bot second",
|
||||
mentions: [
|
||||
{
|
||||
key: "@_user_1",
|
||||
id: { open_id: "ou_bot" },
|
||||
name: "bot",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
||||
const dispatched = getFirstDispatchedEvent();
|
||||
const mergedMentions = dispatched.message.mentions ?? [];
|
||||
expect(mergedMentions.some((mention) => mention.id.open_id === "ou_bot")).toBe(true);
|
||||
expect(mergedMentions.some((mention) => mention.id.open_id === "ou_user_a")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not synthesize mention-forward intent across separate messages", async () => {
|
||||
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
||||
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
||||
vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
|
||||
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
|
||||
const onMessage = await setupDebounceMonitor();
|
||||
|
||||
await onMessage(
|
||||
createTextEvent({
|
||||
messageId: "om_user_mention",
|
||||
text: "@alice first",
|
||||
mentions: [
|
||||
{
|
||||
key: "@_user_1",
|
||||
id: { open_id: "ou_alice" },
|
||||
name: "alice",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await onMessage(
|
||||
createTextEvent({
|
||||
messageId: "om_bot_mention",
|
||||
text: "@bot second",
|
||||
mentions: [
|
||||
{
|
||||
key: "@_user_1",
|
||||
id: { open_id: "ou_bot" },
|
||||
name: "bot",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
||||
const dispatched = getFirstDispatchedEvent();
|
||||
const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
|
||||
expect(parsed.mentionedBot).toBe(true);
|
||||
expect(parsed.mentionTargets).toBeUndefined();
|
||||
const mergedMentions = dispatched.message.mentions ?? [];
|
||||
expect(mergedMentions.every((mention) => mention.id.open_id === "ou_bot")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves bot mention signal when the latest merged message has no mentions", async () => {
|
||||
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
||||
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
||||
vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
|
||||
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
|
||||
const onMessage = await setupDebounceMonitor();
|
||||
|
||||
await onMessage(
|
||||
createTextEvent({
|
||||
messageId: "om_bot_first",
|
||||
text: "@bot first",
|
||||
mentions: [
|
||||
{
|
||||
key: "@_user_1",
|
||||
id: { open_id: "ou_bot" },
|
||||
name: "bot",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await onMessage(
|
||||
createTextEvent({
|
||||
messageId: "om_plain_second",
|
||||
text: "plain follow-up",
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
||||
const dispatched = getFirstDispatchedEvent();
|
||||
const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
|
||||
expect(parsed.mentionedBot).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes previously processed retries from combined debounce text", async () => {
|
||||
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
||||
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
||||
vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old"));
|
||||
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
|
||||
async (messageId) => messageId === "om_old",
|
||||
);
|
||||
const onMessage = await setupDebounceMonitor();
|
||||
|
||||
await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await onMessage(createTextEvent({ messageId: "om_new_1", text: "first" }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await onMessage(createTextEvent({ messageId: "om_new_2", text: "second" }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
||||
const dispatched = getFirstDispatchedEvent();
|
||||
expect(dispatched.message.message_id).toBe("om_new_2");
|
||||
const combined = JSON.parse(dispatched.message.content) as { text?: string };
|
||||
expect(combined.text).toBe("first\nsecond");
|
||||
});
|
||||
|
||||
it("uses latest fresh message id when debounce batch ends with stale retry", async () => {
|
||||
const recordSpy = vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
||||
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
||||
vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old"));
|
||||
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
|
||||
async (messageId) => messageId === "om_old",
|
||||
);
|
||||
const onMessage = await setupDebounceMonitor();
|
||||
|
||||
await onMessage(createTextEvent({ messageId: "om_new", text: "fresh" }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
||||
const dispatched = getFirstDispatchedEvent();
|
||||
expect(dispatched.message.message_id).toBe("om_new");
|
||||
const combined = JSON.parse(dispatched.message.content) as { text?: string };
|
||||
expect(combined.text).toBe("fresh");
|
||||
expect(recordSpy).toHaveBeenCalledWith("default:om_old");
|
||||
expect(recordSpy).not.toHaveBeenCalledWith("default:om_new");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user