Merge pull request #464 from austinm911/fix/slack-thread-replies
feat(slack): implement configurable reply threading
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
- Docs: link Hetzner guide from install + platforms docs. (#592) — thanks @steipete
|
- Docs: link Hetzner guide from install + platforms docs. (#592) — thanks @steipete
|
||||||
- Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc
|
- Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc
|
||||||
- Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro
|
- Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro
|
||||||
|
- Slack: configurable reply threading (`slack.replyToMode`) + proper mrkdwn formatting for outbound messages. (#464) — thanks @austinm911
|
||||||
- Discord: avoid category parent overrides for channel allowlists and refactor thread context helpers. (#588) — thanks @steipete
|
- Discord: avoid category parent overrides for channel allowlists and refactor thread context helpers. (#588) — thanks @steipete
|
||||||
- Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow
|
- Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow
|
||||||
- Discord: log gateway disconnect/reconnect events at info and add verbose gateway metrics. (#595) — thanks @steipete
|
- Discord: log gateway disconnect/reconnect events at info and add verbose gateway metrics. (#595) — thanks @steipete
|
||||||
|
|||||||
@@ -199,12 +199,20 @@ Ack reactions are controlled globally via `messages.ackReaction` +
|
|||||||
- Media uploads are capped by `slack.mediaMaxMb` (default 20).
|
- Media uploads are capped by `slack.mediaMaxMb` (default 20).
|
||||||
|
|
||||||
## Reply threading
|
## Reply threading
|
||||||
Slack supports optional threaded replies via tags:
|
By default, Clawdbot replies in the main channel. Use `slack.replyToMode` to control automatic threading:
|
||||||
- `[[reply_to_current]]` — reply to the triggering message.
|
|
||||||
- `[[reply_to:<id>]]` — reply to a specific message id.
|
|
||||||
|
|
||||||
Controlled by `slack.replyToMode`:
|
| Mode | Behavior |
|
||||||
- `off` (default), `first`, `all`.
|
| --- | --- |
|
||||||
|
| `off` | **Default.** Reply in main channel. Only thread if the triggering message was already in a thread. |
|
||||||
|
| `first` | First reply goes to thread (under the triggering message), subsequent replies go to main channel. Useful for keeping context visible while avoiding thread clutter. |
|
||||||
|
| `all` | All replies go to thread. Keeps conversations contained but may reduce visibility. |
|
||||||
|
|
||||||
|
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
|
||||||
|
|
||||||
|
### Manual threading tags
|
||||||
|
For fine-grained control, use these tags in agent responses:
|
||||||
|
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
|
||||||
|
- `[[reply_to:<id>]]` — reply to a specific message id.
|
||||||
|
|
||||||
## Sessions + routing
|
## Sessions + routing
|
||||||
- DMs share the `main` session (like WhatsApp/Telegram).
|
- DMs share the `main` session (like WhatsApp/Telegram).
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ export function createClawdbotTools(options?: {
|
|||||||
agentDir?: string;
|
agentDir?: string;
|
||||||
sandboxed?: boolean;
|
sandboxed?: boolean;
|
||||||
config?: ClawdbotConfig;
|
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[] {
|
}): AnyAgentTool[] {
|
||||||
const imageTool = createImageTool({
|
const imageTool = createImageTool({
|
||||||
config: options?.config,
|
config: options?.config,
|
||||||
@@ -35,6 +43,10 @@ export function createClawdbotTools(options?: {
|
|||||||
createMessageTool({
|
createMessageTool({
|
||||||
agentAccountId: options?.agentAccountId,
|
agentAccountId: options?.agentAccountId,
|
||||||
config: options?.config,
|
config: options?.config,
|
||||||
|
currentChannelId: options?.currentChannelId,
|
||||||
|
currentThreadTs: options?.currentThreadTs,
|
||||||
|
replyToMode: options?.replyToMode,
|
||||||
|
hasRepliedRef: options?.hasRepliedRef,
|
||||||
}),
|
}),
|
||||||
createGatewayTool({
|
createGatewayTool({
|
||||||
agentSessionKey: options?.agentSessionKey,
|
agentSessionKey: options?.agentSessionKey,
|
||||||
|
|||||||
@@ -869,6 +869,7 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
sessionKey: params.sessionKey ?? params.sessionId,
|
sessionKey: params.sessionKey ?? params.sessionId,
|
||||||
agentDir,
|
agentDir,
|
||||||
config: params.config,
|
config: params.config,
|
||||||
|
// No currentChannelId/currentThreadTs for compaction - not in message context
|
||||||
});
|
});
|
||||||
const machineName = await getMachineDisplayName();
|
const machineName = await getMachineDisplayName();
|
||||||
const runtimeProvider = normalizeMessageProvider(
|
const runtimeProvider = normalizeMessageProvider(
|
||||||
@@ -1000,6 +1001,14 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
messageProvider?: string;
|
messageProvider?: string;
|
||||||
agentAccountId?: 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;
|
sessionFile: string;
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
agentDir?: string;
|
agentDir?: string;
|
||||||
@@ -1201,6 +1210,10 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
sessionKey: params.sessionKey ?? params.sessionId,
|
sessionKey: params.sessionKey ?? params.sessionId,
|
||||||
agentDir,
|
agentDir,
|
||||||
config: params.config,
|
config: params.config,
|
||||||
|
currentChannelId: params.currentChannelId,
|
||||||
|
currentThreadTs: params.currentThreadTs,
|
||||||
|
replyToMode: params.replyToMode,
|
||||||
|
hasRepliedRef: params.hasRepliedRef,
|
||||||
});
|
});
|
||||||
const machineName = await getMachineDisplayName();
|
const machineName = await getMachineDisplayName();
|
||||||
const runtimeInfo = {
|
const runtimeInfo = {
|
||||||
|
|||||||
@@ -510,6 +510,14 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
agentDir?: string;
|
agentDir?: string;
|
||||||
config?: ClawdbotConfig;
|
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[] {
|
}): AnyAgentTool[] {
|
||||||
const bashToolName = "bash";
|
const bashToolName = "bash";
|
||||||
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
|
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
|
||||||
@@ -580,6 +588,10 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
agentDir: options?.agentDir,
|
agentDir: options?.agentDir,
|
||||||
sandboxed: !!sandbox,
|
sandboxed: !!sandbox,
|
||||||
config: options?.config,
|
config: options?.config,
|
||||||
|
currentChannelId: options?.currentChannelId,
|
||||||
|
currentThreadTs: options?.currentThreadTs,
|
||||||
|
replyToMode: options?.replyToMode,
|
||||||
|
hasRepliedRef: options?.hasRepliedRef,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
const toolsFiltered = effectiveToolsPolicy
|
const toolsFiltered = effectiveToolsPolicy
|
||||||
|
|||||||
@@ -134,6 +134,14 @@ const MessageToolSchema = Type.Object({
|
|||||||
type MessageToolOptions = {
|
type MessageToolOptions = {
|
||||||
agentAccountId?: string;
|
agentAccountId?: string;
|
||||||
config?: ClawdbotConfig;
|
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 {
|
function hasTelegramInlineButtons(cfg: ClawdbotConfig): boolean {
|
||||||
@@ -385,6 +393,12 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
|||||||
threadTs: threadId ?? replyTo ?? undefined,
|
threadTs: threadId ?? replyTo ?? undefined,
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
|
{
|
||||||
|
currentChannelId: options?.currentChannelId,
|
||||||
|
currentThreadTs: options?.currentThreadTs,
|
||||||
|
replyToMode: options?.replyToMode,
|
||||||
|
hasRepliedRef: options?.hasRepliedRef,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (provider === "telegram") {
|
if (provider === "telegram") {
|
||||||
|
|||||||
@@ -110,4 +110,252 @@ describe("handleSlackAction", () => {
|
|||||||
),
|
),
|
||||||
).rejects.toThrow(/Slack reactions are disabled/);
|
).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 reactionsActions = new Set(["react", "reactions"]);
|
||||||
const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]);
|
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(
|
export async function handleSlackAction(
|
||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
|
context?: SlackActionContext,
|
||||||
): Promise<AgentToolResult<unknown>> {
|
): Promise<AgentToolResult<unknown>> {
|
||||||
const action = readStringParam(params, "action", { required: true });
|
const action = readStringParam(params, "action", { required: true });
|
||||||
const accountId = readStringParam(params, "accountId");
|
const accountId = readStringParam(params, "accountId");
|
||||||
@@ -91,12 +142,29 @@ export async function handleSlackAction(
|
|||||||
const to = readStringParam(params, "to", { required: true });
|
const to = readStringParam(params, "to", { required: true });
|
||||||
const content = readStringParam(params, "content", { required: true });
|
const content = readStringParam(params, "content", { required: true });
|
||||||
const mediaUrl = readStringParam(params, "mediaUrl");
|
const mediaUrl = readStringParam(params, "mediaUrl");
|
||||||
const threadTs = readStringParam(params, "threadTs");
|
const threadTs = resolveThreadTsFromContext(
|
||||||
|
readStringParam(params, "threadTs"),
|
||||||
|
to,
|
||||||
|
context,
|
||||||
|
);
|
||||||
const result = await sendSlackMessage(to, content, {
|
const result = await sendSlackMessage(to, content, {
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
mediaUrl: mediaUrl ?? undefined,
|
mediaUrl: mediaUrl ?? undefined,
|
||||||
threadTs: threadTs ?? 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 });
|
return jsonResult({ ok: true, result });
|
||||||
}
|
}
|
||||||
case "editMessage": {
|
case "editMessage": {
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ import { SlackToolSchema } from "./slack-schema.js";
|
|||||||
type SlackToolOptions = {
|
type SlackToolOptions = {
|
||||||
agentAccountId?: string;
|
agentAccountId?: string;
|
||||||
config?: ClawdbotConfig;
|
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 {
|
function resolveAgentAccountId(value?: string): string | undefined {
|
||||||
@@ -63,7 +71,12 @@ export function createSlackTool(options?: SlackToolOptions): AnyAgentTool {
|
|||||||
).trim()}`,
|
).trim()}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return await handleSlackAction(resolvedParams, cfg);
|
return await handleSlackAction(resolvedParams, cfg, {
|
||||||
|
currentChannelId: options?.currentChannelId,
|
||||||
|
currentThreadTs: options?.currentThreadTs,
|
||||||
|
replyToMode: options?.replyToMode,
|
||||||
|
hasRepliedRef: options?.hasRepliedRef,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,50 @@ import { createTypingSignaler } from "./typing-mode.js";
|
|||||||
const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i;
|
const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i;
|
||||||
const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000;
|
const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Slack-specific threading context for tool auto-injection.
|
||||||
|
* Returns undefined values for non-Slack providers.
|
||||||
|
*/
|
||||||
|
function buildSlackThreadingContext(params: {
|
||||||
|
sessionCtx: TemplateContext;
|
||||||
|
config: { slack?: { replyToMode?: "off" | "first" | "all" } } | undefined;
|
||||||
|
hasRepliedRef: { value: boolean } | undefined;
|
||||||
|
}): {
|
||||||
|
currentChannelId: string | undefined;
|
||||||
|
currentThreadTs: string | undefined;
|
||||||
|
replyToMode: "off" | "first" | "all" | undefined;
|
||||||
|
hasRepliedRef: { value: boolean } | undefined;
|
||||||
|
} {
|
||||||
|
const { sessionCtx, config, hasRepliedRef } = params;
|
||||||
|
const isSlack = sessionCtx.Provider?.toLowerCase() === "slack";
|
||||||
|
if (!isSlack) {
|
||||||
|
return {
|
||||||
|
currentChannelId: undefined,
|
||||||
|
currentThreadTs: undefined,
|
||||||
|
replyToMode: undefined,
|
||||||
|
hasRepliedRef: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're already inside a thread, never jump replies out of it (even in
|
||||||
|
// replyToMode="off"/"first"). This keeps tool calls consistent with the
|
||||||
|
// auto-reply path.
|
||||||
|
const configuredReplyToMode = config?.slack?.replyToMode ?? "off";
|
||||||
|
const effectiveReplyToMode = sessionCtx.ThreadLabel
|
||||||
|
? ("all" as const)
|
||||||
|
: configuredReplyToMode;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Extract channel from "channel:C123" format
|
||||||
|
currentChannelId: sessionCtx.To?.startsWith("channel:")
|
||||||
|
? sessionCtx.To.slice("channel:".length)
|
||||||
|
: undefined,
|
||||||
|
currentThreadTs: sessionCtx.ReplyToId,
|
||||||
|
replyToMode: effectiveReplyToMode,
|
||||||
|
hasRepliedRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const isBunFetchSocketError = (message?: string) =>
|
const isBunFetchSocketError = (message?: string) =>
|
||||||
Boolean(message && BUN_FETCH_SOCKET_ERROR_RE.test(message));
|
Boolean(message && BUN_FETCH_SOCKET_ERROR_RE.test(message));
|
||||||
|
|
||||||
@@ -375,6 +419,12 @@ export async function runReplyAgent(params: {
|
|||||||
messageProvider:
|
messageProvider:
|
||||||
sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||||
agentAccountId: sessionCtx.AccountId,
|
agentAccountId: sessionCtx.AccountId,
|
||||||
|
// Slack threading context for tool auto-injection
|
||||||
|
...buildSlackThreadingContext({
|
||||||
|
sessionCtx,
|
||||||
|
config: followupRun.run.config,
|
||||||
|
hasRepliedRef: opts?.hasRepliedRef,
|
||||||
|
}),
|
||||||
sessionFile: followupRun.run.sessionFile,
|
sessionFile: followupRun.run.sessionFile,
|
||||||
workspaceDir: followupRun.run.workspaceDir,
|
workspaceDir: followupRun.run.workspaceDir,
|
||||||
agentDir: followupRun.run.agentDir,
|
agentDir: followupRun.run.agentDir,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export type GetReplyOptions = {
|
|||||||
blockReplyTimeoutMs?: number;
|
blockReplyTimeoutMs?: number;
|
||||||
/** If provided, only load these skills for this session (empty = no skills). */
|
/** If provided, only load these skills for this session (empty = no skills). */
|
||||||
skillFilter?: string[];
|
skillFilter?: string[];
|
||||||
|
/** Mutable ref to track if a reply was sent (for Slack "first" threading mode). */
|
||||||
|
hasRepliedRef?: { value: boolean };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ReplyPayload = {
|
export type ReplyPayload = {
|
||||||
|
|||||||
102
src/slack/format.test.ts
Normal file
102
src/slack/format.test.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { markdownToSlackMrkdwn } from "./format.js";
|
||||||
|
|
||||||
|
describe("markdownToSlackMrkdwn", () => {
|
||||||
|
it("converts bold from double asterisks to single", () => {
|
||||||
|
const res = markdownToSlackMrkdwn("**bold text**");
|
||||||
|
expect(res).toBe("*bold text*");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves italic underscore format", () => {
|
||||||
|
const res = markdownToSlackMrkdwn("_italic text_");
|
||||||
|
expect(res).toBe("_italic text_");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts strikethrough from double tilde to single", () => {
|
||||||
|
const res = markdownToSlackMrkdwn("~~strikethrough~~");
|
||||||
|
expect(res).toBe("~strikethrough~");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders basic inline formatting together", () => {
|
||||||
|
const res = markdownToSlackMrkdwn("hi _there_ **boss** `code`");
|
||||||
|
expect(res).toBe("hi _there_ *boss* `code`");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders inline code", () => {
|
||||||
|
const res = markdownToSlackMrkdwn("use `npm install`");
|
||||||
|
expect(res).toBe("use `npm install`");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders fenced code blocks", () => {
|
||||||
|
const res = markdownToSlackMrkdwn("```js\nconst x = 1;\n```");
|
||||||
|
expect(res).toBe("```\nconst x = 1;\n```");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders links with URL in parentheses", () => {
|
||||||
|
const res = markdownToSlackMrkdwn("see [docs](https://example.com)");
|
||||||
|
expect(res).toBe("see docs (https://example.com)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate bare URLs", () => {
|
||||||
|
const res = markdownToSlackMrkdwn("see https://example.com");
|
||||||
|
expect(res).toBe("see https://example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes unsafe characters", () => {
|
||||||
|
const res = markdownToSlackMrkdwn("a & b < c > d");
|
||||||
|
expect(res).toBe("a & b < c > d");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves Slack angle-bracket markup (mentions/links)", () => {
|
||||||
|
const res = markdownToSlackMrkdwn(
|
||||||
|
"hi <@U123> see <https://example.com|docs> and <!here>",
|
||||||
|
);
|
||||||
|
expect(res).toBe("hi <@U123> see <https://example.com|docs> and <!here>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes raw HTML", () => {
|
||||||
|
const res = markdownToSlackMrkdwn("<b>nope</b>");
|
||||||
|
expect(res).toBe("<b>nope</b>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders paragraphs with blank lines", () => {
|
||||||
|
const res = markdownToSlackMrkdwn("first\n\nsecond");
|
||||||
|
expect(res).toBe("first\n\nsecond");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders bullet lists", () => {
|
||||||
|
const res = markdownToSlackMrkdwn("- one\n- two");
|
||||||
|
expect(res).toBe("• one\n• two");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders ordered lists with numbering", () => {
|
||||||
|
const res = markdownToSlackMrkdwn("2. two\n3. three");
|
||||||
|
expect(res).toBe("2. two\n3. three");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders headings as bold text", () => {
|
||||||
|
const res = markdownToSlackMrkdwn("# Title");
|
||||||
|
expect(res).toBe("*Title*");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders blockquotes", () => {
|
||||||
|
const res = markdownToSlackMrkdwn("> Quote");
|
||||||
|
expect(res).toBe("> Quote");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles adjacent list items", () => {
|
||||||
|
const res = markdownToSlackMrkdwn("- item\n - nested");
|
||||||
|
// markdown-it treats indented items as continuation, not nesting
|
||||||
|
expect(res).toBe("• item • nested");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles complex message with multiple elements", () => {
|
||||||
|
const res = markdownToSlackMrkdwn(
|
||||||
|
"**Important:** Check the _docs_ at [link](https://example.com)\n\n- first\n- second",
|
||||||
|
);
|
||||||
|
expect(res).toBe(
|
||||||
|
"*Important:* Check the _docs_ at link (https://example.com)\n\n• first\n• second",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
244
src/slack/format.ts
Normal file
244
src/slack/format.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import MarkdownIt from "markdown-it";
|
||||||
|
|
||||||
|
type ListState = {
|
||||||
|
type: "bullet" | "ordered";
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RenderEnv = {
|
||||||
|
slackListStack?: ListState[];
|
||||||
|
slackLinkStack?: { href: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const md = new MarkdownIt({
|
||||||
|
html: false,
|
||||||
|
// Slack will auto-link plain URLs; keeping linkify off avoids double-rendering
|
||||||
|
// (e.g. "https://x.com" becoming "https://x.com (https://x.com)").
|
||||||
|
linkify: false,
|
||||||
|
breaks: false,
|
||||||
|
typographer: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
md.enable("strikethrough");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape special characters for Slack mrkdwn format.
|
||||||
|
*
|
||||||
|
* By default, Slack uses angle-bracket markup for mentions and links
|
||||||
|
* (e.g. "<@U123>", "<https://…|text>"). We preserve those tokens so agents
|
||||||
|
* can intentionally include them, while escaping other uses of "<" and ">".
|
||||||
|
*/
|
||||||
|
function escapeSlackMrkdwnSegment(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLACK_ANGLE_TOKEN_RE = /<[^>\n]+>/g;
|
||||||
|
|
||||||
|
function isAllowedSlackAngleToken(token: string): boolean {
|
||||||
|
if (!token.startsWith("<") || !token.endsWith(">")) return false;
|
||||||
|
const inner = token.slice(1, -1);
|
||||||
|
return (
|
||||||
|
inner.startsWith("@") ||
|
||||||
|
inner.startsWith("#") ||
|
||||||
|
inner.startsWith("!") ||
|
||||||
|
inner.startsWith("mailto:") ||
|
||||||
|
inner.startsWith("tel:") ||
|
||||||
|
inner.startsWith("http://") ||
|
||||||
|
inner.startsWith("https://") ||
|
||||||
|
inner.startsWith("slack://")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeSlackMrkdwnText(text: string): string {
|
||||||
|
if (!text.includes("&") && !text.includes("<") && !text.includes(">")) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
SLACK_ANGLE_TOKEN_RE.lastIndex = 0;
|
||||||
|
const out: string[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
for (
|
||||||
|
let match = SLACK_ANGLE_TOKEN_RE.exec(text);
|
||||||
|
match;
|
||||||
|
match = SLACK_ANGLE_TOKEN_RE.exec(text)
|
||||||
|
) {
|
||||||
|
const matchIndex = match.index ?? 0;
|
||||||
|
out.push(escapeSlackMrkdwnSegment(text.slice(lastIndex, matchIndex)));
|
||||||
|
const token = match[0] ?? "";
|
||||||
|
out.push(
|
||||||
|
isAllowedSlackAngleToken(token) ? token : escapeSlackMrkdwnSegment(token),
|
||||||
|
);
|
||||||
|
lastIndex = matchIndex + token.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push(escapeSlackMrkdwnSegment(text.slice(lastIndex)));
|
||||||
|
return out.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getListStack(env: RenderEnv): ListState[] {
|
||||||
|
if (!env.slackListStack) env.slackListStack = [];
|
||||||
|
return env.slackListStack;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinkStack(env: RenderEnv): { href: string }[] {
|
||||||
|
if (!env.slackLinkStack) env.slackLinkStack = [];
|
||||||
|
return env.slackLinkStack;
|
||||||
|
}
|
||||||
|
|
||||||
|
md.renderer.rules.text = (tokens, idx) =>
|
||||||
|
escapeSlackMrkdwnText(tokens[idx]?.content ?? "");
|
||||||
|
|
||||||
|
md.renderer.rules.softbreak = () => "\n";
|
||||||
|
md.renderer.rules.hardbreak = () => "\n";
|
||||||
|
|
||||||
|
md.renderer.rules.paragraph_open = () => "";
|
||||||
|
md.renderer.rules.paragraph_close = (_tokens, _idx, _opts, env) => {
|
||||||
|
const stack = getListStack(env as RenderEnv);
|
||||||
|
return stack.length ? "" : "\n\n";
|
||||||
|
};
|
||||||
|
|
||||||
|
md.renderer.rules.heading_open = () => "*";
|
||||||
|
md.renderer.rules.heading_close = () => "*\n\n";
|
||||||
|
|
||||||
|
md.renderer.rules.blockquote_open = () => "> ";
|
||||||
|
md.renderer.rules.blockquote_close = () => "\n";
|
||||||
|
|
||||||
|
md.renderer.rules.bullet_list_open = (_tokens, _idx, _opts, env) => {
|
||||||
|
getListStack(env as RenderEnv).push({ type: "bullet", index: 0 });
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
md.renderer.rules.bullet_list_close = (_tokens, _idx, _opts, env) => {
|
||||||
|
getListStack(env as RenderEnv).pop();
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
md.renderer.rules.ordered_list_open = (tokens, idx, _opts, env) => {
|
||||||
|
const start = Number(tokens[idx]?.attrGet("start") ?? "1");
|
||||||
|
getListStack(env as RenderEnv).push({ type: "ordered", index: start - 1 });
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
md.renderer.rules.ordered_list_close = (_tokens, _idx, _opts, env) => {
|
||||||
|
getListStack(env as RenderEnv).pop();
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
md.renderer.rules.list_item_open = (_tokens, _idx, _opts, env) => {
|
||||||
|
const stack = getListStack(env as RenderEnv);
|
||||||
|
const top = stack[stack.length - 1];
|
||||||
|
if (!top) return "";
|
||||||
|
top.index += 1;
|
||||||
|
const indent = " ".repeat(Math.max(0, stack.length - 1));
|
||||||
|
const prefix = top.type === "ordered" ? `${top.index}. ` : "• ";
|
||||||
|
return `${indent}${prefix}`;
|
||||||
|
};
|
||||||
|
md.renderer.rules.list_item_close = () => "\n";
|
||||||
|
|
||||||
|
// Slack mrkdwn uses _text_ for italic (same as markdown)
|
||||||
|
md.renderer.rules.em_open = () => "_";
|
||||||
|
md.renderer.rules.em_close = () => "_";
|
||||||
|
|
||||||
|
// Slack mrkdwn uses *text* for bold (single asterisk, not double)
|
||||||
|
md.renderer.rules.strong_open = () => "*";
|
||||||
|
md.renderer.rules.strong_close = () => "*";
|
||||||
|
|
||||||
|
// Slack mrkdwn uses ~text~ for strikethrough (single tilde)
|
||||||
|
md.renderer.rules.s_open = () => "~";
|
||||||
|
md.renderer.rules.s_close = () => "~";
|
||||||
|
|
||||||
|
md.renderer.rules.code_inline = (tokens, idx) =>
|
||||||
|
`\`${escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "")}\``;
|
||||||
|
|
||||||
|
md.renderer.rules.code_block = (tokens, idx) =>
|
||||||
|
`\`\`\`\n${escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "")}\`\`\`\n`;
|
||||||
|
|
||||||
|
md.renderer.rules.fence = (tokens, idx) =>
|
||||||
|
`\`\`\`\n${escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "")}\`\`\`\n`;
|
||||||
|
|
||||||
|
md.renderer.rules.link_open = (tokens, idx, _opts, env) => {
|
||||||
|
const href = tokens[idx]?.attrGet("href") ?? "";
|
||||||
|
const stack = getLinkStack(env as RenderEnv);
|
||||||
|
stack.push({ href });
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
md.renderer.rules.link_close = (_tokens, _idx, _opts, env) => {
|
||||||
|
const stack = getLinkStack(env as RenderEnv);
|
||||||
|
const link = stack.pop();
|
||||||
|
if (link?.href) {
|
||||||
|
return ` (${escapeSlackMrkdwnSegment(link.href)})`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
md.renderer.rules.image = (tokens, idx) => {
|
||||||
|
const alt = tokens[idx]?.content ?? "";
|
||||||
|
return escapeSlackMrkdwnSegment(alt);
|
||||||
|
};
|
||||||
|
|
||||||
|
md.renderer.rules.html_block = (tokens, idx) =>
|
||||||
|
escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "");
|
||||||
|
md.renderer.rules.html_inline = (tokens, idx) =>
|
||||||
|
escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "");
|
||||||
|
|
||||||
|
md.renderer.rules.table_open = () => "";
|
||||||
|
md.renderer.rules.table_close = () => "";
|
||||||
|
md.renderer.rules.thead_open = () => "";
|
||||||
|
md.renderer.rules.thead_close = () => "";
|
||||||
|
md.renderer.rules.tbody_open = () => "";
|
||||||
|
md.renderer.rules.tbody_close = () => "";
|
||||||
|
md.renderer.rules.tr_open = () => "";
|
||||||
|
md.renderer.rules.tr_close = () => "\n";
|
||||||
|
md.renderer.rules.th_open = () => "";
|
||||||
|
md.renderer.rules.th_close = () => "\t";
|
||||||
|
md.renderer.rules.td_open = () => "";
|
||||||
|
md.renderer.rules.td_close = () => "\t";
|
||||||
|
|
||||||
|
md.renderer.rules.hr = () => "\n";
|
||||||
|
|
||||||
|
function protectSlackAngleLinks(markdown: string): {
|
||||||
|
markdown: string;
|
||||||
|
tokens: string[];
|
||||||
|
} {
|
||||||
|
const tokens: string[] = [];
|
||||||
|
const protectedMarkdown = (markdown ?? "").replace(
|
||||||
|
/<(?:https?:\/\/|mailto:|tel:|slack:\/\/)[^>\n]+>/g,
|
||||||
|
(match) => {
|
||||||
|
const id = tokens.length;
|
||||||
|
tokens.push(match);
|
||||||
|
return `⟦clawdbot-slacktok:${id}⟧`;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { markdown: protectedMarkdown, tokens };
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreSlackAngleLinks(text: string, tokens: string[]): string {
|
||||||
|
let out = text;
|
||||||
|
for (let i = 0; i < tokens.length; i++) {
|
||||||
|
out = out.replaceAll(`⟦clawdbot-slacktok:${i}⟧`, tokens[i] ?? "");
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert standard Markdown to Slack mrkdwn format.
|
||||||
|
*
|
||||||
|
* Slack mrkdwn differences from standard Markdown:
|
||||||
|
* - Bold: *text* (single asterisk, not double)
|
||||||
|
* - Italic: _text_ (same)
|
||||||
|
* - Strikethrough: ~text~ (single tilde)
|
||||||
|
* - Code: `code` (same)
|
||||||
|
* - Links: <url|text> or plain URL
|
||||||
|
* - Escape &, <, > as &, <, >
|
||||||
|
*/
|
||||||
|
export function markdownToSlackMrkdwn(markdown: string): string {
|
||||||
|
const env: RenderEnv = {};
|
||||||
|
const protectedLinks = protectSlackAngleLinks(markdown ?? "");
|
||||||
|
const rendered = md.render(protectedLinks.markdown, env);
|
||||||
|
const normalized = rendered
|
||||||
|
.replace(/[ \t]+\n/g, "\n")
|
||||||
|
.replace(/\t+\n/g, "\n")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trimEnd();
|
||||||
|
return restoreSlackAngleLinks(normalized, protectedLinks.tokens);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { isSlackRoomAllowedByPolicy } from "./monitor.js";
|
import { isSlackRoomAllowedByPolicy, resolveSlackThreadTs } from "./monitor.js";
|
||||||
|
|
||||||
describe("slack groupPolicy gating", () => {
|
describe("slack groupPolicy gating", () => {
|
||||||
it("allows when policy is open", () => {
|
it("allows when policy is open", () => {
|
||||||
@@ -53,3 +53,102 @@ describe("slack groupPolicy gating", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveSlackThreadTs", () => {
|
||||||
|
const threadTs = "1234567890.123456";
|
||||||
|
const messageTs = "9999999999.999999";
|
||||||
|
|
||||||
|
describe("replyToMode=off", () => {
|
||||||
|
it("returns incomingThreadTs when in a thread", () => {
|
||||||
|
expect(
|
||||||
|
resolveSlackThreadTs({
|
||||||
|
replyToMode: "off",
|
||||||
|
incomingThreadTs: threadTs,
|
||||||
|
messageTs,
|
||||||
|
hasReplied: false,
|
||||||
|
}),
|
||||||
|
).toBe(threadTs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns incomingThreadTs even after replies (stays in thread)", () => {
|
||||||
|
expect(
|
||||||
|
resolveSlackThreadTs({
|
||||||
|
replyToMode: "off",
|
||||||
|
incomingThreadTs: threadTs,
|
||||||
|
messageTs,
|
||||||
|
hasReplied: true,
|
||||||
|
}),
|
||||||
|
).toBe(threadTs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when not in a thread", () => {
|
||||||
|
expect(
|
||||||
|
resolveSlackThreadTs({
|
||||||
|
replyToMode: "off",
|
||||||
|
incomingThreadTs: undefined,
|
||||||
|
messageTs,
|
||||||
|
hasReplied: false,
|
||||||
|
}),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("replyToMode=first", () => {
|
||||||
|
it("returns incomingThreadTs when in a thread (always stays threaded)", () => {
|
||||||
|
expect(
|
||||||
|
resolveSlackThreadTs({
|
||||||
|
replyToMode: "first",
|
||||||
|
incomingThreadTs: threadTs,
|
||||||
|
messageTs,
|
||||||
|
hasReplied: false,
|
||||||
|
}),
|
||||||
|
).toBe(threadTs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns messageTs for first reply when not in a thread", () => {
|
||||||
|
expect(
|
||||||
|
resolveSlackThreadTs({
|
||||||
|
replyToMode: "first",
|
||||||
|
incomingThreadTs: undefined,
|
||||||
|
messageTs,
|
||||||
|
hasReplied: false,
|
||||||
|
}),
|
||||||
|
).toBe(messageTs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for subsequent replies when not in a thread (goes to main channel)", () => {
|
||||||
|
expect(
|
||||||
|
resolveSlackThreadTs({
|
||||||
|
replyToMode: "first",
|
||||||
|
incomingThreadTs: undefined,
|
||||||
|
messageTs,
|
||||||
|
hasReplied: true,
|
||||||
|
}),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("replyToMode=all", () => {
|
||||||
|
it("returns incomingThreadTs when in a thread", () => {
|
||||||
|
expect(
|
||||||
|
resolveSlackThreadTs({
|
||||||
|
replyToMode: "all",
|
||||||
|
incomingThreadTs: threadTs,
|
||||||
|
messageTs,
|
||||||
|
hasReplied: false,
|
||||||
|
}),
|
||||||
|
).toBe(threadTs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns messageTs when not in a thread (starts thread)", () => {
|
||||||
|
expect(
|
||||||
|
resolveSlackThreadTs({
|
||||||
|
replyToMode: "all",
|
||||||
|
incomingThreadTs: undefined,
|
||||||
|
messageTs,
|
||||||
|
hasReplied: true,
|
||||||
|
}),
|
||||||
|
).toBe(messageTs);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -601,8 +601,52 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
expect(ctx.ParentSessionKey).toBe("agent:support:slack:channel:C1");
|
expect(ctx.ParentSessionKey).toBe("agent:support:slack:channel:C1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps replies in channel root when message is not threaded", 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 = {
|
||||||
|
messages: {
|
||||||
|
responsePrefix: "PFX",
|
||||||
|
ackReaction: "👀",
|
||||||
|
ackReactionScope: "group-mentions",
|
||||||
|
},
|
||||||
|
slack: {
|
||||||
|
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
|
||||||
|
replyToMode: "off",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const run = monitorSlackProvider({
|
||||||
|
botToken: "bot-token",
|
||||||
|
appToken: "app-token",
|
||||||
|
abortSignal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForEvent("message");
|
||||||
|
const handler = getSlackHandlers()?.get("message");
|
||||||
|
if (!handler) throw new Error("Slack message handler not registered");
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
event: {
|
||||||
|
type: "message",
|
||||||
|
user: "U1",
|
||||||
|
text: "hello",
|
||||||
|
ts: "789",
|
||||||
|
channel: "C1",
|
||||||
|
channel_type: "im",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
controller.abort();
|
||||||
|
await run;
|
||||||
|
|
||||||
|
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("threads first reply when replyToMode is first and message is not threaded", async () => {
|
||||||
|
replyMock.mockResolvedValue({ text: "first reply" });
|
||||||
config = {
|
config = {
|
||||||
messages: {
|
messages: {
|
||||||
responsePrefix: "PFX",
|
responsePrefix: "PFX",
|
||||||
@@ -642,7 +686,8 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
await run;
|
await run;
|
||||||
|
|
||||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||||
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: undefined });
|
// First reply starts a thread under the incoming message
|
||||||
|
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "789" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forces thread replies when replyToId is set", async () => {
|
it("forces thread replies when replyToId is set", async () => {
|
||||||
|
|||||||
@@ -1069,11 +1069,17 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { replyThreadTs, statusThreadTs } = resolveSlackThreadTargets({
|
// Use helper for status thread; compute baseThreadTs for "first" mode support
|
||||||
|
const { statusThreadTs } = resolveSlackThreadTargets({
|
||||||
message,
|
message,
|
||||||
replyToMode,
|
replyToMode,
|
||||||
});
|
});
|
||||||
|
const messageTs = message.ts ?? message.event_ts;
|
||||||
|
const incomingThreadTs = message.thread_ts;
|
||||||
let didSetStatus = false;
|
let didSetStatus = false;
|
||||||
|
// Shared mutable ref for tracking if a reply was sent (used by both
|
||||||
|
// auto-reply path and tool path for "first" threading mode).
|
||||||
|
const hasRepliedRef = { value: false };
|
||||||
const onReplyStart = async () => {
|
const onReplyStart = async () => {
|
||||||
didSetStatus = true;
|
didSetStatus = true;
|
||||||
await setSlackThreadStatus({
|
await setSlackThreadStatus({
|
||||||
@@ -1087,6 +1093,12 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId)
|
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId)
|
||||||
.responsePrefix,
|
.responsePrefix,
|
||||||
deliver: async (payload) => {
|
deliver: async (payload) => {
|
||||||
|
const effectiveThreadTs = resolveSlackThreadTs({
|
||||||
|
replyToMode,
|
||||||
|
incomingThreadTs,
|
||||||
|
messageTs,
|
||||||
|
hasReplied: hasRepliedRef.value,
|
||||||
|
});
|
||||||
await deliverReplies({
|
await deliverReplies({
|
||||||
replies: [payload],
|
replies: [payload],
|
||||||
target: replyTarget,
|
target: replyTarget,
|
||||||
@@ -1094,8 +1106,9 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
runtime,
|
runtime,
|
||||||
textLimit,
|
textLimit,
|
||||||
replyThreadTs,
|
replyThreadTs: effectiveThreadTs,
|
||||||
});
|
});
|
||||||
|
hasRepliedRef.value = true;
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
runtime.error?.(
|
runtime.error?.(
|
||||||
@@ -1119,6 +1132,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
replyOptions: {
|
replyOptions: {
|
||||||
...replyOptions,
|
...replyOptions,
|
||||||
skillFilter: channelConfig?.skills,
|
skillFilter: channelConfig?.skills,
|
||||||
|
hasRepliedRef,
|
||||||
disableBlockStreaming:
|
disableBlockStreaming:
|
||||||
typeof account.config.blockStreaming === "boolean"
|
typeof account.config.blockStreaming === "boolean"
|
||||||
? !account.config.blockStreaming
|
? !account.config.blockStreaming
|
||||||
@@ -1958,6 +1972,33 @@ export function isSlackRoomAllowedByPolicy(params: {
|
|||||||
return channelAllowed;
|
return channelAllowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute effective threadTs for a Slack reply based on replyToMode.
|
||||||
|
* - "off": stay in thread if already in one, otherwise main channel
|
||||||
|
* - "first": first reply goes to thread, subsequent replies to main channel
|
||||||
|
* - "all": all replies go to thread
|
||||||
|
*/
|
||||||
|
export function resolveSlackThreadTs(params: {
|
||||||
|
replyToMode: "off" | "first" | "all";
|
||||||
|
incomingThreadTs: string | undefined;
|
||||||
|
messageTs: string | undefined;
|
||||||
|
hasReplied: boolean;
|
||||||
|
}): string | undefined {
|
||||||
|
const { replyToMode, incomingThreadTs, messageTs, hasReplied } = params;
|
||||||
|
if (incomingThreadTs) return incomingThreadTs;
|
||||||
|
if (!messageTs) return undefined;
|
||||||
|
if (replyToMode === "all") {
|
||||||
|
// All replies go to thread
|
||||||
|
return messageTs;
|
||||||
|
}
|
||||||
|
if (replyToMode === "first") {
|
||||||
|
// "first": only first reply goes to thread
|
||||||
|
return hasReplied ? undefined : messageTs;
|
||||||
|
}
|
||||||
|
// "off": never start a thread
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
async function deliverSlackSlashReplies(params: {
|
async function deliverSlackSlashReplies(params: {
|
||||||
replies: ReplyPayload[];
|
replies: ReplyPayload[];
|
||||||
respond: SlackRespondFn;
|
respond: SlackRespondFn;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { logVerbose } from "../globals.js";
|
|||||||
import { loadWebMedia } from "../web/media.js";
|
import { loadWebMedia } from "../web/media.js";
|
||||||
import type { SlackTokenSource } from "./accounts.js";
|
import type { SlackTokenSource } from "./accounts.js";
|
||||||
import { resolveSlackAccount } from "./accounts.js";
|
import { resolveSlackAccount } from "./accounts.js";
|
||||||
|
import { markdownToSlackMrkdwn } from "./format.js";
|
||||||
import { resolveSlackBotToken } from "./token.js";
|
import { resolveSlackBotToken } from "./token.js";
|
||||||
|
|
||||||
const SLACK_TEXT_LIMIT = 4000;
|
const SLACK_TEXT_LIMIT = 4000;
|
||||||
@@ -169,7 +170,8 @@ export async function sendMessageSlack(
|
|||||||
const { channelId } = await resolveChannelId(client, recipient);
|
const { channelId } = await resolveChannelId(client, recipient);
|
||||||
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
|
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
|
||||||
const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT);
|
const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT);
|
||||||
const chunks = chunkMarkdownText(trimmedMessage, chunkLimit);
|
const slackFormatted = markdownToSlackMrkdwn(trimmedMessage);
|
||||||
|
const chunks = chunkMarkdownText(slackFormatted, chunkLimit);
|
||||||
const mediaMaxBytes =
|
const mediaMaxBytes =
|
||||||
typeof account.config.mediaMaxMb === "number"
|
typeof account.config.mediaMaxMb === "number"
|
||||||
? account.config.mediaMaxMb * 1024 * 1024
|
? account.config.mediaMaxMb * 1024 * 1024
|
||||||
|
|||||||
Reference in New Issue
Block a user