refactor: unify threading contexts
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
resolveDefaultSlackAccountId,
|
||||
resolveSlackAccount,
|
||||
resolveSlackGroupRequireMention,
|
||||
buildSlackThreadingToolContext,
|
||||
setAccountEnabledInConfigSection,
|
||||
slackOnboardingAdapter,
|
||||
SlackConfigSchema,
|
||||
@@ -164,18 +165,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
resolveReplyToMode: ({ cfg, accountId }) =>
|
||||
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
||||
allowTagsWhenOff: true,
|
||||
buildToolContext: ({ cfg, accountId, context, hasRepliedRef }) => {
|
||||
const configuredReplyToMode = resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off";
|
||||
const effectiveReplyToMode = context.ThreadLabel ? "all" : configuredReplyToMode;
|
||||
return {
|
||||
currentChannelId: context.To?.startsWith("channel:")
|
||||
? context.To.slice("channel:".length)
|
||||
: undefined,
|
||||
currentThreadTs: context.ReplyToId,
|
||||
replyToMode: effectiveReplyToMode,
|
||||
hasRepliedRef,
|
||||
};
|
||||
},
|
||||
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeSlackMessagingTarget,
|
||||
|
||||
@@ -87,4 +87,21 @@ describe("buildThreadingToolContext", () => {
|
||||
|
||||
expect(result.currentChannelId).toBe("chat_id:7");
|
||||
});
|
||||
|
||||
it("prefers MessageThreadId for Slack tool threading", () => {
|
||||
const sessionCtx = {
|
||||
Provider: "slack",
|
||||
To: "channel:C1",
|
||||
MessageThreadId: "123.456",
|
||||
} as TemplateContext;
|
||||
|
||||
const result = buildThreadingToolContext({
|
||||
sessionCtx,
|
||||
config: { channels: { slack: { replyToMode: "all" } } } as ClawdbotConfig,
|
||||
hasRepliedRef: undefined,
|
||||
});
|
||||
|
||||
expect(result.currentChannelId).toBe("C1");
|
||||
expect(result.currentThreadTs).toBe("123.456");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,18 +24,11 @@ export function buildThreadingToolContext(params: {
|
||||
const rawProvider = sessionCtx.Provider?.trim().toLowerCase();
|
||||
if (!rawProvider) return {};
|
||||
const provider = normalizeChannelId(rawProvider);
|
||||
// WhatsApp context isolation keys off conversation id, not the bot's own number.
|
||||
const threadingTo =
|
||||
rawProvider === "whatsapp"
|
||||
? (sessionCtx.From ?? sessionCtx.To)
|
||||
: rawProvider === "imessage" && sessionCtx.ChatType === "direct"
|
||||
? (sessionCtx.From ?? sessionCtx.To)
|
||||
: sessionCtx.To;
|
||||
// Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init)
|
||||
const dock = provider ? getChannelDock(provider) : undefined;
|
||||
if (!dock?.threading?.buildToolContext) {
|
||||
return {
|
||||
currentChannelId: threadingTo?.trim() || undefined,
|
||||
currentChannelId: sessionCtx.To?.trim() || undefined,
|
||||
currentChannelProvider: provider ?? (rawProvider as ChannelId),
|
||||
hasRepliedRef,
|
||||
};
|
||||
@@ -46,7 +39,9 @@ export function buildThreadingToolContext(params: {
|
||||
accountId: sessionCtx.AccountId,
|
||||
context: {
|
||||
Channel: sessionCtx.Provider,
|
||||
To: threadingTo,
|
||||
From: sessionCtx.From,
|
||||
To: sessionCtx.To,
|
||||
ChatType: sessionCtx.ChatType,
|
||||
ReplyToId: sessionCtx.ReplyToId,
|
||||
ThreadLabel: sessionCtx.ThreadLabel,
|
||||
MessageThreadId: sessionCtx.MessageThreadId,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { resolveDiscordAccount } from "../discord/accounts.js";
|
||||
import { resolveIMessageAccount } from "../imessage/accounts.js";
|
||||
import { resolveSignalAccount } from "../signal/accounts.js";
|
||||
import { resolveSlackAccount } from "../slack/accounts.js";
|
||||
import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
|
||||
import { resolveTelegramAccount } from "../telegram/accounts.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import { resolveWhatsAppAccount } from "../web/accounts.js";
|
||||
@@ -150,11 +151,14 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
},
|
||||
},
|
||||
threading: {
|
||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
||||
currentChannelId: context.To?.trim() || undefined,
|
||||
currentThreadTs: context.ReplyToId,
|
||||
hasRepliedRef,
|
||||
}),
|
||||
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||
const channelId = context.From?.trim() || context.To?.trim() || undefined;
|
||||
return {
|
||||
currentChannelId: channelId,
|
||||
currentThreadTs: context.ReplyToId,
|
||||
hasRepliedRef,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
discord: {
|
||||
@@ -221,18 +225,7 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
resolveReplyToMode: ({ cfg, accountId }) =>
|
||||
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
||||
allowTagsWhenOff: true,
|
||||
buildToolContext: ({ cfg, accountId, context, hasRepliedRef }) => {
|
||||
const configuredReplyToMode = resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off";
|
||||
const effectiveReplyToMode = context.ThreadLabel ? "all" : configuredReplyToMode;
|
||||
return {
|
||||
currentChannelId: context.To?.startsWith("channel:")
|
||||
? context.To.slice("channel:".length)
|
||||
: undefined,
|
||||
currentThreadTs: context.ReplyToId,
|
||||
replyToMode: effectiveReplyToMode,
|
||||
hasRepliedRef,
|
||||
};
|
||||
},
|
||||
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||
},
|
||||
},
|
||||
signal: {
|
||||
@@ -259,11 +252,16 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
.filter(Boolean),
|
||||
},
|
||||
threading: {
|
||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
||||
currentChannelId: context.To?.trim() || undefined,
|
||||
currentThreadTs: context.ReplyToId,
|
||||
hasRepliedRef,
|
||||
}),
|
||||
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||
const isDirect = context.ChatType?.toLowerCase() === "direct";
|
||||
const channelId =
|
||||
(isDirect ? (context.From ?? context.To) : context.To)?.trim() || undefined;
|
||||
return {
|
||||
currentChannelId: channelId,
|
||||
currentThreadTs: context.ReplyToId,
|
||||
hasRepliedRef,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
imessage: {
|
||||
@@ -286,11 +284,16 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
resolveRequireMention: resolveIMessageGroupRequireMention,
|
||||
},
|
||||
threading: {
|
||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
||||
currentChannelId: context.To?.trim() || undefined,
|
||||
currentThreadTs: context.ReplyToId,
|
||||
hasRepliedRef,
|
||||
}),
|
||||
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||
const isDirect = context.ChatType?.toLowerCase() === "direct";
|
||||
const channelId =
|
||||
(isDirect ? (context.From ?? context.To) : context.To)?.trim() || undefined;
|
||||
return {
|
||||
currentChannelId: channelId,
|
||||
currentThreadTs: context.ReplyToId,
|
||||
hasRepliedRef,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -210,7 +210,9 @@ export type ChannelThreadingAdapter = {
|
||||
|
||||
export type ChannelThreadingContext = {
|
||||
Channel?: string;
|
||||
From?: string;
|
||||
To?: string;
|
||||
ChatType?: string;
|
||||
ReplyToId?: string;
|
||||
ReplyToIdFull?: string;
|
||||
ThreadLabel?: string;
|
||||
|
||||
@@ -235,6 +235,7 @@ export {
|
||||
looksLikeSlackTargetId,
|
||||
normalizeSlackMessagingTarget,
|
||||
} from "../channels/plugins/normalize/slack.js";
|
||||
export { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
|
||||
|
||||
// Channel: Telegram
|
||||
export {
|
||||
|
||||
145
src/slack/monitor.test-helpers.ts
Normal file
145
src/slack/monitor.test-helpers.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
type SlackHandler = (args: unknown) => Promise<void>;
|
||||
|
||||
const slackTestState = vi.hoisted(() => ({
|
||||
config: {} as Record<string, unknown>,
|
||||
sendMock: vi.fn(),
|
||||
replyMock: vi.fn(),
|
||||
updateLastRouteMock: vi.fn(),
|
||||
reactMock: vi.fn(),
|
||||
readAllowFromStoreMock: vi.fn(),
|
||||
upsertPairingRequestMock: vi.fn(),
|
||||
}));
|
||||
|
||||
export const getSlackTestState = () => slackTestState;
|
||||
|
||||
export const getSlackHandlers = () =>
|
||||
(
|
||||
globalThis as {
|
||||
__slackHandlers?: Map<string, SlackHandler>;
|
||||
}
|
||||
).__slackHandlers;
|
||||
|
||||
export const getSlackClient = () =>
|
||||
(globalThis as { __slackClient?: Record<string, unknown> }).__slackClient;
|
||||
|
||||
export const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
export async function waitForSlackEvent(name: string) {
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
if (getSlackHandlers()?.has(name)) return;
|
||||
await flush();
|
||||
}
|
||||
}
|
||||
|
||||
export const defaultSlackTestConfig = () => ({
|
||||
messages: {
|
||||
responsePrefix: "PFX",
|
||||
ackReaction: "👀",
|
||||
ackReactionScope: "group-mentions",
|
||||
},
|
||||
channels: {
|
||||
slack: {
|
||||
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
||||
groupPolicy: "open",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function resetSlackTestState(config: Record<string, unknown> = defaultSlackTestConfig()) {
|
||||
slackTestState.config = config;
|
||||
slackTestState.sendMock.mockReset().mockResolvedValue(undefined);
|
||||
slackTestState.replyMock.mockReset();
|
||||
slackTestState.updateLastRouteMock.mockReset();
|
||||
slackTestState.reactMock.mockReset();
|
||||
slackTestState.readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
slackTestState.upsertPairingRequestMock.mockReset().mockResolvedValue({
|
||||
code: "PAIRCODE",
|
||||
created: true,
|
||||
});
|
||||
getSlackHandlers()?.clear();
|
||||
}
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => slackTestState.config,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../auto-reply/reply.js", () => ({
|
||||
getReplyFromConfig: (...args: unknown[]) => slackTestState.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[]) => slackTestState.sendMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: (...args: unknown[]) => slackTestState.readAllowFromStoreMock(...args),
|
||||
upsertChannelPairingRequest: (...args: unknown[]) =>
|
||||
slackTestState.upsertPairingRequestMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../config/sessions.js", () => ({
|
||||
resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"),
|
||||
updateLastRoute: (...args: unknown[]) => slackTestState.updateLastRouteMock(...args),
|
||||
resolveSessionKey: vi.fn(),
|
||||
readSessionUpdatedAt: vi.fn(() => undefined),
|
||||
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@slack/bolt", () => {
|
||||
const handlers = new Map<string, SlackHandler>();
|
||||
(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: "dm", is_im: true },
|
||||
}),
|
||||
replies: 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[]) => slackTestState.reactMock(...args),
|
||||
},
|
||||
};
|
||||
(globalThis as { __slackClient?: typeof client }).__slackClient = client;
|
||||
class App {
|
||||
client = client;
|
||||
event(name: string, handler: SlackHandler) {
|
||||
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 } };
|
||||
});
|
||||
@@ -1,143 +1,29 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import {
|
||||
defaultSlackTestConfig,
|
||||
flush,
|
||||
getSlackClient,
|
||||
getSlackHandlers,
|
||||
getSlackTestState,
|
||||
resetSlackTestState,
|
||||
waitForSlackEvent,
|
||||
} from "./monitor.test-helpers.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: "dm", is_im: true },
|
||||
}),
|
||||
replies: 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();
|
||||
}
|
||||
}
|
||||
const slackTestState = getSlackTestState();
|
||||
const { sendMock, replyMock, reactMock, upsertPairingRequestMock } = slackTestState;
|
||||
|
||||
beforeEach(() => {
|
||||
resetInboundDedupe();
|
||||
getSlackHandlers()?.clear();
|
||||
config = {
|
||||
messages: {
|
||||
responsePrefix: "PFX",
|
||||
ackReaction: "👀",
|
||||
ackReactionScope: "group-mentions",
|
||||
},
|
||||
channels: {
|
||||
slack: {
|
||||
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
||||
groupPolicy: "open",
|
||||
},
|
||||
},
|
||||
};
|
||||
sendMock.mockReset().mockResolvedValue(undefined);
|
||||
replyMock.mockReset();
|
||||
updateLastRouteMock.mockReset();
|
||||
reactMock.mockReset();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
resetSlackTestState(defaultSlackTestConfig());
|
||||
});
|
||||
|
||||
describe("monitorSlackProvider tool results", () => {
|
||||
it("forces thread replies when replyToId is set", async () => {
|
||||
replyMock.mockResolvedValue({ text: "forced reply", replyToId: "555" });
|
||||
config = {
|
||||
slackTestState.config = {
|
||||
messages: {
|
||||
responsePrefix: "PFX",
|
||||
ackReaction: "👀",
|
||||
@@ -158,7 +44,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
await waitForSlackEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
@@ -199,7 +85,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
await waitForSlackEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
@@ -226,12 +112,12 @@ describe("monitorSlackProvider tool results", () => {
|
||||
});
|
||||
|
||||
it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => {
|
||||
config = {
|
||||
...config,
|
||||
slackTestState.config = {
|
||||
...slackTestState.config,
|
||||
channels: {
|
||||
...config.channels,
|
||||
...slackTestState.config.channels,
|
||||
slack: {
|
||||
...config.channels?.slack,
|
||||
...slackTestState.config.channels?.slack,
|
||||
dm: { enabled: true, policy: "pairing", allowFrom: [] },
|
||||
},
|
||||
},
|
||||
@@ -244,7 +130,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
await waitForSlackEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
@@ -271,12 +157,12 @@ describe("monitorSlackProvider tool results", () => {
|
||||
});
|
||||
|
||||
it("does not resend pairing code when a request is already pending", async () => {
|
||||
config = {
|
||||
...config,
|
||||
slackTestState.config = {
|
||||
...slackTestState.config,
|
||||
channels: {
|
||||
...config.channels,
|
||||
...slackTestState.config.channels,
|
||||
slack: {
|
||||
...config.channels?.slack,
|
||||
...slackTestState.config.channels?.slack,
|
||||
dm: { enabled: true, policy: "pairing", allowFrom: [] },
|
||||
},
|
||||
},
|
||||
@@ -292,7 +178,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
await waitForSlackEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
|
||||
@@ -3,137 +3,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js";
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js";
|
||||
import {
|
||||
defaultSlackTestConfig,
|
||||
flush,
|
||||
getSlackTestState,
|
||||
getSlackClient,
|
||||
getSlackHandlers,
|
||||
resetSlackTestState,
|
||||
waitForSlackEvent,
|
||||
} from "./monitor.test-helpers.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: "dm", is_im: true },
|
||||
}),
|
||||
replies: 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();
|
||||
}
|
||||
}
|
||||
const slackTestState = getSlackTestState();
|
||||
const { sendMock, replyMock } = slackTestState;
|
||||
|
||||
beforeEach(() => {
|
||||
resetInboundDedupe();
|
||||
getSlackHandlers()?.clear();
|
||||
config = {
|
||||
messages: {
|
||||
responsePrefix: "PFX",
|
||||
ackReaction: "👀",
|
||||
ackReactionScope: "group-mentions",
|
||||
},
|
||||
channels: {
|
||||
slack: {
|
||||
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
||||
groupPolicy: "open",
|
||||
},
|
||||
},
|
||||
};
|
||||
sendMock.mockReset().mockResolvedValue(undefined);
|
||||
replyMock.mockReset();
|
||||
updateLastRouteMock.mockReset();
|
||||
reactMock.mockReset();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
resetSlackTestState(defaultSlackTestConfig());
|
||||
});
|
||||
|
||||
describe("monitorSlackProvider tool results", () => {
|
||||
@@ -150,7 +36,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
await waitForSlackEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
@@ -190,7 +76,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
await waitForSlackEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
@@ -215,7 +101,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
});
|
||||
|
||||
it("does not derive responsePrefix from routed agent identity when unset", async () => {
|
||||
config = {
|
||||
slackTestState.config = {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
@@ -256,7 +142,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
await waitForSlackEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
@@ -281,7 +167,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
});
|
||||
|
||||
it("preserves RawBody without injecting processed room history", async () => {
|
||||
config = {
|
||||
slackTestState.config = {
|
||||
messages: { ackReactionScope: "group-mentions" },
|
||||
channels: {
|
||||
slack: {
|
||||
@@ -305,7 +191,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
await waitForSlackEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
@@ -344,7 +230,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
});
|
||||
|
||||
it("scopes thread history to the thread by default", async () => {
|
||||
config = {
|
||||
slackTestState.config = {
|
||||
messages: { ackReactionScope: "group-mentions" },
|
||||
channels: {
|
||||
slack: {
|
||||
@@ -368,7 +254,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
await waitForSlackEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
@@ -431,7 +317,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
await waitForSlackEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
@@ -470,7 +356,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
});
|
||||
|
||||
it("accepts channel messages when mentionPatterns match", async () => {
|
||||
config = {
|
||||
slackTestState.config = {
|
||||
messages: {
|
||||
responsePrefix: "PFX",
|
||||
groupChat: { mentionPatterns: ["\\bclawd\\b"] },
|
||||
@@ -491,7 +377,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
await waitForSlackEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
@@ -515,7 +401,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
});
|
||||
|
||||
it("treats replies to bot threads as implicit mentions", async () => {
|
||||
config = {
|
||||
slackTestState.config = {
|
||||
channels: {
|
||||
slack: {
|
||||
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
||||
@@ -532,7 +418,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
await waitForSlackEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
@@ -558,7 +444,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
});
|
||||
|
||||
it("accepts channel messages without mention when channels.slack.requireMention is false", async () => {
|
||||
config = {
|
||||
slackTestState.config = {
|
||||
channels: {
|
||||
slack: {
|
||||
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
||||
@@ -576,7 +462,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
await waitForSlackEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
@@ -610,7 +496,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
await waitForSlackEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
@@ -635,7 +521,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
|
||||
it("threads replies when incoming message is in a thread", async () => {
|
||||
replyMock.mockResolvedValue({ text: "thread reply" });
|
||||
config = {
|
||||
slackTestState.config = {
|
||||
messages: {
|
||||
responsePrefix: "PFX",
|
||||
ackReaction: "👀",
|
||||
@@ -656,7 +542,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
await waitForSlackEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
|
||||
@@ -1,143 +1,29 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import {
|
||||
defaultSlackTestConfig,
|
||||
flush,
|
||||
getSlackClient,
|
||||
getSlackHandlers,
|
||||
getSlackTestState,
|
||||
resetSlackTestState,
|
||||
waitForSlackEvent,
|
||||
} from "./monitor.test-helpers.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: "dm", is_im: true },
|
||||
}),
|
||||
replies: 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();
|
||||
}
|
||||
}
|
||||
const slackTestState = getSlackTestState();
|
||||
const { sendMock, replyMock } = slackTestState;
|
||||
|
||||
beforeEach(() => {
|
||||
resetInboundDedupe();
|
||||
getSlackHandlers()?.clear();
|
||||
config = {
|
||||
messages: {
|
||||
responsePrefix: "PFX",
|
||||
ackReaction: "👀",
|
||||
ackReactionScope: "group-mentions",
|
||||
},
|
||||
channels: {
|
||||
slack: {
|
||||
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
||||
groupPolicy: "open",
|
||||
},
|
||||
},
|
||||
};
|
||||
sendMock.mockReset().mockResolvedValue(undefined);
|
||||
replyMock.mockReset();
|
||||
updateLastRouteMock.mockReset();
|
||||
reactMock.mockReset();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
resetSlackTestState(defaultSlackTestConfig());
|
||||
});
|
||||
|
||||
describe("monitorSlackProvider tool results", () => {
|
||||
it("threads top-level replies when replyToMode is all", async () => {
|
||||
replyMock.mockResolvedValue({ text: "thread reply" });
|
||||
config = {
|
||||
slackTestState.config = {
|
||||
messages: {
|
||||
responsePrefix: "PFX",
|
||||
ackReaction: "👀",
|
||||
@@ -158,7 +44,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
await waitForSlackEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
@@ -191,7 +77,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
await waitForSlackEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
@@ -224,7 +110,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
it("keeps thread parent inheritance opt-in", async () => {
|
||||
replyMock.mockResolvedValue({ text: "thread reply" });
|
||||
|
||||
config = {
|
||||
slackTestState.config = {
|
||||
messages: { responsePrefix: "PFX" },
|
||||
channels: {
|
||||
slack: {
|
||||
@@ -242,7 +128,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
await waitForSlackEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
@@ -286,7 +172,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
});
|
||||
}
|
||||
|
||||
config = {
|
||||
slackTestState.config = {
|
||||
messages: { responsePrefix: "PFX" },
|
||||
channels: {
|
||||
slack: {
|
||||
@@ -303,7 +189,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
await waitForSlackEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
@@ -338,7 +224,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
|
||||
it("scopes thread session keys to the routed agent", async () => {
|
||||
replyMock.mockResolvedValue({ text: "ok" });
|
||||
config = {
|
||||
slackTestState.config = {
|
||||
messages: { responsePrefix: "PFX" },
|
||||
channels: {
|
||||
slack: {
|
||||
@@ -369,7 +255,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
await waitForSlackEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
@@ -400,7 +286,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
|
||||
it("keeps replies in channel root when message is not threaded (replyToMode off)", async () => {
|
||||
replyMock.mockResolvedValue({ text: "root reply" });
|
||||
config = {
|
||||
slackTestState.config = {
|
||||
messages: {
|
||||
responsePrefix: "PFX",
|
||||
ackReaction: "👀",
|
||||
@@ -421,7 +307,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
await waitForSlackEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
@@ -446,7 +332,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
|
||||
it("threads first reply when replyToMode is first and message is not threaded", async () => {
|
||||
replyMock.mockResolvedValue({ text: "first reply" });
|
||||
config = {
|
||||
slackTestState.config = {
|
||||
messages: {
|
||||
responsePrefix: "PFX",
|
||||
ackReaction: "👀",
|
||||
@@ -467,7 +353,7 @@ describe("monitorSlackProvider tool results", () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForEvent("message");
|
||||
await waitForSlackEvent("message");
|
||||
const handler = getSlackHandlers()?.get("message");
|
||||
if (!handler) throw new Error("Slack message handler not registered");
|
||||
|
||||
|
||||
@@ -77,4 +77,72 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
||||
expect(prepared).toBeTruthy();
|
||||
expectInboundContextContract(prepared!.ctxPayload as any);
|
||||
});
|
||||
|
||||
it("sets MessageThreadId for top-level messages when replyToMode=all", async () => {
|
||||
const slackCtx = createSlackMonitorContext({
|
||||
cfg: {
|
||||
channels: { slack: { enabled: true, replyToMode: "all" } },
|
||||
} as ClawdbotConfig,
|
||||
accountId: "default",
|
||||
botToken: "token",
|
||||
app: { client: {} } as App,
|
||||
runtime: {} as RuntimeEnv,
|
||||
botUserId: "B1",
|
||||
teamId: "T1",
|
||||
apiAppId: "A1",
|
||||
historyLimit: 0,
|
||||
sessionScope: "per-sender",
|
||||
mainKey: "main",
|
||||
dmEnabled: true,
|
||||
dmPolicy: "open",
|
||||
allowFrom: [],
|
||||
groupDmEnabled: true,
|
||||
groupDmChannels: [],
|
||||
defaultRequireMention: true,
|
||||
groupPolicy: "open",
|
||||
useAccessGroups: false,
|
||||
reactionMode: "off",
|
||||
reactionAllowlist: [],
|
||||
replyToMode: "all",
|
||||
threadHistoryScope: "thread",
|
||||
threadInheritParent: false,
|
||||
slashCommand: {
|
||||
enabled: false,
|
||||
name: "clawd",
|
||||
sessionPrefix: "slack:slash",
|
||||
ephemeral: true,
|
||||
},
|
||||
textLimit: 4000,
|
||||
ackReactionScope: "group-mentions",
|
||||
mediaMaxBytes: 1024,
|
||||
removeAckAfterReply: false,
|
||||
});
|
||||
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
||||
|
||||
const account: ResolvedSlackAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
botTokenSource: "config",
|
||||
appTokenSource: "config",
|
||||
config: { replyToMode: "all" },
|
||||
};
|
||||
|
||||
const message: SlackMessageEvent = {
|
||||
channel: "D123",
|
||||
channel_type: "im",
|
||||
user: "U1",
|
||||
text: "hi",
|
||||
ts: "1.000",
|
||||
} as SlackMessageEvent;
|
||||
|
||||
const prepared = await prepareSlackMessage({
|
||||
ctx: slackCtx,
|
||||
account,
|
||||
message,
|
||||
opts: { source: "message" },
|
||||
});
|
||||
|
||||
expect(prepared).toBeTruthy();
|
||||
expect(prepared!.ctxPayload.MessageThreadId).toBe("1.000");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ import type { ResolvedSlackAccount } from "../../accounts.js";
|
||||
import { reactSlackMessage } from "../../actions.js";
|
||||
import { sendMessageSlack } from "../../send.js";
|
||||
import type { SlackMessageEvent } from "../../types.js";
|
||||
import { resolveSlackThreadContext } from "../../threading.js";
|
||||
|
||||
import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "../allow-list.js";
|
||||
import { resolveSlackEffectiveAllowFrom } from "../auth.js";
|
||||
@@ -188,9 +189,9 @@ export async function prepareSlackMessage(params: {
|
||||
});
|
||||
|
||||
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 threadContext = resolveSlackThreadContext({ message, replyToMode: ctx.replyToMode });
|
||||
const threadTs = threadContext.incomingThreadTs;
|
||||
const isThreadReply = threadContext.isThreadReply;
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey,
|
||||
threadId: isThreadReply ? threadTs : undefined,
|
||||
@@ -474,9 +475,9 @@ export async function prepareSlackMessage(params: {
|
||||
Provider: "slack" as const,
|
||||
Surface: "slack" as const,
|
||||
MessageSid: message.ts,
|
||||
ReplyToId: message.thread_ts ?? message.ts,
|
||||
// Preserve thread context for routed tool notifications (thread replies only).
|
||||
MessageThreadId: isThreadReply ? threadTs : undefined,
|
||||
ReplyToId: threadContext.replyToId,
|
||||
// Preserve thread context for routed tool notifications.
|
||||
MessageThreadId: threadContext.messageThreadId,
|
||||
ParentSessionKey: threadKeys.parentSessionKey,
|
||||
ThreadStarterBody: threadStarterBody,
|
||||
ThreadLabel: threadLabel,
|
||||
|
||||
29
src/slack/threading-tool-context.ts
Normal file
29
src/slack/threading-tool-context.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type {
|
||||
ChannelThreadingContext,
|
||||
ChannelThreadingToolContext,
|
||||
} from "../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
|
||||
export function buildSlackThreadingToolContext(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
context: ChannelThreadingContext;
|
||||
hasRepliedRef?: { value: boolean };
|
||||
}): ChannelThreadingToolContext {
|
||||
const configuredReplyToMode =
|
||||
resolveSlackAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
}).replyToMode ?? "off";
|
||||
const effectiveReplyToMode = params.context.ThreadLabel ? "all" : configuredReplyToMode;
|
||||
const threadId = params.context.MessageThreadId ?? params.context.ReplyToId;
|
||||
return {
|
||||
currentChannelId: params.context.To?.startsWith("channel:")
|
||||
? params.context.To.slice("channel:".length)
|
||||
: undefined,
|
||||
currentThreadTs: threadId != null ? String(threadId) : undefined,
|
||||
replyToMode: effectiveReplyToMode,
|
||||
hasRepliedRef: params.hasRepliedRef,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveSlackThreadTargets } from "./threading.js";
|
||||
import { resolveSlackThreadContext, resolveSlackThreadTargets } from "./threading.js";
|
||||
|
||||
describe("resolveSlackThreadTargets", () => {
|
||||
it("threads replies when message is already threaded", () => {
|
||||
@@ -45,4 +45,35 @@ describe("resolveSlackThreadTargets", () => {
|
||||
expect(replyThreadTs).toBeUndefined();
|
||||
expect(statusThreadTs).toBe("123");
|
||||
});
|
||||
|
||||
it("sets messageThreadId for top-level messages when replyToMode is all", () => {
|
||||
const context = resolveSlackThreadContext({
|
||||
replyToMode: "all",
|
||||
message: {
|
||||
type: "message",
|
||||
channel: "C1",
|
||||
ts: "123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(context.isThreadReply).toBe(false);
|
||||
expect(context.messageThreadId).toBe("123");
|
||||
expect(context.replyToId).toBe("123");
|
||||
});
|
||||
|
||||
it("prefers thread_ts as messageThreadId for replies", () => {
|
||||
const context = resolveSlackThreadContext({
|
||||
replyToMode: "off",
|
||||
message: {
|
||||
type: "message",
|
||||
channel: "C1",
|
||||
ts: "123",
|
||||
thread_ts: "456",
|
||||
},
|
||||
});
|
||||
|
||||
expect(context.isThreadReply).toBe(true);
|
||||
expect(context.messageThreadId).toBe("456");
|
||||
expect(context.replyToId).toBe("456");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,44 @@
|
||||
import type { ReplyToMode } from "../config/types.js";
|
||||
import type { SlackAppMentionEvent, SlackMessageEvent } from "./types.js";
|
||||
|
||||
export type SlackThreadContext = {
|
||||
incomingThreadTs?: string;
|
||||
messageTs?: string;
|
||||
isThreadReply: boolean;
|
||||
replyToId?: string;
|
||||
messageThreadId?: string;
|
||||
};
|
||||
|
||||
export function resolveSlackThreadContext(params: {
|
||||
message: SlackMessageEvent | SlackAppMentionEvent;
|
||||
replyToMode: ReplyToMode;
|
||||
}): SlackThreadContext {
|
||||
const incomingThreadTs = params.message.thread_ts;
|
||||
const eventTs = params.message.event_ts;
|
||||
const messageTs = params.message.ts ?? eventTs;
|
||||
const hasThreadTs = typeof incomingThreadTs === "string" && incomingThreadTs.length > 0;
|
||||
const isThreadReply =
|
||||
hasThreadTs && (incomingThreadTs !== messageTs || Boolean(params.message.parent_user_id));
|
||||
const replyToId = incomingThreadTs ?? messageTs;
|
||||
const messageThreadId = isThreadReply
|
||||
? incomingThreadTs
|
||||
: params.replyToMode === "all"
|
||||
? messageTs
|
||||
: undefined;
|
||||
return {
|
||||
incomingThreadTs,
|
||||
messageTs,
|
||||
isThreadReply,
|
||||
replyToId,
|
||||
messageThreadId,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSlackThreadTargets(params: {
|
||||
message: SlackMessageEvent | SlackAppMentionEvent;
|
||||
replyToMode: ReplyToMode;
|
||||
}) {
|
||||
const incomingThreadTs = params.message.thread_ts;
|
||||
const eventTs = params.message.event_ts;
|
||||
const messageTs = params.message.ts ?? eventTs;
|
||||
const { incomingThreadTs, messageTs } = resolveSlackThreadContext(params);
|
||||
const replyThreadTs = incomingThreadTs ?? (params.replyToMode === "all" ? messageTs : undefined);
|
||||
const statusThreadTs = replyThreadTs ?? messageTs;
|
||||
return { replyThreadTs, statusThreadTs };
|
||||
|
||||
Reference in New Issue
Block a user