feat(discord): support forum tag edits via channel-edit (#12070) (thanks @xiaoyaner0201)

This commit is contained in:
Shadow
2026-02-20 21:14:27 -06:00
committed by Shadow
parent b7644d61a2
commit b294342d7f
7 changed files with 96 additions and 4 deletions

View File

@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
- Discord/Streaming: add stream preview mode for live draft replies with partial/block options and configurable chunking. Thanks @thewilloftheshadow. Inspiration @neoagentic-ship-it.
- Discord/Telegram: add configurable lifecycle status reactions for queued/thinking/tool/done/error phases with a shared controller and emoji/timing overrides. Thanks @wolly-tundracube and @thewilloftheshadow.
- Discord/Voice: add voice channel join/leave/status via `/vc`, plus auto-join configuration for realtime voice conversations. Thanks @thewilloftheshadow.
- Discord: support updating forum `available_tags` via channel edit actions for forum tag management. (#12070) Thanks @xiaoyaner0201.
- Channels: allow per-channel model overrides via `channels.modelByChannel` and note them in /status. Thanks @thewilloftheshadow.
- Discord: include channel topics in trusted inbound metadata on new sessions. Thanks @thewilloftheshadow.
- Docs/Discord: document forum channel thread creation flows and component limits. Thanks @thewilloftheshadow.

View File

@@ -0,0 +1,28 @@
import { describe, expect, test } from "vitest";
import { parseAvailableTags } from "./common.js";
describe("parseAvailableTags", () => {
test("returns undefined for non-array inputs", () => {
expect(parseAvailableTags(undefined)).toBeUndefined();
expect(parseAvailableTags(null)).toBeUndefined();
expect(parseAvailableTags("oops")).toBeUndefined();
});
test("drops entries without a string name and returns undefined when empty", () => {
expect(parseAvailableTags([{ id: "1" }])).toBeUndefined();
expect(parseAvailableTags([{ name: 123 }])).toBeUndefined();
});
test("keeps falsy ids and sanitizes emoji fields", () => {
const result = parseAvailableTags([
{ id: "0", name: "General", emoji_id: null },
{ id: "1", name: "Docs", emoji_name: "📚" },
{ name: "Bad", emoji_id: 123 },
]);
expect(result).toEqual([
{ id: "0", name: "General", emoji_id: null },
{ id: "1", name: "Docs", emoji_name: "📚" },
{ name: "Bad" },
]);
});
});

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { detectMime } from "../../media/mime.js";
import fs from "node:fs/promises";
import type { ImageSanitizationLimits } from "../image-sanitization.js";
import { detectMime } from "../../media/mime.js";
import { sanitizeToolResultImages } from "../tool-images.js";
// oxlint-disable-next-line typescript/no-explicit-any
@@ -273,3 +273,41 @@ export async function imageResultFromFile(params: {
imageSanitization: params.imageSanitization,
});
}
export type AvailableTag = {
id?: string;
name: string;
moderated?: boolean;
emoji_id?: string | null;
emoji_name?: string | null;
};
/**
* Validate and parse an `availableTags` parameter from untrusted input.
* Returns `undefined` when the value is missing or not an array.
* Entries that lack a string `name` are silently dropped.
*/
export function parseAvailableTags(raw: unknown): AvailableTag[] | undefined {
if (raw === undefined || raw === null) {
return undefined;
}
if (!Array.isArray(raw)) {
return undefined;
}
const result = raw
.filter(
(t): t is Record<string, unknown> =>
typeof t === "object" && t !== null && typeof t.name === "string",
)
.map((t) => ({
...(t.id !== undefined && typeof t.id === "string" ? { id: t.id } : {}),
name: t.name as string,
...(typeof t.moderated === "boolean" ? { moderated: t.moderated } : {}),
...(t.emoji_id === null || typeof t.emoji_id === "string" ? { emoji_id: t.emoji_id } : {}),
...(t.emoji_name === null || typeof t.emoji_name === "string"
? { emoji_name: t.emoji_name }
: {}),
}));
// Return undefined instead of empty array to avoid accidentally clearing all tags
return result.length ? result : undefined;
}

View File

@@ -24,6 +24,7 @@ import {
import {
type ActionGate,
jsonResult,
parseAvailableTags,
readNumberParam,
readStringArrayParam,
readStringParam,
@@ -334,6 +335,7 @@ export async function handleDiscordGuildAction(
const autoArchiveDuration = readNumberParam(params, "autoArchiveDuration", {
integer: true,
});
const availableTags = parseAvailableTags(params.availableTags);
const channel = accountId
? await editChannelDiscord(
{
@@ -347,6 +349,7 @@ export async function handleDiscordGuildAction(
archived,
locked,
autoArchiveDuration: autoArchiveDuration ?? undefined,
availableTags,
},
{ accountId },
)
@@ -361,6 +364,7 @@ export async function handleDiscordGuildAction(
archived,
locked,
autoArchiveDuration: autoArchiveDuration ?? undefined,
availableTags,
});
return jsonResult({ ok: true, channel });
}

View File

@@ -1,5 +1,7 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ChannelMessageActionContext } from "../../types.js";
import {
parseAvailableTags,
readNumberParam,
readStringArrayParam,
readStringParam,
@@ -9,7 +11,6 @@ import {
readDiscordModerationCommand,
} from "../../../../agents/tools/discord-actions-moderation-shared.js";
import { handleDiscordAction } from "../../../../agents/tools/discord-actions.js";
import type { ChannelMessageActionContext } from "../../types.js";
type Ctx = Pick<
ChannelMessageActionContext,
@@ -195,6 +196,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
const autoArchiveDuration = readNumberParam(actionParams, "autoArchiveDuration", {
integer: true,
});
const availableTags = parseAvailableTags(actionParams.availableTags);
return await handleDiscordAction(
{
action: "channelEdit",
@@ -209,6 +211,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
archived,
locked,
autoArchiveDuration: autoArchiveDuration ?? undefined,
availableTags,
},
cfg,
);

View File

@@ -1,6 +1,5 @@
import type { APIChannel } from "discord-api-types/v10";
import { Routes } from "discord-api-types/v10";
import { resolveDiscordRest } from "./send.shared.js";
import type {
DiscordChannelCreate,
DiscordChannelEdit,
@@ -8,6 +7,7 @@ import type {
DiscordChannelPermissionSet,
DiscordReactOpts,
} from "./send.types.js";
import { resolveDiscordRest } from "./send.shared.js";
export async function createChannelDiscord(
payload: DiscordChannelCreate,
@@ -70,6 +70,15 @@ export async function editChannelDiscord(
if (payload.autoArchiveDuration !== undefined) {
body.auto_archive_duration = payload.autoArchiveDuration;
}
if (payload.availableTags !== undefined) {
body.available_tags = payload.availableTags.map((t) => ({
...(t.id !== undefined && { id: t.id }),
name: t.name,
...(t.moderated !== undefined && { moderated: t.moderated }),
...(t.emoji_id !== undefined && { emoji_id: t.emoji_id }),
...(t.emoji_name !== undefined && { emoji_name: t.emoji_name }),
}));
}
return (await rest.patch(Routes.channel(payload.channelId), {
body,
})) as APIChannel;

View File

@@ -134,6 +134,14 @@ export type DiscordChannelCreate = {
nsfw?: boolean;
};
export type DiscordForumTag = {
id?: string;
name: string;
moderated?: boolean;
emoji_id?: string | null;
emoji_name?: string | null;
};
export type DiscordChannelEdit = {
channelId: string;
name?: string;
@@ -145,6 +153,7 @@ export type DiscordChannelEdit = {
archived?: boolean;
locked?: boolean;
autoArchiveDuration?: number;
availableTags?: DiscordForumTag[];
};
export type DiscordChannelMove = {