diff --git a/extensions/slack/src/channel.read-threadid.test.ts b/extensions/slack/src/channel.read-threadid.test.ts new file mode 100644 index 000000000..4ccc965e0 --- /dev/null +++ b/extensions/slack/src/channel.read-threadid.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; + +import { slackPlugin } from "./channel.js"; +import { setSlackRuntime } from "./runtime.js"; + +import type { MoltbotConfig } from "../../../src/config/config.js"; +import { createPluginRuntime } from "../../../src/plugins/runtime/index.js"; + +describe("slack plugin read action", () => { + it("forwards threadId to readMessages", async () => { + const runtime = createPluginRuntime(); + + const handleSlackAction = vi.fn(async () => ({ ok: true })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (runtime.channel.slack as any).handleSlackAction = handleSlackAction; + + setSlackRuntime(runtime); + + const cfg = { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + }, + }, + } as MoltbotConfig; + + await slackPlugin.actions.handleAction({ + action: "read", + params: { + channelId: "C123", + threadId: "1712345678.000100", + limit: 3, + }, + cfg, + accountId: undefined, + toolContext: undefined, + }); + + expect(handleSlackAction).toHaveBeenCalledTimes(1); + expect(handleSlackAction.mock.calls[0]?.[0]).toMatchObject({ + action: "readMessages", + channelId: "C123", + limit: 3, + threadId: "1712345678.000100", + }); + }); +}); diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 720a59c57..9d337e5d3 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -337,9 +337,11 @@ export const slackPlugin: ChannelPlugin = { limit, before: readStringParam(params, "before"), after: readStringParam(params, "after"), + threadId: readStringParam(params, "threadId"), accountId: accountId ?? undefined, }, cfg, + toolContext, ); } diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index d7050e4e6..f4275fe9c 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -112,6 +112,12 @@ function buildFetchSchema() { around: Type.Optional(Type.String()), fromMe: Type.Optional(Type.Boolean()), includeArchived: Type.Optional(Type.Boolean()), + threadId: Type.Optional( + Type.String({ + description: + "Thread ID (ts) to read replies from. Required for reading Slack thread messages.", + }), + ), }; } diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index 3bb8ea717..1059f1a7a 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -214,13 +214,22 @@ export async function handleSlackAction( typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined; const before = readStringParam(params, "before"); const after = readStringParam(params, "after"); - const threadId = readStringParam(params, "threadId"); + const explicitThreadId = readStringParam(params, "threadId"); + // Auto-inject threadId from context when reading from current channel in a thread. + // For reads, always inject if we're in a thread context and reading from the same channel. + let threadId = explicitThreadId ?? undefined; + if (!threadId && context?.currentThreadTs && context?.currentChannelId) { + const parsedTarget = parseSlackTarget(channelId, { defaultKind: "channel" }); + if (parsedTarget?.kind === "channel" && parsedTarget.id === context.currentChannelId) { + threadId = context.currentThreadTs; + } + } const result = await readSlackMessages(channelId, { ...readOpts, limit, before: before ?? undefined, after: after ?? undefined, - threadId: threadId ?? undefined, + threadId, }); const messages = result.messages.map((message) => withNormalizedTimestamp( diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 19c4df49b..b34a25c80 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -231,6 +231,10 @@ export async function runPreparedReply( isNewSession && threadStarterBody ? `[Thread starter - for context]\n${threadStarterBody}` : undefined; + const threadRepliesBody = ctx.ThreadRepliesBody?.trim(); + const threadRepliesNote = threadRepliesBody + ? `[Thread replies - for context]\n${threadRepliesBody}` + : undefined; const skillResult = await ensureSkillSnapshot({ sessionEntry, sessionStore, @@ -245,7 +249,9 @@ export async function runPreparedReply( sessionEntry = skillResult.sessionEntry ?? sessionEntry; currentSystemSent = skillResult.systemSent; const skillsSnapshot = skillResult.skillsSnapshot; - const prefixedBody = [threadStarterNote, prefixedBodyBase].filter(Boolean).join("\n\n"); + const prefixedBody = [threadStarterNote, threadRepliesNote, prefixedBodyBase] + .filter(Boolean) + .join("\n\n"); const mediaNote = buildInboundMediaNote(ctx); const mediaReplyHint = mediaNote ? "To send an image back, prefer the message tool (media/path/filePath). If you must inline, use MEDIA:/path or MEDIA:https://example.com/image.jpg (spaces ok, quote if needed). Keep caption in the text body." diff --git a/src/auto-reply/reply/inbound-context.ts b/src/auto-reply/reply/inbound-context.ts index 1af20f7e9..4e30e3d19 100644 --- a/src/auto-reply/reply/inbound-context.ts +++ b/src/auto-reply/reply/inbound-context.ts @@ -29,6 +29,7 @@ export function finalizeInboundContext>( normalized.CommandBody = normalizeTextField(normalized.CommandBody); normalized.Transcript = normalizeTextField(normalized.Transcript); normalized.ThreadStarterBody = normalizeTextField(normalized.ThreadStarterBody); + normalized.ThreadRepliesBody = normalizeTextField(normalized.ThreadRepliesBody); const chatType = normalizeChatType(normalized.ChatType); if (chatType && (opts.forceChatType || normalized.ChatType !== chatType)) { diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 1e07f6a32..400350a3e 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -58,6 +58,8 @@ export type MsgContext = { ForwardedFromSignature?: string; ForwardedDate?: number; ThreadStarterBody?: string; + /** Thread replies context (recent messages in the thread, excluding starter and current). */ + ThreadRepliesBody?: string; ThreadLabel?: string; MediaPath?: string; MediaUrl?: string; diff --git a/src/channels/plugins/slack.actions.ts b/src/channels/plugins/slack.actions.ts index ca8aa6fb8..e2c1be539 100644 --- a/src/channels/plugins/slack.actions.ts +++ b/src/channels/plugins/slack.actions.ts @@ -137,6 +137,7 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap accountId: accountId ?? undefined, }, cfg, + toolContext, ); } diff --git a/src/slack/monitor/media.ts b/src/slack/monitor/media.ts index 2674e2d50..c9d4a396c 100644 --- a/src/slack/monitor/media.ts +++ b/src/slack/monitor/media.ts @@ -90,6 +90,12 @@ export type SlackThreadStarter = { files?: SlackFile[]; }; +export type SlackThreadReply = { + text: string; + userId?: string; + ts?: string; +}; + const THREAD_STARTER_CACHE = new Map(); export async function resolveSlackThreadStarter(params: { @@ -122,3 +128,50 @@ export async function resolveSlackThreadStarter(params: { return null; } } + +/** + * Fetches recent thread replies (excluding the thread starter and a specific message). + * Returns messages in chronological order (oldest first). + */ +export async function resolveSlackThreadReplies(params: { + channelId: string; + threadTs: string; + client: SlackWebClient; + /** Message ts to exclude (usually the current inbound message). */ + excludeTs?: string; + /** Maximum number of replies to fetch (default: 10). */ + limit?: number; +}): Promise { + const limit = params.limit ?? 10; + try { + const response = (await params.client.conversations.replies({ + channel: params.channelId, + ts: params.threadTs, + // Fetch more than limit to account for exclusions. + limit: limit + 2, + inclusive: true, + })) as { messages?: Array<{ text?: string; user?: string; ts?: string }> }; + const messages = response?.messages ?? []; + // Filter out the thread starter (first message) and the excluded message. + const replies = messages + .filter((msg) => { + if (!msg.ts) return false; + // Exclude thread starter (ts === threadTs). + if (msg.ts === params.threadTs) return false; + // Exclude the specified message (usually the current inbound). + if (params.excludeTs && msg.ts === params.excludeTs) return false; + return true; + }) + .map((msg) => ({ + text: (msg.text ?? "").trim(), + userId: msg.user, + ts: msg.ts, + })) + .filter((reply) => reply.text.length > 0); + // Return in chronological order, limited to the specified count. + // conversations.replies returns messages in chronological order by default. + return replies.slice(-limit); + } catch { + return []; + } +} diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 8a2a9e111..d8c45d0f7 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -44,7 +44,11 @@ import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "../allow-li import { resolveSlackEffectiveAllowFrom } from "../auth.js"; import { resolveSlackChannelConfig } from "../channel-config.js"; import { normalizeSlackChannelType, type SlackMonitorContext } from "../context.js"; -import { resolveSlackMedia, resolveSlackThreadStarter } from "../media.js"; +import { + resolveSlackMedia, + resolveSlackThreadReplies, + resolveSlackThreadStarter, +} from "../media.js"; import type { PreparedSlackMessage } from "./types.js"; @@ -452,6 +456,7 @@ export async function prepareSlackMessage(params: { systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; let threadStarterBody: string | undefined; + let threadRepliesBody: string | undefined; let threadLabel: string | undefined; let threadStarterMedia: Awaited> = null; if (isThreadReply && threadTs) { @@ -489,6 +494,37 @@ export async function prepareSlackMessage(params: { } else { threadLabel = `Slack thread ${roomLabel}`; } + + // Fetch recent thread replies (excluding starter and current message). + // Respect historyLimit=0 to disable context injection. + if (ctx.historyLimit > 0) { + const threadReplies = await resolveSlackThreadReplies({ + channelId: message.channel, + threadTs, + client: ctx.app.client, + excludeTs: message.ts, + limit: ctx.historyLimit, + }); + if (threadReplies.length > 0) { + const formattedReplies = await Promise.all( + threadReplies.map(async (reply) => { + const replyUser = reply.userId ? await ctx.resolveUserName(reply.userId) : null; + const replyName = replyUser?.name ?? reply.userId ?? "Unknown"; + const replyWithId = `${reply.text}\n[slack message id: ${reply.ts ?? "unknown"} channel: ${message.channel}]`; + return formatInboundEnvelope({ + channel: "Slack", + from: roomLabel, + timestamp: reply.ts ? Math.round(Number(reply.ts) * 1000) : undefined, + body: replyWithId, + chatType: "channel", + senderLabel: replyName, + envelope: envelopeOptions, + }); + }), + ); + threadRepliesBody = formattedReplies.join("\n\n"); + } + } } // Use thread starter media if current message has none @@ -516,6 +552,7 @@ export async function prepareSlackMessage(params: { MessageThreadId: threadContext.messageThreadId, ParentSessionKey: threadKeys.parentSessionKey, ThreadStarterBody: threadStarterBody, + ThreadRepliesBody: threadRepliesBody, ThreadLabel: threadLabel, Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined, WasMentioned: isRoomish ? effectiveWasMentioned : undefined,