refactor: unify threading contexts
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
|||||||
resolveDefaultSlackAccountId,
|
resolveDefaultSlackAccountId,
|
||||||
resolveSlackAccount,
|
resolveSlackAccount,
|
||||||
resolveSlackGroupRequireMention,
|
resolveSlackGroupRequireMention,
|
||||||
|
buildSlackThreadingToolContext,
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
slackOnboardingAdapter,
|
slackOnboardingAdapter,
|
||||||
SlackConfigSchema,
|
SlackConfigSchema,
|
||||||
@@ -164,18 +165,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
|||||||
resolveReplyToMode: ({ cfg, accountId }) =>
|
resolveReplyToMode: ({ cfg, accountId }) =>
|
||||||
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
||||||
allowTagsWhenOff: true,
|
allowTagsWhenOff: true,
|
||||||
buildToolContext: ({ cfg, accountId, context, hasRepliedRef }) => {
|
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||||
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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
messaging: {
|
messaging: {
|
||||||
normalizeTarget: normalizeSlackMessagingTarget,
|
normalizeTarget: normalizeSlackMessagingTarget,
|
||||||
|
|||||||
@@ -87,4 +87,21 @@ describe("buildThreadingToolContext", () => {
|
|||||||
|
|
||||||
expect(result.currentChannelId).toBe("chat_id:7");
|
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();
|
const rawProvider = sessionCtx.Provider?.trim().toLowerCase();
|
||||||
if (!rawProvider) return {};
|
if (!rawProvider) return {};
|
||||||
const provider = normalizeChannelId(rawProvider);
|
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)
|
// Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init)
|
||||||
const dock = provider ? getChannelDock(provider) : undefined;
|
const dock = provider ? getChannelDock(provider) : undefined;
|
||||||
if (!dock?.threading?.buildToolContext) {
|
if (!dock?.threading?.buildToolContext) {
|
||||||
return {
|
return {
|
||||||
currentChannelId: threadingTo?.trim() || undefined,
|
currentChannelId: sessionCtx.To?.trim() || undefined,
|
||||||
currentChannelProvider: provider ?? (rawProvider as ChannelId),
|
currentChannelProvider: provider ?? (rawProvider as ChannelId),
|
||||||
hasRepliedRef,
|
hasRepliedRef,
|
||||||
};
|
};
|
||||||
@@ -46,7 +39,9 @@ export function buildThreadingToolContext(params: {
|
|||||||
accountId: sessionCtx.AccountId,
|
accountId: sessionCtx.AccountId,
|
||||||
context: {
|
context: {
|
||||||
Channel: sessionCtx.Provider,
|
Channel: sessionCtx.Provider,
|
||||||
To: threadingTo,
|
From: sessionCtx.From,
|
||||||
|
To: sessionCtx.To,
|
||||||
|
ChatType: sessionCtx.ChatType,
|
||||||
ReplyToId: sessionCtx.ReplyToId,
|
ReplyToId: sessionCtx.ReplyToId,
|
||||||
ThreadLabel: sessionCtx.ThreadLabel,
|
ThreadLabel: sessionCtx.ThreadLabel,
|
||||||
MessageThreadId: sessionCtx.MessageThreadId,
|
MessageThreadId: sessionCtx.MessageThreadId,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { resolveDiscordAccount } from "../discord/accounts.js";
|
|||||||
import { resolveIMessageAccount } from "../imessage/accounts.js";
|
import { resolveIMessageAccount } from "../imessage/accounts.js";
|
||||||
import { resolveSignalAccount } from "../signal/accounts.js";
|
import { resolveSignalAccount } from "../signal/accounts.js";
|
||||||
import { resolveSlackAccount } from "../slack/accounts.js";
|
import { resolveSlackAccount } from "../slack/accounts.js";
|
||||||
|
import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
|
||||||
import { resolveTelegramAccount } from "../telegram/accounts.js";
|
import { resolveTelegramAccount } from "../telegram/accounts.js";
|
||||||
import { normalizeE164 } from "../utils.js";
|
import { normalizeE164 } from "../utils.js";
|
||||||
import { resolveWhatsAppAccount } from "../web/accounts.js";
|
import { resolveWhatsAppAccount } from "../web/accounts.js";
|
||||||
@@ -150,11 +151,14 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
threading: {
|
threading: {
|
||||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||||
currentChannelId: context.To?.trim() || undefined,
|
const channelId = context.From?.trim() || context.To?.trim() || undefined;
|
||||||
currentThreadTs: context.ReplyToId,
|
return {
|
||||||
hasRepliedRef,
|
currentChannelId: channelId,
|
||||||
}),
|
currentThreadTs: context.ReplyToId,
|
||||||
|
hasRepliedRef,
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
discord: {
|
discord: {
|
||||||
@@ -221,18 +225,7 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
|||||||
resolveReplyToMode: ({ cfg, accountId }) =>
|
resolveReplyToMode: ({ cfg, accountId }) =>
|
||||||
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
||||||
allowTagsWhenOff: true,
|
allowTagsWhenOff: true,
|
||||||
buildToolContext: ({ cfg, accountId, context, hasRepliedRef }) => {
|
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||||
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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
signal: {
|
signal: {
|
||||||
@@ -259,11 +252,16 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
|||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
},
|
},
|
||||||
threading: {
|
threading: {
|
||||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||||
currentChannelId: context.To?.trim() || undefined,
|
const isDirect = context.ChatType?.toLowerCase() === "direct";
|
||||||
currentThreadTs: context.ReplyToId,
|
const channelId =
|
||||||
hasRepliedRef,
|
(isDirect ? (context.From ?? context.To) : context.To)?.trim() || undefined;
|
||||||
}),
|
return {
|
||||||
|
currentChannelId: channelId,
|
||||||
|
currentThreadTs: context.ReplyToId,
|
||||||
|
hasRepliedRef,
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
imessage: {
|
imessage: {
|
||||||
@@ -286,11 +284,16 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
|||||||
resolveRequireMention: resolveIMessageGroupRequireMention,
|
resolveRequireMention: resolveIMessageGroupRequireMention,
|
||||||
},
|
},
|
||||||
threading: {
|
threading: {
|
||||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||||
currentChannelId: context.To?.trim() || undefined,
|
const isDirect = context.ChatType?.toLowerCase() === "direct";
|
||||||
currentThreadTs: context.ReplyToId,
|
const channelId =
|
||||||
hasRepliedRef,
|
(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 = {
|
export type ChannelThreadingContext = {
|
||||||
Channel?: string;
|
Channel?: string;
|
||||||
|
From?: string;
|
||||||
To?: string;
|
To?: string;
|
||||||
|
ChatType?: string;
|
||||||
ReplyToId?: string;
|
ReplyToId?: string;
|
||||||
ReplyToIdFull?: string;
|
ReplyToIdFull?: string;
|
||||||
ThreadLabel?: string;
|
ThreadLabel?: string;
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ export {
|
|||||||
looksLikeSlackTargetId,
|
looksLikeSlackTargetId,
|
||||||
normalizeSlackMessagingTarget,
|
normalizeSlackMessagingTarget,
|
||||||
} from "../channels/plugins/normalize/slack.js";
|
} from "../channels/plugins/normalize/slack.js";
|
||||||
|
export { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
|
||||||
|
|
||||||
// Channel: Telegram
|
// Channel: Telegram
|
||||||
export {
|
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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
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";
|
import { monitorSlackProvider } from "./monitor.js";
|
||||||
|
|
||||||
const sendMock = vi.fn();
|
const slackTestState = getSlackTestState();
|
||||||
const replyMock = vi.fn();
|
const { sendMock, replyMock, reactMock, upsertPairingRequestMock } = slackTestState;
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetInboundDedupe();
|
resetInboundDedupe();
|
||||||
getSlackHandlers()?.clear();
|
resetSlackTestState(defaultSlackTestConfig());
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("monitorSlackProvider tool results", () => {
|
describe("monitorSlackProvider tool results", () => {
|
||||||
it("forces thread replies when replyToId is set", async () => {
|
it("forces thread replies when replyToId is set", async () => {
|
||||||
replyMock.mockResolvedValue({ text: "forced reply", replyToId: "555" });
|
replyMock.mockResolvedValue({ text: "forced reply", replyToId: "555" });
|
||||||
config = {
|
slackTestState.config = {
|
||||||
messages: {
|
messages: {
|
||||||
responsePrefix: "PFX",
|
responsePrefix: "PFX",
|
||||||
ackReaction: "👀",
|
ackReaction: "👀",
|
||||||
@@ -158,7 +44,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForEvent("message");
|
await waitForSlackEvent("message");
|
||||||
const handler = getSlackHandlers()?.get("message");
|
const handler = getSlackHandlers()?.get("message");
|
||||||
if (!handler) throw new Error("Slack message handler not registered");
|
if (!handler) throw new Error("Slack message handler not registered");
|
||||||
|
|
||||||
@@ -199,7 +85,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForEvent("message");
|
await waitForSlackEvent("message");
|
||||||
const handler = getSlackHandlers()?.get("message");
|
const handler = getSlackHandlers()?.get("message");
|
||||||
if (!handler) throw new Error("Slack message handler not registered");
|
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 () => {
|
it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => {
|
||||||
config = {
|
slackTestState.config = {
|
||||||
...config,
|
...slackTestState.config,
|
||||||
channels: {
|
channels: {
|
||||||
...config.channels,
|
...slackTestState.config.channels,
|
||||||
slack: {
|
slack: {
|
||||||
...config.channels?.slack,
|
...slackTestState.config.channels?.slack,
|
||||||
dm: { enabled: true, policy: "pairing", allowFrom: [] },
|
dm: { enabled: true, policy: "pairing", allowFrom: [] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -244,7 +130,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForEvent("message");
|
await waitForSlackEvent("message");
|
||||||
const handler = getSlackHandlers()?.get("message");
|
const handler = getSlackHandlers()?.get("message");
|
||||||
if (!handler) throw new Error("Slack message handler not registered");
|
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 () => {
|
it("does not resend pairing code when a request is already pending", async () => {
|
||||||
config = {
|
slackTestState.config = {
|
||||||
...config,
|
...slackTestState.config,
|
||||||
channels: {
|
channels: {
|
||||||
...config.channels,
|
...slackTestState.config.channels,
|
||||||
slack: {
|
slack: {
|
||||||
...config.channels?.slack,
|
...slackTestState.config.channels?.slack,
|
||||||
dm: { enabled: true, policy: "pairing", allowFrom: [] },
|
dm: { enabled: true, policy: "pairing", allowFrom: [] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -292,7 +178,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForEvent("message");
|
await waitForSlackEvent("message");
|
||||||
const handler = getSlackHandlers()?.get("message");
|
const handler = getSlackHandlers()?.get("message");
|
||||||
if (!handler) throw new Error("Slack message handler not registered");
|
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 { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js";
|
||||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||||
import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.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";
|
import { monitorSlackProvider } from "./monitor.js";
|
||||||
|
|
||||||
const sendMock = vi.fn();
|
const slackTestState = getSlackTestState();
|
||||||
const replyMock = vi.fn();
|
const { sendMock, replyMock } = slackTestState;
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetInboundDedupe();
|
resetInboundDedupe();
|
||||||
getSlackHandlers()?.clear();
|
resetSlackTestState(defaultSlackTestConfig());
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("monitorSlackProvider tool results", () => {
|
describe("monitorSlackProvider tool results", () => {
|
||||||
@@ -150,7 +36,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForEvent("message");
|
await waitForSlackEvent("message");
|
||||||
const handler = getSlackHandlers()?.get("message");
|
const handler = getSlackHandlers()?.get("message");
|
||||||
if (!handler) throw new Error("Slack message handler not registered");
|
if (!handler) throw new Error("Slack message handler not registered");
|
||||||
|
|
||||||
@@ -190,7 +76,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForEvent("message");
|
await waitForSlackEvent("message");
|
||||||
const handler = getSlackHandlers()?.get("message");
|
const handler = getSlackHandlers()?.get("message");
|
||||||
if (!handler) throw new Error("Slack message handler not registered");
|
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 () => {
|
it("does not derive responsePrefix from routed agent identity when unset", async () => {
|
||||||
config = {
|
slackTestState.config = {
|
||||||
agents: {
|
agents: {
|
||||||
list: [
|
list: [
|
||||||
{
|
{
|
||||||
@@ -256,7 +142,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForEvent("message");
|
await waitForSlackEvent("message");
|
||||||
const handler = getSlackHandlers()?.get("message");
|
const handler = getSlackHandlers()?.get("message");
|
||||||
if (!handler) throw new Error("Slack message handler not registered");
|
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 () => {
|
it("preserves RawBody without injecting processed room history", async () => {
|
||||||
config = {
|
slackTestState.config = {
|
||||||
messages: { ackReactionScope: "group-mentions" },
|
messages: { ackReactionScope: "group-mentions" },
|
||||||
channels: {
|
channels: {
|
||||||
slack: {
|
slack: {
|
||||||
@@ -305,7 +191,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForEvent("message");
|
await waitForSlackEvent("message");
|
||||||
const handler = getSlackHandlers()?.get("message");
|
const handler = getSlackHandlers()?.get("message");
|
||||||
if (!handler) throw new Error("Slack message handler not registered");
|
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 () => {
|
it("scopes thread history to the thread by default", async () => {
|
||||||
config = {
|
slackTestState.config = {
|
||||||
messages: { ackReactionScope: "group-mentions" },
|
messages: { ackReactionScope: "group-mentions" },
|
||||||
channels: {
|
channels: {
|
||||||
slack: {
|
slack: {
|
||||||
@@ -368,7 +254,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForEvent("message");
|
await waitForSlackEvent("message");
|
||||||
const handler = getSlackHandlers()?.get("message");
|
const handler = getSlackHandlers()?.get("message");
|
||||||
if (!handler) throw new Error("Slack message handler not registered");
|
if (!handler) throw new Error("Slack message handler not registered");
|
||||||
|
|
||||||
@@ -431,7 +317,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForEvent("message");
|
await waitForSlackEvent("message");
|
||||||
const handler = getSlackHandlers()?.get("message");
|
const handler = getSlackHandlers()?.get("message");
|
||||||
if (!handler) throw new Error("Slack message handler not registered");
|
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 () => {
|
it("accepts channel messages when mentionPatterns match", async () => {
|
||||||
config = {
|
slackTestState.config = {
|
||||||
messages: {
|
messages: {
|
||||||
responsePrefix: "PFX",
|
responsePrefix: "PFX",
|
||||||
groupChat: { mentionPatterns: ["\\bclawd\\b"] },
|
groupChat: { mentionPatterns: ["\\bclawd\\b"] },
|
||||||
@@ -491,7 +377,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForEvent("message");
|
await waitForSlackEvent("message");
|
||||||
const handler = getSlackHandlers()?.get("message");
|
const handler = getSlackHandlers()?.get("message");
|
||||||
if (!handler) throw new Error("Slack message handler not registered");
|
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 () => {
|
it("treats replies to bot threads as implicit mentions", async () => {
|
||||||
config = {
|
slackTestState.config = {
|
||||||
channels: {
|
channels: {
|
||||||
slack: {
|
slack: {
|
||||||
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
||||||
@@ -532,7 +418,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForEvent("message");
|
await waitForSlackEvent("message");
|
||||||
const handler = getSlackHandlers()?.get("message");
|
const handler = getSlackHandlers()?.get("message");
|
||||||
if (!handler) throw new Error("Slack message handler not registered");
|
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 () => {
|
it("accepts channel messages without mention when channels.slack.requireMention is false", async () => {
|
||||||
config = {
|
slackTestState.config = {
|
||||||
channels: {
|
channels: {
|
||||||
slack: {
|
slack: {
|
||||||
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
||||||
@@ -576,7 +462,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForEvent("message");
|
await waitForSlackEvent("message");
|
||||||
const handler = getSlackHandlers()?.get("message");
|
const handler = getSlackHandlers()?.get("message");
|
||||||
if (!handler) throw new Error("Slack message handler not registered");
|
if (!handler) throw new Error("Slack message handler not registered");
|
||||||
|
|
||||||
@@ -610,7 +496,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForEvent("message");
|
await waitForSlackEvent("message");
|
||||||
const handler = getSlackHandlers()?.get("message");
|
const handler = getSlackHandlers()?.get("message");
|
||||||
if (!handler) throw new Error("Slack message handler not registered");
|
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 () => {
|
it("threads replies when incoming message is in a thread", async () => {
|
||||||
replyMock.mockResolvedValue({ text: "thread reply" });
|
replyMock.mockResolvedValue({ text: "thread reply" });
|
||||||
config = {
|
slackTestState.config = {
|
||||||
messages: {
|
messages: {
|
||||||
responsePrefix: "PFX",
|
responsePrefix: "PFX",
|
||||||
ackReaction: "👀",
|
ackReaction: "👀",
|
||||||
@@ -656,7 +542,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForEvent("message");
|
await waitForSlackEvent("message");
|
||||||
const handler = getSlackHandlers()?.get("message");
|
const handler = getSlackHandlers()?.get("message");
|
||||||
if (!handler) throw new Error("Slack message handler not registered");
|
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 { 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";
|
import { monitorSlackProvider } from "./monitor.js";
|
||||||
|
|
||||||
const sendMock = vi.fn();
|
const slackTestState = getSlackTestState();
|
||||||
const replyMock = vi.fn();
|
const { sendMock, replyMock } = slackTestState;
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetInboundDedupe();
|
resetInboundDedupe();
|
||||||
getSlackHandlers()?.clear();
|
resetSlackTestState(defaultSlackTestConfig());
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("monitorSlackProvider tool results", () => {
|
describe("monitorSlackProvider tool results", () => {
|
||||||
it("threads top-level replies when replyToMode is all", async () => {
|
it("threads top-level replies when replyToMode is all", async () => {
|
||||||
replyMock.mockResolvedValue({ text: "thread reply" });
|
replyMock.mockResolvedValue({ text: "thread reply" });
|
||||||
config = {
|
slackTestState.config = {
|
||||||
messages: {
|
messages: {
|
||||||
responsePrefix: "PFX",
|
responsePrefix: "PFX",
|
||||||
ackReaction: "👀",
|
ackReaction: "👀",
|
||||||
@@ -158,7 +44,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForEvent("message");
|
await waitForSlackEvent("message");
|
||||||
const handler = getSlackHandlers()?.get("message");
|
const handler = getSlackHandlers()?.get("message");
|
||||||
if (!handler) throw new Error("Slack message handler not registered");
|
if (!handler) throw new Error("Slack message handler not registered");
|
||||||
|
|
||||||
@@ -191,7 +77,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForEvent("message");
|
await waitForSlackEvent("message");
|
||||||
const handler = getSlackHandlers()?.get("message");
|
const handler = getSlackHandlers()?.get("message");
|
||||||
if (!handler) throw new Error("Slack message handler not registered");
|
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 () => {
|
it("keeps thread parent inheritance opt-in", async () => {
|
||||||
replyMock.mockResolvedValue({ text: "thread reply" });
|
replyMock.mockResolvedValue({ text: "thread reply" });
|
||||||
|
|
||||||
config = {
|
slackTestState.config = {
|
||||||
messages: { responsePrefix: "PFX" },
|
messages: { responsePrefix: "PFX" },
|
||||||
channels: {
|
channels: {
|
||||||
slack: {
|
slack: {
|
||||||
@@ -242,7 +128,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForEvent("message");
|
await waitForSlackEvent("message");
|
||||||
const handler = getSlackHandlers()?.get("message");
|
const handler = getSlackHandlers()?.get("message");
|
||||||
if (!handler) throw new Error("Slack message handler not registered");
|
if (!handler) throw new Error("Slack message handler not registered");
|
||||||
|
|
||||||
@@ -286,7 +172,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
config = {
|
slackTestState.config = {
|
||||||
messages: { responsePrefix: "PFX" },
|
messages: { responsePrefix: "PFX" },
|
||||||
channels: {
|
channels: {
|
||||||
slack: {
|
slack: {
|
||||||
@@ -303,7 +189,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForEvent("message");
|
await waitForSlackEvent("message");
|
||||||
const handler = getSlackHandlers()?.get("message");
|
const handler = getSlackHandlers()?.get("message");
|
||||||
if (!handler) throw new Error("Slack message handler not registered");
|
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 () => {
|
it("scopes thread session keys to the routed agent", async () => {
|
||||||
replyMock.mockResolvedValue({ text: "ok" });
|
replyMock.mockResolvedValue({ text: "ok" });
|
||||||
config = {
|
slackTestState.config = {
|
||||||
messages: { responsePrefix: "PFX" },
|
messages: { responsePrefix: "PFX" },
|
||||||
channels: {
|
channels: {
|
||||||
slack: {
|
slack: {
|
||||||
@@ -369,7 +255,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForEvent("message");
|
await waitForSlackEvent("message");
|
||||||
const handler = getSlackHandlers()?.get("message");
|
const handler = getSlackHandlers()?.get("message");
|
||||||
if (!handler) throw new Error("Slack message handler not registered");
|
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 () => {
|
it("keeps replies in channel root when message is not threaded (replyToMode off)", async () => {
|
||||||
replyMock.mockResolvedValue({ text: "root reply" });
|
replyMock.mockResolvedValue({ text: "root reply" });
|
||||||
config = {
|
slackTestState.config = {
|
||||||
messages: {
|
messages: {
|
||||||
responsePrefix: "PFX",
|
responsePrefix: "PFX",
|
||||||
ackReaction: "👀",
|
ackReaction: "👀",
|
||||||
@@ -421,7 +307,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForEvent("message");
|
await waitForSlackEvent("message");
|
||||||
const handler = getSlackHandlers()?.get("message");
|
const handler = getSlackHandlers()?.get("message");
|
||||||
if (!handler) throw new Error("Slack message handler not registered");
|
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 () => {
|
it("threads first reply when replyToMode is first and message is not threaded", async () => {
|
||||||
replyMock.mockResolvedValue({ text: "first reply" });
|
replyMock.mockResolvedValue({ text: "first reply" });
|
||||||
config = {
|
slackTestState.config = {
|
||||||
messages: {
|
messages: {
|
||||||
responsePrefix: "PFX",
|
responsePrefix: "PFX",
|
||||||
ackReaction: "👀",
|
ackReaction: "👀",
|
||||||
@@ -467,7 +353,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
abortSignal: controller.signal,
|
abortSignal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitForEvent("message");
|
await waitForSlackEvent("message");
|
||||||
const handler = getSlackHandlers()?.get("message");
|
const handler = getSlackHandlers()?.get("message");
|
||||||
if (!handler) throw new Error("Slack message handler not registered");
|
if (!handler) throw new Error("Slack message handler not registered");
|
||||||
|
|
||||||
|
|||||||
@@ -77,4 +77,72 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
|||||||
expect(prepared).toBeTruthy();
|
expect(prepared).toBeTruthy();
|
||||||
expectInboundContextContract(prepared!.ctxPayload as any);
|
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 { reactSlackMessage } from "../../actions.js";
|
||||||
import { sendMessageSlack } from "../../send.js";
|
import { sendMessageSlack } from "../../send.js";
|
||||||
import type { SlackMessageEvent } from "../../types.js";
|
import type { SlackMessageEvent } from "../../types.js";
|
||||||
|
import { resolveSlackThreadContext } from "../../threading.js";
|
||||||
|
|
||||||
import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "../allow-list.js";
|
import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "../allow-list.js";
|
||||||
import { resolveSlackEffectiveAllowFrom } from "../auth.js";
|
import { resolveSlackEffectiveAllowFrom } from "../auth.js";
|
||||||
@@ -188,9 +189,9 @@ export async function prepareSlackMessage(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const baseSessionKey = route.sessionKey;
|
const baseSessionKey = route.sessionKey;
|
||||||
const threadTs = message.thread_ts;
|
const threadContext = resolveSlackThreadContext({ message, replyToMode: ctx.replyToMode });
|
||||||
const hasThreadTs = typeof threadTs === "string" && threadTs.length > 0;
|
const threadTs = threadContext.incomingThreadTs;
|
||||||
const isThreadReply = hasThreadTs && (threadTs !== message.ts || Boolean(message.parent_user_id));
|
const isThreadReply = threadContext.isThreadReply;
|
||||||
const threadKeys = resolveThreadSessionKeys({
|
const threadKeys = resolveThreadSessionKeys({
|
||||||
baseSessionKey,
|
baseSessionKey,
|
||||||
threadId: isThreadReply ? threadTs : undefined,
|
threadId: isThreadReply ? threadTs : undefined,
|
||||||
@@ -474,9 +475,9 @@ export async function prepareSlackMessage(params: {
|
|||||||
Provider: "slack" as const,
|
Provider: "slack" as const,
|
||||||
Surface: "slack" as const,
|
Surface: "slack" as const,
|
||||||
MessageSid: message.ts,
|
MessageSid: message.ts,
|
||||||
ReplyToId: message.thread_ts ?? message.ts,
|
ReplyToId: threadContext.replyToId,
|
||||||
// Preserve thread context for routed tool notifications (thread replies only).
|
// Preserve thread context for routed tool notifications.
|
||||||
MessageThreadId: isThreadReply ? threadTs : undefined,
|
MessageThreadId: threadContext.messageThreadId,
|
||||||
ParentSessionKey: threadKeys.parentSessionKey,
|
ParentSessionKey: threadKeys.parentSessionKey,
|
||||||
ThreadStarterBody: threadStarterBody,
|
ThreadStarterBody: threadStarterBody,
|
||||||
ThreadLabel: threadLabel,
|
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 { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { resolveSlackThreadTargets } from "./threading.js";
|
import { resolveSlackThreadContext, resolveSlackThreadTargets } from "./threading.js";
|
||||||
|
|
||||||
describe("resolveSlackThreadTargets", () => {
|
describe("resolveSlackThreadTargets", () => {
|
||||||
it("threads replies when message is already threaded", () => {
|
it("threads replies when message is already threaded", () => {
|
||||||
@@ -45,4 +45,35 @@ describe("resolveSlackThreadTargets", () => {
|
|||||||
expect(replyThreadTs).toBeUndefined();
|
expect(replyThreadTs).toBeUndefined();
|
||||||
expect(statusThreadTs).toBe("123");
|
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 { ReplyToMode } from "../config/types.js";
|
||||||
import type { SlackAppMentionEvent, SlackMessageEvent } from "./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: {
|
export function resolveSlackThreadTargets(params: {
|
||||||
message: SlackMessageEvent | SlackAppMentionEvent;
|
message: SlackMessageEvent | SlackAppMentionEvent;
|
||||||
replyToMode: ReplyToMode;
|
replyToMode: ReplyToMode;
|
||||||
}) {
|
}) {
|
||||||
const incomingThreadTs = params.message.thread_ts;
|
const { incomingThreadTs, messageTs } = resolveSlackThreadContext(params);
|
||||||
const eventTs = params.message.event_ts;
|
|
||||||
const messageTs = params.message.ts ?? eventTs;
|
|
||||||
const replyThreadTs = incomingThreadTs ?? (params.replyToMode === "all" ? messageTs : undefined);
|
const replyThreadTs = incomingThreadTs ?? (params.replyToMode === "all" ? messageTs : undefined);
|
||||||
const statusThreadTs = replyThreadTs ?? messageTs;
|
const statusThreadTs = replyThreadTs ?? messageTs;
|
||||||
return { replyThreadTs, statusThreadTs };
|
return { replyThreadTs, statusThreadTs };
|
||||||
|
|||||||
Reference in New Issue
Block a user