This commit is contained in:
tumf 2026-01-30 16:49:48 +09:00 committed by GitHub
commit 5fbdb8e3cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 169 additions and 4 deletions

View File

@ -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",
});
});
});

View File

@ -337,9 +337,11 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
limit,
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
threadId: readStringParam(params, "threadId"),
accountId: accountId ?? undefined,
},
cfg,
toolContext,
);
}

View File

@ -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.",
}),
),
};
}

View File

@ -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(

View File

@ -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."

View File

@ -29,6 +29,7 @@ export function finalizeInboundContext<T extends Record<string, unknown>>(
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)) {

View File

@ -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;

View File

@ -137,6 +137,7 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap
accountId: accountId ?? undefined,
},
cfg,
toolContext,
);
}

View File

@ -90,6 +90,12 @@ export type SlackThreadStarter = {
files?: SlackFile[];
};
export type SlackThreadReply = {
text: string;
userId?: string;
ts?: string;
};
const THREAD_STARTER_CACHE = new Map<string, SlackThreadStarter>();
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<SlackThreadReply[]> {
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 [];
}
}

View File

@ -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<ReturnType<typeof resolveSlackMedia>> = 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,