fix(slack): mrkdwn + thread edge cases (#464) (thanks @austinm911)
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
|
||||||
|
|||||||
@@ -197,6 +197,52 @@ describe("handleSlackAction", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it("replyToMode=first without hasRepliedRef does not thread", async () => {
|
||||||
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
|
const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig;
|
||||||
sendSlackMessage.mockClear();
|
sendSlackMessage.mockClear();
|
||||||
|
|||||||
@@ -152,6 +152,19 @@ export async function handleSlackAction(
|
|||||||
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": {
|
||||||
|
|||||||
@@ -86,13 +86,22 @@ function buildSlackThreadingContext(params: {
|
|||||||
hasRepliedRef: 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 {
|
return {
|
||||||
// Extract channel from "channel:C123" format
|
// Extract channel from "channel:C123" format
|
||||||
currentChannelId: sessionCtx.To?.startsWith("channel:")
|
currentChannelId: sessionCtx.To?.startsWith("channel:")
|
||||||
? sessionCtx.To.slice("channel:".length)
|
? sessionCtx.To.slice("channel:".length)
|
||||||
: undefined,
|
: undefined,
|
||||||
currentThreadTs: sessionCtx.ReplyToId,
|
currentThreadTs: sessionCtx.ReplyToId,
|
||||||
replyToMode: config?.slack?.replyToMode ?? "off",
|
replyToMode: effectiveReplyToMode,
|
||||||
hasRepliedRef,
|
hasRepliedRef,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,11 +38,23 @@ describe("markdownToSlackMrkdwn", () => {
|
|||||||
expect(res).toBe("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", () => {
|
it("escapes unsafe characters", () => {
|
||||||
const res = markdownToSlackMrkdwn("a & b < c > d");
|
const res = markdownToSlackMrkdwn("a & b < c > d");
|
||||||
expect(res).toBe("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", () => {
|
it("escapes raw HTML", () => {
|
||||||
const res = markdownToSlackMrkdwn("<b>nope</b>");
|
const res = markdownToSlackMrkdwn("<b>nope</b>");
|
||||||
expect(res).toBe("<b>nope</b>");
|
expect(res).toBe("<b>nope</b>");
|
||||||
@@ -68,9 +80,9 @@ describe("markdownToSlackMrkdwn", () => {
|
|||||||
expect(res).toBe("*Title*");
|
expect(res).toBe("*Title*");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders blockquotes with escaped angle bracket", () => {
|
it("renders blockquotes", () => {
|
||||||
const res = markdownToSlackMrkdwn("> Quote");
|
const res = markdownToSlackMrkdwn("> Quote");
|
||||||
expect(res).toBe("> Quote");
|
expect(res).toBe("> Quote");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles adjacent list items", () => {
|
it("handles adjacent list items", () => {
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ type RenderEnv = {
|
|||||||
|
|
||||||
const md = new MarkdownIt({
|
const md = new MarkdownIt({
|
||||||
html: false,
|
html: false,
|
||||||
linkify: true,
|
// 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,
|
breaks: false,
|
||||||
typographer: false,
|
typographer: false,
|
||||||
});
|
});
|
||||||
@@ -21,15 +23,62 @@ md.enable("strikethrough");
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Escape special characters for Slack mrkdwn format.
|
* Escape special characters for Slack mrkdwn format.
|
||||||
* Slack requires escaping &, <, > to prevent injection.
|
*
|
||||||
|
* 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 escapeSlackMrkdwn(text: string): string {
|
function escapeSlackMrkdwnSegment(text: string): string {
|
||||||
return text
|
return text
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
.replace(/</g, "<")
|
.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[] {
|
function getListStack(env: RenderEnv): ListState[] {
|
||||||
if (!env.slackListStack) env.slackListStack = [];
|
if (!env.slackListStack) env.slackListStack = [];
|
||||||
return env.slackListStack;
|
return env.slackListStack;
|
||||||
@@ -41,7 +90,7 @@ function getLinkStack(env: RenderEnv): { href: string }[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
md.renderer.rules.text = (tokens, idx) =>
|
md.renderer.rules.text = (tokens, idx) =>
|
||||||
escapeSlackMrkdwn(tokens[idx]?.content ?? "");
|
escapeSlackMrkdwnText(tokens[idx]?.content ?? "");
|
||||||
|
|
||||||
md.renderer.rules.softbreak = () => "\n";
|
md.renderer.rules.softbreak = () => "\n";
|
||||||
md.renderer.rules.hardbreak = () => "\n";
|
md.renderer.rules.hardbreak = () => "\n";
|
||||||
@@ -55,7 +104,7 @@ md.renderer.rules.paragraph_close = (_tokens, _idx, _opts, env) => {
|
|||||||
md.renderer.rules.heading_open = () => "*";
|
md.renderer.rules.heading_open = () => "*";
|
||||||
md.renderer.rules.heading_close = () => "*\n\n";
|
md.renderer.rules.heading_close = () => "*\n\n";
|
||||||
|
|
||||||
md.renderer.rules.blockquote_open = () => "> ";
|
md.renderer.rules.blockquote_open = () => "> ";
|
||||||
md.renderer.rules.blockquote_close = () => "\n";
|
md.renderer.rules.blockquote_close = () => "\n";
|
||||||
|
|
||||||
md.renderer.rules.bullet_list_open = (_tokens, _idx, _opts, env) => {
|
md.renderer.rules.bullet_list_open = (_tokens, _idx, _opts, env) => {
|
||||||
@@ -99,15 +148,14 @@ md.renderer.rules.s_open = () => "~";
|
|||||||
md.renderer.rules.s_close = () => "~";
|
md.renderer.rules.s_close = () => "~";
|
||||||
|
|
||||||
md.renderer.rules.code_inline = (tokens, idx) =>
|
md.renderer.rules.code_inline = (tokens, idx) =>
|
||||||
`\`${escapeSlackMrkdwn(tokens[idx]?.content ?? "")}\``;
|
`\`${escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "")}\``;
|
||||||
|
|
||||||
md.renderer.rules.code_block = (tokens, idx) =>
|
md.renderer.rules.code_block = (tokens, idx) =>
|
||||||
`\`\`\`\n${escapeSlackMrkdwn(tokens[idx]?.content ?? "")}\`\`\`\n`;
|
`\`\`\`\n${escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "")}\`\`\`\n`;
|
||||||
|
|
||||||
md.renderer.rules.fence = (tokens, idx) =>
|
md.renderer.rules.fence = (tokens, idx) =>
|
||||||
`\`\`\`\n${escapeSlackMrkdwn(tokens[idx]?.content ?? "")}\`\`\`\n`;
|
`\`\`\`\n${escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "")}\`\`\`\n`;
|
||||||
|
|
||||||
// Slack links use <url|text> format
|
|
||||||
md.renderer.rules.link_open = (tokens, idx, _opts, env) => {
|
md.renderer.rules.link_open = (tokens, idx, _opts, env) => {
|
||||||
const href = tokens[idx]?.attrGet("href") ?? "";
|
const href = tokens[idx]?.attrGet("href") ?? "";
|
||||||
const stack = getLinkStack(env as RenderEnv);
|
const stack = getLinkStack(env as RenderEnv);
|
||||||
@@ -118,20 +166,20 @@ md.renderer.rules.link_close = (_tokens, _idx, _opts, env) => {
|
|||||||
const stack = getLinkStack(env as RenderEnv);
|
const stack = getLinkStack(env as RenderEnv);
|
||||||
const link = stack.pop();
|
const link = stack.pop();
|
||||||
if (link?.href) {
|
if (link?.href) {
|
||||||
return ` (${escapeSlackMrkdwn(link.href)})`;
|
return ` (${escapeSlackMrkdwnSegment(link.href)})`;
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
md.renderer.rules.image = (tokens, idx) => {
|
md.renderer.rules.image = (tokens, idx) => {
|
||||||
const alt = tokens[idx]?.content ?? "";
|
const alt = tokens[idx]?.content ?? "";
|
||||||
return escapeSlackMrkdwn(alt);
|
return escapeSlackMrkdwnSegment(alt);
|
||||||
};
|
};
|
||||||
|
|
||||||
md.renderer.rules.html_block = (tokens, idx) =>
|
md.renderer.rules.html_block = (tokens, idx) =>
|
||||||
escapeSlackMrkdwn(tokens[idx]?.content ?? "");
|
escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "");
|
||||||
md.renderer.rules.html_inline = (tokens, idx) =>
|
md.renderer.rules.html_inline = (tokens, idx) =>
|
||||||
escapeSlackMrkdwn(tokens[idx]?.content ?? "");
|
escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "");
|
||||||
|
|
||||||
md.renderer.rules.table_open = () => "";
|
md.renderer.rules.table_open = () => "";
|
||||||
md.renderer.rules.table_close = () => "";
|
md.renderer.rules.table_close = () => "";
|
||||||
@@ -148,6 +196,30 @@ md.renderer.rules.td_close = () => "\t";
|
|||||||
|
|
||||||
md.renderer.rules.hr = () => "\n";
|
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.
|
* Convert standard Markdown to Slack mrkdwn format.
|
||||||
*
|
*
|
||||||
@@ -161,10 +233,12 @@ md.renderer.rules.hr = () => "\n";
|
|||||||
*/
|
*/
|
||||||
export function markdownToSlackMrkdwn(markdown: string): string {
|
export function markdownToSlackMrkdwn(markdown: string): string {
|
||||||
const env: RenderEnv = {};
|
const env: RenderEnv = {};
|
||||||
const rendered = md.render(markdown ?? "", env);
|
const protectedLinks = protectSlackAngleLinks(markdown ?? "");
|
||||||
return rendered
|
const rendered = md.render(protectedLinks.markdown, env);
|
||||||
|
const normalized = rendered
|
||||||
.replace(/[ \t]+\n/g, "\n")
|
.replace(/[ \t]+\n/g, "\n")
|
||||||
.replace(/\t+\n/g, "\n")
|
.replace(/\t+\n/g, "\n")
|
||||||
.replace(/\n{3,}/g, "\n\n")
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
.trimEnd();
|
.trimEnd();
|
||||||
|
return restoreSlackAngleLinks(normalized, protectedLinks.tokens);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,23 +56,26 @@ describe("slack groupPolicy gating", () => {
|
|||||||
|
|
||||||
describe("resolveSlackThreadTs", () => {
|
describe("resolveSlackThreadTs", () => {
|
||||||
const threadTs = "1234567890.123456";
|
const threadTs = "1234567890.123456";
|
||||||
|
const messageTs = "9999999999.999999";
|
||||||
|
|
||||||
describe("replyToMode=off", () => {
|
describe("replyToMode=off", () => {
|
||||||
it("returns baseThreadTs when in a thread", () => {
|
it("returns incomingThreadTs when in a thread", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveSlackThreadTs({
|
resolveSlackThreadTs({
|
||||||
replyToMode: "off",
|
replyToMode: "off",
|
||||||
baseThreadTs: threadTs,
|
incomingThreadTs: threadTs,
|
||||||
|
messageTs,
|
||||||
hasReplied: false,
|
hasReplied: false,
|
||||||
}),
|
}),
|
||||||
).toBe(threadTs);
|
).toBe(threadTs);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns baseThreadTs even after replies (stays in thread)", () => {
|
it("returns incomingThreadTs even after replies (stays in thread)", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveSlackThreadTs({
|
resolveSlackThreadTs({
|
||||||
replyToMode: "off",
|
replyToMode: "off",
|
||||||
baseThreadTs: threadTs,
|
incomingThreadTs: threadTs,
|
||||||
|
messageTs,
|
||||||
hasReplied: true,
|
hasReplied: true,
|
||||||
}),
|
}),
|
||||||
).toBe(threadTs);
|
).toBe(threadTs);
|
||||||
@@ -82,7 +85,8 @@ describe("resolveSlackThreadTs", () => {
|
|||||||
expect(
|
expect(
|
||||||
resolveSlackThreadTs({
|
resolveSlackThreadTs({
|
||||||
replyToMode: "off",
|
replyToMode: "off",
|
||||||
baseThreadTs: undefined,
|
incomingThreadTs: undefined,
|
||||||
|
messageTs,
|
||||||
hasReplied: false,
|
hasReplied: false,
|
||||||
}),
|
}),
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
@@ -90,21 +94,34 @@ describe("resolveSlackThreadTs", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("replyToMode=first", () => {
|
describe("replyToMode=first", () => {
|
||||||
it("returns baseThreadTs for first reply", () => {
|
it("returns incomingThreadTs when in a thread (always stays threaded)", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveSlackThreadTs({
|
resolveSlackThreadTs({
|
||||||
replyToMode: "first",
|
replyToMode: "first",
|
||||||
baseThreadTs: threadTs,
|
incomingThreadTs: threadTs,
|
||||||
|
messageTs,
|
||||||
hasReplied: false,
|
hasReplied: false,
|
||||||
}),
|
}),
|
||||||
).toBe(threadTs);
|
).toBe(threadTs);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns undefined for subsequent replies (goes to main channel)", () => {
|
it("returns messageTs for first reply when not in a thread", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveSlackThreadTs({
|
resolveSlackThreadTs({
|
||||||
replyToMode: "first",
|
replyToMode: "first",
|
||||||
baseThreadTs: threadTs,
|
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,
|
hasReplied: true,
|
||||||
}),
|
}),
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
@@ -112,24 +129,26 @@ describe("resolveSlackThreadTs", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("replyToMode=all", () => {
|
describe("replyToMode=all", () => {
|
||||||
it("returns baseThreadTs for first reply", () => {
|
it("returns incomingThreadTs when in a thread", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveSlackThreadTs({
|
resolveSlackThreadTs({
|
||||||
replyToMode: "all",
|
replyToMode: "all",
|
||||||
baseThreadTs: threadTs,
|
incomingThreadTs: threadTs,
|
||||||
|
messageTs,
|
||||||
hasReplied: false,
|
hasReplied: false,
|
||||||
}),
|
}),
|
||||||
).toBe(threadTs);
|
).toBe(threadTs);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns baseThreadTs for subsequent replies (all go to thread)", () => {
|
it("returns messageTs when not in a thread (starts thread)", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveSlackThreadTs({
|
resolveSlackThreadTs({
|
||||||
replyToMode: "all",
|
replyToMode: "all",
|
||||||
baseThreadTs: threadTs,
|
incomingThreadTs: undefined,
|
||||||
|
messageTs,
|
||||||
hasReplied: true,
|
hasReplied: true,
|
||||||
}),
|
}),
|
||||||
).toBe(threadTs);
|
).toBe(messageTs);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1074,13 +1074,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
message,
|
message,
|
||||||
replyToMode,
|
replyToMode,
|
||||||
});
|
});
|
||||||
// Base thread timestamp: where should first reply go?
|
const messageTs = message.ts ?? message.event_ts;
|
||||||
// - "off": only thread if already in a thread
|
const incomingThreadTs = message.thread_ts;
|
||||||
// - "first"/"all": start thread under the message
|
|
||||||
const baseThreadTs =
|
|
||||||
replyToMode === "off"
|
|
||||||
? message.thread_ts
|
|
||||||
: (message.thread_ts ?? message.ts);
|
|
||||||
let didSetStatus = false;
|
let didSetStatus = false;
|
||||||
// Shared mutable ref for tracking if a reply was sent (used by both
|
// Shared mutable ref for tracking if a reply was sent (used by both
|
||||||
// auto-reply path and tool path for "first" threading mode).
|
// auto-reply path and tool path for "first" threading mode).
|
||||||
@@ -1100,7 +1095,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
deliver: async (payload) => {
|
deliver: async (payload) => {
|
||||||
const effectiveThreadTs = resolveSlackThreadTs({
|
const effectiveThreadTs = resolveSlackThreadTs({
|
||||||
replyToMode,
|
replyToMode,
|
||||||
baseThreadTs,
|
incomingThreadTs,
|
||||||
|
messageTs,
|
||||||
hasReplied: hasRepliedRef.value,
|
hasReplied: hasRepliedRef.value,
|
||||||
});
|
});
|
||||||
await deliverReplies({
|
await deliverReplies({
|
||||||
@@ -1984,20 +1980,23 @@ export function isSlackRoomAllowedByPolicy(params: {
|
|||||||
*/
|
*/
|
||||||
export function resolveSlackThreadTs(params: {
|
export function resolveSlackThreadTs(params: {
|
||||||
replyToMode: "off" | "first" | "all";
|
replyToMode: "off" | "first" | "all";
|
||||||
baseThreadTs: string | undefined;
|
incomingThreadTs: string | undefined;
|
||||||
|
messageTs: string | undefined;
|
||||||
hasReplied: boolean;
|
hasReplied: boolean;
|
||||||
}): string | undefined {
|
}): string | undefined {
|
||||||
const { replyToMode, baseThreadTs, hasReplied } = params;
|
const { replyToMode, incomingThreadTs, messageTs, hasReplied } = params;
|
||||||
if (replyToMode === "off") {
|
if (incomingThreadTs) return incomingThreadTs;
|
||||||
// Always stay in thread if already in one
|
if (!messageTs) return undefined;
|
||||||
return baseThreadTs;
|
|
||||||
}
|
|
||||||
if (replyToMode === "all") {
|
if (replyToMode === "all") {
|
||||||
// All replies go to thread
|
// All replies go to thread
|
||||||
return baseThreadTs;
|
return messageTs;
|
||||||
}
|
}
|
||||||
// "first": only first reply goes to thread
|
if (replyToMode === "first") {
|
||||||
return hasReplied ? undefined : baseThreadTs;
|
// "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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user