fix(hooks): canonicalize slack thread ownership ids

This commit is contained in:
Vincent Koc
2026-04-22 12:26:31 -07:00
parent 7d088f198f
commit 4663e7394b
2 changed files with 62 additions and 5 deletions

View File

@@ -122,6 +122,30 @@ describe("thread-ownership plugin", () => {
);
});
it("canonicalizes non-canonical Slack targets when shared conversationId is missing", async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(JSON.stringify({ owner: "test-agent" }), { status: 200 }),
);
const result = await hooks.message_sending(
{
content: "hello",
replyToId: "1234.5678",
to: "channel:C123",
},
{ channelId: "slack", conversationId: "" },
);
expect(result).toBeUndefined();
expect(globalThis.fetch).toHaveBeenCalledWith(
"http://localhost:8750/api/v1/ownership/C123/1234.5678",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ agent_id: "test-agent" }),
}),
);
});
it("cancels when thread owned by another agent", async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(JSON.stringify({ owner: "other-agent" }), { status: 409 }),
@@ -194,6 +218,29 @@ describe("thread-ownership plugin", () => {
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("canonicalizes inbound non-canonical metadata without shared conversation context", async () => {
await hooks.message_received(
{
content: "Hey @TestBot help me",
threadId: "9999.0003",
metadata: { channelId: "channel:C456" },
},
{ channelId: "slack", conversationId: "" },
);
const result = await hooks.message_sending(
{
content: "Sure!",
replyToId: "9999.0003",
to: "C456",
},
{ channelId: "slack", conversationId: "C456" },
);
expect(result).toBeUndefined();
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("ignores @-mentions on non-slack channels", async () => {
// Use a unique thread key so module-level state from other tests doesn't interfere.
await hooks.message_received(

View File

@@ -24,6 +24,16 @@ function resolveThreadToken(value: unknown): string {
return typeof value === "string" || typeof value === "number" ? String(value) : "";
}
function resolveSlackConversationId(value: unknown): string {
const raw = normalizeOptionalString(value) ?? "";
if (!raw) {
return "";
}
const trimmed = raw.trim();
const match = /^(?:slack:)?channel:(.+)$/i.exec(trimmed);
return match?.[1]?.trim() || trimmed;
}
function cleanExpiredMentions(): void {
const now = Date.now();
for (const [key, ts] of mentionedThreads) {
@@ -81,8 +91,8 @@ export default definePluginEntry({
resolveThreadToken(event.metadata?.threadId) ||
resolveThreadToken(event.metadata?.threadTs);
const channelId =
normalizeOptionalString(ctx.conversationId) ||
normalizeOptionalString(event.metadata?.channelId) ||
resolveSlackConversationId(ctx.conversationId) ||
resolveSlackConversationId(event.metadata?.channelId) ||
"";
if (!threadTs || !channelId) {
return;
@@ -108,9 +118,9 @@ export default definePluginEntry({
resolveThreadToken(event.metadata?.threadId) ||
resolveThreadToken(event.metadata?.threadTs);
const channelId =
normalizeOptionalString(ctx.conversationId) ||
normalizeOptionalString(event.metadata?.channelId) ||
normalizeOptionalString(event.to) ||
resolveSlackConversationId(ctx.conversationId) ||
resolveSlackConversationId(event.metadata?.channelId) ||
resolveSlackConversationId(event.to) ||
"";
if (!threadTs || !channelId) {
return undefined;