Files
openclaw/extensions/discord/src/send.shared.ts
Peter Steinberger 4349687b9d refactor(extensions): burn down Discord and Slack dead exports (#106017)
* refactor(discord): trim dead internal exports

* refactor(slack): trim dead internal exports

* test(discord): cover inherited streaming migration

* chore(tooling): ratchet reduced extension LOC

* refactor(discord): remove stale approval helper import

* chore(deadcode): remove reintroduced transcript baseline
2026-07-12 22:58:22 -07:00

555 lines
16 KiB
TypeScript

import { PollLayoutType } from "discord-api-types/payloads/v10";
import type { RESTAPIPoll } from "discord-api-types/rest/v10";
import type { APIChannel } from "discord-api-types/v10";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
// Discord plugin module implements send.shared behavior.
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
import { buildOutboundMediaLoadOptions } from "openclaw/plugin-sdk/media-runtime";
import { extensionForMime } from "openclaw/plugin-sdk/media-runtime";
import {
normalizePollDurationHours,
normalizePollInput,
type OutboundMediaAccess,
type PollInput,
} from "openclaw/plugin-sdk/media-runtime";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import type { ChunkMode } from "openclaw/plugin-sdk/reply-chunking";
import { resolveTextChunksWithFallback } from "openclaw/plugin-sdk/reply-payload";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
import { chunkDiscordTextWithMode } from "./chunk.js";
import { createDiscordClient, resolveDiscordRest, type DiscordClientOpts } from "./client.js";
import {
createChannelMessage,
createUserDmChannel,
getChannel,
RequestClient,
} from "./internal/discord.js";
import { parseAndResolveRecipient } from "./recipient-resolution.js";
import { resolveDiscordReplyMessageId, type DiscordReplyReference } from "./reply-reference.js";
import type { DiscordRetryRunner } from "./retry.js";
import { fetchChannelPermissionsDiscord, isThreadChannelType } from "./send.permissions.js";
import { DiscordSendError } from "./send.types.js";
const DISCORD_TEXT_LIMIT = 2000;
const DISCORD_MAX_STICKERS = 3;
const DISCORD_POLL_MAX_ANSWERS = 10;
const DISCORD_POLL_MAX_DURATION_HOURS = 32 * 24;
const DISCORD_MISSING_PERMISSIONS = 50013;
const DISCORD_CANNOT_DM = 50007;
const DISCORD_UPLOAD_TOO_LARGE = 40005;
const DISCORD_UPLOAD_TOO_LARGE_STATUS = 413;
const DISCORD_UPLOAD_TOO_LARGE_NOTICE =
"Attachment skipped: Discord rejected the file as too large.";
type DiscordRequest = DiscordRetryRunner;
export {
buildDiscordMessageRequest,
createDiscordMessageNonce,
resolveDiscordMessageFlags,
resolveDiscordSendComponents,
resolveDiscordSendEmbeds,
stripUndefinedFields,
SUPPRESS_NOTIFICATIONS_FLAG,
type DiscordAllowedMentions,
type DiscordSendComponents,
type DiscordSendEmbeds,
} from "./send.message-request.js";
import {
buildDiscordMessageRequest,
resolveDiscordMessageFlags,
resolveDiscordSendComponents,
resolveDiscordSendEmbeds,
type DiscordAllowedMentions,
type DiscordSendComponents,
type DiscordSendEmbeds,
} from "./send.message-request.js";
type DiscordRecipient =
| {
kind: "user";
id: string;
}
| {
kind: "channel";
id: string;
};
function normalizeReactionEmoji(raw: string) {
const trimmed = raw.trim();
if (!trimmed) {
throw new Error("emoji required");
}
const customMatch = trimmed.match(/^<a?:([^:>]+):(\d+)>$/);
const identifier = customMatch
? `${customMatch[1]}:${customMatch[2]}`
: trimmed.replace(/[\uFE0E\uFE0F]/g, "");
return encodeURIComponent(identifier);
}
function normalizeStickerIds(raw: string[]) {
const ids = normalizeStringEntries(raw);
if (ids.length === 0) {
throw new Error("At least one sticker id is required");
}
if (ids.length > DISCORD_MAX_STICKERS) {
throw new Error("Discord supports up to 3 stickers per message");
}
return ids;
}
function normalizeEmojiName(raw: string, label: string) {
const name = raw.trim();
if (!name) {
throw new Error(`${label} is required`);
}
return name;
}
function normalizeDiscordPollInput(input: PollInput): RESTAPIPoll {
const poll = normalizePollInput(input, {
maxOptions: DISCORD_POLL_MAX_ANSWERS,
});
const duration = normalizePollDurationHours(poll.durationHours, {
defaultHours: 24,
maxHours: DISCORD_POLL_MAX_DURATION_HOURS,
});
return {
question: { text: poll.question },
answers: poll.options.map((answer) => ({ poll_media: { text: answer } })),
duration,
allow_multiselect: poll.maxSelections > 1,
layout_type: PollLayoutType.Default,
};
}
function getDiscordErrorCode(err: unknown) {
if (!err || typeof err !== "object") {
return undefined;
}
const candidate =
"code" in err && err.code !== undefined
? err.code
: "rawError" in err && err.rawError && typeof err.rawError === "object"
? (err.rawError as { code?: unknown }).code
: undefined;
if (typeof candidate === "number") {
return candidate;
}
if (typeof candidate === "string" && /^\d+$/.test(candidate)) {
return Number(candidate);
}
return undefined;
}
function getDiscordErrorStatus(err: unknown) {
if (!err || typeof err !== "object") {
return undefined;
}
const candidate =
"status" in err && err.status !== undefined
? err.status
: "statusCode" in err && err.statusCode !== undefined
? err.statusCode
: undefined;
if (typeof candidate === "number" && Number.isFinite(candidate)) {
return candidate;
}
if (typeof candidate === "string" && /^\d+$/.test(candidate)) {
return Number(candidate);
}
return undefined;
}
function isDiscordUploadTooLargeError(err: unknown) {
return (
getDiscordErrorCode(err) === DISCORD_UPLOAD_TOO_LARGE ||
getDiscordErrorStatus(err) === DISCORD_UPLOAD_TOO_LARGE_STATUS
);
}
function buildDiscordUploadTooLargeFallbackText(text: string) {
return text.trim()
? `${text}\n\n[${DISCORD_UPLOAD_TOO_LARGE_NOTICE}]`
: DISCORD_UPLOAD_TOO_LARGE_NOTICE;
}
async function buildDiscordSendError(
err: unknown,
ctx: {
channelId: string;
cfg: OpenClawConfig;
rest: RequestClient;
token: string;
hasMedia: boolean;
},
) {
if (err instanceof DiscordSendError) {
return err;
}
const code = getDiscordErrorCode(err);
if (code === DISCORD_CANNOT_DM) {
return new DiscordSendError(
`discord dm failed: user blocks dms or privacy settings disallow it (code=${code})`,
{ kind: "dm-blocked", discordCode: code, status: getDiscordErrorStatus(err) },
);
}
if (code !== DISCORD_MISSING_PERMISSIONS) {
return err;
}
let missing: string[] = [];
let probedChannelType: number | undefined;
try {
const permissions = await fetchChannelPermissionsDiscord(ctx.channelId, {
rest: ctx.rest,
token: ctx.token,
cfg: ctx.cfg,
});
probedChannelType = permissions.channelType;
const current = new Set(permissions.permissions);
const required = ["ViewChannel", "SendMessages"];
if (isThreadChannelType(probedChannelType)) {
required.push("SendMessagesInThreads");
}
if (ctx.hasMedia) {
required.push("AttachFiles");
}
missing = required.filter((permission) => !current.has(permission));
} catch {
/* ignore permission probe errors */
}
const status = getDiscordErrorStatus(err);
const apiDetails = [`code=${code}`, status != null ? `status=${status}` : undefined]
.filter(Boolean)
.join(" ");
const probedPermissions = ["ViewChannel", "SendMessages"];
if (isThreadChannelType(probedChannelType)) {
probedPermissions.push("SendMessagesInThreads");
}
if (ctx.hasMedia) {
probedPermissions.push("AttachFiles");
}
const probeSummary = probedPermissions.join("/");
const missingLabel = missing.length
? `discord missing permissions in channel ${ctx.channelId}: ${missing.join(", ")}`
: `discord missing permissions in channel ${ctx.channelId}; permission probe did not identify missing ${probeSummary}`;
return new DiscordSendError(
`${missingLabel} (${apiDetails}). bot might be blocked by channel/thread overrides, archived thread state, reply target visibility, or app-role position`,
{
kind: "missing-permissions",
channelId: ctx.channelId,
missingPermissions: missing,
discordCode: code,
status,
},
);
}
async function resolveChannelId(
rest: RequestClient,
recipient: DiscordRecipient,
request: DiscordRequest,
): Promise<{ channelId: string; dm?: boolean }> {
if (recipient.kind === "channel") {
return { channelId: recipient.id };
}
const dmChannel = (await request(
() => createUserDmChannel(rest, recipient.id),
"dm-channel",
)) as { id: string };
if (!dmChannel?.id) {
throw new Error("Failed to create Discord DM channel");
}
return { channelId: dmChannel.id, dm: true };
}
async function resolveDiscordTargetChannelId(
raw: string,
opts: DiscordClientOpts & { cfg: OpenClawConfig },
): Promise<{ channelId: string; dm?: boolean }> {
const cfg = requireRuntimeConfig(opts.cfg, "Discord target channel resolution");
const recipient = await parseAndResolveRecipient(raw, cfg, opts.accountId, {
defaultKind: "channel",
});
const { rest, request } = createDiscordClient(opts);
return await resolveChannelId(rest, recipient, request);
}
export async function resolveDiscordChannel(
rest: RequestClient,
channelId: string,
): Promise<APIChannel | undefined> {
try {
return await getChannel(rest, channelId);
} catch {
return undefined;
}
}
export function buildDiscordTextChunks(
text: string,
opts: { maxLinesPerMessage?: number; chunkMode?: ChunkMode; maxChars?: number } = {},
): string[] {
if (!text) {
return [];
}
const chunks = chunkDiscordTextWithMode(text, {
maxChars: opts.maxChars ?? DISCORD_TEXT_LIMIT,
maxLines: opts.maxLinesPerMessage,
chunkMode: opts.chunkMode,
});
return resolveTextChunksWithFallback(text, chunks);
}
export type DiscordSendProgress = (
result: { id: string; channel_id: string },
kind: "text" | "media",
replyToId?: string,
) => Promise<void> | void;
type DiscordTextSendParams = {
rest: RequestClient;
channelId: string;
text: string;
request: DiscordRequest;
reply?: DiscordReplyReference;
maxLinesPerMessage?: number;
components?: DiscordSendComponents;
embeds?: DiscordSendEmbeds;
allowedMentions?: DiscordAllowedMentions;
chunkMode?: ChunkMode;
silent?: boolean;
suppressEmbeds?: boolean;
maxChars?: number;
onResult?: DiscordSendProgress;
};
async function sendDiscordText(params: DiscordTextSendParams) {
const {
rest,
channelId,
text,
request,
reply,
maxLinesPerMessage,
components,
embeds,
allowedMentions,
chunkMode,
silent,
suppressEmbeds,
maxChars,
onResult,
} = params;
if (!text.trim()) {
throw new Error("Message must be non-empty for Discord sends");
}
const chunks = buildDiscordTextChunks(text, { maxLinesPerMessage, chunkMode, maxChars });
const sendChunk = async (chunk: string, isFirst: boolean) => {
const chunkReplyTo = resolveDiscordReplyMessageId(reply, isFirst);
const chunkComponents = resolveDiscordSendComponents({
components,
text: chunk,
isFirst,
});
const chunkEmbeds = resolveDiscordSendEmbeds({ embeds, isFirst });
const flags = resolveDiscordMessageFlags({
silent,
suppressEmbeds: suppressEmbeds && !chunkEmbeds?.length,
});
const body = buildDiscordMessageRequest({
endpoint: "create-message",
text: chunk,
components: chunkComponents,
embeds: chunkEmbeds,
allowedMentions,
flags,
replyTo: chunkReplyTo,
});
const result = (await request(
() => createChannelMessage<{ id: string; channel_id: string }>(rest, channelId, { body }),
"text",
{ safety: "nonce-protected-create" },
)) as { id: string; channel_id: string };
return { result, replyToId: chunkReplyTo };
};
if (chunks.length === 1) {
const chunk = expectDefined(chunks.at(0), "single Discord text chunk");
const { result, replyToId } = await sendChunk(chunk, true);
await onResult?.(result, "text", replyToId);
return { ...result, platformMessageIds: result.id ? [result.id] : [] };
}
const platformMessageIds: string[] = [];
let last: { id: string; channel_id: string } | null = null;
for (const [index, chunk] of chunks.entries()) {
const sent = await sendChunk(chunk, index === 0);
last = sent.result;
await onResult?.(last, "text", sent.replyToId);
if (last.id) {
platformMessageIds.push(last.id);
}
}
if (!last) {
throw new Error("Discord send failed (empty chunk result)");
}
return { ...last, platformMessageIds };
}
type DiscordMediaSendParams = DiscordTextSendParams & {
mediaUrl: string;
filename?: string;
mediaAccess?: OutboundMediaAccess;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
maxBytes?: number;
};
async function sendDiscordMedia(params: DiscordMediaSendParams) {
const {
rest,
channelId,
text,
mediaUrl,
filename,
mediaAccess,
mediaLocalRoots,
mediaReadFile,
maxBytes,
reply,
request,
maxLinesPerMessage,
components,
embeds,
allowedMentions,
chunkMode,
silent,
suppressEmbeds,
maxChars,
onResult,
} = params;
const media = await loadWebMedia(
mediaUrl,
buildOutboundMediaLoadOptions({ maxBytes, mediaAccess, mediaLocalRoots, mediaReadFile }),
);
const requestedFileName = filename?.trim();
const resolvedFileName =
requestedFileName ||
media.fileName ||
(media.contentType ? `upload${extensionForMime(media.contentType) ?? ""}` : "") ||
"upload";
const chunks = text
? buildDiscordTextChunks(text, { maxLinesPerMessage, chunkMode, maxChars })
: [];
const caption = chunks[0] ?? "";
const captionComponents = resolveDiscordSendComponents({
components,
text: caption,
isFirst: true,
});
const captionEmbeds = resolveDiscordSendEmbeds({ embeds, isFirst: true });
const flags = resolveDiscordMessageFlags({
silent,
suppressEmbeds: suppressEmbeds && !captionEmbeds?.length,
});
const body = buildDiscordMessageRequest({
endpoint: "create-message",
text: caption,
components: captionComponents,
embeds: captionEmbeds,
allowedMentions,
flags,
replyTo: resolveDiscordReplyMessageId(reply, true),
files: [
{
data: media.buffer,
name: resolvedFileName,
},
],
});
let res: { id: string; channel_id: string };
try {
res = (await request(
() => createChannelMessage<{ id: string; channel_id: string }>(rest, channelId, { body }),
"media",
{ safety: "nonce-protected-create" },
)) as { id: string; channel_id: string };
} catch (err) {
if (!isDiscordUploadTooLargeError(err)) {
throw err;
}
// The multipart request is all-or-nothing. Retry the portable text only;
// attachment-coupled embeds/components may be invalid or misleading without it.
return sendDiscordText({
rest,
channelId,
text: buildDiscordUploadTooLargeFallbackText(text),
reply,
request,
maxLinesPerMessage,
chunkMode,
silent,
suppressEmbeds,
allowedMentions,
maxChars,
onResult,
});
}
await onResult?.(res, "media", reply?.messageId);
const platformMessageIds = res.id ? [res.id] : [];
const followupReply = reply?.scope === "all" ? reply : undefined;
for (const chunk of chunks.slice(1)) {
if (!chunk.trim()) {
continue;
}
const followup = await sendDiscordText({
rest,
channelId,
text: chunk,
reply: followupReply,
request,
maxLinesPerMessage,
chunkMode,
silent,
suppressEmbeds,
allowedMentions,
maxChars,
onResult,
});
for (const id of followup.platformMessageIds) {
if (id) {
platformMessageIds.push(id);
}
}
}
return { ...res, platformMessageIds };
}
function buildReactionIdentifier(emoji: { id?: string | null; name?: string | null }) {
if (emoji.id && emoji.name) {
return `${emoji.name}:${emoji.id}`;
}
return emoji.name ?? "";
}
function formatReactionEmoji(emoji: { id?: string | null; name?: string | null }) {
return buildReactionIdentifier(emoji);
}
export {
buildDiscordSendError,
buildReactionIdentifier,
createDiscordClient,
formatReactionEmoji,
normalizeDiscordPollInput,
normalizeEmojiName,
normalizeReactionEmoji,
normalizeStickerIds,
resolveChannelId,
resolveDiscordTargetChannelId,
resolveDiscordRest,
sendDiscordMedia,
sendDiscordText,
};