mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
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:
@@ -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 });
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user