diff --git a/.changeset/fix-dm-thread-ts.md b/.changeset/fix-dm-thread-ts.md new file mode 100644 index 000000000..e8d06e070 --- /dev/null +++ b/.changeset/fix-dm-thread-ts.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/slack": patch +--- + +Fix DM messages failing with `invalid_thread_ts` by guarding Slack API calls with `threadTs || undefined` diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index 91cd09891..6bd2dfaf0 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -2253,7 +2253,7 @@ describe("postMessage", () => { expect(client.chat.postMessage).toHaveBeenCalledWith( expect.objectContaining({ channel: "C123", - thread_ts: "", + thread_ts: undefined, }) ); }); @@ -2349,7 +2349,7 @@ describe("postEphemeral", () => { ); }); - it("omits thread_ts when empty", async () => { + it("normalizes empty threadTs to undefined", async () => { const adapter = createSlackAdapter({ botToken: "xoxb-test-token", signingSecret: secret, @@ -3303,7 +3303,7 @@ describe("postChannelMessage", () => { expect(client.chat.postMessage).toHaveBeenCalledWith( expect.objectContaining({ channel: "C123", - thread_ts: "", + thread_ts: undefined, }) ); }); @@ -4982,3 +4982,61 @@ describe("reverse user lookup", () => { }); }); }); + +// ============================================================================ +// Empty threadTs normalization tests +// ============================================================================ + +describe("stream with empty threadTs", () => { + it("throws ValidationError when threadTs is empty", async () => { + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: "test-signing-secret", + logger: mockLogger, + }); + + async function* emptyStream() { + yield "hello"; + } + + await expect( + adapter.stream("slack:C123:", emptyStream(), { + recipientUserId: "U123", + recipientTeamId: "T123", + }) + ).rejects.toThrow(ValidationError); + }); +}); + +describe("scheduleMessage with empty threadTs", () => { + it("normalizes empty threadTs to undefined", async () => { + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: "test-signing-secret", + logger: mockLogger, + }); + + mockClientMethod( + adapter, + "chat.scheduleMessage", + vi.fn().mockResolvedValue({ + ok: true, + scheduled_message_id: "Q123", + post_at: Math.floor(Date.now() / 1000) + 3600, + }) + ); + + const futureDate = new Date(Date.now() + 3600 * 1000); + await adapter.scheduleMessage("slack:C123:", "Scheduled msg", { + postAt: futureDate, + }); + + const client = getClient(adapter); + expect(client.chat.scheduleMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "C123", + thread_ts: undefined, + }) + ); + }); +}); diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index ebc419d4b..e7c7782b4 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -2161,14 +2161,16 @@ export class SlackAdapter implements Adapter { _message: AdapterPostableMessage ): Promise> { const message = await this.resolveMessageMentions(_message, threadId); - const { channel, threadTs } = this.decodeThreadId(threadId); + const { channel, threadTs: rawThreadTs } = this.decodeThreadId(threadId); + // Normalize empty threadTs to undefined to avoid Slack API "invalid_thread_ts" errors + const threadTs = rawThreadTs || undefined; try { // Check for files to upload const files = extractFiles(message); if (files.length > 0) { // Upload files first (they're shared to the channel automatically) - await this.uploadFiles(files, channel, threadTs || undefined); + await this.uploadFiles(files, channel, threadTs); // If message only has files (no text/card), return early const hasText = @@ -2302,7 +2304,8 @@ export class SlackAdapter implements Adapter { _message: AdapterPostableMessage ): Promise { const message = await this.resolveMessageMentions(_message, threadId); - const { channel, threadTs } = this.decodeThreadId(threadId); + const { channel, threadTs: rawThreadTs } = this.decodeThreadId(threadId); + const threadTs = rawThreadTs || undefined; try { // Check if message contains a card @@ -2323,7 +2326,7 @@ export class SlackAdapter implements Adapter { const result = await this.client.chat.postEphemeral( this.withToken({ channel, - thread_ts: threadTs || undefined, + thread_ts: threadTs, user: userId, text: fallbackText, blocks, @@ -2356,7 +2359,7 @@ export class SlackAdapter implements Adapter { const result = await this.client.chat.postEphemeral( this.withToken({ channel, - thread_ts: threadTs || undefined, + thread_ts: threadTs, user: userId, text: tableResult.text, blocks: tableResult.blocks, @@ -2392,7 +2395,7 @@ export class SlackAdapter implements Adapter { const result = await this.client.chat.postEphemeral( this.withToken({ channel, - thread_ts: threadTs || undefined, + thread_ts: threadTs, user: userId, text, }) @@ -2420,7 +2423,8 @@ export class SlackAdapter implements Adapter { options: { postAt: Date } ): Promise { const message = await this.resolveMessageMentions(_message, threadId); - const { channel, threadTs } = this.decodeThreadId(threadId); + const { channel, threadTs: rawThreadTs } = this.decodeThreadId(threadId); + const threadTs = rawThreadTs || undefined; const postAtUnix = Math.floor(options.postAt.getTime() / 1000); if (postAtUnix <= Math.floor(Date.now() / 1000)) { @@ -2456,7 +2460,7 @@ export class SlackAdapter implements Adapter { const result = await this.client.chat.scheduleMessage({ token, channel, - thread_ts: threadTs || undefined, + thread_ts: threadTs, post_at: postAtUnix, text: fallbackText, blocks, @@ -2498,7 +2502,7 @@ export class SlackAdapter implements Adapter { const result = await this.client.chat.scheduleMessage({ token, channel, - thread_ts: threadTs || undefined, + thread_ts: threadTs, post_at: postAtUnix, text, unfurl_links: false, @@ -2929,7 +2933,16 @@ export class SlackAdapter implements Adapter { "Slack streaming requires recipientUserId and recipientTeamId in options" ); } - const { channel, threadTs } = this.decodeThreadId(threadId); + const { channel, threadTs: rawThreadTs } = this.decodeThreadId(threadId); + // Normalize empty threadTs to undefined to avoid Slack API "invalid_thread_ts" errors + const threadTs = rawThreadTs || undefined; + if (!threadTs) { + this.logger.debug("Slack: stream skipped - no thread context"); + throw new ValidationError( + "slack", + "Slack streaming requires a valid thread context (non-empty threadTs)" + ); + } this.logger.debug("Slack: starting stream", { channel, threadTs }); const token = this.getToken();