diff --git a/CHANGELOG.md b/CHANGELOG.md index f29e19d75..cf18d3020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/tools/common.test.ts b/src/agents/tools/common.test.ts new file mode 100644 index 000000000..c99c62f5a --- /dev/null +++ b/src/agents/tools/common.test.ts @@ -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" }, + ]); + }); +}); diff --git a/src/agents/tools/common.ts b/src/agents/tools/common.ts index 93f1db42e..74865abf2 100644 --- a/src/agents/tools/common.ts +++ b/src/agents/tools/common.ts @@ -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 => + 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; +} diff --git a/src/agents/tools/discord-actions-guild.ts b/src/agents/tools/discord-actions-guild.ts index 85fbdc572..630c6e9ac 100644 --- a/src/agents/tools/discord-actions-guild.ts +++ b/src/agents/tools/discord-actions-guild.ts @@ -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 }); } diff --git a/src/channels/plugins/actions/discord/handle-action.guild-admin.ts b/src/channels/plugins/actions/discord/handle-action.guild-admin.ts index 688698dd6..9e957bce4 100644 --- a/src/channels/plugins/actions/discord/handle-action.guild-admin.ts +++ b/src/channels/plugins/actions/discord/handle-action.guild-admin.ts @@ -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, ); diff --git a/src/discord/send.channels.ts b/src/discord/send.channels.ts index 829afeb1f..9a4097c25 100644 --- a/src/discord/send.channels.ts +++ b/src/discord/send.channels.ts @@ -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; diff --git a/src/discord/send.types.ts b/src/discord/send.types.ts index fa8b3b831..a13f90b1e 100644 --- a/src/discord/send.types.ts +++ b/src/discord/send.types.ts @@ -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 = {