mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-18 08:31:49 -06:00
187 lines
5.6 KiB
TypeScript
187 lines
5.6 KiB
TypeScript
// Discord plugin module implements native command reply behavior.
|
|
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime";
|
|
import {
|
|
resolveSendableOutboundReplyParts,
|
|
resolveTextChunksWithFallback,
|
|
} from "openclaw/plugin-sdk/reply-payload";
|
|
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
|
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
|
|
import { chunkDiscordTextWithMode } from "../chunk.js";
|
|
import type {
|
|
ButtonInteraction,
|
|
CommandInteraction,
|
|
StringSelectMenuInteraction,
|
|
TopLevelComponents,
|
|
} from "../internal/discord.js";
|
|
|
|
export const DISCORD_EMPTY_VISIBLE_REPLY_WARNING = "⚠️ Command produced no visible reply.";
|
|
|
|
export function isDiscordUnknownInteraction(error: unknown): boolean {
|
|
if (!error || typeof error !== "object") {
|
|
return false;
|
|
}
|
|
const err = error as {
|
|
discordCode?: number;
|
|
status?: number;
|
|
message?: string;
|
|
rawBody?: { code?: number; message?: string };
|
|
};
|
|
if (err.discordCode === 10062 || err.rawBody?.code === 10062) {
|
|
return true;
|
|
}
|
|
if (err.status === 404 && /Unknown interaction/i.test(err.message ?? "")) {
|
|
return true;
|
|
}
|
|
if (/Unknown interaction/i.test(err.rawBody?.message ?? "")) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function hasRenderableReplyPayload(payload: ReplyPayload): boolean {
|
|
if (resolveSendableOutboundReplyParts(payload).hasContent) {
|
|
return true;
|
|
}
|
|
const discordData = payload.channelData?.discord as
|
|
| { components?: TopLevelComponents[] }
|
|
| undefined;
|
|
if (Array.isArray(discordData?.components) && discordData.components.length > 0) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export async function safeDiscordInteractionCall<T>(
|
|
label: string,
|
|
fn: () => Promise<T>,
|
|
): Promise<T | null> {
|
|
try {
|
|
return await fn();
|
|
} catch (error) {
|
|
if (isDiscordUnknownInteraction(error)) {
|
|
logVerbose(`discord: ${label} skipped (interaction expired)`);
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function deliverDiscordInteractionReply(params: {
|
|
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
|
|
payload: ReplyPayload;
|
|
mediaLocalRoots?: readonly string[];
|
|
textLimit: number;
|
|
maxLinesPerMessage?: number;
|
|
preferFollowUp: boolean;
|
|
responseEphemeral?: boolean;
|
|
chunkMode: "length" | "newline";
|
|
}) {
|
|
const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params;
|
|
const reply = resolveSendableOutboundReplyParts(payload);
|
|
const discordData = payload.channelData?.discord as
|
|
| { components?: TopLevelComponents[] }
|
|
| undefined;
|
|
let firstMessageComponents =
|
|
Array.isArray(discordData?.components) && discordData.components.length > 0
|
|
? discordData.components
|
|
: undefined;
|
|
|
|
let hasReplied = false;
|
|
const sendMessage = async (
|
|
content: string,
|
|
files?: { name: string; data: Buffer }[],
|
|
components?: TopLevelComponents[],
|
|
) => {
|
|
const contentPayload = content ? { content } : {};
|
|
const payloadLocal =
|
|
files && files.length > 0
|
|
? {
|
|
...contentPayload,
|
|
...(components ? { components } : {}),
|
|
...(params.responseEphemeral !== undefined
|
|
? { ephemeral: params.responseEphemeral }
|
|
: {}),
|
|
files: files.map((file) => {
|
|
if (file.data instanceof Blob) {
|
|
return { name: file.name, data: file.data };
|
|
}
|
|
const arrayBuffer = Uint8Array.from(file.data).buffer;
|
|
return { name: file.name, data: new Blob([arrayBuffer]) };
|
|
}),
|
|
}
|
|
: {
|
|
...contentPayload,
|
|
...(components ? { components } : {}),
|
|
...(params.responseEphemeral !== undefined
|
|
? { ephemeral: params.responseEphemeral }
|
|
: {}),
|
|
};
|
|
await safeDiscordInteractionCall("interaction send", async () => {
|
|
if (!preferFollowUp && !hasReplied) {
|
|
await interaction.reply(payloadLocal);
|
|
hasReplied = true;
|
|
firstMessageComponents = undefined;
|
|
return;
|
|
}
|
|
await interaction.followUp(payloadLocal);
|
|
hasReplied = true;
|
|
firstMessageComponents = undefined;
|
|
});
|
|
};
|
|
|
|
if (reply.hasMedia) {
|
|
const media = await Promise.all(
|
|
reply.mediaUrls.map(async (url) => {
|
|
const loaded = await loadWebMedia(url, {
|
|
localRoots: params.mediaLocalRoots,
|
|
});
|
|
return {
|
|
name: loaded.fileName ?? "upload",
|
|
data: loaded.buffer,
|
|
};
|
|
}),
|
|
);
|
|
const chunks = resolveTextChunksWithFallback(
|
|
reply.text,
|
|
chunkDiscordTextWithMode(reply.text, {
|
|
maxChars: textLimit,
|
|
maxLines: maxLinesPerMessage,
|
|
chunkMode,
|
|
}),
|
|
);
|
|
const caption = chunks[0] ?? "";
|
|
await sendMessage(caption, media, firstMessageComponents);
|
|
for (const chunk of chunks.slice(1)) {
|
|
if (!chunk.trim()) {
|
|
continue;
|
|
}
|
|
await sendMessage(chunk);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!reply.hasText && !firstMessageComponents) {
|
|
return;
|
|
}
|
|
let chunks =
|
|
reply.text || firstMessageComponents
|
|
? resolveTextChunksWithFallback(
|
|
reply.text,
|
|
chunkDiscordTextWithMode(reply.text, {
|
|
maxChars: textLimit,
|
|
maxLines: maxLinesPerMessage,
|
|
chunkMode,
|
|
}),
|
|
)
|
|
: [];
|
|
if (chunks.length === 0 && firstMessageComponents) {
|
|
chunks = [""];
|
|
}
|
|
for (const chunk of chunks) {
|
|
if (!chunk.trim() && !firstMessageComponents) {
|
|
continue;
|
|
}
|
|
await sendMessage(chunk, undefined, firstMessageComponents);
|
|
}
|
|
}
|