Merge bb5aa53e37 into 6af205a13a
This commit is contained in:
commit
5fbdb8e3cf
48
extensions/slack/src/channel.read-threadid.test.ts
Normal file
48
extensions/slack/src/channel.read-threadid.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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.",
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -137,6 +137,7 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
toolContext,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user