Threads: add Slack/Discord thread sessions
This commit is contained in:
committed by
Peter Steinberger
parent
422477499c
commit
7e5cef29a0
@@ -57,6 +57,7 @@ vi.mock("@slack/bolt", () => {
|
||||
info: vi.fn().mockResolvedValue({
|
||||
channel: { name: "dm", is_im: true },
|
||||
}),
|
||||
replies: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
},
|
||||
users: {
|
||||
info: vi.fn().mockResolvedValue({
|
||||
@@ -283,6 +284,114 @@ describe("monitorSlackProvider tool results", () => {
|
||||
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "456" });
|
||||
});
|
||||
|
||||
it("treats parent_user_id as a thread reply even when thread_ts matches ts", async () => {
|
||||
const { resolveSessionKey } = await import("../config/sessions.js");
|
||||
vi.mocked(resolveSessionKey).mockReturnValue("main");
|
||||
replyMock.mockResolvedValue({ text: "thread reply" });
|
||||
|
||||
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: "123",
|
||||
thread_ts: "123",
|
||||
parent_user_id: "U2",
|
||||
channel: "C1",
|
||||
channel_type: "im",
|
||||
},
|
||||
});
|
||||
|
||||
await flush();
|
||||
controller.abort();
|
||||
await run;
|
||||
|
||||
expect(replyMock).toHaveBeenCalledTimes(1);
|
||||
const ctx = replyMock.mock.calls[0]?.[0] as {
|
||||
SessionKey?: string;
|
||||
ParentSessionKey?: string;
|
||||
};
|
||||
expect(ctx.SessionKey).toBe("slack:thread:C1:123");
|
||||
expect(ctx.ParentSessionKey).toBe("main");
|
||||
});
|
||||
|
||||
it("forks thread sessions and injects starter context", async () => {
|
||||
const { resolveSessionKey } = await import("../config/sessions.js");
|
||||
vi.mocked(resolveSessionKey).mockReturnValue("slack:channel:C1");
|
||||
replyMock.mockResolvedValue({ text: "ok" });
|
||||
|
||||
const client = getSlackClient();
|
||||
if (client?.conversations?.info) {
|
||||
client.conversations.info.mockResolvedValue({
|
||||
channel: { name: "general", is_channel: true },
|
||||
});
|
||||
}
|
||||
if (client?.conversations?.replies) {
|
||||
client.conversations.replies.mockResolvedValue({
|
||||
messages: [{ text: "starter message", user: "U2", ts: "111.222" }],
|
||||
});
|
||||
}
|
||||
|
||||
config = {
|
||||
messages: { responsePrefix: "PFX" },
|
||||
slack: {
|
||||
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
||||
channels: { C1: { allow: true, requireMention: false } },
|
||||
},
|
||||
routing: { allowFrom: [] },
|
||||
};
|
||||
|
||||
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: "thread reply",
|
||||
ts: "123.456",
|
||||
thread_ts: "111.222",
|
||||
channel: "C1",
|
||||
channel_type: "channel",
|
||||
},
|
||||
});
|
||||
|
||||
await flush();
|
||||
controller.abort();
|
||||
await run;
|
||||
|
||||
expect(replyMock).toHaveBeenCalledTimes(1);
|
||||
const ctx = replyMock.mock.calls[0]?.[0] as {
|
||||
SessionKey?: string;
|
||||
ParentSessionKey?: string;
|
||||
ThreadStarterBody?: string;
|
||||
ThreadLabel?: string;
|
||||
};
|
||||
expect(ctx.SessionKey).toBe("slack:thread:C1:111.222");
|
||||
expect(ctx.ParentSessionKey).toBe("slack:channel:C1");
|
||||
expect(ctx.ThreadStarterBody).toContain("starter message");
|
||||
expect(ctx.ThreadLabel).toContain("Slack thread #general");
|
||||
});
|
||||
|
||||
it("keeps replies in channel root when message is not threaded", async () => {
|
||||
replyMock.mockResolvedValue({ text: "root reply" });
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type SlackCommandMiddlewareArgs,
|
||||
type SlackEventMiddlewareArgs,
|
||||
} from "@slack/bolt";
|
||||
import type { WebClient as SlackWebClient } from "@slack/web-api";
|
||||
import {
|
||||
chunkMarkdownText,
|
||||
resolveTextChunkLimit,
|
||||
@@ -74,6 +75,7 @@ type SlackMessageEvent = {
|
||||
text?: string;
|
||||
ts?: string;
|
||||
thread_ts?: string;
|
||||
parent_user_id?: string;
|
||||
channel: string;
|
||||
channel_type?: "im" | "mpim" | "channel" | "group";
|
||||
files?: SlackFile[];
|
||||
@@ -86,6 +88,7 @@ type SlackAppMentionEvent = {
|
||||
text?: string;
|
||||
ts?: string;
|
||||
thread_ts?: string;
|
||||
parent_user_id?: string;
|
||||
channel: string;
|
||||
channel_type?: "im" | "mpim" | "channel" | "group";
|
||||
};
|
||||
@@ -390,6 +393,44 @@ async function resolveSlackMedia(params: {
|
||||
return null;
|
||||
}
|
||||
|
||||
type SlackThreadStarter = {
|
||||
text: string;
|
||||
userId?: string;
|
||||
ts?: string;
|
||||
};
|
||||
|
||||
const THREAD_STARTER_CACHE = new Map<string, SlackThreadStarter>();
|
||||
|
||||
async function resolveSlackThreadStarter(params: {
|
||||
channelId: string;
|
||||
threadTs: string;
|
||||
client: SlackWebClient;
|
||||
}): Promise<SlackThreadStarter | null> {
|
||||
const cacheKey = `${params.channelId}:${params.threadTs}`;
|
||||
const cached = THREAD_STARTER_CACHE.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const response = (await params.client.conversations.replies({
|
||||
channel: params.channelId,
|
||||
ts: params.threadTs,
|
||||
limit: 1,
|
||||
inclusive: true,
|
||||
})) as { messages?: Array<{ text?: string; user?: string; ts?: string }> };
|
||||
const message = response?.messages?.[0];
|
||||
const text = (message?.text ?? "").trim();
|
||||
if (!message || !text) return null;
|
||||
const starter: SlackThreadStarter = {
|
||||
text,
|
||||
userId: message.user,
|
||||
ts: message.ts,
|
||||
};
|
||||
THREAD_STARTER_CACHE.set(cacheKey, starter);
|
||||
return starter;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const cfg = loadConfig();
|
||||
const sessionCfg = cfg.session;
|
||||
@@ -883,7 +924,16 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
id: isDirectMessage ? (message.user ?? "unknown") : message.channel,
|
||||
},
|
||||
});
|
||||
const sessionKey = route.sessionKey;
|
||||
const baseSessionKey = route.sessionKey;
|
||||
const threadTs = message.thread_ts;
|
||||
const hasThreadTs = typeof threadTs === "string" && threadTs.length > 0;
|
||||
const isThreadReply =
|
||||
hasThreadTs && (threadTs !== message.ts || Boolean(message.parent_user_id));
|
||||
const threadSessionKey = isThreadReply && threadTs
|
||||
? `slack:thread:${message.channel}:${threadTs}`
|
||||
: undefined;
|
||||
const parentSessionKey = isThreadReply ? baseSessionKey : undefined;
|
||||
const sessionKey = threadSessionKey ?? baseSessionKey;
|
||||
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
||||
sessionKey,
|
||||
contextKey: `slack:message:${message.channel}:${message.ts ?? "unknown"}`,
|
||||
@@ -912,11 +962,39 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
].filter((entry): entry is string => Boolean(entry));
|
||||
const groupSystemPrompt =
|
||||
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
|
||||
let threadStarterBody: string | undefined;
|
||||
let threadLabel: string | undefined;
|
||||
if (isThreadReply && threadTs) {
|
||||
const starter = await resolveSlackThreadStarter({
|
||||
channelId: message.channel,
|
||||
threadTs,
|
||||
client: app.client,
|
||||
});
|
||||
if (starter?.text) {
|
||||
const starterUser = starter.userId
|
||||
? await resolveUserName(starter.userId)
|
||||
: null;
|
||||
const starterName = starterUser?.name ?? starter.userId ?? "Unknown";
|
||||
const starterWithId = `${starter.text}\n[slack message id: ${starter.ts ?? threadTs} channel: ${message.channel}]`;
|
||||
threadStarterBody = formatAgentEnvelope({
|
||||
provider: "Slack",
|
||||
from: starterName,
|
||||
timestamp: starter.ts
|
||||
? Math.round(Number(starter.ts) * 1000)
|
||||
: undefined,
|
||||
body: starterWithId,
|
||||
});
|
||||
const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80);
|
||||
threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`;
|
||||
} else {
|
||||
threadLabel = `Slack thread ${roomLabel}`;
|
||||
}
|
||||
}
|
||||
const ctxPayload = {
|
||||
Body: body,
|
||||
From: slackFrom,
|
||||
To: slackTo,
|
||||
SessionKey: route.sessionKey,
|
||||
SessionKey: sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group",
|
||||
GroupSubject: isRoomish ? roomLabel : undefined,
|
||||
@@ -927,6 +1005,9 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
Surface: "slack" as const,
|
||||
MessageSid: message.ts,
|
||||
ReplyToId: message.thread_ts ?? message.ts,
|
||||
ParentSessionKey: parentSessionKey,
|
||||
ThreadStarterBody: threadStarterBody,
|
||||
ThreadLabel: threadLabel,
|
||||
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
|
||||
WasMentioned: isRoomish ? wasMentioned : undefined,
|
||||
MediaPath: media?.path,
|
||||
|
||||
Reference in New Issue
Block a user