fix(discord): inherit default_auto_archive_duration in createThreadDiscord (#103413)

* 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 <noreply@anthropic.com>

* refactor(discord): preserve parent thread archive defaults

Co-authored-by: 陈志强0668000989 <chen.zhiqiang1@xydigit.com>

* docs(changelog): credit Discord archive default fix

Co-authored-by: 陈志强0668000989 <chen.zhiqiang1@xydigit.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
wings1029
2026-07-14 01:31:46 +08:00
committed by GitHub
parent 4f2553be5f
commit fd49e97ee5
5 changed files with 93 additions and 32 deletions
+1
View File
@@ -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.
@@ -287,7 +287,6 @@ export async function createThreadForBinding(params: {
params.channelId,
{
name: params.threadName,
autoArchiveMinutes: 60,
},
{
cfg: params.cfg,
@@ -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"),
@@ -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 });
+18 -10
View File
@@ -44,6 +44,13 @@ function assertDiscordResponseObject(value: unknown, label: string): Record<stri
return value as Record<string, unknown>;
}
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<string, unknown> = { 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 };