fix: resolve heartbeat sender and Slack thread_ts
This commit is contained in:
@@ -0,0 +1,100 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import * as replyModule from "../auto-reply/reply.js";
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { resolveMainSessionKey } from "../config/sessions.js";
|
||||||
|
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||||
|
import { createPluginRuntime } from "../plugins/runtime/index.js";
|
||||||
|
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||||
|
import { slackPlugin } from "../../extensions/slack/src/channel.js";
|
||||||
|
import { setSlackRuntime } from "../../extensions/slack/src/runtime.js";
|
||||||
|
import { telegramPlugin } from "../../extensions/telegram/src/channel.js";
|
||||||
|
import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js";
|
||||||
|
import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js";
|
||||||
|
import { setWhatsAppRuntime } from "../../extensions/whatsapp/src/runtime.js";
|
||||||
|
import { runHeartbeatOnce } from "./heartbeat-runner.js";
|
||||||
|
|
||||||
|
// Avoid pulling optional runtime deps during isolated runs.
|
||||||
|
vi.mock("jiti", () => ({ createJiti: () => () => ({}) }));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const runtime = createPluginRuntime();
|
||||||
|
setSlackRuntime(runtime);
|
||||||
|
setTelegramRuntime(runtime);
|
||||||
|
setWhatsAppRuntime(runtime);
|
||||||
|
setActivePluginRegistry(
|
||||||
|
createTestRegistry([
|
||||||
|
{ pluginId: "slack", plugin: slackPlugin, source: "test" },
|
||||||
|
{ pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" },
|
||||||
|
{ pluginId: "telegram", plugin: telegramPlugin, source: "test" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runHeartbeatOnce", () => {
|
||||||
|
it("uses the delivery target as sender when lastTo differs", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||||
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||||
|
try {
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
heartbeat: {
|
||||||
|
every: "5m",
|
||||||
|
target: "slack",
|
||||||
|
to: "C0A9P2N8QHY",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: { store: storePath },
|
||||||
|
};
|
||||||
|
const sessionKey = resolveMainSessionKey(cfg);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
storePath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
[sessionKey]: {
|
||||||
|
sessionId: "sid",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
lastChannel: "telegram",
|
||||||
|
lastProvider: "telegram",
|
||||||
|
lastTo: "1644620762",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
replySpy.mockImplementation(async (ctx) => {
|
||||||
|
expect(ctx.To).toBe("C0A9P2N8QHY");
|
||||||
|
expect(ctx.From).toBe("C0A9P2N8QHY");
|
||||||
|
return { text: "ok" };
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendSlack = vi.fn().mockResolvedValue({
|
||||||
|
messageId: "m1",
|
||||||
|
channelId: "C0A9P2N8QHY",
|
||||||
|
});
|
||||||
|
|
||||||
|
await runHeartbeatOnce({
|
||||||
|
cfg,
|
||||||
|
deps: {
|
||||||
|
sendSlack,
|
||||||
|
getQueueSize: () => 0,
|
||||||
|
nowMs: () => 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendSlack).toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
replySpy.mockRestore();
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,7 +39,10 @@ import {
|
|||||||
} from "./heartbeat-wake.js";
|
} from "./heartbeat-wake.js";
|
||||||
import type { OutboundSendDeps } from "./outbound/deliver.js";
|
import type { OutboundSendDeps } from "./outbound/deliver.js";
|
||||||
import { deliverOutboundPayloads } from "./outbound/deliver.js";
|
import { deliverOutboundPayloads } from "./outbound/deliver.js";
|
||||||
import { resolveHeartbeatDeliveryTarget } from "./outbound/targets.js";
|
import {
|
||||||
|
resolveHeartbeatDeliveryTarget,
|
||||||
|
resolveHeartbeatSenderContext,
|
||||||
|
} from "./outbound/targets.js";
|
||||||
|
|
||||||
type HeartbeatDeps = OutboundSendDeps &
|
type HeartbeatDeps = OutboundSendDeps &
|
||||||
ChannelHeartbeatDeps & {
|
ChannelHeartbeatDeps & {
|
||||||
@@ -362,34 +365,6 @@ function resolveHeartbeatReasoningPayloads(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveHeartbeatSender(params: {
|
|
||||||
allowFrom: Array<string | number>;
|
|
||||||
lastTo?: string;
|
|
||||||
provider?: string | null;
|
|
||||||
}) {
|
|
||||||
const { allowFrom, lastTo, provider } = params;
|
|
||||||
const candidates = [
|
|
||||||
lastTo?.trim(),
|
|
||||||
provider && lastTo ? `${provider}:${lastTo}` : undefined,
|
|
||||||
].filter((val): val is string => Boolean(val?.trim()));
|
|
||||||
|
|
||||||
const allowList = allowFrom
|
|
||||||
.map((entry) => String(entry))
|
|
||||||
.filter((entry) => entry && entry !== "*");
|
|
||||||
if (allowFrom.includes("*")) {
|
|
||||||
return candidates[0] ?? "heartbeat";
|
|
||||||
}
|
|
||||||
if (candidates.length > 0 && allowList.length > 0) {
|
|
||||||
const matched = candidates.find((candidate) => allowList.includes(candidate));
|
|
||||||
if (matched) return matched;
|
|
||||||
}
|
|
||||||
if (candidates.length > 0 && allowList.length === 0) {
|
|
||||||
return candidates[0];
|
|
||||||
}
|
|
||||||
if (allowList.length > 0) return allowList[0];
|
|
||||||
return candidates[0] ?? "heartbeat";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restoreHeartbeatUpdatedAt(params: {
|
async function restoreHeartbeatUpdatedAt(params: {
|
||||||
storePath: string;
|
storePath: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
@@ -468,20 +443,7 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat);
|
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat);
|
||||||
const previousUpdatedAt = entry?.updatedAt;
|
const previousUpdatedAt = entry?.updatedAt;
|
||||||
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
|
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
|
||||||
const lastChannel = delivery.lastChannel;
|
const { sender } = resolveHeartbeatSenderContext({ cfg, entry, delivery });
|
||||||
const lastAccountId = delivery.lastAccountId;
|
|
||||||
const senderProvider = delivery.channel !== "none" ? delivery.channel : lastChannel;
|
|
||||||
const senderAllowFrom = senderProvider
|
|
||||||
? (getChannelPlugin(senderProvider)?.config.resolveAllowFrom?.({
|
|
||||||
cfg,
|
|
||||||
accountId: senderProvider === lastChannel ? lastAccountId : undefined,
|
|
||||||
}) ?? [])
|
|
||||||
: [];
|
|
||||||
const sender = resolveHeartbeatSender({
|
|
||||||
allowFrom: senderAllowFrom,
|
|
||||||
lastTo: entry?.lastTo,
|
|
||||||
provider: senderProvider,
|
|
||||||
});
|
|
||||||
const prompt = resolveHeartbeatPrompt(cfg, heartbeat);
|
const prompt = resolveHeartbeatPrompt(cfg, heartbeat);
|
||||||
const ctx = {
|
const ctx = {
|
||||||
Body: prompt,
|
Body: prompt,
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ export type OutboundTarget = {
|
|||||||
lastAccountId?: string;
|
lastAccountId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HeartbeatSenderContext = {
|
||||||
|
sender: string;
|
||||||
|
provider?: DeliverableMessageChannel;
|
||||||
|
allowFrom: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type OutboundTargetResolution = { ok: true; to: string } | { ok: false; error: Error };
|
export type OutboundTargetResolution = { ok: true; to: string } | { ok: false; error: Error };
|
||||||
|
|
||||||
export type SessionDeliveryTarget = {
|
export type SessionDeliveryTarget = {
|
||||||
@@ -250,3 +256,59 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
|||||||
lastAccountId: resolvedTarget.lastAccountId,
|
lastAccountId: resolvedTarget.lastAccountId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveHeartbeatSenderId(params: {
|
||||||
|
allowFrom: Array<string | number>;
|
||||||
|
deliveryTo?: string;
|
||||||
|
lastTo?: string;
|
||||||
|
provider?: string | null;
|
||||||
|
}) {
|
||||||
|
const { allowFrom, deliveryTo, lastTo, provider } = params;
|
||||||
|
const candidates = [
|
||||||
|
deliveryTo?.trim(),
|
||||||
|
provider && deliveryTo ? `${provider}:${deliveryTo}` : undefined,
|
||||||
|
lastTo?.trim(),
|
||||||
|
provider && lastTo ? `${provider}:${lastTo}` : undefined,
|
||||||
|
].filter((val): val is string => Boolean(val?.trim()));
|
||||||
|
|
||||||
|
const allowList = allowFrom
|
||||||
|
.map((entry) => String(entry))
|
||||||
|
.filter((entry) => entry && entry !== "*");
|
||||||
|
if (allowFrom.includes("*")) {
|
||||||
|
return candidates[0] ?? "heartbeat";
|
||||||
|
}
|
||||||
|
if (candidates.length > 0 && allowList.length > 0) {
|
||||||
|
const matched = candidates.find((candidate) => allowList.includes(candidate));
|
||||||
|
if (matched) return matched;
|
||||||
|
}
|
||||||
|
if (candidates.length > 0 && allowList.length === 0) {
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
if (allowList.length > 0) return allowList[0];
|
||||||
|
return candidates[0] ?? "heartbeat";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveHeartbeatSenderContext(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
entry?: SessionEntry;
|
||||||
|
delivery: OutboundTarget;
|
||||||
|
}): HeartbeatSenderContext {
|
||||||
|
const provider =
|
||||||
|
params.delivery.channel !== "none" ? params.delivery.channel : params.delivery.lastChannel;
|
||||||
|
const allowFrom = provider
|
||||||
|
? (getChannelPlugin(provider)?.config.resolveAllowFrom?.({
|
||||||
|
cfg: params.cfg,
|
||||||
|
accountId:
|
||||||
|
provider === params.delivery.lastChannel ? params.delivery.lastAccountId : undefined,
|
||||||
|
}) ?? [])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const sender = resolveHeartbeatSenderId({
|
||||||
|
allowFrom,
|
||||||
|
deliveryTo: params.delivery.to,
|
||||||
|
lastTo: params.entry?.lastTo,
|
||||||
|
provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { sender, provider, allowFrom };
|
||||||
|
}
|
||||||
|
|||||||
178
src/slack/monitor.threading.missing-thread-ts.test.ts
Normal file
178
src/slack/monitor.threading.missing-thread-ts.test.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||||
|
import { monitorSlackProvider } from "./monitor.js";
|
||||||
|
|
||||||
|
const sendMock = vi.fn();
|
||||||
|
const replyMock = vi.fn();
|
||||||
|
const updateLastRouteMock = vi.fn();
|
||||||
|
const reactMock = vi.fn();
|
||||||
|
let config: Record<string, unknown> = {};
|
||||||
|
const readAllowFromStoreMock = vi.fn();
|
||||||
|
const upsertPairingRequestMock = vi.fn();
|
||||||
|
const getSlackHandlers = () =>
|
||||||
|
(
|
||||||
|
globalThis as {
|
||||||
|
__slackHandlers?: Map<string, (args: unknown) => Promise<void>>;
|
||||||
|
}
|
||||||
|
).__slackHandlers;
|
||||||
|
const getSlackClient = () =>
|
||||||
|
(globalThis as { __slackClient?: Record<string, unknown> }).__slackClient;
|
||||||
|
|
||||||
|
vi.mock("../config/config.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
loadConfig: () => config,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../auto-reply/reply.js", () => ({
|
||||||
|
getReplyFromConfig: (...args: unknown[]) => replyMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./resolve-channels.js", () => ({
|
||||||
|
resolveSlackChannelAllowlist: async ({ entries }: { entries: string[] }) =>
|
||||||
|
entries.map((input) => ({ input, resolved: false })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./resolve-users.js", () => ({
|
||||||
|
resolveSlackUserAllowlist: async ({ entries }: { entries: string[] }) =>
|
||||||
|
entries.map((input) => ({ input, resolved: false })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./send.js", () => ({
|
||||||
|
sendMessageSlack: (...args: unknown[]) => sendMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../pairing/pairing-store.js", () => ({
|
||||||
|
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||||
|
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../config/sessions.js", () => ({
|
||||||
|
resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"),
|
||||||
|
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
|
||||||
|
resolveSessionKey: vi.fn(),
|
||||||
|
readSessionUpdatedAt: vi.fn(() => undefined),
|
||||||
|
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@slack/bolt", () => {
|
||||||
|
const handlers = new Map<string, (args: unknown) => Promise<void>>();
|
||||||
|
(globalThis as { __slackHandlers?: typeof handlers }).__slackHandlers = handlers;
|
||||||
|
const client = {
|
||||||
|
auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) },
|
||||||
|
conversations: {
|
||||||
|
info: vi.fn().mockResolvedValue({
|
||||||
|
channel: { name: "general", is_channel: true },
|
||||||
|
}),
|
||||||
|
replies: vi.fn().mockResolvedValue({ messages: [] }),
|
||||||
|
history: vi.fn().mockResolvedValue({ messages: [] }),
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
info: vi.fn().mockResolvedValue({
|
||||||
|
user: { profile: { display_name: "Ada" } },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
assistant: {
|
||||||
|
threads: {
|
||||||
|
setStatus: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reactions: {
|
||||||
|
add: (...args: unknown[]) => reactMock(...args),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(globalThis as { __slackClient?: typeof client }).__slackClient = client;
|
||||||
|
class App {
|
||||||
|
client = client;
|
||||||
|
event(name: string, handler: (args: unknown) => Promise<void>) {
|
||||||
|
handlers.set(name, handler);
|
||||||
|
}
|
||||||
|
command() {
|
||||||
|
/* no-op */
|
||||||
|
}
|
||||||
|
start = vi.fn().mockResolvedValue(undefined);
|
||||||
|
stop = vi.fn().mockResolvedValue(undefined);
|
||||||
|
}
|
||||||
|
class HTTPReceiver {
|
||||||
|
requestListener = vi.fn();
|
||||||
|
}
|
||||||
|
return { App, HTTPReceiver, default: { App, HTTPReceiver } };
|
||||||
|
});
|
||||||
|
|
||||||
|
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
async function waitForEvent(name: string) {
|
||||||
|
for (let i = 0; i < 10; i += 1) {
|
||||||
|
if (getSlackHandlers()?.has(name)) return;
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetInboundDedupe();
|
||||||
|
getSlackHandlers()?.clear();
|
||||||
|
config = {
|
||||||
|
messages: { responsePrefix: "PFX" },
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
||||||
|
groupPolicy: "open",
|
||||||
|
channels: { C1: { allow: true, requireMention: false } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
sendMock.mockReset().mockResolvedValue(undefined);
|
||||||
|
replyMock.mockReset();
|
||||||
|
updateLastRouteMock.mockReset();
|
||||||
|
reactMock.mockReset();
|
||||||
|
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||||
|
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("monitorSlackProvider threading", () => {
|
||||||
|
it("recovers missing thread_ts when parent_user_id is present", async () => {
|
||||||
|
replyMock.mockResolvedValue({ text: "thread reply" });
|
||||||
|
|
||||||
|
const client = getSlackClient();
|
||||||
|
if (!client) throw new Error("Slack client not registered");
|
||||||
|
const conversations = client.conversations as {
|
||||||
|
history: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
conversations.history.mockResolvedValueOnce({
|
||||||
|
messages: [{ ts: "456", thread_ts: "111.222" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const run = monitorSlackProvider({
|
||||||
|
botToken: "bot-token",
|
||||||
|
appToken: "app-token",
|
||||||
|
abortSignal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForEvent("message");
|
||||||
|
const handler = getSlackHandlers()?.get("message");
|
||||||
|
if (!handler) throw new Error("Slack message handler not registered");
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
event: {
|
||||||
|
type: "message",
|
||||||
|
user: "U1",
|
||||||
|
text: "hello",
|
||||||
|
ts: "456",
|
||||||
|
parent_user_id: "U2",
|
||||||
|
channel: "C1",
|
||||||
|
channel_type: "channel",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
controller.abort();
|
||||||
|
await run;
|
||||||
|
|
||||||
|
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "111.222" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import type { SlackMessageEvent } from "../types.js";
|
|||||||
import type { SlackMonitorContext } from "./context.js";
|
import type { SlackMonitorContext } from "./context.js";
|
||||||
import { dispatchPreparedSlackMessage } from "./message-handler/dispatch.js";
|
import { dispatchPreparedSlackMessage } from "./message-handler/dispatch.js";
|
||||||
import { prepareSlackMessage } from "./message-handler/prepare.js";
|
import { prepareSlackMessage } from "./message-handler/prepare.js";
|
||||||
|
import { createSlackThreadTsResolver } from "./thread-resolution.js";
|
||||||
|
|
||||||
export type SlackMessageHandler = (
|
export type SlackMessageHandler = (
|
||||||
message: SlackMessageEvent,
|
message: SlackMessageEvent,
|
||||||
@@ -20,6 +21,7 @@ export function createSlackMessageHandler(params: {
|
|||||||
}): SlackMessageHandler {
|
}): SlackMessageHandler {
|
||||||
const { ctx, account } = params;
|
const { ctx, account } = params;
|
||||||
const debounceMs = resolveInboundDebounceMs({ cfg: ctx.cfg, channel: "slack" });
|
const debounceMs = resolveInboundDebounceMs({ cfg: ctx.cfg, channel: "slack" });
|
||||||
|
const threadTsResolver = createSlackThreadTsResolver({ client: ctx.app.client });
|
||||||
|
|
||||||
const debouncer = createInboundDebouncer<{
|
const debouncer = createInboundDebouncer<{
|
||||||
message: SlackMessageEvent;
|
message: SlackMessageEvent;
|
||||||
@@ -29,9 +31,13 @@ export function createSlackMessageHandler(params: {
|
|||||||
buildKey: (entry) => {
|
buildKey: (entry) => {
|
||||||
const senderId = entry.message.user ?? entry.message.bot_id;
|
const senderId = entry.message.user ?? entry.message.bot_id;
|
||||||
if (!senderId) return null;
|
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
|
const threadKey = entry.message.thread_ts
|
||||||
? `${entry.message.channel}:${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}`;
|
return `slack:${ctx.accountId}:${threadKey}:${senderId}`;
|
||||||
},
|
},
|
||||||
shouldDebounce: (entry) => {
|
shouldDebounce: (entry) => {
|
||||||
@@ -91,6 +97,7 @@ export function createSlackMessageHandler(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ctx.markMessageSeen(message.channel, message.ts)) 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