Merge pull request #464 from austinm911/fix/slack-thread-replies
feat(slack): implement configurable reply threading
This commit is contained in:
@@ -22,6 +22,14 @@ export function createClawdbotTools(options?: {
|
||||
agentDir?: string;
|
||||
sandboxed?: boolean;
|
||||
config?: ClawdbotConfig;
|
||||
/** Current channel ID for auto-threading (Slack). */
|
||||
currentChannelId?: string;
|
||||
/** Current thread timestamp for auto-threading (Slack). */
|
||||
currentThreadTs?: string;
|
||||
/** Reply-to mode for Slack auto-threading. */
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
||||
hasRepliedRef?: { value: boolean };
|
||||
}): AnyAgentTool[] {
|
||||
const imageTool = createImageTool({
|
||||
config: options?.config,
|
||||
@@ -35,6 +43,10 @@ export function createClawdbotTools(options?: {
|
||||
createMessageTool({
|
||||
agentAccountId: options?.agentAccountId,
|
||||
config: options?.config,
|
||||
currentChannelId: options?.currentChannelId,
|
||||
currentThreadTs: options?.currentThreadTs,
|
||||
replyToMode: options?.replyToMode,
|
||||
hasRepliedRef: options?.hasRepliedRef,
|
||||
}),
|
||||
createGatewayTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
|
||||
@@ -869,6 +869,7 @@ export async function compactEmbeddedPiSession(params: {
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
agentDir,
|
||||
config: params.config,
|
||||
// No currentChannelId/currentThreadTs for compaction - not in message context
|
||||
});
|
||||
const machineName = await getMachineDisplayName();
|
||||
const runtimeProvider = normalizeMessageProvider(
|
||||
@@ -1000,6 +1001,14 @@ export async function runEmbeddedPiAgent(params: {
|
||||
sessionKey?: string;
|
||||
messageProvider?: string;
|
||||
agentAccountId?: string;
|
||||
/** Current channel ID for auto-threading (Slack). */
|
||||
currentChannelId?: string;
|
||||
/** Current thread timestamp for auto-threading (Slack). */
|
||||
currentThreadTs?: string;
|
||||
/** Reply-to mode for Slack auto-threading. */
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
||||
hasRepliedRef?: { value: boolean };
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
agentDir?: string;
|
||||
@@ -1201,6 +1210,10 @@ export async function runEmbeddedPiAgent(params: {
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
agentDir,
|
||||
config: params.config,
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentThreadTs: params.currentThreadTs,
|
||||
replyToMode: params.replyToMode,
|
||||
hasRepliedRef: params.hasRepliedRef,
|
||||
});
|
||||
const machineName = await getMachineDisplayName();
|
||||
const runtimeInfo = {
|
||||
|
||||
@@ -510,6 +510,14 @@ export function createClawdbotCodingTools(options?: {
|
||||
sessionKey?: string;
|
||||
agentDir?: string;
|
||||
config?: ClawdbotConfig;
|
||||
/** Current channel ID for auto-threading (Slack). */
|
||||
currentChannelId?: string;
|
||||
/** Current thread timestamp for auto-threading (Slack). */
|
||||
currentThreadTs?: string;
|
||||
/** Reply-to mode for Slack auto-threading. */
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
||||
hasRepliedRef?: { value: boolean };
|
||||
}): AnyAgentTool[] {
|
||||
const bashToolName = "bash";
|
||||
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
|
||||
@@ -580,6 +588,10 @@ export function createClawdbotCodingTools(options?: {
|
||||
agentDir: options?.agentDir,
|
||||
sandboxed: !!sandbox,
|
||||
config: options?.config,
|
||||
currentChannelId: options?.currentChannelId,
|
||||
currentThreadTs: options?.currentThreadTs,
|
||||
replyToMode: options?.replyToMode,
|
||||
hasRepliedRef: options?.hasRepliedRef,
|
||||
}),
|
||||
];
|
||||
const toolsFiltered = effectiveToolsPolicy
|
||||
|
||||
@@ -134,6 +134,14 @@ const MessageToolSchema = Type.Object({
|
||||
type MessageToolOptions = {
|
||||
agentAccountId?: string;
|
||||
config?: ClawdbotConfig;
|
||||
/** Current channel ID for auto-threading (Slack). */
|
||||
currentChannelId?: string;
|
||||
/** Current thread timestamp for auto-threading (Slack). */
|
||||
currentThreadTs?: string;
|
||||
/** Reply-to mode for Slack auto-threading. */
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
||||
hasRepliedRef?: { value: boolean };
|
||||
};
|
||||
|
||||
function hasTelegramInlineButtons(cfg: ClawdbotConfig): boolean {
|
||||
@@ -385,6 +393,12 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
||||
threadTs: threadId ?? replyTo ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
{
|
||||
currentChannelId: options?.currentChannelId,
|
||||
currentThreadTs: options?.currentThreadTs,
|
||||
replyToMode: options?.replyToMode,
|
||||
hasRepliedRef: options?.hasRepliedRef,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (provider === "telegram") {
|
||||
|
||||
@@ -110,4 +110,252 @@ describe("handleSlackAction", () => {
|
||||
),
|
||||
).rejects.toThrow(/Slack reactions are disabled/);
|
||||
});
|
||||
|
||||
it("passes threadTs to sendSlackMessage for thread replies", async () => {
|
||||
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
|
||||
await handleSlackAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to: "channel:C123",
|
||||
content: "Hello thread",
|
||||
threadTs: "1234567890.123456",
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
expect(sendSlackMessage).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"Hello thread",
|
||||
{
|
||||
mediaUrl: undefined,
|
||||
threadTs: "1234567890.123456",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-injects threadTs from context when replyToMode=all", async () => {
|
||||
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
|
||||
sendSlackMessage.mockClear();
|
||||
await handleSlackAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to: "channel:C123",
|
||||
content: "Auto-threaded",
|
||||
},
|
||||
cfg,
|
||||
{
|
||||
currentChannelId: "C123",
|
||||
currentThreadTs: "1111111111.111111",
|
||||
replyToMode: "all",
|
||||
},
|
||||
);
|
||||
expect(sendSlackMessage).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"Auto-threaded",
|
||||
{
|
||||
mediaUrl: undefined,
|
||||
threadTs: "1111111111.111111",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("replyToMode=first threads first message then stops", async () => {
|
||||
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
|
||||
sendSlackMessage.mockClear();
|
||||
const hasRepliedRef = { value: false };
|
||||
const context = {
|
||||
currentChannelId: "C123",
|
||||
currentThreadTs: "1111111111.111111",
|
||||
replyToMode: "first" as const,
|
||||
hasRepliedRef,
|
||||
};
|
||||
|
||||
// First message should be threaded
|
||||
await handleSlackAction(
|
||||
{ action: "sendMessage", to: "channel:C123", content: "First" },
|
||||
cfg,
|
||||
context,
|
||||
);
|
||||
expect(sendSlackMessage).toHaveBeenLastCalledWith("channel:C123", "First", {
|
||||
mediaUrl: undefined,
|
||||
threadTs: "1111111111.111111",
|
||||
});
|
||||
expect(hasRepliedRef.value).toBe(true);
|
||||
|
||||
// Second message should NOT be threaded
|
||||
await handleSlackAction(
|
||||
{ action: "sendMessage", to: "channel:C123", content: "Second" },
|
||||
cfg,
|
||||
context,
|
||||
);
|
||||
expect(sendSlackMessage).toHaveBeenLastCalledWith(
|
||||
"channel:C123",
|
||||
"Second",
|
||||
{
|
||||
mediaUrl: undefined,
|
||||
threadTs: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("replyToMode=first marks hasRepliedRef even when threadTs is explicit", async () => {
|
||||
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
|
||||
sendSlackMessage.mockClear();
|
||||
const hasRepliedRef = { value: false };
|
||||
const context = {
|
||||
currentChannelId: "C123",
|
||||
currentThreadTs: "1111111111.111111",
|
||||
replyToMode: "first" as const,
|
||||
hasRepliedRef,
|
||||
};
|
||||
|
||||
await handleSlackAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to: "channel:C123",
|
||||
content: "Explicit",
|
||||
threadTs: "2222222222.222222",
|
||||
},
|
||||
cfg,
|
||||
context,
|
||||
);
|
||||
expect(sendSlackMessage).toHaveBeenLastCalledWith(
|
||||
"channel:C123",
|
||||
"Explicit",
|
||||
{
|
||||
mediaUrl: undefined,
|
||||
threadTs: "2222222222.222222",
|
||||
},
|
||||
);
|
||||
expect(hasRepliedRef.value).toBe(true);
|
||||
|
||||
await handleSlackAction(
|
||||
{ action: "sendMessage", to: "channel:C123", content: "Second" },
|
||||
cfg,
|
||||
context,
|
||||
);
|
||||
expect(sendSlackMessage).toHaveBeenLastCalledWith(
|
||||
"channel:C123",
|
||||
"Second",
|
||||
{
|
||||
mediaUrl: undefined,
|
||||
threadTs: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("replyToMode=first without hasRepliedRef does not thread", async () => {
|
||||
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
|
||||
sendSlackMessage.mockClear();
|
||||
await handleSlackAction(
|
||||
{ action: "sendMessage", to: "channel:C123", content: "No ref" },
|
||||
cfg,
|
||||
{
|
||||
currentChannelId: "C123",
|
||||
currentThreadTs: "1111111111.111111",
|
||||
replyToMode: "first",
|
||||
// no hasRepliedRef
|
||||
},
|
||||
);
|
||||
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "No ref", {
|
||||
mediaUrl: undefined,
|
||||
threadTs: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not auto-inject threadTs when replyToMode=off", async () => {
|
||||
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
|
||||
sendSlackMessage.mockClear();
|
||||
await handleSlackAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to: "channel:C123",
|
||||
content: "Off mode",
|
||||
},
|
||||
cfg,
|
||||
{
|
||||
currentChannelId: "C123",
|
||||
currentThreadTs: "1111111111.111111",
|
||||
replyToMode: "off",
|
||||
},
|
||||
);
|
||||
expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Off mode", {
|
||||
mediaUrl: undefined,
|
||||
threadTs: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not auto-inject threadTs when sending to different channel", async () => {
|
||||
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
|
||||
sendSlackMessage.mockClear();
|
||||
await handleSlackAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to: "channel:C999",
|
||||
content: "Different channel",
|
||||
},
|
||||
cfg,
|
||||
{
|
||||
currentChannelId: "C123",
|
||||
currentThreadTs: "1111111111.111111",
|
||||
replyToMode: "all",
|
||||
},
|
||||
);
|
||||
expect(sendSlackMessage).toHaveBeenCalledWith(
|
||||
"channel:C999",
|
||||
"Different channel",
|
||||
{
|
||||
mediaUrl: undefined,
|
||||
threadTs: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("explicit threadTs overrides context threadTs", async () => {
|
||||
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
|
||||
sendSlackMessage.mockClear();
|
||||
await handleSlackAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to: "channel:C123",
|
||||
content: "Explicit thread",
|
||||
threadTs: "2222222222.222222",
|
||||
},
|
||||
cfg,
|
||||
{
|
||||
currentChannelId: "C123",
|
||||
currentThreadTs: "1111111111.111111",
|
||||
replyToMode: "all",
|
||||
},
|
||||
);
|
||||
expect(sendSlackMessage).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"Explicit thread",
|
||||
{
|
||||
mediaUrl: undefined,
|
||||
threadTs: "2222222222.222222",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("handles channel target without prefix when replyToMode=all", async () => {
|
||||
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
|
||||
sendSlackMessage.mockClear();
|
||||
await handleSlackAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to: "C123",
|
||||
content: "No prefix",
|
||||
},
|
||||
cfg,
|
||||
{
|
||||
currentChannelId: "C123",
|
||||
currentThreadTs: "1111111111.111111",
|
||||
replyToMode: "all",
|
||||
},
|
||||
);
|
||||
expect(sendSlackMessage).toHaveBeenCalledWith("C123", "No prefix", {
|
||||
mediaUrl: undefined,
|
||||
threadTs: "1111111111.111111",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,9 +34,60 @@ const messagingActions = new Set([
|
||||
const reactionsActions = new Set(["react", "reactions"]);
|
||||
const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]);
|
||||
|
||||
export type SlackActionContext = {
|
||||
/** Current channel ID for auto-threading. */
|
||||
currentChannelId?: string;
|
||||
/** Current thread timestamp for auto-threading. */
|
||||
currentThreadTs?: string;
|
||||
/** Reply-to mode for auto-threading. */
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
||||
hasRepliedRef?: { value: boolean };
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve threadTs for a Slack message based on context and replyToMode.
|
||||
* - "all": always inject threadTs
|
||||
* - "first": inject only for first message (updates hasRepliedRef)
|
||||
* - "off": never auto-inject
|
||||
*/
|
||||
function resolveThreadTsFromContext(
|
||||
explicitThreadTs: string | undefined,
|
||||
targetChannel: string,
|
||||
context: SlackActionContext | undefined,
|
||||
): string | undefined {
|
||||
// Agent explicitly provided threadTs - use it
|
||||
if (explicitThreadTs) return explicitThreadTs;
|
||||
// No context or missing required fields
|
||||
if (!context?.currentThreadTs || !context?.currentChannelId) return undefined;
|
||||
|
||||
// Normalize target (strip "channel:" prefix if present)
|
||||
const normalizedTarget = targetChannel.startsWith("channel:")
|
||||
? targetChannel.slice("channel:".length)
|
||||
: targetChannel;
|
||||
|
||||
// Different channel - don't inject
|
||||
if (normalizedTarget !== context.currentChannelId) return undefined;
|
||||
|
||||
// Check replyToMode
|
||||
if (context.replyToMode === "all") {
|
||||
return context.currentThreadTs;
|
||||
}
|
||||
if (
|
||||
context.replyToMode === "first" &&
|
||||
context.hasRepliedRef &&
|
||||
!context.hasRepliedRef.value
|
||||
) {
|
||||
context.hasRepliedRef.value = true;
|
||||
return context.currentThreadTs;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function handleSlackAction(
|
||||
params: Record<string, unknown>,
|
||||
cfg: ClawdbotConfig,
|
||||
context?: SlackActionContext,
|
||||
): Promise<AgentToolResult<unknown>> {
|
||||
const action = readStringParam(params, "action", { required: true });
|
||||
const accountId = readStringParam(params, "accountId");
|
||||
@@ -91,12 +142,29 @@ export async function handleSlackAction(
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const content = readStringParam(params, "content", { required: true });
|
||||
const mediaUrl = readStringParam(params, "mediaUrl");
|
||||
const threadTs = readStringParam(params, "threadTs");
|
||||
const threadTs = resolveThreadTsFromContext(
|
||||
readStringParam(params, "threadTs"),
|
||||
to,
|
||||
context,
|
||||
);
|
||||
const result = await sendSlackMessage(to, content, {
|
||||
accountId: accountId ?? undefined,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
threadTs: threadTs ?? undefined,
|
||||
});
|
||||
|
||||
// Keep "first" mode consistent even when the agent explicitly provided
|
||||
// threadTs: once we send a message to the current channel, consider the
|
||||
// first reply "used" so later tool calls don't auto-thread again.
|
||||
if (context?.hasRepliedRef && context.currentChannelId) {
|
||||
const normalizedTarget = to.startsWith("channel:")
|
||||
? to.slice("channel:".length)
|
||||
: to;
|
||||
if (normalizedTarget === context.currentChannelId) {
|
||||
context.hasRepliedRef.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
return jsonResult({ ok: true, result });
|
||||
}
|
||||
case "editMessage": {
|
||||
|
||||
@@ -9,6 +9,14 @@ import { SlackToolSchema } from "./slack-schema.js";
|
||||
type SlackToolOptions = {
|
||||
agentAccountId?: string;
|
||||
config?: ClawdbotConfig;
|
||||
/** Current channel ID for auto-threading. */
|
||||
currentChannelId?: string;
|
||||
/** Current thread timestamp for auto-threading. */
|
||||
currentThreadTs?: string;
|
||||
/** Reply-to mode for auto-threading. */
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
||||
hasRepliedRef?: { value: boolean };
|
||||
};
|
||||
|
||||
function resolveAgentAccountId(value?: string): string | undefined {
|
||||
@@ -63,7 +71,12 @@ export function createSlackTool(options?: SlackToolOptions): AnyAgentTool {
|
||||
).trim()}`,
|
||||
);
|
||||
}
|
||||
return await handleSlackAction(resolvedParams, cfg);
|
||||
return await handleSlackAction(resolvedParams, cfg, {
|
||||
currentChannelId: options?.currentChannelId,
|
||||
currentThreadTs: options?.currentThreadTs,
|
||||
replyToMode: options?.replyToMode,
|
||||
hasRepliedRef: options?.hasRepliedRef,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user