Skip to content

Commit 8539e02

Browse files
committed
fix(slack): bound app mention retry clocks
1 parent ef88f0f commit 8539e02

2 files changed

Lines changed: 72 additions & 9 deletions

File tree

extensions/slack/src/monitor/message-handler.app-mention-race.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
1+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
22

33
const prepareSlackMessageMock =
44
vi.fn<
@@ -151,6 +151,10 @@ describe("createSlackMessageHandler app_mention race handling", () => {
151151
clearSlackRuntime();
152152
});
153153

154+
afterEach(() => {
155+
vi.restoreAllMocks();
156+
});
157+
154158
it("allows a single app_mention retry when message event was dropped before dispatch", async () => {
155159
prepareSlackMessageMock.mockImplementation(async ({ opts }) => {
156160
if (opts.source === "message") {
@@ -169,6 +173,43 @@ describe("createSlackMessageHandler app_mention race handling", () => {
169173
expect(dispatchPreparedSlackMessageMock).toHaveBeenCalledTimes(1);
170174
});
171175

176+
it("does not retain app_mention retry allowance when the current clock is not a valid date timestamp", async () => {
177+
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
178+
prepareSlackMessageMock.mockImplementation(async ({ opts }) => {
179+
if (opts.source === "message") {
180+
return null;
181+
}
182+
return { ctxPayload: {} };
183+
});
184+
185+
const handler = createTestHandler();
186+
187+
await sendMessageEvent(handler, "1700000000.000125");
188+
nowSpy.mockReturnValue(1_700_000_000_000);
189+
await sendMentionEvent(handler, "1700000000.000125");
190+
191+
expect(prepareSlackMessageMock).toHaveBeenCalledTimes(1);
192+
expect(dispatchPreparedSlackMessageMock).not.toHaveBeenCalled();
193+
});
194+
195+
it("does not retain app_mention retry allowance when the expiry timestamp would exceed the valid date range", async () => {
196+
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
197+
prepareSlackMessageMock.mockImplementation(async ({ opts }) => {
198+
if (opts.source === "message") {
199+
return null;
200+
}
201+
return { ctxPayload: {} };
202+
});
203+
204+
const handler = createTestHandler();
205+
206+
await sendMessageEvent(handler, "1700000000.000126");
207+
await sendMentionEvent(handler, "1700000000.000126");
208+
209+
expect(prepareSlackMessageMock).toHaveBeenCalledTimes(1);
210+
expect(dispatchPreparedSlackMessageMock).not.toHaveBeenCalled();
211+
});
212+
172213
it("allows app_mention while message handling is still in-flight, then keeps later duplicates deduped", async () => {
173214
const { handler, messagePending, resolveMessagePrepare } =
174215
await createInFlightMessageScenario("1700000000.000150");

extensions/slack/src/monitor/message-handler.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import {
33
shouldDebounceTextInbound,
44
} from "openclaw/plugin-sdk/channel-inbound";
55
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
6+
import {
7+
asDateTimestampMs,
8+
resolveExpiresAtMsFromDurationMs,
9+
} from "openclaw/plugin-sdk/number-runtime";
610
import type { ResolvedSlackAccount } from "../accounts.js";
711
import type { SlackMessageEvent } from "../types.js";
812
import { stripSlackMentionsForCommandDetection } from "./commands.js";
@@ -123,7 +127,7 @@ export function createSlackMessageHandler(params: {
123127
pruneAppMentionRetryKeys(Date.now());
124128
if (last.opts.source === "app_mention") {
125129
// If app_mention wins the race and dispatches first, drop the later message dispatch.
126-
appMentionDispatchedKeys.set(seenMessageKey, Date.now() + APP_MENTION_RETRY_TTL_MS);
130+
rememberExpiringAppMentionKey(appMentionDispatchedKeys, seenMessageKey);
127131
} else if (
128132
last.opts.source === "message" &&
129133
appMentionDispatchedKeys.has(seenMessageKey)
@@ -176,28 +180,46 @@ export function createSlackMessageHandler(params: {
176180
const appMentionRetryKeys = new Map<string, number>();
177181
const appMentionDispatchedKeys = new Map<string, number>();
178182

179-
const pruneAppMentionRetryKeys = (now: number) => {
183+
const pruneAppMentionRetryKeys = (rawNow: number): boolean => {
184+
const now = asDateTimestampMs(rawNow);
185+
if (now === undefined) {
186+
appMentionRetryKeys.clear();
187+
appMentionDispatchedKeys.clear();
188+
return false;
189+
}
180190
for (const [key, expiresAt] of appMentionRetryKeys) {
181-
if (expiresAt <= now) {
191+
if (asDateTimestampMs(expiresAt) === undefined || expiresAt <= now) {
182192
appMentionRetryKeys.delete(key);
183193
}
184194
}
185195
for (const [key, expiresAt] of appMentionDispatchedKeys) {
186-
if (expiresAt <= now) {
196+
if (asDateTimestampMs(expiresAt) === undefined || expiresAt <= now) {
187197
appMentionDispatchedKeys.delete(key);
188198
}
189199
}
200+
return true;
190201
};
191202

192-
const rememberAppMentionRetryKey = (key: string) => {
203+
const rememberExpiringAppMentionKey = (map: Map<string, number>, key: string): void => {
193204
const now = Date.now();
194-
pruneAppMentionRetryKeys(now);
195-
appMentionRetryKeys.set(key, now + APP_MENTION_RETRY_TTL_MS);
205+
if (!pruneAppMentionRetryKeys(now)) {
206+
return;
207+
}
208+
const expiresAt = resolveExpiresAtMsFromDurationMs(APP_MENTION_RETRY_TTL_MS, { nowMs: now });
209+
if (expiresAt !== undefined) {
210+
map.set(key, expiresAt);
211+
}
212+
};
213+
214+
const rememberAppMentionRetryKey = (key: string) => {
215+
rememberExpiringAppMentionKey(appMentionRetryKeys, key);
196216
};
197217

198218
const consumeAppMentionRetryKey = (key: string) => {
199219
const now = Date.now();
200-
pruneAppMentionRetryKeys(now);
220+
if (!pruneAppMentionRetryKeys(now)) {
221+
return false;
222+
}
201223
if (!appMentionRetryKeys.has(key)) {
202224
return false;
203225
}

0 commit comments

Comments
 (0)