From fd49e97ee50441d110c0bdc224dfa7553f79476d Mon Sep 17 00:00:00 2001 From: wings1029 Date: Tue, 14 Jul 2026 01:31:46 +0800 Subject: [PATCH] fix(discord): inherit default_auto_archive_duration in createThreadDiscord (#103413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(discord): inherit default_auto_archive_duration in createThreadDiscord When createThreadForBinding hardcoded autoArchiveMinutes: 60, the parent channel's default_auto_archive_duration (configured by operators at 1440, 4320, or 10080) was silently overridden. This is the same pattern fixed in #103033 for sendMessageDiscord's implicit forum thread creation. - Move the auto_archive_duration set after the channel fetch in createThreadDiscord so channel.default_auto_archive_duration can serve as a fallback when autoArchiveMinutes is not provided. - Remove the hardcoded autoArchiveMinutes: 60 from createThreadForBinding so it inherits the channel default for forum/media channels and omits the field for text channels (preserving Discord's server-side default). - Explicit autoArchiveMinutes from callers (thread-create action, auto-thread config) still take priority via the ?? operator. Co-Authored-By: Claude * refactor(discord): preserve parent thread archive defaults Co-authored-by: 陈志强0668000989 * docs(changelog): credit Discord archive default fix Co-authored-by: 陈志强0668000989 --------- Co-authored-by: Claude Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../monitor/thread-bindings.discord-api.ts | 1 - .../monitor/thread-bindings.lifecycle.test.ts | 31 +++------ .../discord/src/send.creates-thread.test.ts | 64 +++++++++++++++++++ extensions/discord/src/send.messages.ts | 28 +++++--- 5 files changed, 93 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf8e6d400e53..a84fb9690fd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai - **Gateway command SecretRefs:** preserve authoritative active-snapshot values when another command secret remains unresolved, falling back locally only for missing paths instead of emitting a per-turn `secrets.resolve` failure. (#96661) Thanks @SunnyShu0925. - **Cron delivery status:** keep successful isolated agent turns at `status=ok` when downstream delivery fails, while preserving the send failure separately in delivery state and run logs. (#95419) Thanks @Alix-007. - **Channel ingress recovery:** tombstone and scrub malformed durable ingress payloads without letting corrupt rows hide or starve later valid messages. (#98402) Thanks @Pick-cat. +- **Discord thread archive defaults:** inherit each parent channel's configured auto-archive duration for binding-created threads instead of forcing 60 minutes, while preserving explicit overrides. (#103413) Thanks @wings1029. - **Installed plugin loading:** make native-module fallback use jiti's transform path instead of retrying the same synchronous ESM load, preventing Node 24 startup races when official plugins import SDK contract modules. - **QA profile channel execution:** partition mixed Crabline channel scenarios into one aggregate host suite so taxonomy-backed profile commands and evidence workflows no longer abort before execution. - **Plugin SDK API baseline:** cover every public entrypoint, preserve complete declaration shapes without source-line churn, and run baseline and export-surface guards from changed-file validation. diff --git a/extensions/discord/src/monitor/thread-bindings.discord-api.ts b/extensions/discord/src/monitor/thread-bindings.discord-api.ts index 361c786cc7e6..7918d6480a9a 100644 --- a/extensions/discord/src/monitor/thread-bindings.discord-api.ts +++ b/extensions/discord/src/monitor/thread-bindings.discord-api.ts @@ -287,7 +287,6 @@ export async function createThreadForBinding(params: { params.channelId, { name: params.threadName, - autoArchiveMinutes: 60, }, { cfg: params.cfg, diff --git a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts index 94d7beaea79d..87685dd65dd4 100644 --- a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts +++ b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts @@ -109,6 +109,12 @@ function expectFields( return record; } +function expectThreadCreateOptionsWithoutArchiveOverride(value: unknown): void { + const options = requireRecord(value, "thread options"); + expect(options.name).toBeTypeOf("string"); + expect(options).not.toHaveProperty("autoArchiveMinutes"); +} + function mockCallArg(mock: unknown, callIndex: number, argIndex: number, label: string) { const calls = (mock as { mock?: { calls?: unknown[][] } }).mock?.calls; if (!Array.isArray(calls)) { @@ -220,7 +226,6 @@ describe("thread binding lifecycle", () => { params.channelId, { name: params.threadName, - autoArchiveMinutes: 60, }, { accountId: params.accountId, @@ -895,12 +900,8 @@ describe("thread binding lifecycle", () => { }); expect(hoisted.createThreadDiscord).toHaveBeenCalledTimes(1); expect(mockCallArg(hoisted.createThreadDiscord, 0, 0, "createThreadDiscord")).toBe("parent-1"); - expectFields( + expectThreadCreateOptionsWithoutArchiveOverride( mockCallArg(hoisted.createThreadDiscord, 0, 1, "createThreadDiscord"), - "thread options", - { - autoArchiveMinutes: 60, - }, ); expectFields( mockCallArg(hoisted.createThreadDiscord, 0, 2, "createThreadDiscord"), @@ -945,12 +946,8 @@ describe("thread binding lifecycle", () => { expectFields(childBinding, "child binding", { channelId: "parent-1" }); expect(hoisted.restGet).toHaveBeenCalledTimes(1); expect(mockCallArg(hoisted.createThreadDiscord, 0, 0, "createThreadDiscord")).toBe("parent-1"); - expectFields( + expectThreadCreateOptionsWithoutArchiveOverride( mockCallArg(hoisted.createThreadDiscord, 0, 1, "createThreadDiscord"), - "thread options", - { - autoArchiveMinutes: 60, - }, ); expectFields( mockCallArg(hoisted.createThreadDiscord, 0, 2, "createThreadDiscord"), @@ -1117,12 +1114,8 @@ describe("thread binding lifecycle", () => { expect(mockCallArg(hoisted.createThreadDiscord, 0, 0, "createThreadDiscord")).toBe( "parent-runtime", ); - expectFields( + expectThreadCreateOptionsWithoutArchiveOverride( mockCallArg(hoisted.createThreadDiscord, 0, 1, "createThreadDiscord"), - "thread options", - { - autoArchiveMinutes: 60, - }, ); expectFields( mockCallArg(hoisted.createThreadDiscord, 0, 2, "createThreadDiscord"), @@ -1180,12 +1173,8 @@ describe("thread binding lifecycle", () => { expect(mockCallArg(hoisted.createThreadDiscord, 0, 0, "createThreadDiscord")).toBe( "1491611525914558667", ); - expectFields( + expectThreadCreateOptionsWithoutArchiveOverride( mockCallArg(hoisted.createThreadDiscord, 0, 1, "createThreadDiscord"), - "thread options", - { - autoArchiveMinutes: 60, - }, ); expectFields( mockCallArg(hoisted.createThreadDiscord, 0, 2, "createThreadDiscord"), diff --git a/extensions/discord/src/send.creates-thread.test.ts b/extensions/discord/src/send.creates-thread.test.ts index 845dea663091..cd7ff21454bb 100644 --- a/extensions/discord/src/send.creates-thread.test.ts +++ b/extensions/discord/src/send.creates-thread.test.ts @@ -156,6 +156,70 @@ describe("sendMessageDiscord", () => { }); }); + it("inherits default_auto_archive_duration for forum threads", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ + type: ChannelType.GuildForum, + default_auto_archive_duration: 1440, + }); + postMock.mockResolvedValue({ id: "t1" }); + await createThreadDiscord("chan1", { name: "thread" }, discordClientOpts(rest)); + expect(requestBody(postMock as unknown as MockCallSource)).toEqual({ + name: "thread", + auto_archive_duration: 1440, + message: { content: "thread" }, + }); + }); + + it("inherits default_auto_archive_duration for text-channel threads", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ + type: ChannelType.GuildText, + default_auto_archive_duration: 10080, + }); + postMock.mockResolvedValue({ id: "t1" }); + await createThreadDiscord("chan1", { name: "thread" }, discordClientOpts(rest)); + expect(requestBody(postMock as unknown as MockCallSource)).toEqual({ + name: "thread", + auto_archive_duration: 10080, + type: ChannelType.PublicThread, + }); + }); + + it("prefers explicit autoArchiveMinutes over channel default", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ + type: ChannelType.GuildForum, + default_auto_archive_duration: 1440, + }); + postMock.mockResolvedValue({ id: "t1" }); + await createThreadDiscord( + "chan1", + { name: "thread", autoArchiveMinutes: 4320 }, + discordClientOpts(rest), + ); + expect(requestBody(postMock as unknown as MockCallSource)).toEqual({ + name: "thread", + auto_archive_duration: 4320, + message: { content: "thread" }, + }); + }); + + it("preserves explicit autoArchiveMinutes for message-attached threads", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + postMock.mockResolvedValue({ id: "t1" }); + await createThreadDiscord( + "chan1", + { name: "thread", messageId: "m1", autoArchiveMinutes: 4320 }, + discordClientOpts(rest), + ); + expect(getMock).not.toHaveBeenCalled(); + expect(requestBody(postMock as unknown as MockCallSource)).toEqual({ + name: "thread", + auto_archive_duration: 4320, + }); + }); + it("creates media threads with provided content", async () => { const { rest, getMock, postMock } = makeDiscordRest(); getMock.mockResolvedValue({ type: ChannelType.GuildMedia }); diff --git a/extensions/discord/src/send.messages.ts b/extensions/discord/src/send.messages.ts index 1df94d3e6091..4d0aad8b0080 100644 --- a/extensions/discord/src/send.messages.ts +++ b/extensions/discord/src/send.messages.ts @@ -44,6 +44,13 @@ function assertDiscordResponseObject(value: unknown, label: string): Record; } +function resolveDefaultThreadAutoArchiveDuration(channel?: APIChannel): number | undefined { + if (!channel || !("default_auto_archive_duration" in channel)) { + return undefined; + } + return channel.default_auto_archive_duration; +} + export class DiscordThreadInitialMessageError extends Error { readonly initialMessageError: string; readonly thread: APIChannel; @@ -158,25 +165,26 @@ export async function createThreadDiscord( ) { const rest = resolveDiscordRest(opts); const body: Record = { name: payload.name }; - if (payload.autoArchiveMinutes) { - body.auto_archive_duration = payload.autoArchiveMinutes; - } if (!payload.messageId && payload.type !== undefined) { body.type = payload.type; } - let channelType: ChannelType | undefined; + let channel: APIChannel | undefined; if (!payload.messageId) { - // Only detect channel kind for route-less thread creation. - // If this lookup fails, keep prior behavior and let Discord validate. try { - const channel = await getChannel(rest, channelId); - channelType = channel?.type; + channel = await getChannel(rest, channelId); } catch { - channelType = undefined; + // Channel metadata only enriches standalone creation; Discord still validates it. } } + // Discord clients preselect the parent default, but REST thread creation needs + // it explicitly. Keep a caller override authoritative when one was supplied. + const archiveDuration = + payload.autoArchiveMinutes ?? resolveDefaultThreadAutoArchiveDuration(channel); + if (archiveDuration !== undefined) { + body.auto_archive_duration = archiveDuration; + } const isForumLike = - channelType === ChannelType.GuildForum || channelType === ChannelType.GuildMedia; + channel?.type === ChannelType.GuildForum || channel?.type === ChannelType.GuildMedia; if (isForumLike) { const starterContent = payload.content?.trim() ? payload.content : payload.name; body.message = { content: starterContent };