fix: resolve heartbeat sender and Slack thread_ts
This commit is contained in:
@@ -8,6 +8,7 @@ import type { SlackMessageEvent } from "../types.js";
|
||||
import type { SlackMonitorContext } from "./context.js";
|
||||
import { dispatchPreparedSlackMessage } from "./message-handler/dispatch.js";
|
||||
import { prepareSlackMessage } from "./message-handler/prepare.js";
|
||||
import { createSlackThreadTsResolver } from "./thread-resolution.js";
|
||||
|
||||
export type SlackMessageHandler = (
|
||||
message: SlackMessageEvent,
|
||||
@@ -20,6 +21,7 @@ export function createSlackMessageHandler(params: {
|
||||
}): SlackMessageHandler {
|
||||
const { ctx, account } = params;
|
||||
const debounceMs = resolveInboundDebounceMs({ cfg: ctx.cfg, channel: "slack" });
|
||||
const threadTsResolver = createSlackThreadTsResolver({ client: ctx.app.client });
|
||||
|
||||
const debouncer = createInboundDebouncer<{
|
||||
message: SlackMessageEvent;
|
||||
@@ -29,9 +31,13 @@ export function createSlackMessageHandler(params: {
|
||||
buildKey: (entry) => {
|
||||
const senderId = entry.message.user ?? entry.message.bot_id;
|
||||
if (!senderId) return null;
|
||||
const messageTs = entry.message.ts ?? entry.message.event_ts;
|
||||
// If Slack flags a thread reply but omits thread_ts, isolate it from root debouncing.
|
||||
const threadKey = entry.message.thread_ts
|
||||
? `${entry.message.channel}:${entry.message.thread_ts}`
|
||||
: entry.message.channel;
|
||||
: entry.message.parent_user_id && messageTs
|
||||
? `${entry.message.channel}:maybe-thread:${messageTs}`
|
||||
: entry.message.channel;
|
||||
return `slack:${ctx.accountId}:${threadKey}:${senderId}`;
|
||||
},
|
||||
shouldDebounce: (entry) => {
|
||||
@@ -91,6 +97,7 @@ export function createSlackMessageHandler(params: {
|
||||
return;
|
||||
}
|
||||
if (ctx.markMessageSeen(message.channel, message.ts)) return;
|
||||
await debouncer.enqueue({ message, opts });
|
||||
const resolvedMessage = await threadTsResolver.resolve({ message, source: opts.source });
|
||||
await debouncer.enqueue({ message: resolvedMessage, opts });
|
||||
};
|
||||
}
|
||||
|
||||
30
src/slack/monitor/thread-resolution.test.ts
Normal file
30
src/slack/monitor/thread-resolution.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { SlackMessageEvent } from "../types.js";
|
||||
import { createSlackThreadTsResolver } from "./thread-resolution.js";
|
||||
|
||||
describe("createSlackThreadTsResolver", () => {
|
||||
it("caches resolved thread_ts lookups", async () => {
|
||||
const historyMock = vi.fn().mockResolvedValue({
|
||||
messages: [{ ts: "1", thread_ts: "9" }],
|
||||
});
|
||||
const resolver = createSlackThreadTsResolver({
|
||||
client: { conversations: { history: historyMock } } as any,
|
||||
cacheTtlMs: 60_000,
|
||||
maxSize: 5,
|
||||
});
|
||||
|
||||
const message = {
|
||||
channel: "C1",
|
||||
parent_user_id: "U2",
|
||||
ts: "1",
|
||||
} as SlackMessageEvent;
|
||||
|
||||
const first = await resolver.resolve({ message, source: "message" });
|
||||
const second = await resolver.resolve({ message, source: "message" });
|
||||
|
||||
expect(first.thread_ts).toBe("9");
|
||||
expect(second.thread_ts).toBe("9");
|
||||
expect(historyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
140
src/slack/monitor/thread-resolution.ts
Normal file
140
src/slack/monitor/thread-resolution.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { WebClient as SlackWebClient } from "@slack/web-api";
|
||||
|
||||
import { logVerbose, shouldLogVerbose } from "../../globals.js";
|
||||
import type { SlackMessageEvent } from "../types.js";
|
||||
|
||||
type ThreadTsCacheEntry = {
|
||||
threadTs: string | null;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
const DEFAULT_THREAD_TS_CACHE_TTL_MS = 60_000;
|
||||
const DEFAULT_THREAD_TS_CACHE_MAX = 500;
|
||||
|
||||
const normalizeThreadTs = (threadTs?: string | null) => {
|
||||
const trimmed = threadTs?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
};
|
||||
|
||||
async function resolveThreadTsFromHistory(params: {
|
||||
client: SlackWebClient;
|
||||
channelId: string;
|
||||
messageTs: string;
|
||||
}) {
|
||||
try {
|
||||
const response = (await params.client.conversations.history({
|
||||
channel: params.channelId,
|
||||
latest: params.messageTs,
|
||||
oldest: params.messageTs,
|
||||
inclusive: true,
|
||||
limit: 1,
|
||||
})) as { messages?: Array<{ ts?: string; thread_ts?: string }> };
|
||||
const message =
|
||||
response.messages?.find((entry) => entry.ts === params.messageTs) ?? response.messages?.[0];
|
||||
return normalizeThreadTs(message?.thread_ts);
|
||||
} catch (err) {
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`slack inbound: failed to resolve thread_ts via conversations.history for channel=${params.channelId} ts=${params.messageTs}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function createSlackThreadTsResolver(params: {
|
||||
client: SlackWebClient;
|
||||
cacheTtlMs?: number;
|
||||
maxSize?: number;
|
||||
}) {
|
||||
const ttlMs = Math.max(0, params.cacheTtlMs ?? DEFAULT_THREAD_TS_CACHE_TTL_MS);
|
||||
const maxSize = Math.max(0, params.maxSize ?? DEFAULT_THREAD_TS_CACHE_MAX);
|
||||
const cache = new Map<string, ThreadTsCacheEntry>();
|
||||
const inflight = new Map<string, Promise<string | undefined>>();
|
||||
|
||||
const getCached = (key: string, now: number) => {
|
||||
const entry = cache.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (ttlMs > 0 && now - entry.updatedAt > ttlMs) {
|
||||
cache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
cache.delete(key);
|
||||
cache.set(key, { ...entry, updatedAt: now });
|
||||
return entry.threadTs;
|
||||
};
|
||||
|
||||
const setCached = (key: string, threadTs: string | null, now: number) => {
|
||||
cache.delete(key);
|
||||
cache.set(key, { threadTs, updatedAt: now });
|
||||
if (maxSize <= 0) {
|
||||
cache.clear();
|
||||
return;
|
||||
}
|
||||
while (cache.size > maxSize) {
|
||||
const oldestKey = cache.keys().next().value as string | undefined;
|
||||
if (!oldestKey) break;
|
||||
cache.delete(oldestKey);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
resolve: async (request: {
|
||||
message: SlackMessageEvent;
|
||||
source: "message" | "app_mention";
|
||||
}): Promise<SlackMessageEvent> => {
|
||||
const { message } = request;
|
||||
if (!message.parent_user_id || message.thread_ts || !message.ts) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const cacheKey = `${message.channel}:${message.ts}`;
|
||||
const now = Date.now();
|
||||
const cached = getCached(cacheKey, now);
|
||||
if (cached !== undefined) {
|
||||
return cached ? { ...message, thread_ts: cached } : message;
|
||||
}
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`slack inbound: missing thread_ts for thread reply channel=${message.channel} ts=${message.ts} source=${request.source}`,
|
||||
);
|
||||
}
|
||||
|
||||
let pending = inflight.get(cacheKey);
|
||||
if (!pending) {
|
||||
pending = resolveThreadTsFromHistory({
|
||||
client: params.client,
|
||||
channelId: message.channel,
|
||||
messageTs: message.ts,
|
||||
});
|
||||
inflight.set(cacheKey, pending);
|
||||
}
|
||||
|
||||
let resolved: string | undefined;
|
||||
try {
|
||||
resolved = await pending;
|
||||
} finally {
|
||||
inflight.delete(cacheKey);
|
||||
}
|
||||
|
||||
setCached(cacheKey, resolved ?? null, Date.now());
|
||||
|
||||
if (resolved) {
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`slack inbound: resolved missing thread_ts channel=${message.channel} ts=${message.ts} -> thread_ts=${resolved}`,
|
||||
);
|
||||
}
|
||||
return { ...message, thread_ts: resolved };
|
||||
}
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`slack inbound: could not resolve missing thread_ts channel=${message.channel} ts=${message.ts}`,
|
||||
);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user