feat(discord): support forum tag edits via channel-edit (#12070) (thanks @xiaoyaner0201)
This commit is contained in:
@@ -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.
|
||||
|
||||
28
src/agents/tools/common.test.ts
Normal file
28
src/agents/tools/common.test.ts
Normal 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user