mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:50:43 +00:00
refactor(discord): split message and binding helpers
This commit is contained in:
@@ -39,15 +39,6 @@ export {
|
||||
export { resolveOpenProviderRuntimeGroupPolicy as resolveDiscordRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
export { collectDiscordStatusIssues } from "./src/status-issues.js";
|
||||
|
||||
// Deprecated compatibility surface for existing @openclaw/discord/api.js consumers.
|
||||
type HandleDiscordMessageAction =
|
||||
typeof import("./src/actions/handle-action.js").handleDiscordMessageAction;
|
||||
|
||||
export const handleDiscordMessageAction: HandleDiscordMessageAction = (async (...args) => {
|
||||
const { handleDiscordMessageAction: run } = await import("./src/actions/handle-action.js");
|
||||
return run(...args);
|
||||
}) as HandleDiscordMessageAction;
|
||||
|
||||
export {
|
||||
buildDiscordComponentCustomId,
|
||||
buildDiscordComponentMessageFlags,
|
||||
@@ -82,10 +73,6 @@ export {
|
||||
type DiscordModalFieldSpec,
|
||||
type DiscordModalSpec,
|
||||
} from "./src/components.js";
|
||||
export {
|
||||
parseDiscordComponentCustomIdForInteraction as parseDiscordComponentCustomIdForCarbon,
|
||||
parseDiscordModalCustomIdForInteraction as parseDiscordModalCustomIdForCarbon,
|
||||
} from "./src/component-custom-id.js";
|
||||
export {
|
||||
getDiscordExecApprovalApprovers,
|
||||
isDiscordExecApprovalApprover,
|
||||
|
||||
74
extensions/discord/src/api-barrel.test.ts
Normal file
74
extensions/discord/src/api-barrel.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const API_SOURCE_PATH = resolve(dirname(fileURLToPath(import.meta.url)), "../api.ts");
|
||||
const itOnSupportedNode = Number(process.versions.node.split(".")[0]) >= 22 ? it : it.skip;
|
||||
|
||||
function collectExportedNames(): Set<string> {
|
||||
const source = ts.createSourceFile(
|
||||
API_SOURCE_PATH,
|
||||
readFileSync(API_SOURCE_PATH, "utf8"),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const names = new Set<string>();
|
||||
for (const statement of source.statements) {
|
||||
if (
|
||||
ts.isVariableStatement(statement) &&
|
||||
statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
|
||||
) {
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
if (ts.isIdentifier(declaration.name)) {
|
||||
names.add(declaration.name.text);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!ts.isExportDeclaration(statement) || !statement.exportClause) {
|
||||
continue;
|
||||
}
|
||||
if (ts.isNamedExports(statement.exportClause)) {
|
||||
for (const element of statement.exportClause.elements) {
|
||||
names.add(element.name.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
describe("discord API barrel", () => {
|
||||
it("exports current internal entrypoints", () => {
|
||||
const exportedNames = collectExportedNames();
|
||||
|
||||
for (const exportName of [
|
||||
"discordPlugin",
|
||||
"discordSetupPlugin",
|
||||
"buildDiscordComponentCustomId",
|
||||
"parseDiscordComponentCustomIdForInteraction",
|
||||
"parseDiscordModalCustomIdForInteraction",
|
||||
"fetchDiscordApplicationSummary",
|
||||
"DiscordSendResult",
|
||||
]) {
|
||||
expect(exportedNames).toContain(exportName);
|
||||
}
|
||||
});
|
||||
|
||||
itOnSupportedNode("links runtime exports used by bundled Discord wiring", async () => {
|
||||
const api = await import("../api.js");
|
||||
|
||||
for (const exportName of [
|
||||
"DISCORD_COMPONENT_CUSTOM_ID_KEY",
|
||||
"buildDiscordComponentMessageFlags",
|
||||
"createDiscordFormModal",
|
||||
"handleDiscordSubagentSpawning",
|
||||
"listEnabledDiscordAccounts",
|
||||
"resolveDiscordRuntimeGroupPolicy",
|
||||
"tryHandleDiscordMessageActionGuildAdmin",
|
||||
]) {
|
||||
expect(api).toHaveProperty(exportName);
|
||||
}
|
||||
});
|
||||
});
|
||||
106
extensions/discord/src/monitor/message-forwarded.ts
Normal file
106
extensions/discord/src/monitor/message-forwarded.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { APIAttachment, APIStickerItem } from "discord-api-types/v10";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { Message } from "../internal/discord.js";
|
||||
|
||||
export type DiscordSnapshotAuthor = {
|
||||
id?: string | null;
|
||||
username?: string | null;
|
||||
discriminator?: string | null;
|
||||
global_name?: string | null;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
export type DiscordSnapshotMessage = {
|
||||
content?: string | null;
|
||||
embeds?: Array<{ description?: string | null; title?: string | null }> | null;
|
||||
attachments?: APIAttachment[] | null;
|
||||
stickers?: APIStickerItem[] | null;
|
||||
sticker_items?: APIStickerItem[] | null;
|
||||
author?: DiscordSnapshotAuthor | null;
|
||||
};
|
||||
|
||||
export type DiscordMessageSnapshot = {
|
||||
message?: DiscordSnapshotMessage | null;
|
||||
};
|
||||
|
||||
const FORWARD_MESSAGE_REFERENCE_TYPE = 1;
|
||||
|
||||
export function normalizeDiscordStickerItems(value: unknown): APIStickerItem[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter(
|
||||
(entry): entry is APIStickerItem =>
|
||||
Boolean(entry) &&
|
||||
typeof entry === "object" &&
|
||||
typeof (entry as { id?: unknown }).id === "string" &&
|
||||
typeof (entry as { name?: unknown }).name === "string",
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDiscordMessageStickers(message: Message): APIStickerItem[] {
|
||||
const stickers = (message as { stickers?: unknown }).stickers;
|
||||
const normalized = normalizeDiscordStickerItems(stickers);
|
||||
if (normalized.length > 0) {
|
||||
return normalized;
|
||||
}
|
||||
const rawData = (message as { rawData?: { sticker_items?: unknown; stickers?: unknown } })
|
||||
.rawData;
|
||||
return normalizeDiscordStickerItems(rawData?.sticker_items ?? rawData?.stickers);
|
||||
}
|
||||
|
||||
export function resolveDiscordSnapshotStickers(snapshot: DiscordSnapshotMessage): APIStickerItem[] {
|
||||
return normalizeDiscordStickerItems(snapshot.stickers ?? snapshot.sticker_items);
|
||||
}
|
||||
|
||||
export function hasDiscordMessageStickers(message: Message): boolean {
|
||||
return resolveDiscordMessageStickers(message).length > 0;
|
||||
}
|
||||
|
||||
export function resolveDiscordMessageSnapshots(message: Message): DiscordMessageSnapshot[] {
|
||||
const rawData = (message as { rawData?: { message_snapshots?: unknown } }).rawData;
|
||||
return normalizeDiscordMessageSnapshots(
|
||||
rawData?.message_snapshots ??
|
||||
(message as { message_snapshots?: unknown }).message_snapshots ??
|
||||
(message as { messageSnapshots?: unknown }).messageSnapshots,
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeDiscordMessageSnapshots(snapshots: unknown): DiscordMessageSnapshot[] {
|
||||
if (!Array.isArray(snapshots)) {
|
||||
return [];
|
||||
}
|
||||
return snapshots.filter(
|
||||
(entry): entry is DiscordMessageSnapshot => Boolean(entry) && typeof entry === "object",
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDiscordReferencedForwardMessage(message: Message): Message | null {
|
||||
const referenceType = message.messageReference?.type;
|
||||
return Number(referenceType) === FORWARD_MESSAGE_REFERENCE_TYPE
|
||||
? message.referencedMessage
|
||||
: null;
|
||||
}
|
||||
|
||||
export function formatDiscordSnapshotAuthor(
|
||||
author: DiscordSnapshotAuthor | null | undefined,
|
||||
): string | undefined {
|
||||
if (!author) {
|
||||
return undefined;
|
||||
}
|
||||
const globalName = normalizeOptionalString(author.global_name) ?? undefined;
|
||||
const username = normalizeOptionalString(author.username) ?? undefined;
|
||||
const name = normalizeOptionalString(author.name) ?? undefined;
|
||||
const discriminator = normalizeOptionalString(author.discriminator) ?? undefined;
|
||||
const base = globalName || username || name;
|
||||
if (username && discriminator && discriminator !== "0") {
|
||||
return `@${username}#${discriminator}`;
|
||||
}
|
||||
if (base) {
|
||||
return `@${base}`;
|
||||
}
|
||||
if (author.id) {
|
||||
return `@${author.id}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DEFAULT_EMOJIS } from "openclaw/plugin-sdk/channel-feedback";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
|
||||
|
||||
const sendMocks = vi.hoisted(() => ({
|
||||
reactMessageDiscord: vi.fn<
|
||||
@@ -262,7 +263,7 @@ function createNoQueuedDispatchResult() {
|
||||
|
||||
async function processStreamOffDiscordMessage() {
|
||||
const ctx = await createBaseContext({ discordConfig: { streamMode: "off" } });
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -346,8 +347,8 @@ function getLastDispatchReplyOptions(): DispatchInboundParams["replyOptions"] |
|
||||
return params?.replyOptions;
|
||||
}
|
||||
|
||||
async function runProcessDiscordMessage(ctx: unknown): Promise<void> {
|
||||
await processDiscordMessage(ctx as any);
|
||||
async function runProcessDiscordMessage(ctx: DiscordMessagePreflightContext): Promise<void> {
|
||||
await processDiscordMessage(ctx);
|
||||
}
|
||||
|
||||
async function runInPartialStreamMode(): Promise<void> {
|
||||
@@ -447,7 +448,7 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
effectiveWasMentioned: false,
|
||||
});
|
||||
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(sendMocks.reactMessageDiscord).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -466,7 +467,7 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expectReactAckCallAt(0, "👀", {
|
||||
accountId: "ops",
|
||||
@@ -486,7 +487,7 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
effectiveWasMentioned: true,
|
||||
});
|
||||
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expectReactAckCallAt(0, "👀", {
|
||||
channelId: "fallback-channel",
|
||||
@@ -537,7 +538,7 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
|
||||
const ctx = await createAutomaticSourceDeliveryContext();
|
||||
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
const emojis = getReactionEmojis();
|
||||
expect(emojis).toContain("👀");
|
||||
@@ -558,7 +559,7 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
});
|
||||
|
||||
const ctx = await createAutomaticSourceDeliveryContext();
|
||||
const runPromise = processDiscordMessage(ctx as any);
|
||||
const runPromise = runProcessDiscordMessage(ctx);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_001);
|
||||
releaseDispatch();
|
||||
@@ -592,7 +593,7 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
const emojis = getReactionEmojis();
|
||||
expect(emojis).toContain("🟦");
|
||||
@@ -645,7 +646,7 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const runPromise = processDiscordMessage(ctx as any);
|
||||
const runPromise = runProcessDiscordMessage(ctx);
|
||||
await vi.advanceTimersByTimeAsync(2_500);
|
||||
await vi.runAllTimersAsync();
|
||||
await runPromise;
|
||||
@@ -673,7 +674,7 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
await vi.waitFor(() => expect(sendMocks.removeReactionDiscord).toHaveBeenCalled());
|
||||
expectRemoveAckCallAt(0, "👀", {
|
||||
@@ -738,7 +739,7 @@ describe("processDiscordMessage session routing", () => {
|
||||
mediaMaxBytes: 1024 * 1024,
|
||||
});
|
||||
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(getLastDispatchCtx()).toMatchObject({
|
||||
BodyForAgent: "hello from discord voice",
|
||||
@@ -760,7 +761,7 @@ describe("processDiscordMessage session routing", () => {
|
||||
messageChannelId: "dm1",
|
||||
});
|
||||
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(getLastRouteUpdate()).toEqual({
|
||||
sessionKey: "agent:main:discord:direct:u1",
|
||||
@@ -776,7 +777,7 @@ describe("processDiscordMessage session routing", () => {
|
||||
route: BASE_CHANNEL_ROUTE,
|
||||
});
|
||||
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(getLastRouteUpdate()).toEqual({
|
||||
sessionKey: "agent:main:discord:channel:c1",
|
||||
@@ -794,7 +795,7 @@ describe("processDiscordMessage session routing", () => {
|
||||
route: BASE_CHANNEL_ROUTE,
|
||||
});
|
||||
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(getLastDispatchReplyOptions()).toMatchObject({
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
@@ -821,7 +822,7 @@ describe("processDiscordMessage session routing", () => {
|
||||
route: BASE_CHANNEL_ROUTE,
|
||||
});
|
||||
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
||||
expect(sendMocks.reactMessageDiscord).not.toHaveBeenCalled();
|
||||
@@ -829,18 +830,18 @@ describe("processDiscordMessage session routing", () => {
|
||||
});
|
||||
|
||||
it("defaults guild replies to message-tool-only source delivery", async () => {
|
||||
await processDiscordMessage(
|
||||
(await createBaseContext({
|
||||
await runProcessDiscordMessage(
|
||||
await createBaseContext({
|
||||
shouldRequireMention: true,
|
||||
effectiveWasMentioned: true,
|
||||
route: BASE_CHANNEL_ROUTE,
|
||||
})) as any,
|
||||
}),
|
||||
);
|
||||
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
||||
|
||||
dispatchInboundMessage.mockClear();
|
||||
await processDiscordMessage(
|
||||
(await createBaseContext({
|
||||
await runProcessDiscordMessage(
|
||||
await createBaseContext({
|
||||
shouldRequireMention: true,
|
||||
effectiveWasMentioned: true,
|
||||
cfg: {
|
||||
@@ -852,15 +853,15 @@ describe("processDiscordMessage session routing", () => {
|
||||
session: { store: "/tmp/openclaw-discord-process-test-sessions.json" },
|
||||
},
|
||||
route: BASE_CHANNEL_ROUTE,
|
||||
})) as any,
|
||||
}),
|
||||
);
|
||||
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("automatic");
|
||||
|
||||
dispatchInboundMessage.mockClear();
|
||||
await processDiscordMessage(
|
||||
(await createBaseContext({
|
||||
await runProcessDiscordMessage(
|
||||
await createBaseContext({
|
||||
...createDirectMessageContextOverrides(),
|
||||
})) as any,
|
||||
}),
|
||||
);
|
||||
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("automatic");
|
||||
});
|
||||
@@ -891,7 +892,7 @@ describe("processDiscordMessage session routing", () => {
|
||||
route: BASE_CHANNEL_ROUTE,
|
||||
});
|
||||
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(getLastDispatchCtx()).toMatchObject({
|
||||
SessionKey: "agent:main:subagent:child",
|
||||
@@ -924,7 +925,7 @@ describe("processDiscordMessage session routing", () => {
|
||||
discordConfig: { thread: { inheritParent: false } },
|
||||
});
|
||||
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(getLastDispatchCtx()).toMatchObject({
|
||||
SessionKey: "agent:main:discord:channel:thread-1",
|
||||
@@ -946,7 +947,7 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
discordConfig,
|
||||
});
|
||||
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
}
|
||||
|
||||
async function createBlockModeContext() {
|
||||
@@ -1007,7 +1008,7 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
discordConfig: { streamMode: "partial" },
|
||||
});
|
||||
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(editMessageDiscord).toHaveBeenCalledWith(
|
||||
"c1",
|
||||
@@ -1033,7 +1034,7 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
discordConfig: { streamMode: "partial", maxLinesPerMessage: 5 },
|
||||
});
|
||||
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(editMessageDiscord).not.toHaveBeenCalled();
|
||||
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
|
||||
@@ -1053,7 +1054,7 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
discordConfig: { streamMode: "partial", maxLinesPerMessage: 5 },
|
||||
});
|
||||
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(draftStream.flush).not.toHaveBeenCalled();
|
||||
expect(draftStream.discardPending).toHaveBeenCalledTimes(1);
|
||||
@@ -1076,7 +1077,7 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
discordConfig: { streamMode: "partial", maxLinesPerMessage: 5 },
|
||||
});
|
||||
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(draftStream.flush).not.toHaveBeenCalled();
|
||||
expect(draftStream.discardPending).toHaveBeenCalledTimes(1);
|
||||
@@ -1105,7 +1106,7 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
discordConfig: { streamMode: "off" },
|
||||
});
|
||||
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
expect(editMessageDiscord).not.toHaveBeenCalled();
|
||||
@@ -1128,7 +1129,7 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
|
||||
const ctx = await createBlockModeContext();
|
||||
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
const updates = draftStream.update.mock.calls.map((call) => call[0]);
|
||||
expect(updates).toEqual(["Hello", "HelloWorld"]);
|
||||
@@ -1148,7 +1149,7 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
discordConfig: { streamMode: "partial" },
|
||||
});
|
||||
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(draftStream.update).toHaveBeenCalledWith("Hello world");
|
||||
});
|
||||
@@ -1164,7 +1165,7 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
|
||||
const ctx = await createBlockModeContext();
|
||||
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
507
extensions/discord/src/monitor/message-media.ts
Normal file
507
extensions/discord/src/monitor/message-media.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10";
|
||||
import { getFileExtension } from "openclaw/plugin-sdk/media-mime";
|
||||
import {
|
||||
fetchRemoteMedia,
|
||||
saveMediaBuffer,
|
||||
type FetchLike,
|
||||
} from "openclaw/plugin-sdk/media-runtime";
|
||||
import { buildMediaPayload } from "openclaw/plugin-sdk/reply-payload";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { Message } from "../internal/discord.js";
|
||||
import {
|
||||
resolveDiscordMessageSnapshots,
|
||||
resolveDiscordMessageStickers,
|
||||
resolveDiscordReferencedForwardMessage,
|
||||
resolveDiscordSnapshotStickers,
|
||||
} from "./message-forwarded.js";
|
||||
import { mergeAbortSignals } from "./timeouts.js";
|
||||
|
||||
const DISCORD_CDN_HOSTNAMES = [
|
||||
"cdn.discordapp.com",
|
||||
"media.discordapp.net",
|
||||
"*.discordapp.com",
|
||||
"*.discordapp.net",
|
||||
];
|
||||
|
||||
// Allow Discord CDN downloads when VPN/proxy DNS resolves to RFC2544 benchmark ranges.
|
||||
const DISCORD_MEDIA_SSRF_POLICY: SsrFPolicy = {
|
||||
hostnameAllowlist: DISCORD_CDN_HOSTNAMES,
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
};
|
||||
|
||||
const AUDIO_ATTACHMENT_EXTENSIONS = new Set([
|
||||
".aac",
|
||||
".caf",
|
||||
".flac",
|
||||
".m4a",
|
||||
".mp3",
|
||||
".oga",
|
||||
".ogg",
|
||||
".opus",
|
||||
".wav",
|
||||
]);
|
||||
|
||||
const DISCORD_STICKER_ASSET_BASE_URL = "https://media.discordapp.net/stickers";
|
||||
|
||||
export type DiscordMediaInfo = {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
};
|
||||
|
||||
export type DiscordMediaResolveOptions = {
|
||||
fetchImpl?: FetchLike;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
readIdleTimeoutMs?: number;
|
||||
totalTimeoutMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
};
|
||||
|
||||
type DiscordStickerAssetCandidate = {
|
||||
url: string;
|
||||
fileName: string;
|
||||
};
|
||||
|
||||
function isDiscordAudioAttachmentFileName(fileName?: string | null): boolean {
|
||||
const ext = getFileExtension(fileName);
|
||||
return Boolean(ext && AUDIO_ATTACHMENT_EXTENSIONS.has(ext));
|
||||
}
|
||||
|
||||
function hasDiscordVoiceAttachmentFields(attachment: APIAttachment): boolean {
|
||||
return typeof attachment.duration_secs === "number" || typeof attachment.waveform === "string";
|
||||
}
|
||||
|
||||
function mergeHostnameList(...lists: Array<string[] | undefined>): string[] | undefined {
|
||||
const merged = lists
|
||||
.flatMap((list) => list ?? [])
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
if (merged.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Array.from(new Set(merged));
|
||||
}
|
||||
|
||||
function resolveDiscordMediaSsrFPolicy(policy?: SsrFPolicy): SsrFPolicy {
|
||||
if (!policy) {
|
||||
return DISCORD_MEDIA_SSRF_POLICY;
|
||||
}
|
||||
const hostnameAllowlist = mergeHostnameList(
|
||||
DISCORD_MEDIA_SSRF_POLICY.hostnameAllowlist,
|
||||
policy.hostnameAllowlist,
|
||||
);
|
||||
const allowedHostnames = mergeHostnameList(
|
||||
DISCORD_MEDIA_SSRF_POLICY.allowedHostnames,
|
||||
policy.allowedHostnames,
|
||||
);
|
||||
return {
|
||||
...DISCORD_MEDIA_SSRF_POLICY,
|
||||
...policy,
|
||||
...(allowedHostnames ? { allowedHostnames } : {}),
|
||||
...(hostnameAllowlist ? { hostnameAllowlist } : {}),
|
||||
allowRfc2544BenchmarkRange:
|
||||
Boolean(DISCORD_MEDIA_SSRF_POLICY.allowRfc2544BenchmarkRange) ||
|
||||
Boolean(policy.allowRfc2544BenchmarkRange),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveMediaList(
|
||||
message: Message,
|
||||
maxBytes: number,
|
||||
options?: DiscordMediaResolveOptions,
|
||||
): Promise<DiscordMediaInfo[]> {
|
||||
const out: DiscordMediaInfo[] = [];
|
||||
const resolvedSsrFPolicy = resolveDiscordMediaSsrFPolicy(options?.ssrfPolicy);
|
||||
await appendResolvedMediaFromAttachments({
|
||||
attachments: message.attachments ?? [],
|
||||
maxBytes,
|
||||
out,
|
||||
errorPrefix: "discord: failed to download attachment",
|
||||
fetchImpl: options?.fetchImpl,
|
||||
ssrfPolicy: resolvedSsrFPolicy,
|
||||
readIdleTimeoutMs: options?.readIdleTimeoutMs,
|
||||
totalTimeoutMs: options?.totalTimeoutMs,
|
||||
abortSignal: options?.abortSignal,
|
||||
});
|
||||
await appendResolvedMediaFromStickers({
|
||||
stickers: resolveDiscordMessageStickers(message),
|
||||
maxBytes,
|
||||
out,
|
||||
errorPrefix: "discord: failed to download sticker",
|
||||
fetchImpl: options?.fetchImpl,
|
||||
ssrfPolicy: resolvedSsrFPolicy,
|
||||
readIdleTimeoutMs: options?.readIdleTimeoutMs,
|
||||
totalTimeoutMs: options?.totalTimeoutMs,
|
||||
abortSignal: options?.abortSignal,
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function resolveForwardedMediaList(
|
||||
message: Message,
|
||||
maxBytes: number,
|
||||
options?: DiscordMediaResolveOptions,
|
||||
): Promise<DiscordMediaInfo[]> {
|
||||
const snapshots = resolveDiscordMessageSnapshots(message);
|
||||
const out: DiscordMediaInfo[] = [];
|
||||
const resolvedSsrFPolicy = resolveDiscordMediaSsrFPolicy(options?.ssrfPolicy);
|
||||
if (snapshots.length > 0) {
|
||||
for (const snapshot of snapshots) {
|
||||
await appendResolvedMediaFromAttachments({
|
||||
attachments: snapshot.message?.attachments,
|
||||
maxBytes,
|
||||
out,
|
||||
errorPrefix: "discord: failed to download forwarded attachment",
|
||||
fetchImpl: options?.fetchImpl,
|
||||
ssrfPolicy: resolvedSsrFPolicy,
|
||||
readIdleTimeoutMs: options?.readIdleTimeoutMs,
|
||||
totalTimeoutMs: options?.totalTimeoutMs,
|
||||
abortSignal: options?.abortSignal,
|
||||
});
|
||||
await appendResolvedMediaFromStickers({
|
||||
stickers: snapshot.message ? resolveDiscordSnapshotStickers(snapshot.message) : [],
|
||||
maxBytes,
|
||||
out,
|
||||
errorPrefix: "discord: failed to download forwarded sticker",
|
||||
fetchImpl: options?.fetchImpl,
|
||||
ssrfPolicy: resolvedSsrFPolicy,
|
||||
readIdleTimeoutMs: options?.readIdleTimeoutMs,
|
||||
totalTimeoutMs: options?.totalTimeoutMs,
|
||||
abortSignal: options?.abortSignal,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const referencedForward = resolveDiscordReferencedForwardMessage(message);
|
||||
if (!referencedForward) {
|
||||
return out;
|
||||
}
|
||||
await appendResolvedMediaFromAttachments({
|
||||
attachments: referencedForward.attachments,
|
||||
maxBytes,
|
||||
out,
|
||||
errorPrefix: "discord: failed to download forwarded attachment",
|
||||
fetchImpl: options?.fetchImpl,
|
||||
ssrfPolicy: resolvedSsrFPolicy,
|
||||
readIdleTimeoutMs: options?.readIdleTimeoutMs,
|
||||
totalTimeoutMs: options?.totalTimeoutMs,
|
||||
abortSignal: options?.abortSignal,
|
||||
});
|
||||
await appendResolvedMediaFromStickers({
|
||||
stickers: resolveDiscordMessageStickers(referencedForward),
|
||||
maxBytes,
|
||||
out,
|
||||
errorPrefix: "discord: failed to download forwarded sticker",
|
||||
fetchImpl: options?.fetchImpl,
|
||||
ssrfPolicy: resolvedSsrFPolicy,
|
||||
readIdleTimeoutMs: options?.readIdleTimeoutMs,
|
||||
totalTimeoutMs: options?.totalTimeoutMs,
|
||||
abortSignal: options?.abortSignal,
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchDiscordMedia(params: {
|
||||
url: string;
|
||||
filePathHint: string;
|
||||
maxBytes: number;
|
||||
fetchImpl?: FetchLike;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
readIdleTimeoutMs?: number;
|
||||
totalTimeoutMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}) {
|
||||
const timeoutAbortController = params.totalTimeoutMs ? new AbortController() : undefined;
|
||||
const signal = mergeAbortSignals([params.abortSignal, timeoutAbortController?.signal]);
|
||||
let timedOut = false;
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const fetchPromise = fetchRemoteMedia({
|
||||
url: params.url,
|
||||
filePathHint: params.filePathHint,
|
||||
maxBytes: params.maxBytes,
|
||||
fetchImpl: params.fetchImpl,
|
||||
ssrfPolicy: params.ssrfPolicy,
|
||||
readIdleTimeoutMs: params.readIdleTimeoutMs,
|
||||
...(signal ? { requestInit: { signal } } : {}),
|
||||
}).catch((error) => {
|
||||
if (timedOut) {
|
||||
return new Promise<never>(() => {});
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
try {
|
||||
if (!params.totalTimeoutMs) {
|
||||
return await fetchPromise;
|
||||
}
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
timedOut = true;
|
||||
timeoutAbortController?.abort();
|
||||
reject(new Error(`discord media download timed out after ${params.totalTimeoutMs}ms`));
|
||||
}, params.totalTimeoutMs);
|
||||
timeoutHandle.unref?.();
|
||||
});
|
||||
return await Promise.race([fetchPromise, timeoutPromise]);
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function appendResolvedMediaFromAttachments(params: {
|
||||
attachments?: APIAttachment[] | null;
|
||||
maxBytes: number;
|
||||
out: DiscordMediaInfo[];
|
||||
errorPrefix: string;
|
||||
fetchImpl?: FetchLike;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
readIdleTimeoutMs?: number;
|
||||
totalTimeoutMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}) {
|
||||
const attachments = params.attachments;
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const attachment of attachments) {
|
||||
const attachmentUrl = normalizeOptionalString(attachment.url);
|
||||
if (!attachmentUrl) {
|
||||
logVerbose(
|
||||
`${params.errorPrefix} ${attachment.id ?? attachment.filename ?? "attachment"}: missing url`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const fetched = await fetchDiscordMedia({
|
||||
url: attachmentUrl,
|
||||
filePathHint: attachment.filename ?? attachmentUrl,
|
||||
maxBytes: params.maxBytes,
|
||||
fetchImpl: params.fetchImpl,
|
||||
ssrfPolicy: params.ssrfPolicy,
|
||||
readIdleTimeoutMs: params.readIdleTimeoutMs,
|
||||
totalTimeoutMs: params.totalTimeoutMs,
|
||||
abortSignal: params.abortSignal,
|
||||
});
|
||||
const saved = await saveMediaBuffer(
|
||||
fetched.buffer,
|
||||
fetched.contentType ?? attachment.content_type,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
);
|
||||
params.out.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: inferPlaceholder(attachment),
|
||||
});
|
||||
} catch (err) {
|
||||
const id = attachment.id ?? attachmentUrl;
|
||||
logVerbose(`${params.errorPrefix} ${id}: ${String(err)}`);
|
||||
params.out.push({
|
||||
path: attachmentUrl,
|
||||
contentType: attachment.content_type,
|
||||
placeholder: inferPlaceholder(attachment),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStickerAssetCandidates(sticker: APIStickerItem): DiscordStickerAssetCandidate[] {
|
||||
const baseName = sticker.name?.trim() || `sticker-${sticker.id}`;
|
||||
switch (sticker.format_type) {
|
||||
case StickerFormatType.GIF:
|
||||
return [
|
||||
{ url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.gif`, fileName: `${baseName}.gif` },
|
||||
];
|
||||
case StickerFormatType.Lottie:
|
||||
return [
|
||||
{
|
||||
url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.png?size=160`,
|
||||
fileName: `${baseName}.png`,
|
||||
},
|
||||
{
|
||||
url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.json`,
|
||||
fileName: `${baseName}.json`,
|
||||
},
|
||||
];
|
||||
case StickerFormatType.APNG:
|
||||
case StickerFormatType.PNG:
|
||||
default:
|
||||
return [
|
||||
{ url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.png`, fileName: `${baseName}.png` },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function formatStickerError(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(err) ?? "unknown error";
|
||||
} catch {
|
||||
return "unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
function inferStickerContentType(sticker: APIStickerItem): string | undefined {
|
||||
switch (sticker.format_type) {
|
||||
case StickerFormatType.GIF:
|
||||
return "image/gif";
|
||||
case StickerFormatType.APNG:
|
||||
case StickerFormatType.Lottie:
|
||||
case StickerFormatType.PNG:
|
||||
return "image/png";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function appendResolvedMediaFromStickers(params: {
|
||||
stickers?: APIStickerItem[] | null;
|
||||
maxBytes: number;
|
||||
out: DiscordMediaInfo[];
|
||||
errorPrefix: string;
|
||||
fetchImpl?: FetchLike;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
readIdleTimeoutMs?: number;
|
||||
totalTimeoutMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}) {
|
||||
const stickers = params.stickers;
|
||||
if (!stickers || stickers.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const sticker of stickers) {
|
||||
const candidates = resolveStickerAssetCandidates(sticker);
|
||||
let lastError: unknown;
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const fetched = await fetchDiscordMedia({
|
||||
url: candidate.url,
|
||||
filePathHint: candidate.fileName,
|
||||
maxBytes: params.maxBytes,
|
||||
fetchImpl: params.fetchImpl,
|
||||
ssrfPolicy: params.ssrfPolicy,
|
||||
readIdleTimeoutMs: params.readIdleTimeoutMs,
|
||||
totalTimeoutMs: params.totalTimeoutMs,
|
||||
abortSignal: params.abortSignal,
|
||||
});
|
||||
const saved = await saveMediaBuffer(
|
||||
fetched.buffer,
|
||||
fetched.contentType,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
);
|
||||
params.out.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: "<media:sticker>",
|
||||
});
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
if (lastError) {
|
||||
logVerbose(`${params.errorPrefix} ${sticker.id}: ${formatStickerError(lastError)}`);
|
||||
const fallback = candidates[0];
|
||||
if (fallback) {
|
||||
params.out.push({
|
||||
path: fallback.url,
|
||||
contentType: inferStickerContentType(sticker),
|
||||
placeholder: "<media:sticker>",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function inferPlaceholder(attachment: APIAttachment): string {
|
||||
const mime = attachment.content_type ?? "";
|
||||
if (mime.startsWith("image/")) {
|
||||
return "<media:image>";
|
||||
}
|
||||
if (mime.startsWith("video/")) {
|
||||
return "<media:video>";
|
||||
}
|
||||
if (mime.startsWith("audio/")) {
|
||||
return "<media:audio>";
|
||||
}
|
||||
if (hasDiscordVoiceAttachmentFields(attachment)) {
|
||||
return "<media:audio>";
|
||||
}
|
||||
if (isDiscordAudioAttachmentFileName(attachment.filename ?? attachment.url)) {
|
||||
return "<media:audio>";
|
||||
}
|
||||
return "<media:document>";
|
||||
}
|
||||
|
||||
function isImageAttachment(attachment: APIAttachment): boolean {
|
||||
const mime = attachment.content_type ?? "";
|
||||
if (mime.startsWith("image/")) {
|
||||
return true;
|
||||
}
|
||||
const name = normalizeLowercaseStringOrEmpty(attachment.filename);
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
return /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/.test(name);
|
||||
}
|
||||
|
||||
function buildDiscordAttachmentPlaceholder(attachments?: APIAttachment[]): string {
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const count = attachments.length;
|
||||
const allImages = attachments.every(isImageAttachment);
|
||||
const label = allImages ? "image" : "file";
|
||||
const suffix = count === 1 ? label : `${label}s`;
|
||||
const tag = allImages ? "<media:image>" : "<media:document>";
|
||||
return `${tag} (${count} ${suffix})`;
|
||||
}
|
||||
|
||||
function buildDiscordStickerPlaceholder(stickers?: APIStickerItem[]): string {
|
||||
if (!stickers || stickers.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const count = stickers.length;
|
||||
const label = count === 1 ? "sticker" : "stickers";
|
||||
return `<media:sticker> (${count} ${label})`;
|
||||
}
|
||||
|
||||
export function buildDiscordMediaPlaceholder(params: {
|
||||
attachments?: APIAttachment[];
|
||||
stickers?: APIStickerItem[];
|
||||
}): string {
|
||||
const attachmentText = buildDiscordAttachmentPlaceholder(params.attachments);
|
||||
const stickerText = buildDiscordStickerPlaceholder(params.stickers);
|
||||
if (attachmentText && stickerText) {
|
||||
return `${attachmentText}\n${stickerText}`;
|
||||
}
|
||||
return attachmentText || stickerText || "";
|
||||
}
|
||||
|
||||
export function buildDiscordMediaPayload(
|
||||
mediaList: Array<{ path: string; contentType?: string }>,
|
||||
): {
|
||||
MediaPath?: string;
|
||||
MediaType?: string;
|
||||
MediaUrl?: string;
|
||||
MediaPaths?: string[];
|
||||
MediaUrls?: string[];
|
||||
MediaTypes?: string[];
|
||||
} {
|
||||
return buildMediaPayload(mediaList);
|
||||
}
|
||||
123
extensions/discord/src/monitor/message-text.ts
Normal file
123
extensions/discord/src/monitor/message-text.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { Message } from "../internal/discord.js";
|
||||
import {
|
||||
formatDiscordSnapshotAuthor,
|
||||
normalizeDiscordMessageSnapshots,
|
||||
resolveDiscordMessageSnapshots,
|
||||
resolveDiscordMessageStickers,
|
||||
resolveDiscordReferencedForwardMessage,
|
||||
resolveDiscordSnapshotStickers,
|
||||
type DiscordSnapshotMessage,
|
||||
} from "./message-forwarded.js";
|
||||
import { buildDiscordMediaPlaceholder } from "./message-media.js";
|
||||
|
||||
export function resolveDiscordEmbedText(
|
||||
embed?: { title?: string | null; description?: string | null } | null,
|
||||
): string {
|
||||
const title = normalizeOptionalString(embed?.title) ?? "";
|
||||
const description = normalizeOptionalString(embed?.description) ?? "";
|
||||
if (title && description) {
|
||||
return `${title}\n${description}`;
|
||||
}
|
||||
return title || description || "";
|
||||
}
|
||||
|
||||
export function resolveDiscordMessageText(
|
||||
message: Message,
|
||||
options?: { fallbackText?: string; includeForwarded?: boolean },
|
||||
): string {
|
||||
const embedText = resolveDiscordEmbedText(
|
||||
(message.embeds?.[0] as { title?: string | null; description?: string | null } | undefined) ??
|
||||
null,
|
||||
);
|
||||
const rawText =
|
||||
normalizeOptionalString(message.content) ||
|
||||
buildDiscordMediaPlaceholder({
|
||||
attachments: message.attachments ?? undefined,
|
||||
stickers: resolveDiscordMessageStickers(message),
|
||||
}) ||
|
||||
embedText ||
|
||||
normalizeOptionalString(options?.fallbackText) ||
|
||||
"";
|
||||
const baseText = resolveDiscordMentions(rawText, message);
|
||||
if (!options?.includeForwarded) {
|
||||
return baseText;
|
||||
}
|
||||
const forwardedText = resolveDiscordForwardedMessagesText(message);
|
||||
if (!forwardedText) {
|
||||
return baseText;
|
||||
}
|
||||
if (!baseText) {
|
||||
return forwardedText;
|
||||
}
|
||||
return `${baseText}\n${forwardedText}`;
|
||||
}
|
||||
|
||||
function resolveDiscordMentions(text: string, message: Message): string {
|
||||
if (!text.includes("<")) {
|
||||
return text;
|
||||
}
|
||||
const mentions = message.mentionedUsers ?? [];
|
||||
if (!Array.isArray(mentions) || mentions.length === 0) {
|
||||
return text;
|
||||
}
|
||||
let out = text;
|
||||
for (const user of mentions) {
|
||||
const label = user.globalName || user.username;
|
||||
out = out.replace(new RegExp(`<@!?${user.id}>`, "g"), `@${label}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveDiscordForwardedMessagesText(message: Message): string {
|
||||
const snapshots = resolveDiscordMessageSnapshots(message);
|
||||
if (snapshots.length > 0) {
|
||||
return resolveDiscordForwardedMessagesTextFromSnapshots(snapshots);
|
||||
}
|
||||
const referencedForward = resolveDiscordReferencedForwardMessage(message);
|
||||
if (!referencedForward) {
|
||||
return "";
|
||||
}
|
||||
const referencedText = resolveDiscordMessageText(referencedForward);
|
||||
if (!referencedText) {
|
||||
return "";
|
||||
}
|
||||
const authorLabel = formatDiscordSnapshotAuthor(referencedForward.author);
|
||||
const heading = authorLabel ? `[Forwarded message from ${authorLabel}]` : "[Forwarded message]";
|
||||
return `${heading}\n${referencedText}`;
|
||||
}
|
||||
|
||||
export function resolveDiscordForwardedMessagesTextFromSnapshots(snapshots: unknown): string {
|
||||
const forwardedBlocks = normalizeDiscordMessageSnapshots(snapshots)
|
||||
.map((snapshot) => buildDiscordForwardedMessageBlock(snapshot.message))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
if (forwardedBlocks.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return forwardedBlocks.join("\n\n");
|
||||
}
|
||||
|
||||
function buildDiscordForwardedMessageBlock(
|
||||
snapshotMessage: DiscordSnapshotMessage | null | undefined,
|
||||
): string | null {
|
||||
if (!snapshotMessage) {
|
||||
return null;
|
||||
}
|
||||
const text = resolveDiscordSnapshotMessageText(snapshotMessage);
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const authorLabel = formatDiscordSnapshotAuthor(snapshotMessage.author);
|
||||
const heading = authorLabel ? `[Forwarded message from ${authorLabel}]` : "[Forwarded message]";
|
||||
return `${heading}\n${text}`;
|
||||
}
|
||||
|
||||
function resolveDiscordSnapshotMessageText(snapshot: DiscordSnapshotMessage): string {
|
||||
const content = normalizeOptionalString(snapshot.content) ?? "";
|
||||
const attachmentText = buildDiscordMediaPlaceholder({
|
||||
attachments: snapshot.attachments ?? undefined,
|
||||
stickers: resolveDiscordSnapshotStickers(snapshot),
|
||||
});
|
||||
const embedText = resolveDiscordEmbedText(snapshot.embeds?.[0]);
|
||||
return content || attachmentText || embedText || "";
|
||||
}
|
||||
@@ -1,731 +1,32 @@
|
||||
import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10";
|
||||
import { getFileExtension } from "openclaw/plugin-sdk/media-mime";
|
||||
import { fetchRemoteMedia, type FetchLike } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { buildMediaPayload } from "openclaw/plugin-sdk/reply-payload";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { Message } from "../internal/discord.js";
|
||||
export {
|
||||
__resetDiscordChannelInfoCacheForTest,
|
||||
resolveDiscordChannelInfo,
|
||||
type DiscordChannelInfoClient,
|
||||
resolveDiscordMessageChannelId,
|
||||
type DiscordChannelInfo,
|
||||
type DiscordChannelInfoClient,
|
||||
} from "./message-channel-info.js";
|
||||
import { mergeAbortSignals } from "./timeouts.js";
|
||||
|
||||
const DISCORD_CDN_HOSTNAMES = [
|
||||
"cdn.discordapp.com",
|
||||
"media.discordapp.net",
|
||||
"*.discordapp.com",
|
||||
"*.discordapp.net",
|
||||
];
|
||||
|
||||
// Allow Discord CDN downloads when VPN/proxy DNS resolves to RFC2544 benchmark ranges.
|
||||
const DISCORD_MEDIA_SSRF_POLICY: SsrFPolicy = {
|
||||
hostnameAllowlist: DISCORD_CDN_HOSTNAMES,
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
};
|
||||
|
||||
const AUDIO_ATTACHMENT_EXTENSIONS = new Set([
|
||||
".aac",
|
||||
".caf",
|
||||
".flac",
|
||||
".m4a",
|
||||
".mp3",
|
||||
".oga",
|
||||
".ogg",
|
||||
".opus",
|
||||
".wav",
|
||||
]);
|
||||
|
||||
function isDiscordAudioAttachmentFileName(fileName?: string | null): boolean {
|
||||
const ext = getFileExtension(fileName);
|
||||
return Boolean(ext && AUDIO_ATTACHMENT_EXTENSIONS.has(ext));
|
||||
}
|
||||
|
||||
function hasDiscordVoiceAttachmentFields(attachment: APIAttachment): boolean {
|
||||
return typeof attachment.duration_secs === "number" || typeof attachment.waveform === "string";
|
||||
}
|
||||
|
||||
function mergeHostnameList(...lists: Array<string[] | undefined>): string[] | undefined {
|
||||
const merged = lists
|
||||
.flatMap((list) => list ?? [])
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
if (merged.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Array.from(new Set(merged));
|
||||
}
|
||||
|
||||
function resolveDiscordMediaSsrFPolicy(policy?: SsrFPolicy): SsrFPolicy {
|
||||
if (!policy) {
|
||||
return DISCORD_MEDIA_SSRF_POLICY;
|
||||
}
|
||||
const hostnameAllowlist = mergeHostnameList(
|
||||
DISCORD_MEDIA_SSRF_POLICY.hostnameAllowlist,
|
||||
policy.hostnameAllowlist,
|
||||
);
|
||||
const allowedHostnames = mergeHostnameList(
|
||||
DISCORD_MEDIA_SSRF_POLICY.allowedHostnames,
|
||||
policy.allowedHostnames,
|
||||
);
|
||||
return {
|
||||
...DISCORD_MEDIA_SSRF_POLICY,
|
||||
...policy,
|
||||
...(allowedHostnames ? { allowedHostnames } : {}),
|
||||
...(hostnameAllowlist ? { hostnameAllowlist } : {}),
|
||||
allowRfc2544BenchmarkRange:
|
||||
Boolean(DISCORD_MEDIA_SSRF_POLICY.allowRfc2544BenchmarkRange) ||
|
||||
Boolean(policy.allowRfc2544BenchmarkRange),
|
||||
};
|
||||
}
|
||||
|
||||
export type DiscordMediaInfo = {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
};
|
||||
|
||||
type DiscordMediaResolveOptions = {
|
||||
fetchImpl?: FetchLike;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
readIdleTimeoutMs?: number;
|
||||
totalTimeoutMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
};
|
||||
|
||||
type DiscordSnapshotAuthor = {
|
||||
id?: string | null;
|
||||
username?: string | null;
|
||||
discriminator?: string | null;
|
||||
global_name?: string | null;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
type DiscordSnapshotMessage = {
|
||||
content?: string | null;
|
||||
embeds?: Array<{ description?: string | null; title?: string | null }> | null;
|
||||
attachments?: APIAttachment[] | null;
|
||||
stickers?: APIStickerItem[] | null;
|
||||
sticker_items?: APIStickerItem[] | null;
|
||||
author?: DiscordSnapshotAuthor | null;
|
||||
};
|
||||
|
||||
const FORWARD_MESSAGE_REFERENCE_TYPE = 1;
|
||||
|
||||
type DiscordMessageSnapshot = {
|
||||
message?: DiscordSnapshotMessage | null;
|
||||
};
|
||||
|
||||
const DISCORD_STICKER_ASSET_BASE_URL = "https://media.discordapp.net/stickers";
|
||||
|
||||
function normalizeStickerItems(value: unknown): APIStickerItem[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter(
|
||||
(entry): entry is APIStickerItem =>
|
||||
Boolean(entry) &&
|
||||
typeof entry === "object" &&
|
||||
typeof (entry as { id?: unknown }).id === "string" &&
|
||||
typeof (entry as { name?: unknown }).name === "string",
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDiscordMessageStickers(message: Message): APIStickerItem[] {
|
||||
const stickers = (message as { stickers?: unknown }).stickers;
|
||||
const normalized = normalizeStickerItems(stickers);
|
||||
if (normalized.length > 0) {
|
||||
return normalized;
|
||||
}
|
||||
const rawData = (message as { rawData?: { sticker_items?: unknown; stickers?: unknown } })
|
||||
.rawData;
|
||||
return normalizeStickerItems(rawData?.sticker_items ?? rawData?.stickers);
|
||||
}
|
||||
|
||||
function resolveDiscordSnapshotStickers(snapshot: DiscordSnapshotMessage): APIStickerItem[] {
|
||||
return normalizeStickerItems(snapshot.stickers ?? snapshot.sticker_items);
|
||||
}
|
||||
|
||||
export function hasDiscordMessageStickers(message: Message): boolean {
|
||||
return resolveDiscordMessageStickers(message).length > 0;
|
||||
}
|
||||
|
||||
export async function resolveMediaList(
|
||||
message: Message,
|
||||
maxBytes: number,
|
||||
options?: DiscordMediaResolveOptions,
|
||||
): Promise<DiscordMediaInfo[]> {
|
||||
const out: DiscordMediaInfo[] = [];
|
||||
const resolvedSsrFPolicy = resolveDiscordMediaSsrFPolicy(options?.ssrfPolicy);
|
||||
await appendResolvedMediaFromAttachments({
|
||||
attachments: message.attachments ?? [],
|
||||
maxBytes,
|
||||
out,
|
||||
errorPrefix: "discord: failed to download attachment",
|
||||
fetchImpl: options?.fetchImpl,
|
||||
ssrfPolicy: resolvedSsrFPolicy,
|
||||
readIdleTimeoutMs: options?.readIdleTimeoutMs,
|
||||
totalTimeoutMs: options?.totalTimeoutMs,
|
||||
abortSignal: options?.abortSignal,
|
||||
});
|
||||
await appendResolvedMediaFromStickers({
|
||||
stickers: resolveDiscordMessageStickers(message),
|
||||
maxBytes,
|
||||
out,
|
||||
errorPrefix: "discord: failed to download sticker",
|
||||
fetchImpl: options?.fetchImpl,
|
||||
ssrfPolicy: resolvedSsrFPolicy,
|
||||
readIdleTimeoutMs: options?.readIdleTimeoutMs,
|
||||
totalTimeoutMs: options?.totalTimeoutMs,
|
||||
abortSignal: options?.abortSignal,
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function resolveForwardedMediaList(
|
||||
message: Message,
|
||||
maxBytes: number,
|
||||
options?: DiscordMediaResolveOptions,
|
||||
): Promise<DiscordMediaInfo[]> {
|
||||
const snapshots = resolveDiscordMessageSnapshots(message);
|
||||
const out: DiscordMediaInfo[] = [];
|
||||
const resolvedSsrFPolicy = resolveDiscordMediaSsrFPolicy(options?.ssrfPolicy);
|
||||
if (snapshots.length > 0) {
|
||||
for (const snapshot of snapshots) {
|
||||
await appendResolvedMediaFromAttachments({
|
||||
attachments: snapshot.message?.attachments,
|
||||
maxBytes,
|
||||
out,
|
||||
errorPrefix: "discord: failed to download forwarded attachment",
|
||||
fetchImpl: options?.fetchImpl,
|
||||
ssrfPolicy: resolvedSsrFPolicy,
|
||||
readIdleTimeoutMs: options?.readIdleTimeoutMs,
|
||||
totalTimeoutMs: options?.totalTimeoutMs,
|
||||
abortSignal: options?.abortSignal,
|
||||
});
|
||||
await appendResolvedMediaFromStickers({
|
||||
stickers: snapshot.message ? resolveDiscordSnapshotStickers(snapshot.message) : [],
|
||||
maxBytes,
|
||||
out,
|
||||
errorPrefix: "discord: failed to download forwarded sticker",
|
||||
fetchImpl: options?.fetchImpl,
|
||||
ssrfPolicy: resolvedSsrFPolicy,
|
||||
readIdleTimeoutMs: options?.readIdleTimeoutMs,
|
||||
totalTimeoutMs: options?.totalTimeoutMs,
|
||||
abortSignal: options?.abortSignal,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const referencedForward = resolveDiscordReferencedForwardMessage(message);
|
||||
if (!referencedForward) {
|
||||
return out;
|
||||
}
|
||||
await appendResolvedMediaFromAttachments({
|
||||
attachments: referencedForward.attachments,
|
||||
maxBytes,
|
||||
out,
|
||||
errorPrefix: "discord: failed to download forwarded attachment",
|
||||
fetchImpl: options?.fetchImpl,
|
||||
ssrfPolicy: resolvedSsrFPolicy,
|
||||
readIdleTimeoutMs: options?.readIdleTimeoutMs,
|
||||
totalTimeoutMs: options?.totalTimeoutMs,
|
||||
abortSignal: options?.abortSignal,
|
||||
});
|
||||
await appendResolvedMediaFromStickers({
|
||||
stickers: resolveDiscordMessageStickers(referencedForward),
|
||||
maxBytes,
|
||||
out,
|
||||
errorPrefix: "discord: failed to download forwarded sticker",
|
||||
fetchImpl: options?.fetchImpl,
|
||||
ssrfPolicy: resolvedSsrFPolicy,
|
||||
readIdleTimeoutMs: options?.readIdleTimeoutMs,
|
||||
totalTimeoutMs: options?.totalTimeoutMs,
|
||||
abortSignal: options?.abortSignal,
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchDiscordMedia(params: {
|
||||
url: string;
|
||||
filePathHint: string;
|
||||
maxBytes: number;
|
||||
fetchImpl?: FetchLike;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
readIdleTimeoutMs?: number;
|
||||
totalTimeoutMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}) {
|
||||
// `totalTimeoutMs` is enforced per individual attachment or sticker fetch.
|
||||
// The caller abort signal remains the outer bound for the message.
|
||||
const timeoutAbortController = params.totalTimeoutMs ? new AbortController() : undefined;
|
||||
const signal = mergeAbortSignals([params.abortSignal, timeoutAbortController?.signal]);
|
||||
let timedOut = false;
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const fetchPromise = fetchRemoteMedia({
|
||||
url: params.url,
|
||||
filePathHint: params.filePathHint,
|
||||
maxBytes: params.maxBytes,
|
||||
fetchImpl: params.fetchImpl,
|
||||
ssrfPolicy: params.ssrfPolicy,
|
||||
readIdleTimeoutMs: params.readIdleTimeoutMs,
|
||||
...(signal ? { requestInit: { signal } } : {}),
|
||||
}).catch((error) => {
|
||||
if (timedOut) {
|
||||
// After the timeout wins the race we abort the underlying fetch and keep
|
||||
// this branch pending so the later AbortError does not surface as an
|
||||
// unhandled rejection after Promise.race has already settled.
|
||||
return new Promise<never>(() => {});
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
try {
|
||||
if (!params.totalTimeoutMs) {
|
||||
return await fetchPromise;
|
||||
}
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
timedOut = true;
|
||||
timeoutAbortController?.abort();
|
||||
reject(new Error(`discord media download timed out after ${params.totalTimeoutMs}ms`));
|
||||
}, params.totalTimeoutMs);
|
||||
timeoutHandle.unref?.();
|
||||
});
|
||||
return await Promise.race([fetchPromise, timeoutPromise]);
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function appendResolvedMediaFromAttachments(params: {
|
||||
attachments?: APIAttachment[] | null;
|
||||
maxBytes: number;
|
||||
out: DiscordMediaInfo[];
|
||||
errorPrefix: string;
|
||||
fetchImpl?: FetchLike;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
readIdleTimeoutMs?: number;
|
||||
totalTimeoutMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}) {
|
||||
const attachments = params.attachments;
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const attachment of attachments) {
|
||||
const attachmentUrl = normalizeOptionalString(attachment.url);
|
||||
if (!attachmentUrl) {
|
||||
logVerbose(
|
||||
`${params.errorPrefix} ${attachment.id ?? attachment.filename ?? "attachment"}: missing url`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const fetched = await fetchDiscordMedia({
|
||||
url: attachmentUrl,
|
||||
filePathHint: attachment.filename ?? attachmentUrl,
|
||||
maxBytes: params.maxBytes,
|
||||
fetchImpl: params.fetchImpl,
|
||||
ssrfPolicy: params.ssrfPolicy,
|
||||
readIdleTimeoutMs: params.readIdleTimeoutMs,
|
||||
totalTimeoutMs: params.totalTimeoutMs,
|
||||
abortSignal: params.abortSignal,
|
||||
});
|
||||
const saved = await saveMediaBuffer(
|
||||
fetched.buffer,
|
||||
fetched.contentType ?? attachment.content_type,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
);
|
||||
params.out.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: inferPlaceholder(attachment),
|
||||
});
|
||||
} catch (err) {
|
||||
const id = attachment.id ?? attachmentUrl;
|
||||
logVerbose(`${params.errorPrefix} ${id}: ${String(err)}`);
|
||||
// Preserve attachment context even when remote fetch is blocked/fails.
|
||||
params.out.push({
|
||||
path: attachmentUrl,
|
||||
contentType: attachment.content_type,
|
||||
placeholder: inferPlaceholder(attachment),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type DiscordStickerAssetCandidate = {
|
||||
url: string;
|
||||
fileName: string;
|
||||
};
|
||||
|
||||
function resolveStickerAssetCandidates(sticker: APIStickerItem): DiscordStickerAssetCandidate[] {
|
||||
const baseName = sticker.name?.trim() || `sticker-${sticker.id}`;
|
||||
switch (sticker.format_type) {
|
||||
case StickerFormatType.GIF:
|
||||
return [
|
||||
{
|
||||
url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.gif`,
|
||||
fileName: `${baseName}.gif`,
|
||||
},
|
||||
];
|
||||
case StickerFormatType.Lottie:
|
||||
return [
|
||||
{
|
||||
url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.png?size=160`,
|
||||
fileName: `${baseName}.png`,
|
||||
},
|
||||
{
|
||||
url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.json`,
|
||||
fileName: `${baseName}.json`,
|
||||
},
|
||||
];
|
||||
case StickerFormatType.APNG:
|
||||
case StickerFormatType.PNG:
|
||||
default:
|
||||
return [
|
||||
{
|
||||
url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.png`,
|
||||
fileName: `${baseName}.png`,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function formatStickerError(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(err) ?? "unknown error";
|
||||
} catch {
|
||||
return "unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
function inferStickerContentType(sticker: APIStickerItem): string | undefined {
|
||||
switch (sticker.format_type) {
|
||||
case StickerFormatType.GIF:
|
||||
return "image/gif";
|
||||
case StickerFormatType.APNG:
|
||||
case StickerFormatType.Lottie:
|
||||
case StickerFormatType.PNG:
|
||||
return "image/png";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function appendResolvedMediaFromStickers(params: {
|
||||
stickers?: APIStickerItem[] | null;
|
||||
maxBytes: number;
|
||||
out: DiscordMediaInfo[];
|
||||
errorPrefix: string;
|
||||
fetchImpl?: FetchLike;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
readIdleTimeoutMs?: number;
|
||||
totalTimeoutMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}) {
|
||||
const stickers = params.stickers;
|
||||
if (!stickers || stickers.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const sticker of stickers) {
|
||||
const candidates = resolveStickerAssetCandidates(sticker);
|
||||
let lastError: unknown;
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const fetched = await fetchDiscordMedia({
|
||||
url: candidate.url,
|
||||
filePathHint: candidate.fileName,
|
||||
maxBytes: params.maxBytes,
|
||||
fetchImpl: params.fetchImpl,
|
||||
ssrfPolicy: params.ssrfPolicy,
|
||||
readIdleTimeoutMs: params.readIdleTimeoutMs,
|
||||
totalTimeoutMs: params.totalTimeoutMs,
|
||||
abortSignal: params.abortSignal,
|
||||
});
|
||||
const saved = await saveMediaBuffer(
|
||||
fetched.buffer,
|
||||
fetched.contentType,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
);
|
||||
params.out.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: "<media:sticker>",
|
||||
});
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
if (lastError) {
|
||||
logVerbose(`${params.errorPrefix} ${sticker.id}: ${formatStickerError(lastError)}`);
|
||||
const fallback = candidates[0];
|
||||
if (fallback) {
|
||||
params.out.push({
|
||||
path: fallback.url,
|
||||
contentType: inferStickerContentType(sticker),
|
||||
placeholder: "<media:sticker>",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function inferPlaceholder(attachment: APIAttachment): string {
|
||||
const mime = attachment.content_type ?? "";
|
||||
if (mime.startsWith("image/")) {
|
||||
return "<media:image>";
|
||||
}
|
||||
if (mime.startsWith("video/")) {
|
||||
return "<media:video>";
|
||||
}
|
||||
if (mime.startsWith("audio/")) {
|
||||
return "<media:audio>";
|
||||
}
|
||||
if (hasDiscordVoiceAttachmentFields(attachment)) {
|
||||
return "<media:audio>";
|
||||
}
|
||||
if (isDiscordAudioAttachmentFileName(attachment.filename ?? attachment.url)) {
|
||||
return "<media:audio>";
|
||||
}
|
||||
return "<media:document>";
|
||||
}
|
||||
|
||||
function isImageAttachment(attachment: APIAttachment): boolean {
|
||||
const mime = attachment.content_type ?? "";
|
||||
if (mime.startsWith("image/")) {
|
||||
return true;
|
||||
}
|
||||
const name = normalizeLowercaseStringOrEmpty(attachment.filename);
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
return /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/.test(name);
|
||||
}
|
||||
|
||||
function buildDiscordAttachmentPlaceholder(attachments?: APIAttachment[]): string {
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const count = attachments.length;
|
||||
const allImages = attachments.every(isImageAttachment);
|
||||
const label = allImages ? "image" : "file";
|
||||
const suffix = count === 1 ? label : `${label}s`;
|
||||
const tag = allImages ? "<media:image>" : "<media:document>";
|
||||
return `${tag} (${count} ${suffix})`;
|
||||
}
|
||||
|
||||
function buildDiscordStickerPlaceholder(stickers?: APIStickerItem[]): string {
|
||||
if (!stickers || stickers.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const count = stickers.length;
|
||||
const label = count === 1 ? "sticker" : "stickers";
|
||||
return `<media:sticker> (${count} ${label})`;
|
||||
}
|
||||
|
||||
function buildDiscordMediaPlaceholder(params: {
|
||||
attachments?: APIAttachment[];
|
||||
stickers?: APIStickerItem[];
|
||||
}): string {
|
||||
const attachmentText = buildDiscordAttachmentPlaceholder(params.attachments);
|
||||
const stickerText = buildDiscordStickerPlaceholder(params.stickers);
|
||||
if (attachmentText && stickerText) {
|
||||
return `${attachmentText}\n${stickerText}`;
|
||||
}
|
||||
return attachmentText || stickerText || "";
|
||||
}
|
||||
|
||||
export function resolveDiscordEmbedText(
|
||||
embed?: { title?: string | null; description?: string | null } | null,
|
||||
): string {
|
||||
const title = normalizeOptionalString(embed?.title) ?? "";
|
||||
const description = normalizeOptionalString(embed?.description) ?? "";
|
||||
if (title && description) {
|
||||
return `${title}\n${description}`;
|
||||
}
|
||||
return title || description || "";
|
||||
}
|
||||
|
||||
export function resolveDiscordMessageText(
|
||||
message: Message,
|
||||
options?: { fallbackText?: string; includeForwarded?: boolean },
|
||||
): string {
|
||||
const embedText = resolveDiscordEmbedText(
|
||||
(message.embeds?.[0] as { title?: string | null; description?: string | null } | undefined) ??
|
||||
null,
|
||||
);
|
||||
const rawText =
|
||||
normalizeOptionalString(message.content) ||
|
||||
buildDiscordMediaPlaceholder({
|
||||
attachments: message.attachments ?? undefined,
|
||||
stickers: resolveDiscordMessageStickers(message),
|
||||
}) ||
|
||||
embedText ||
|
||||
normalizeOptionalString(options?.fallbackText) ||
|
||||
"";
|
||||
const baseText = resolveDiscordMentions(rawText, message);
|
||||
if (!options?.includeForwarded) {
|
||||
return baseText;
|
||||
}
|
||||
const forwardedText = resolveDiscordForwardedMessagesText(message);
|
||||
if (!forwardedText) {
|
||||
return baseText;
|
||||
}
|
||||
if (!baseText) {
|
||||
return forwardedText;
|
||||
}
|
||||
return `${baseText}\n${forwardedText}`;
|
||||
}
|
||||
|
||||
function resolveDiscordMentions(text: string, message: Message): string {
|
||||
if (!text.includes("<")) {
|
||||
return text;
|
||||
}
|
||||
const mentions = message.mentionedUsers ?? [];
|
||||
if (!Array.isArray(mentions) || mentions.length === 0) {
|
||||
return text;
|
||||
}
|
||||
let out = text;
|
||||
for (const user of mentions) {
|
||||
const label = user.globalName || user.username;
|
||||
out = out.replace(new RegExp(`<@!?${user.id}>`, "g"), `@${label}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveDiscordForwardedMessagesText(message: Message): string {
|
||||
const snapshots = resolveDiscordMessageSnapshots(message);
|
||||
if (snapshots.length > 0) {
|
||||
return resolveDiscordForwardedMessagesTextFromSnapshots(snapshots);
|
||||
}
|
||||
const referencedForward = resolveDiscordReferencedForwardMessage(message);
|
||||
if (!referencedForward) {
|
||||
return "";
|
||||
}
|
||||
const referencedText = resolveDiscordMessageText(referencedForward);
|
||||
if (!referencedText) {
|
||||
return "";
|
||||
}
|
||||
const authorLabel = formatDiscordSnapshotAuthor(referencedForward.author);
|
||||
const heading = authorLabel ? `[Forwarded message from ${authorLabel}]` : "[Forwarded message]";
|
||||
return `${heading}\n${referencedText}`;
|
||||
}
|
||||
|
||||
function resolveDiscordMessageSnapshots(message: Message): DiscordMessageSnapshot[] {
|
||||
const rawData = (message as { rawData?: { message_snapshots?: unknown } }).rawData;
|
||||
return normalizeDiscordMessageSnapshots(
|
||||
rawData?.message_snapshots ??
|
||||
(message as { message_snapshots?: unknown }).message_snapshots ??
|
||||
(message as { messageSnapshots?: unknown }).messageSnapshots,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDiscordMessageSnapshots(snapshots: unknown): DiscordMessageSnapshot[] {
|
||||
if (!Array.isArray(snapshots)) {
|
||||
return [];
|
||||
}
|
||||
return snapshots.filter(
|
||||
(entry): entry is DiscordMessageSnapshot => Boolean(entry) && typeof entry === "object",
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDiscordForwardedMessagesTextFromSnapshots(snapshots: unknown): string {
|
||||
const forwardedBlocks = normalizeDiscordMessageSnapshots(snapshots)
|
||||
.map((snapshot) => buildDiscordForwardedMessageBlock(snapshot.message))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
if (forwardedBlocks.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return forwardedBlocks.join("\n\n");
|
||||
}
|
||||
|
||||
function buildDiscordForwardedMessageBlock(
|
||||
snapshotMessage: DiscordSnapshotMessage | null | undefined,
|
||||
): string | null {
|
||||
if (!snapshotMessage) {
|
||||
return null;
|
||||
}
|
||||
const text = resolveDiscordSnapshotMessageText(snapshotMessage);
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const authorLabel = formatDiscordSnapshotAuthor(snapshotMessage.author);
|
||||
const heading = authorLabel ? `[Forwarded message from ${authorLabel}]` : "[Forwarded message]";
|
||||
return `${heading}\n${text}`;
|
||||
}
|
||||
|
||||
function resolveDiscordReferencedForwardMessage(message: Message): Message | null {
|
||||
const referenceType = message.messageReference?.type;
|
||||
return Number(referenceType) === FORWARD_MESSAGE_REFERENCE_TYPE
|
||||
? message.referencedMessage
|
||||
: null;
|
||||
}
|
||||
|
||||
function resolveDiscordSnapshotMessageText(snapshot: DiscordSnapshotMessage): string {
|
||||
const content = normalizeOptionalString(snapshot.content) ?? "";
|
||||
const attachmentText = buildDiscordMediaPlaceholder({
|
||||
attachments: snapshot.attachments ?? undefined,
|
||||
stickers: resolveDiscordSnapshotStickers(snapshot),
|
||||
});
|
||||
const embedText = resolveDiscordEmbedText(snapshot.embeds?.[0]);
|
||||
return content || attachmentText || embedText || "";
|
||||
}
|
||||
|
||||
function formatDiscordSnapshotAuthor(
|
||||
author: DiscordSnapshotAuthor | null | undefined,
|
||||
): string | undefined {
|
||||
if (!author) {
|
||||
return undefined;
|
||||
}
|
||||
const globalName = author.global_name ?? undefined;
|
||||
const username = author.username ?? undefined;
|
||||
const name = author.name ?? undefined;
|
||||
const discriminator = author.discriminator ?? undefined;
|
||||
const base = globalName || username || name;
|
||||
if (username && discriminator && discriminator !== "0") {
|
||||
return `@${username}#${discriminator}`;
|
||||
}
|
||||
if (base) {
|
||||
return `@${base}`;
|
||||
}
|
||||
if (author.id) {
|
||||
return `@${author.id}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildDiscordMediaPayload(
|
||||
mediaList: Array<{ path: string; contentType?: string }>,
|
||||
): {
|
||||
MediaPath?: string;
|
||||
MediaType?: string;
|
||||
MediaUrl?: string;
|
||||
MediaPaths?: string[];
|
||||
MediaUrls?: string[];
|
||||
MediaTypes?: string[];
|
||||
} {
|
||||
return buildMediaPayload(mediaList);
|
||||
}
|
||||
export {
|
||||
hasDiscordMessageStickers,
|
||||
normalizeDiscordMessageSnapshots,
|
||||
normalizeDiscordStickerItems,
|
||||
resolveDiscordMessageSnapshots,
|
||||
resolveDiscordMessageStickers,
|
||||
resolveDiscordReferencedForwardMessage,
|
||||
resolveDiscordSnapshotStickers,
|
||||
type DiscordMessageSnapshot,
|
||||
type DiscordSnapshotAuthor,
|
||||
type DiscordSnapshotMessage,
|
||||
} from "./message-forwarded.js";
|
||||
export {
|
||||
buildDiscordMediaPayload,
|
||||
buildDiscordMediaPlaceholder,
|
||||
resolveForwardedMediaList,
|
||||
resolveMediaList,
|
||||
type DiscordMediaInfo,
|
||||
type DiscordMediaResolveOptions,
|
||||
} from "./message-media.js";
|
||||
export {
|
||||
resolveDiscordEmbedText,
|
||||
resolveDiscordForwardedMessagesTextFromSnapshots,
|
||||
resolveDiscordMessageText,
|
||||
} from "./message-text.js";
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
import type { APISelectMenuOption } from "discord-api-types/v10";
|
||||
import { ButtonStyle } from "discord-api-types/v10";
|
||||
import type { ModelsProviderData } from "openclaw/plugin-sdk/models-provider-runtime";
|
||||
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Row,
|
||||
Separator,
|
||||
StringSelectMenu,
|
||||
TextDisplay,
|
||||
type MessagePayloadObject,
|
||||
type TopLevelComponents,
|
||||
} from "../internal/discord.js";
|
||||
export {
|
||||
buildDiscordModelPickerCustomId,
|
||||
buildDiscordModelPickerProviderItems,
|
||||
@@ -29,18 +15,6 @@ export {
|
||||
parseDiscordModelPickerCustomId,
|
||||
parseDiscordModelPickerData,
|
||||
} from "./model-picker.state.js";
|
||||
import {
|
||||
buildDiscordModelPickerCustomId,
|
||||
DISCORD_COMPONENT_MAX_BUTTONS_PER_ROW,
|
||||
getDiscordModelPickerModelPage,
|
||||
getDiscordModelPickerProviderPage,
|
||||
normalizeModelPickerPage,
|
||||
type DiscordModelPickerCommandContext,
|
||||
type DiscordModelPickerLayout,
|
||||
type DiscordModelPickerModelPage,
|
||||
type DiscordModelPickerPage,
|
||||
type DiscordModelPickerProviderItem,
|
||||
} from "./model-picker.state.js";
|
||||
export type {
|
||||
DiscordModelPickerAction,
|
||||
DiscordModelPickerCommandContext,
|
||||
@@ -50,672 +24,15 @@ export type {
|
||||
DiscordModelPickerState,
|
||||
DiscordModelPickerView,
|
||||
} from "./model-picker.state.js";
|
||||
|
||||
const DISCORD_PROVIDER_BUTTON_LABEL_MAX_CHARS = 18;
|
||||
|
||||
type DiscordModelPickerButtonOptions = {
|
||||
label: string;
|
||||
customId: string;
|
||||
style?: ButtonStyle;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type DiscordModelPickerCurrentModelRef = {
|
||||
provider: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
type DiscordModelPickerRow = Row<Button> | Row<StringSelectMenu>;
|
||||
|
||||
type DiscordModelPickerRenderShellParams = {
|
||||
layout: DiscordModelPickerLayout;
|
||||
title: string;
|
||||
detailLines: string[];
|
||||
rows: DiscordModelPickerRow[];
|
||||
footer?: string;
|
||||
/** Text shown after the divider but before the interactive rows. */
|
||||
preRowText?: string;
|
||||
/** Extra rows appended after the main rows, preceded by a divider. */
|
||||
trailingRows?: DiscordModelPickerRow[];
|
||||
};
|
||||
|
||||
export type DiscordModelPickerRenderedView = {
|
||||
layout: DiscordModelPickerLayout;
|
||||
content?: string;
|
||||
components: TopLevelComponents[];
|
||||
};
|
||||
|
||||
export type DiscordModelPickerProviderViewParams = {
|
||||
command: DiscordModelPickerCommandContext;
|
||||
userId: string;
|
||||
data: ModelsProviderData;
|
||||
page?: number;
|
||||
currentModel?: string;
|
||||
layout?: DiscordModelPickerLayout;
|
||||
};
|
||||
|
||||
export type DiscordModelPickerModelViewParams = {
|
||||
command: DiscordModelPickerCommandContext;
|
||||
userId: string;
|
||||
data: ModelsProviderData;
|
||||
provider: string;
|
||||
page?: number;
|
||||
providerPage?: number;
|
||||
currentModel?: string;
|
||||
pendingModel?: string;
|
||||
pendingModelIndex?: number;
|
||||
currentRuntime?: string;
|
||||
pendingRuntime?: string;
|
||||
quickModels?: string[];
|
||||
layout?: DiscordModelPickerLayout;
|
||||
};
|
||||
|
||||
function parseCurrentModelRef(raw?: string): DiscordModelPickerCurrentModelRef | null {
|
||||
const trimmed = raw?.trim();
|
||||
const match = trimmed?.match(/^([^/]+)\/(.+)$/u);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const provider = normalizeProviderId(match[1]);
|
||||
// Preserve the model suffix exactly as entered after "/" so select defaults
|
||||
// continue to mirror the stored ref for Discord interactions.
|
||||
const model = match[2];
|
||||
if (!provider || !model) {
|
||||
return null;
|
||||
}
|
||||
return { provider, model };
|
||||
}
|
||||
|
||||
function formatCurrentModelLine(currentModel?: string): string {
|
||||
const parsed = parseCurrentModelRef(currentModel);
|
||||
if (!parsed) {
|
||||
return "Current model: default";
|
||||
}
|
||||
return `Current model: ${parsed.provider}/${parsed.model}`;
|
||||
}
|
||||
|
||||
function formatProviderButtonLabel(provider: string): string {
|
||||
if (provider.length <= DISCORD_PROVIDER_BUTTON_LABEL_MAX_CHARS) {
|
||||
return provider;
|
||||
}
|
||||
return `${provider.slice(0, DISCORD_PROVIDER_BUTTON_LABEL_MAX_CHARS - 1)}…`;
|
||||
}
|
||||
|
||||
function chunkProvidersForRows(
|
||||
items: DiscordModelPickerProviderItem[],
|
||||
): DiscordModelPickerProviderItem[][] {
|
||||
if (items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rowCount = Math.max(1, Math.ceil(items.length / DISCORD_COMPONENT_MAX_BUTTONS_PER_ROW));
|
||||
const minPerRow = Math.floor(items.length / rowCount);
|
||||
const rowsWithExtraItem = items.length % rowCount;
|
||||
|
||||
const counts = Array.from({ length: rowCount }, (_, index) =>
|
||||
index < rowCount - rowsWithExtraItem ? minPerRow : minPerRow + 1,
|
||||
);
|
||||
|
||||
const rows: DiscordModelPickerProviderItem[][] = [];
|
||||
let cursor = 0;
|
||||
for (const count of counts) {
|
||||
rows.push(items.slice(cursor, cursor + count));
|
||||
cursor += count;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function createModelPickerButton(params: DiscordModelPickerButtonOptions): Button {
|
||||
class DiscordModelPickerButton extends Button {
|
||||
label = params.label;
|
||||
customId = params.customId;
|
||||
style = params.style ?? ButtonStyle.Secondary;
|
||||
disabled = params.disabled ?? false;
|
||||
}
|
||||
return new DiscordModelPickerButton();
|
||||
}
|
||||
|
||||
function createModelSelect(params: {
|
||||
customId: string;
|
||||
options: APISelectMenuOption[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}): StringSelectMenu {
|
||||
class DiscordModelPickerSelect extends StringSelectMenu {
|
||||
customId = params.customId;
|
||||
options = params.options;
|
||||
minValues = 1;
|
||||
maxValues = 1;
|
||||
placeholder = params.placeholder;
|
||||
disabled = params.disabled ?? false;
|
||||
}
|
||||
return new DiscordModelPickerSelect();
|
||||
}
|
||||
|
||||
function getRuntimeChoices(params: {
|
||||
data: ModelsProviderData;
|
||||
provider: string;
|
||||
}): Array<{ id: string; label: string; description?: string }> {
|
||||
return (
|
||||
params.data.runtimeChoicesByProvider?.get(normalizeProviderId(params.provider)) ?? [
|
||||
{
|
||||
id: "pi",
|
||||
label: "OpenClaw Pi Default",
|
||||
description: "Use the built-in OpenClaw Pi runtime.",
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSelectedRuntime(params: {
|
||||
data: ModelsProviderData;
|
||||
provider: string;
|
||||
currentRuntime?: string;
|
||||
pendingRuntime?: string;
|
||||
}): string {
|
||||
const choices = getRuntimeChoices({ data: params.data, provider: params.provider });
|
||||
const allowed = new Set(choices.map((choice) => choice.id));
|
||||
const pending = params.pendingRuntime?.trim();
|
||||
if (pending && allowed.has(pending)) {
|
||||
return pending;
|
||||
}
|
||||
const current = params.currentRuntime?.trim();
|
||||
return current && allowed.has(current) ? current : "pi";
|
||||
}
|
||||
|
||||
function buildRenderedShell(
|
||||
params: DiscordModelPickerRenderShellParams,
|
||||
): DiscordModelPickerRenderedView {
|
||||
if (params.layout === "classic") {
|
||||
const lines = [params.title, ...params.detailLines, "", params.footer].filter(Boolean);
|
||||
return {
|
||||
layout: "classic",
|
||||
content: lines.join("\n"),
|
||||
components: params.rows,
|
||||
};
|
||||
}
|
||||
|
||||
const containerComponents: Array<TextDisplay | Separator | DiscordModelPickerRow> = [
|
||||
new TextDisplay(`## ${params.title}`),
|
||||
];
|
||||
if (params.detailLines.length > 0) {
|
||||
containerComponents.push(new TextDisplay(params.detailLines.join("\n")));
|
||||
}
|
||||
containerComponents.push(new Separator({ divider: true, spacing: "small" }));
|
||||
if (params.preRowText) {
|
||||
containerComponents.push(new TextDisplay(params.preRowText));
|
||||
}
|
||||
containerComponents.push(...params.rows);
|
||||
if (params.trailingRows && params.trailingRows.length > 0) {
|
||||
containerComponents.push(new Separator({ divider: true, spacing: "small" }));
|
||||
containerComponents.push(...params.trailingRows);
|
||||
}
|
||||
if (params.footer) {
|
||||
containerComponents.push(new Separator({ divider: false, spacing: "small" }));
|
||||
containerComponents.push(new TextDisplay(`-# ${params.footer}`));
|
||||
}
|
||||
|
||||
const container = new Container(containerComponents);
|
||||
return {
|
||||
layout: "v2",
|
||||
components: [container],
|
||||
};
|
||||
}
|
||||
|
||||
function buildProviderRows(params: {
|
||||
command: DiscordModelPickerCommandContext;
|
||||
userId: string;
|
||||
page: DiscordModelPickerPage<DiscordModelPickerProviderItem>;
|
||||
currentProvider?: string;
|
||||
}): Row<Button>[] {
|
||||
const rows = chunkProvidersForRows(params.page.items).map(
|
||||
(providers) =>
|
||||
new Row(
|
||||
providers.map((provider) => {
|
||||
const style =
|
||||
provider.id === params.currentProvider ? ButtonStyle.Primary : ButtonStyle.Secondary;
|
||||
return createModelPickerButton({
|
||||
label: formatProviderButtonLabel(provider.id),
|
||||
style,
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "provider",
|
||||
view: "models",
|
||||
provider: provider.id,
|
||||
page: params.page.page,
|
||||
userId: params.userId,
|
||||
}),
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function buildModelRows(params: {
|
||||
command: DiscordModelPickerCommandContext;
|
||||
userId: string;
|
||||
data: ModelsProviderData;
|
||||
providerPage: number;
|
||||
modelPage: DiscordModelPickerModelPage;
|
||||
currentModel?: string;
|
||||
pendingModel?: string;
|
||||
pendingModelIndex?: number;
|
||||
currentRuntime?: string;
|
||||
pendingRuntime?: string;
|
||||
quickModels?: string[];
|
||||
}): { rows: DiscordModelPickerRow[]; buttonRow: Row<Button> } {
|
||||
const parsedCurrentModel = parseCurrentModelRef(params.currentModel);
|
||||
const parsedPendingModel = parseCurrentModelRef(params.pendingModel);
|
||||
const rows: DiscordModelPickerRow[] = [];
|
||||
|
||||
const hasQuickModels = (params.quickModels ?? []).length > 0;
|
||||
|
||||
const providerPage = getDiscordModelPickerProviderPage({
|
||||
data: params.data,
|
||||
page: params.providerPage,
|
||||
});
|
||||
const providerOptions: APISelectMenuOption[] = providerPage.items.map((provider) => ({
|
||||
label: provider.id,
|
||||
value: provider.id,
|
||||
default: provider.id === params.modelPage.provider,
|
||||
}));
|
||||
|
||||
rows.push(
|
||||
new Row([
|
||||
createModelSelect({
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "provider",
|
||||
view: "models",
|
||||
provider: params.modelPage.provider,
|
||||
page: providerPage.page,
|
||||
providerPage: providerPage.page,
|
||||
userId: params.userId,
|
||||
}),
|
||||
options: providerOptions,
|
||||
placeholder: "Select provider",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const runtimeChoices = getRuntimeChoices({
|
||||
data: params.data,
|
||||
provider: params.modelPage.provider,
|
||||
});
|
||||
const selectedRuntime = resolveSelectedRuntime({
|
||||
data: params.data,
|
||||
provider: params.modelPage.provider,
|
||||
currentRuntime: params.currentRuntime,
|
||||
pendingRuntime: params.pendingRuntime,
|
||||
});
|
||||
const normalizedCurrentRuntime = params.currentRuntime?.trim();
|
||||
const shouldCarryRuntime =
|
||||
runtimeChoices.length > 1 ||
|
||||
(Boolean(normalizedCurrentRuntime) &&
|
||||
normalizedCurrentRuntime !== "auto" &&
|
||||
normalizedCurrentRuntime !== "pi" &&
|
||||
normalizedCurrentRuntime !== "default");
|
||||
const stateRuntime = shouldCarryRuntime ? selectedRuntime : undefined;
|
||||
if (runtimeChoices.length > 1) {
|
||||
rows.push(
|
||||
new Row([
|
||||
createModelSelect({
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "runtime",
|
||||
view: "models",
|
||||
provider: params.modelPage.provider,
|
||||
runtime: selectedRuntime,
|
||||
page: params.modelPage.page,
|
||||
providerPage: providerPage.page,
|
||||
modelIndex: params.pendingModelIndex,
|
||||
userId: params.userId,
|
||||
}),
|
||||
options: runtimeChoices.map((choice) => {
|
||||
const option: APISelectMenuOption = {
|
||||
label: choice.label,
|
||||
value: choice.id,
|
||||
default: choice.id === selectedRuntime,
|
||||
};
|
||||
if (choice.description) {
|
||||
option.description = choice.description;
|
||||
}
|
||||
return option;
|
||||
}),
|
||||
placeholder: "Select runtime",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const selectedModelRef = parsedPendingModel ?? parsedCurrentModel;
|
||||
const modelOptions: APISelectMenuOption[] = params.modelPage.items.map((model) => ({
|
||||
label: model,
|
||||
value: model,
|
||||
default: selectedModelRef
|
||||
? selectedModelRef.provider === params.modelPage.provider && selectedModelRef.model === model
|
||||
: false,
|
||||
}));
|
||||
|
||||
rows.push(
|
||||
new Row([
|
||||
createModelSelect({
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "model",
|
||||
view: "models",
|
||||
provider: params.modelPage.provider,
|
||||
runtime: stateRuntime,
|
||||
page: params.modelPage.page,
|
||||
providerPage: providerPage.page,
|
||||
userId: params.userId,
|
||||
}),
|
||||
options: modelOptions,
|
||||
placeholder: `Select ${params.modelPage.provider} model`,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const resolvedDefault = params.data.resolvedDefault;
|
||||
const shouldDisableReset =
|
||||
Boolean(parsedCurrentModel) &&
|
||||
parsedCurrentModel?.provider === resolvedDefault.provider &&
|
||||
parsedCurrentModel?.model === resolvedDefault.model;
|
||||
|
||||
const hasPendingSelection =
|
||||
Boolean(parsedPendingModel) &&
|
||||
parsedPendingModel?.provider === params.modelPage.provider &&
|
||||
typeof params.pendingModelIndex === "number" &&
|
||||
params.pendingModelIndex > 0;
|
||||
|
||||
const buttonRowItems: Button[] = [
|
||||
createModelPickerButton({
|
||||
label: "Cancel",
|
||||
style: ButtonStyle.Secondary,
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "cancel",
|
||||
view: "models",
|
||||
provider: params.modelPage.provider,
|
||||
runtime: stateRuntime,
|
||||
page: params.modelPage.page,
|
||||
providerPage: providerPage.page,
|
||||
userId: params.userId,
|
||||
}),
|
||||
}),
|
||||
createModelPickerButton({
|
||||
label: "Reset to default",
|
||||
style: ButtonStyle.Secondary,
|
||||
disabled: shouldDisableReset,
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "reset",
|
||||
view: "models",
|
||||
provider: params.modelPage.provider,
|
||||
runtime: stateRuntime,
|
||||
page: params.modelPage.page,
|
||||
providerPage: providerPage.page,
|
||||
userId: params.userId,
|
||||
}),
|
||||
}),
|
||||
];
|
||||
|
||||
if (hasQuickModels) {
|
||||
buttonRowItems.push(
|
||||
createModelPickerButton({
|
||||
label: "Recents",
|
||||
style: ButtonStyle.Secondary,
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "recents",
|
||||
view: "recents",
|
||||
provider: params.modelPage.provider,
|
||||
runtime: stateRuntime,
|
||||
page: params.modelPage.page,
|
||||
providerPage: providerPage.page,
|
||||
userId: params.userId,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
buttonRowItems.push(
|
||||
createModelPickerButton({
|
||||
label: "Submit",
|
||||
style: ButtonStyle.Primary,
|
||||
disabled: !hasPendingSelection,
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "submit",
|
||||
view: "models",
|
||||
provider: params.modelPage.provider,
|
||||
runtime: stateRuntime,
|
||||
page: params.modelPage.page,
|
||||
providerPage: providerPage.page,
|
||||
modelIndex: params.pendingModelIndex,
|
||||
userId: params.userId,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
return { rows, buttonRow: new Row(buttonRowItems) };
|
||||
}
|
||||
|
||||
export function renderDiscordModelPickerProvidersView(
|
||||
params: DiscordModelPickerProviderViewParams,
|
||||
): DiscordModelPickerRenderedView {
|
||||
const page = getDiscordModelPickerProviderPage({ data: params.data, page: params.page });
|
||||
const parsedCurrent = parseCurrentModelRef(params.currentModel);
|
||||
const rows = buildProviderRows({
|
||||
command: params.command,
|
||||
userId: params.userId,
|
||||
page,
|
||||
currentProvider: parsedCurrent?.provider,
|
||||
});
|
||||
|
||||
const detailLines = [
|
||||
formatCurrentModelLine(params.currentModel),
|
||||
`Select a provider (${page.totalItems} available).`,
|
||||
];
|
||||
return buildRenderedShell({
|
||||
layout: params.layout ?? "v2",
|
||||
title: "Model Picker",
|
||||
detailLines,
|
||||
rows,
|
||||
footer: `All ${page.totalItems} providers shown`,
|
||||
});
|
||||
}
|
||||
|
||||
export function renderDiscordModelPickerModelsView(
|
||||
params: DiscordModelPickerModelViewParams,
|
||||
): DiscordModelPickerRenderedView {
|
||||
const providerPage = normalizeModelPickerPage(params.providerPage);
|
||||
const modelPage = getDiscordModelPickerModelPage({
|
||||
data: params.data,
|
||||
provider: params.provider,
|
||||
page: params.page,
|
||||
});
|
||||
|
||||
if (!modelPage) {
|
||||
const rows: Row<Button>[] = [
|
||||
new Row([
|
||||
createModelPickerButton({
|
||||
label: "Back",
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "back",
|
||||
view: "providers",
|
||||
page: providerPage,
|
||||
userId: params.userId,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
];
|
||||
|
||||
return buildRenderedShell({
|
||||
layout: params.layout ?? "v2",
|
||||
title: "Model Picker",
|
||||
detailLines: [
|
||||
formatCurrentModelLine(params.currentModel),
|
||||
`Provider not found: ${normalizeProviderId(params.provider)}`,
|
||||
],
|
||||
rows,
|
||||
footer: "Choose a different provider.",
|
||||
});
|
||||
}
|
||||
|
||||
const { rows, buttonRow } = buildModelRows({
|
||||
command: params.command,
|
||||
userId: params.userId,
|
||||
data: params.data,
|
||||
providerPage,
|
||||
modelPage,
|
||||
currentModel: params.currentModel,
|
||||
pendingModel: params.pendingModel,
|
||||
pendingModelIndex: params.pendingModelIndex,
|
||||
currentRuntime: params.currentRuntime,
|
||||
pendingRuntime: params.pendingRuntime,
|
||||
quickModels: params.quickModels,
|
||||
});
|
||||
|
||||
const defaultModel = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
|
||||
const pendingLine = params.pendingModel
|
||||
? `Selected: ${params.pendingModel} · runtime ${resolveSelectedRuntime({
|
||||
data: params.data,
|
||||
provider: modelPage.provider,
|
||||
currentRuntime: params.currentRuntime,
|
||||
pendingRuntime: params.pendingRuntime,
|
||||
})} (press Submit)`
|
||||
: "Select a model, then press Submit.";
|
||||
|
||||
return buildRenderedShell({
|
||||
layout: params.layout ?? "v2",
|
||||
title: "Model Picker",
|
||||
detailLines: [formatCurrentModelLine(params.currentModel), `Default: ${defaultModel}`],
|
||||
preRowText: pendingLine,
|
||||
rows,
|
||||
trailingRows: [buttonRow],
|
||||
});
|
||||
}
|
||||
|
||||
export type DiscordModelPickerRecentsViewParams = {
|
||||
command: DiscordModelPickerCommandContext;
|
||||
userId: string;
|
||||
data: ModelsProviderData;
|
||||
quickModels: string[];
|
||||
currentModel?: string;
|
||||
provider?: string;
|
||||
page?: number;
|
||||
providerPage?: number;
|
||||
layout?: DiscordModelPickerLayout;
|
||||
};
|
||||
|
||||
function formatRecentsButtonLabel(modelRef: string, suffix?: string): string {
|
||||
const maxLen = 80;
|
||||
const label = suffix ? `${modelRef} ${suffix}` : modelRef;
|
||||
if (label.length <= maxLen) {
|
||||
return label;
|
||||
}
|
||||
const trimmed = suffix
|
||||
? `${modelRef.slice(0, maxLen - suffix.length - 2)}… ${suffix}`
|
||||
: `${modelRef.slice(0, maxLen - 1)}…`;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function renderDiscordModelPickerRecentsView(
|
||||
params: DiscordModelPickerRecentsViewParams,
|
||||
): DiscordModelPickerRenderedView {
|
||||
const defaultModelRef = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
|
||||
const rows: DiscordModelPickerRow[] = [];
|
||||
|
||||
// Dedupe: filter recents that match the default model.
|
||||
const dedupedQuickModels = params.quickModels.filter((modelRef) => modelRef !== defaultModelRef);
|
||||
|
||||
// Default model button — slot 1.
|
||||
rows.push(
|
||||
new Row([
|
||||
createModelPickerButton({
|
||||
label: formatRecentsButtonLabel(defaultModelRef, "(default)"),
|
||||
style: ButtonStyle.Secondary,
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "submit",
|
||||
view: "recents",
|
||||
recentSlot: 1,
|
||||
provider: params.provider,
|
||||
page: params.page,
|
||||
providerPage: params.providerPage,
|
||||
userId: params.userId,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// Recent model buttons — slot 2+.
|
||||
for (let i = 0; i < dedupedQuickModels.length; i++) {
|
||||
const modelRef = dedupedQuickModels[i];
|
||||
rows.push(
|
||||
new Row([
|
||||
createModelPickerButton({
|
||||
label: formatRecentsButtonLabel(modelRef),
|
||||
style: ButtonStyle.Secondary,
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "submit",
|
||||
view: "recents",
|
||||
recentSlot: i + 2,
|
||||
provider: params.provider,
|
||||
page: params.page,
|
||||
providerPage: params.providerPage,
|
||||
userId: params.userId,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// Back button after a divider (via trailingRows).
|
||||
const backRow: Row<Button> = new Row([
|
||||
createModelPickerButton({
|
||||
label: "Back",
|
||||
style: ButtonStyle.Secondary,
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "back",
|
||||
view: "models",
|
||||
provider: params.provider,
|
||||
page: params.page,
|
||||
providerPage: params.providerPage,
|
||||
userId: params.userId,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
return buildRenderedShell({
|
||||
layout: params.layout ?? "v2",
|
||||
title: "Recents",
|
||||
detailLines: [
|
||||
"Models you've previously selected appear here.",
|
||||
formatCurrentModelLine(params.currentModel),
|
||||
],
|
||||
preRowText: "Tap a model to switch.",
|
||||
rows,
|
||||
trailingRows: [backRow],
|
||||
});
|
||||
}
|
||||
|
||||
export function toDiscordModelPickerMessagePayload(
|
||||
view: DiscordModelPickerRenderedView,
|
||||
): MessagePayloadObject {
|
||||
if (view.layout === "classic") {
|
||||
return {
|
||||
content: view.content,
|
||||
components: view.components,
|
||||
};
|
||||
}
|
||||
return {
|
||||
components: view.components,
|
||||
};
|
||||
}
|
||||
export {
|
||||
renderDiscordModelPickerModelsView,
|
||||
renderDiscordModelPickerProvidersView,
|
||||
renderDiscordModelPickerRecentsView,
|
||||
toDiscordModelPickerMessagePayload,
|
||||
} from "./model-picker.view.js";
|
||||
export type {
|
||||
DiscordModelPickerModelViewParams,
|
||||
DiscordModelPickerProviderViewParams,
|
||||
DiscordModelPickerRecentsViewParams,
|
||||
DiscordModelPickerRenderedView,
|
||||
} from "./model-picker.view.js";
|
||||
|
||||
695
extensions/discord/src/monitor/model-picker.view.ts
Normal file
695
extensions/discord/src/monitor/model-picker.view.ts
Normal file
@@ -0,0 +1,695 @@
|
||||
import type { APISelectMenuOption } from "discord-api-types/v10";
|
||||
import { ButtonStyle } from "discord-api-types/v10";
|
||||
import type { ModelsProviderData } from "openclaw/plugin-sdk/models-provider-runtime";
|
||||
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Row,
|
||||
Separator,
|
||||
StringSelectMenu,
|
||||
TextDisplay,
|
||||
type MessagePayloadObject,
|
||||
type TopLevelComponents,
|
||||
} from "../internal/discord.js";
|
||||
import {
|
||||
buildDiscordModelPickerCustomId,
|
||||
DISCORD_COMPONENT_MAX_BUTTONS_PER_ROW,
|
||||
getDiscordModelPickerModelPage,
|
||||
getDiscordModelPickerProviderPage,
|
||||
normalizeModelPickerPage,
|
||||
type DiscordModelPickerCommandContext,
|
||||
type DiscordModelPickerLayout,
|
||||
type DiscordModelPickerModelPage,
|
||||
type DiscordModelPickerPage,
|
||||
type DiscordModelPickerProviderItem,
|
||||
} from "./model-picker.state.js";
|
||||
|
||||
const DISCORD_PROVIDER_BUTTON_LABEL_MAX_CHARS = 18;
|
||||
|
||||
type DiscordModelPickerButtonOptions = {
|
||||
label: string;
|
||||
customId: string;
|
||||
style?: ButtonStyle;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type DiscordModelPickerCurrentModelRef = {
|
||||
provider: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
type DiscordModelPickerRow = Row<Button> | Row<StringSelectMenu>;
|
||||
|
||||
type DiscordModelPickerRenderShellParams = {
|
||||
layout: DiscordModelPickerLayout;
|
||||
title: string;
|
||||
detailLines: string[];
|
||||
rows: DiscordModelPickerRow[];
|
||||
footer?: string;
|
||||
/** Text shown after the divider but before the interactive rows. */
|
||||
preRowText?: string;
|
||||
/** Extra rows appended after the main rows, preceded by a divider. */
|
||||
trailingRows?: DiscordModelPickerRow[];
|
||||
};
|
||||
|
||||
export type DiscordModelPickerRenderedView = {
|
||||
layout: DiscordModelPickerLayout;
|
||||
content?: string;
|
||||
components: TopLevelComponents[];
|
||||
};
|
||||
|
||||
export type DiscordModelPickerProviderViewParams = {
|
||||
command: DiscordModelPickerCommandContext;
|
||||
userId: string;
|
||||
data: ModelsProviderData;
|
||||
page?: number;
|
||||
currentModel?: string;
|
||||
layout?: DiscordModelPickerLayout;
|
||||
};
|
||||
|
||||
export type DiscordModelPickerModelViewParams = {
|
||||
command: DiscordModelPickerCommandContext;
|
||||
userId: string;
|
||||
data: ModelsProviderData;
|
||||
provider: string;
|
||||
page?: number;
|
||||
providerPage?: number;
|
||||
currentModel?: string;
|
||||
pendingModel?: string;
|
||||
pendingModelIndex?: number;
|
||||
currentRuntime?: string;
|
||||
pendingRuntime?: string;
|
||||
quickModels?: string[];
|
||||
layout?: DiscordModelPickerLayout;
|
||||
};
|
||||
|
||||
function parseCurrentModelRef(raw?: string): DiscordModelPickerCurrentModelRef | null {
|
||||
const trimmed = raw?.trim();
|
||||
const match = trimmed?.match(/^([^/]+)\/(.+)$/u);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const provider = normalizeProviderId(match[1]);
|
||||
// Preserve the model suffix exactly as entered after "/" so select defaults
|
||||
// continue to mirror the stored ref for Discord interactions.
|
||||
const model = match[2];
|
||||
if (!provider || !model) {
|
||||
return null;
|
||||
}
|
||||
return { provider, model };
|
||||
}
|
||||
|
||||
function formatCurrentModelLine(currentModel?: string): string {
|
||||
const parsed = parseCurrentModelRef(currentModel);
|
||||
if (!parsed) {
|
||||
return "Current model: default";
|
||||
}
|
||||
return `Current model: ${parsed.provider}/${parsed.model}`;
|
||||
}
|
||||
|
||||
function formatProviderButtonLabel(provider: string): string {
|
||||
if (provider.length <= DISCORD_PROVIDER_BUTTON_LABEL_MAX_CHARS) {
|
||||
return provider;
|
||||
}
|
||||
return `${provider.slice(0, DISCORD_PROVIDER_BUTTON_LABEL_MAX_CHARS - 1)}…`;
|
||||
}
|
||||
|
||||
function chunkProvidersForRows(
|
||||
items: DiscordModelPickerProviderItem[],
|
||||
): DiscordModelPickerProviderItem[][] {
|
||||
if (items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rowCount = Math.max(1, Math.ceil(items.length / DISCORD_COMPONENT_MAX_BUTTONS_PER_ROW));
|
||||
const minPerRow = Math.floor(items.length / rowCount);
|
||||
const rowsWithExtraItem = items.length % rowCount;
|
||||
|
||||
const counts = Array.from({ length: rowCount }, (_, index) =>
|
||||
index < rowCount - rowsWithExtraItem ? minPerRow : minPerRow + 1,
|
||||
);
|
||||
|
||||
const rows: DiscordModelPickerProviderItem[][] = [];
|
||||
let cursor = 0;
|
||||
for (const count of counts) {
|
||||
rows.push(items.slice(cursor, cursor + count));
|
||||
cursor += count;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function createModelPickerButton(params: DiscordModelPickerButtonOptions): Button {
|
||||
class DiscordModelPickerButton extends Button {
|
||||
label = params.label;
|
||||
customId = params.customId;
|
||||
style = params.style ?? ButtonStyle.Secondary;
|
||||
disabled = params.disabled ?? false;
|
||||
}
|
||||
return new DiscordModelPickerButton();
|
||||
}
|
||||
|
||||
function createModelSelect(params: {
|
||||
customId: string;
|
||||
options: APISelectMenuOption[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}): StringSelectMenu {
|
||||
class DiscordModelPickerSelect extends StringSelectMenu {
|
||||
customId = params.customId;
|
||||
options = params.options;
|
||||
minValues = 1;
|
||||
maxValues = 1;
|
||||
placeholder = params.placeholder;
|
||||
disabled = params.disabled ?? false;
|
||||
}
|
||||
return new DiscordModelPickerSelect();
|
||||
}
|
||||
|
||||
function getRuntimeChoices(params: {
|
||||
data: ModelsProviderData;
|
||||
provider: string;
|
||||
}): Array<{ id: string; label: string; description?: string }> {
|
||||
return (
|
||||
params.data.runtimeChoicesByProvider?.get(normalizeProviderId(params.provider)) ?? [
|
||||
{
|
||||
id: "pi",
|
||||
label: "OpenClaw Pi Default",
|
||||
description: "Use the built-in OpenClaw Pi runtime.",
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSelectedRuntime(params: {
|
||||
data: ModelsProviderData;
|
||||
provider: string;
|
||||
currentRuntime?: string;
|
||||
pendingRuntime?: string;
|
||||
}): string {
|
||||
const choices = getRuntimeChoices({ data: params.data, provider: params.provider });
|
||||
const allowed = new Set(choices.map((choice) => choice.id));
|
||||
const pending = params.pendingRuntime?.trim();
|
||||
if (pending && allowed.has(pending)) {
|
||||
return pending;
|
||||
}
|
||||
const current = params.currentRuntime?.trim();
|
||||
return current && allowed.has(current) ? current : "pi";
|
||||
}
|
||||
|
||||
function buildRenderedShell(
|
||||
params: DiscordModelPickerRenderShellParams,
|
||||
): DiscordModelPickerRenderedView {
|
||||
if (params.layout === "classic") {
|
||||
const lines = [params.title, ...params.detailLines, "", params.footer].filter(Boolean);
|
||||
return {
|
||||
layout: "classic",
|
||||
content: lines.join("\n"),
|
||||
components: params.rows,
|
||||
};
|
||||
}
|
||||
|
||||
const containerComponents: Array<TextDisplay | Separator | DiscordModelPickerRow> = [
|
||||
new TextDisplay(`## ${params.title}`),
|
||||
];
|
||||
if (params.detailLines.length > 0) {
|
||||
containerComponents.push(new TextDisplay(params.detailLines.join("\n")));
|
||||
}
|
||||
containerComponents.push(new Separator({ divider: true, spacing: "small" }));
|
||||
if (params.preRowText) {
|
||||
containerComponents.push(new TextDisplay(params.preRowText));
|
||||
}
|
||||
containerComponents.push(...params.rows);
|
||||
if (params.trailingRows && params.trailingRows.length > 0) {
|
||||
containerComponents.push(new Separator({ divider: true, spacing: "small" }));
|
||||
containerComponents.push(...params.trailingRows);
|
||||
}
|
||||
if (params.footer) {
|
||||
containerComponents.push(new Separator({ divider: false, spacing: "small" }));
|
||||
containerComponents.push(new TextDisplay(`-# ${params.footer}`));
|
||||
}
|
||||
|
||||
const container = new Container(containerComponents);
|
||||
return {
|
||||
layout: "v2",
|
||||
components: [container],
|
||||
};
|
||||
}
|
||||
|
||||
function buildProviderRows(params: {
|
||||
command: DiscordModelPickerCommandContext;
|
||||
userId: string;
|
||||
page: DiscordModelPickerPage<DiscordModelPickerProviderItem>;
|
||||
currentProvider?: string;
|
||||
}): Row<Button>[] {
|
||||
const rows = chunkProvidersForRows(params.page.items).map(
|
||||
(providers) =>
|
||||
new Row(
|
||||
providers.map((provider) => {
|
||||
const style =
|
||||
provider.id === params.currentProvider ? ButtonStyle.Primary : ButtonStyle.Secondary;
|
||||
return createModelPickerButton({
|
||||
label: formatProviderButtonLabel(provider.id),
|
||||
style,
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "provider",
|
||||
view: "models",
|
||||
provider: provider.id,
|
||||
page: params.page.page,
|
||||
userId: params.userId,
|
||||
}),
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function buildModelRows(params: {
|
||||
command: DiscordModelPickerCommandContext;
|
||||
userId: string;
|
||||
data: ModelsProviderData;
|
||||
providerPage: number;
|
||||
modelPage: DiscordModelPickerModelPage;
|
||||
currentModel?: string;
|
||||
pendingModel?: string;
|
||||
pendingModelIndex?: number;
|
||||
currentRuntime?: string;
|
||||
pendingRuntime?: string;
|
||||
quickModels?: string[];
|
||||
}): { rows: DiscordModelPickerRow[]; buttonRow: Row<Button> } {
|
||||
const parsedCurrentModel = parseCurrentModelRef(params.currentModel);
|
||||
const parsedPendingModel = parseCurrentModelRef(params.pendingModel);
|
||||
const rows: DiscordModelPickerRow[] = [];
|
||||
|
||||
const hasQuickModels = (params.quickModels ?? []).length > 0;
|
||||
|
||||
const providerPage = getDiscordModelPickerProviderPage({
|
||||
data: params.data,
|
||||
page: params.providerPage,
|
||||
});
|
||||
const providerOptions: APISelectMenuOption[] = providerPage.items.map((provider) => ({
|
||||
label: provider.id,
|
||||
value: provider.id,
|
||||
default: provider.id === params.modelPage.provider,
|
||||
}));
|
||||
|
||||
rows.push(
|
||||
new Row([
|
||||
createModelSelect({
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "provider",
|
||||
view: "models",
|
||||
provider: params.modelPage.provider,
|
||||
page: providerPage.page,
|
||||
providerPage: providerPage.page,
|
||||
userId: params.userId,
|
||||
}),
|
||||
options: providerOptions,
|
||||
placeholder: "Select provider",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const runtimeChoices = getRuntimeChoices({
|
||||
data: params.data,
|
||||
provider: params.modelPage.provider,
|
||||
});
|
||||
const selectedRuntime = resolveSelectedRuntime({
|
||||
data: params.data,
|
||||
provider: params.modelPage.provider,
|
||||
currentRuntime: params.currentRuntime,
|
||||
pendingRuntime: params.pendingRuntime,
|
||||
});
|
||||
const normalizedCurrentRuntime = params.currentRuntime?.trim();
|
||||
const shouldCarryRuntime =
|
||||
runtimeChoices.length > 1 ||
|
||||
(Boolean(normalizedCurrentRuntime) &&
|
||||
normalizedCurrentRuntime !== "auto" &&
|
||||
normalizedCurrentRuntime !== "pi" &&
|
||||
normalizedCurrentRuntime !== "default");
|
||||
const stateRuntime = shouldCarryRuntime ? selectedRuntime : undefined;
|
||||
if (runtimeChoices.length > 1) {
|
||||
rows.push(
|
||||
new Row([
|
||||
createModelSelect({
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "runtime",
|
||||
view: "models",
|
||||
provider: params.modelPage.provider,
|
||||
runtime: selectedRuntime,
|
||||
page: params.modelPage.page,
|
||||
providerPage: providerPage.page,
|
||||
modelIndex: params.pendingModelIndex,
|
||||
userId: params.userId,
|
||||
}),
|
||||
options: runtimeChoices.map((choice) => {
|
||||
const option: APISelectMenuOption = {
|
||||
label: choice.label,
|
||||
value: choice.id,
|
||||
default: choice.id === selectedRuntime,
|
||||
};
|
||||
if (choice.description) {
|
||||
option.description = choice.description;
|
||||
}
|
||||
return option;
|
||||
}),
|
||||
placeholder: "Select runtime",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const selectedModelRef = parsedPendingModel ?? parsedCurrentModel;
|
||||
const modelOptions: APISelectMenuOption[] = params.modelPage.items.map((model) => ({
|
||||
label: model,
|
||||
value: model,
|
||||
default: selectedModelRef
|
||||
? selectedModelRef.provider === params.modelPage.provider && selectedModelRef.model === model
|
||||
: false,
|
||||
}));
|
||||
|
||||
rows.push(
|
||||
new Row([
|
||||
createModelSelect({
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "model",
|
||||
view: "models",
|
||||
provider: params.modelPage.provider,
|
||||
runtime: stateRuntime,
|
||||
page: params.modelPage.page,
|
||||
providerPage: providerPage.page,
|
||||
userId: params.userId,
|
||||
}),
|
||||
options: modelOptions,
|
||||
placeholder: `Select ${params.modelPage.provider} model`,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const resolvedDefault = params.data.resolvedDefault;
|
||||
const shouldDisableReset =
|
||||
Boolean(parsedCurrentModel) &&
|
||||
parsedCurrentModel?.provider === resolvedDefault.provider &&
|
||||
parsedCurrentModel?.model === resolvedDefault.model;
|
||||
|
||||
const hasPendingSelection =
|
||||
Boolean(parsedPendingModel) &&
|
||||
parsedPendingModel?.provider === params.modelPage.provider &&
|
||||
typeof params.pendingModelIndex === "number" &&
|
||||
params.pendingModelIndex > 0;
|
||||
|
||||
const buttonRowItems: Button[] = [
|
||||
createModelPickerButton({
|
||||
label: "Cancel",
|
||||
style: ButtonStyle.Secondary,
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "cancel",
|
||||
view: "models",
|
||||
provider: params.modelPage.provider,
|
||||
runtime: stateRuntime,
|
||||
page: params.modelPage.page,
|
||||
providerPage: providerPage.page,
|
||||
userId: params.userId,
|
||||
}),
|
||||
}),
|
||||
createModelPickerButton({
|
||||
label: "Reset to default",
|
||||
style: ButtonStyle.Secondary,
|
||||
disabled: shouldDisableReset,
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "reset",
|
||||
view: "models",
|
||||
provider: params.modelPage.provider,
|
||||
runtime: stateRuntime,
|
||||
page: params.modelPage.page,
|
||||
providerPage: providerPage.page,
|
||||
userId: params.userId,
|
||||
}),
|
||||
}),
|
||||
];
|
||||
|
||||
if (hasQuickModels) {
|
||||
buttonRowItems.push(
|
||||
createModelPickerButton({
|
||||
label: "Recents",
|
||||
style: ButtonStyle.Secondary,
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "recents",
|
||||
view: "recents",
|
||||
provider: params.modelPage.provider,
|
||||
runtime: stateRuntime,
|
||||
page: params.modelPage.page,
|
||||
providerPage: providerPage.page,
|
||||
userId: params.userId,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
buttonRowItems.push(
|
||||
createModelPickerButton({
|
||||
label: "Submit",
|
||||
style: ButtonStyle.Primary,
|
||||
disabled: !hasPendingSelection,
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "submit",
|
||||
view: "models",
|
||||
provider: params.modelPage.provider,
|
||||
runtime: stateRuntime,
|
||||
page: params.modelPage.page,
|
||||
providerPage: providerPage.page,
|
||||
modelIndex: params.pendingModelIndex,
|
||||
userId: params.userId,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
return { rows, buttonRow: new Row(buttonRowItems) };
|
||||
}
|
||||
|
||||
export function renderDiscordModelPickerProvidersView(
|
||||
params: DiscordModelPickerProviderViewParams,
|
||||
): DiscordModelPickerRenderedView {
|
||||
const page = getDiscordModelPickerProviderPage({ data: params.data, page: params.page });
|
||||
const parsedCurrent = parseCurrentModelRef(params.currentModel);
|
||||
const rows = buildProviderRows({
|
||||
command: params.command,
|
||||
userId: params.userId,
|
||||
page,
|
||||
currentProvider: parsedCurrent?.provider,
|
||||
});
|
||||
|
||||
const detailLines = [
|
||||
formatCurrentModelLine(params.currentModel),
|
||||
`Select a provider (${page.totalItems} available).`,
|
||||
];
|
||||
return buildRenderedShell({
|
||||
layout: params.layout ?? "v2",
|
||||
title: "Model Picker",
|
||||
detailLines,
|
||||
rows,
|
||||
footer: `All ${page.totalItems} providers shown`,
|
||||
});
|
||||
}
|
||||
|
||||
export function renderDiscordModelPickerModelsView(
|
||||
params: DiscordModelPickerModelViewParams,
|
||||
): DiscordModelPickerRenderedView {
|
||||
const providerPage = normalizeModelPickerPage(params.providerPage);
|
||||
const modelPage = getDiscordModelPickerModelPage({
|
||||
data: params.data,
|
||||
provider: params.provider,
|
||||
page: params.page,
|
||||
});
|
||||
|
||||
if (!modelPage) {
|
||||
const rows: Row<Button>[] = [
|
||||
new Row([
|
||||
createModelPickerButton({
|
||||
label: "Back",
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "back",
|
||||
view: "providers",
|
||||
page: providerPage,
|
||||
userId: params.userId,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
];
|
||||
|
||||
return buildRenderedShell({
|
||||
layout: params.layout ?? "v2",
|
||||
title: "Model Picker",
|
||||
detailLines: [
|
||||
formatCurrentModelLine(params.currentModel),
|
||||
`Provider not found: ${normalizeProviderId(params.provider)}`,
|
||||
],
|
||||
rows,
|
||||
footer: "Choose a different provider.",
|
||||
});
|
||||
}
|
||||
|
||||
const { rows, buttonRow } = buildModelRows({
|
||||
command: params.command,
|
||||
userId: params.userId,
|
||||
data: params.data,
|
||||
providerPage,
|
||||
modelPage,
|
||||
currentModel: params.currentModel,
|
||||
pendingModel: params.pendingModel,
|
||||
pendingModelIndex: params.pendingModelIndex,
|
||||
currentRuntime: params.currentRuntime,
|
||||
pendingRuntime: params.pendingRuntime,
|
||||
quickModels: params.quickModels,
|
||||
});
|
||||
|
||||
const defaultModel = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
|
||||
const pendingLine = params.pendingModel
|
||||
? `Selected: ${params.pendingModel} · runtime ${resolveSelectedRuntime({
|
||||
data: params.data,
|
||||
provider: modelPage.provider,
|
||||
currentRuntime: params.currentRuntime,
|
||||
pendingRuntime: params.pendingRuntime,
|
||||
})} (press Submit)`
|
||||
: "Select a model, then press Submit.";
|
||||
|
||||
return buildRenderedShell({
|
||||
layout: params.layout ?? "v2",
|
||||
title: "Model Picker",
|
||||
detailLines: [formatCurrentModelLine(params.currentModel), `Default: ${defaultModel}`],
|
||||
preRowText: pendingLine,
|
||||
rows,
|
||||
trailingRows: [buttonRow],
|
||||
});
|
||||
}
|
||||
|
||||
export type DiscordModelPickerRecentsViewParams = {
|
||||
command: DiscordModelPickerCommandContext;
|
||||
userId: string;
|
||||
data: ModelsProviderData;
|
||||
quickModels: string[];
|
||||
currentModel?: string;
|
||||
provider?: string;
|
||||
page?: number;
|
||||
providerPage?: number;
|
||||
layout?: DiscordModelPickerLayout;
|
||||
};
|
||||
|
||||
function formatRecentsButtonLabel(modelRef: string, suffix?: string): string {
|
||||
const maxLen = 80;
|
||||
const label = suffix ? `${modelRef} ${suffix}` : modelRef;
|
||||
if (label.length <= maxLen) {
|
||||
return label;
|
||||
}
|
||||
const trimmed = suffix
|
||||
? `${modelRef.slice(0, maxLen - suffix.length - 2)}… ${suffix}`
|
||||
: `${modelRef.slice(0, maxLen - 1)}…`;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function renderDiscordModelPickerRecentsView(
|
||||
params: DiscordModelPickerRecentsViewParams,
|
||||
): DiscordModelPickerRenderedView {
|
||||
const defaultModelRef = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
|
||||
const rows: DiscordModelPickerRow[] = [];
|
||||
|
||||
// Dedupe: filter recents that match the default model.
|
||||
const dedupedQuickModels = params.quickModels.filter((modelRef) => modelRef !== defaultModelRef);
|
||||
|
||||
// Default model button — slot 1.
|
||||
rows.push(
|
||||
new Row([
|
||||
createModelPickerButton({
|
||||
label: formatRecentsButtonLabel(defaultModelRef, "(default)"),
|
||||
style: ButtonStyle.Secondary,
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "submit",
|
||||
view: "recents",
|
||||
recentSlot: 1,
|
||||
provider: params.provider,
|
||||
page: params.page,
|
||||
providerPage: params.providerPage,
|
||||
userId: params.userId,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// Recent model buttons — slot 2+.
|
||||
for (let i = 0; i < dedupedQuickModels.length; i++) {
|
||||
const modelRef = dedupedQuickModels[i];
|
||||
rows.push(
|
||||
new Row([
|
||||
createModelPickerButton({
|
||||
label: formatRecentsButtonLabel(modelRef),
|
||||
style: ButtonStyle.Secondary,
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "submit",
|
||||
view: "recents",
|
||||
recentSlot: i + 2,
|
||||
provider: params.provider,
|
||||
page: params.page,
|
||||
providerPage: params.providerPage,
|
||||
userId: params.userId,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// Back button after a divider (via trailingRows).
|
||||
const backRow: Row<Button> = new Row([
|
||||
createModelPickerButton({
|
||||
label: "Back",
|
||||
style: ButtonStyle.Secondary,
|
||||
customId: buildDiscordModelPickerCustomId({
|
||||
command: params.command,
|
||||
action: "back",
|
||||
view: "models",
|
||||
provider: params.provider,
|
||||
page: params.page,
|
||||
providerPage: params.providerPage,
|
||||
userId: params.userId,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
return buildRenderedShell({
|
||||
layout: params.layout ?? "v2",
|
||||
title: "Recents",
|
||||
detailLines: [
|
||||
"Models you've previously selected appear here.",
|
||||
formatCurrentModelLine(params.currentModel),
|
||||
],
|
||||
preRowText: "Tap a model to switch.",
|
||||
rows,
|
||||
trailingRows: [backRow],
|
||||
});
|
||||
}
|
||||
|
||||
export function toDiscordModelPickerMessagePayload(
|
||||
view: DiscordModelPickerRenderedView,
|
||||
): MessagePayloadObject {
|
||||
if (view.layout === "classic") {
|
||||
return {
|
||||
content: view.content,
|
||||
components: view.components,
|
||||
};
|
||||
}
|
||||
return {
|
||||
components: view.components,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import {
|
||||
registerSessionBindingAdapter,
|
||||
resolveThreadBindingConversationIdFromBindingId,
|
||||
unregisterSessionBindingAdapter,
|
||||
type BindingTargetKind,
|
||||
type SessionBindingAdapter,
|
||||
type SessionBindingRecord,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { normalizeAccountId, resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
@@ -15,7 +11,6 @@ import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { createDiscordRestClient } from "../client.js";
|
||||
import { getChannel } from "../internal/discord.js";
|
||||
import { resolveDiscordChannelId } from "../target-parsing.js";
|
||||
import {
|
||||
createThreadForBinding,
|
||||
createWebhookForChannel,
|
||||
@@ -30,6 +25,7 @@ import {
|
||||
resolveThreadBindingFarewellText,
|
||||
resolveThreadBindingThreadName,
|
||||
} from "./thread-bindings.messages.js";
|
||||
import { createThreadBindingSessionAdapter } from "./thread-bindings.session-adapter.js";
|
||||
import {
|
||||
BINDINGS_BY_THREAD_ID,
|
||||
forgetThreadBindingToken,
|
||||
@@ -68,18 +64,6 @@ function registerManager(manager: ThreadBindingManager) {
|
||||
MANAGERS_BY_ACCOUNT_ID.set(manager.accountId, manager);
|
||||
}
|
||||
|
||||
function normalizeChildBindingParentChannelId(raw?: string | null): string | undefined {
|
||||
const trimmed = normalizeOptionalString(raw) ?? "";
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return resolveDiscordChannelId(trimmed);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function unregisterManager(accountId: string, manager: ThreadBindingManager) {
|
||||
const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId);
|
||||
if (existing === manager) {
|
||||
@@ -89,25 +73,6 @@ function unregisterManager(accountId: string, manager: ThreadBindingManager) {
|
||||
|
||||
const SWEEPERS_BY_ACCOUNT_ID = new Map<string, () => Promise<void>>();
|
||||
|
||||
function resolveEffectiveBindingExpiresAt(params: {
|
||||
record: ThreadBindingRecord;
|
||||
defaultIdleTimeoutMs: number;
|
||||
defaultMaxAgeMs: number;
|
||||
}): number | undefined {
|
||||
const inactivityExpiresAt = resolveThreadBindingInactivityExpiresAt({
|
||||
record: params.record,
|
||||
defaultIdleTimeoutMs: params.defaultIdleTimeoutMs,
|
||||
});
|
||||
const maxAgeExpiresAt = resolveThreadBindingMaxAgeExpiresAt({
|
||||
record: params.record,
|
||||
defaultMaxAgeMs: params.defaultMaxAgeMs,
|
||||
});
|
||||
if (inactivityExpiresAt != null && maxAgeExpiresAt != null) {
|
||||
return Math.min(inactivityExpiresAt, maxAgeExpiresAt);
|
||||
}
|
||||
return inactivityExpiresAt ?? maxAgeExpiresAt;
|
||||
}
|
||||
|
||||
function createNoopManager(accountIdRaw?: string): ThreadBindingManager {
|
||||
const accountId = normalizeAccountId(accountIdRaw);
|
||||
return {
|
||||
@@ -126,65 +91,11 @@ function createNoopManager(accountIdRaw?: string): ThreadBindingManager {
|
||||
};
|
||||
}
|
||||
|
||||
function toSessionBindingTargetKind(raw: string): BindingTargetKind {
|
||||
return raw === "subagent" ? "subagent" : "session";
|
||||
}
|
||||
|
||||
function toThreadBindingTargetKind(raw: BindingTargetKind): "subagent" | "acp" {
|
||||
return raw === "subagent" ? "subagent" : "acp";
|
||||
}
|
||||
|
||||
function isDirectConversationBindingId(value?: string | null): boolean {
|
||||
const trimmed = normalizeOptionalString(value);
|
||||
return Boolean(trimmed && /^(user:|channel:)/i.test(trimmed));
|
||||
}
|
||||
|
||||
function toSessionBindingRecord(
|
||||
record: ThreadBindingRecord,
|
||||
defaults: { idleTimeoutMs: number; maxAgeMs: number },
|
||||
): SessionBindingRecord {
|
||||
const bindingId =
|
||||
resolveBindingRecordKey({
|
||||
accountId: record.accountId,
|
||||
threadId: record.threadId,
|
||||
}) ?? `${record.accountId}:${record.threadId}`;
|
||||
return {
|
||||
bindingId,
|
||||
targetSessionKey: record.targetSessionKey,
|
||||
targetKind: toSessionBindingTargetKind(record.targetKind),
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: record.accountId,
|
||||
conversationId: record.threadId,
|
||||
parentConversationId: record.channelId,
|
||||
},
|
||||
status: "active",
|
||||
boundAt: record.boundAt,
|
||||
expiresAt: resolveEffectiveBindingExpiresAt({
|
||||
record,
|
||||
defaultIdleTimeoutMs: defaults.idleTimeoutMs,
|
||||
defaultMaxAgeMs: defaults.maxAgeMs,
|
||||
}),
|
||||
metadata: {
|
||||
agentId: record.agentId,
|
||||
label: record.label,
|
||||
webhookId: record.webhookId,
|
||||
webhookToken: record.webhookToken,
|
||||
boundBy: record.boundBy,
|
||||
lastActivityAt: record.lastActivityAt,
|
||||
idleTimeoutMs: resolveThreadBindingIdleTimeoutMs({
|
||||
record,
|
||||
defaultIdleTimeoutMs: defaults.idleTimeoutMs,
|
||||
}),
|
||||
maxAgeMs: resolveThreadBindingMaxAgeMs({
|
||||
record,
|
||||
defaultMaxAgeMs: defaults.maxAgeMs,
|
||||
}),
|
||||
...record.metadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createThreadBindingManager(params: {
|
||||
accountId?: string;
|
||||
token?: string;
|
||||
@@ -606,123 +517,13 @@ export function createThreadBindingManager(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const sessionBindingAdapter: SessionBindingAdapter = {
|
||||
channel: "discord",
|
||||
const sessionBindingAdapter = createThreadBindingSessionAdapter({
|
||||
accountId,
|
||||
capabilities: {
|
||||
placements: ["current", "child"],
|
||||
},
|
||||
bind: async (input) => {
|
||||
if (input.conversation.channel !== "discord") {
|
||||
return null;
|
||||
}
|
||||
const targetSessionKey = input.targetSessionKey.trim();
|
||||
if (!targetSessionKey) {
|
||||
return null;
|
||||
}
|
||||
const conversationId = normalizeOptionalString(input.conversation.conversationId) ?? "";
|
||||
const placement = input.placement === "child" ? "child" : "current";
|
||||
const metadata = input.metadata ?? {};
|
||||
const label = normalizeOptionalString(metadata.label);
|
||||
const threadName =
|
||||
typeof metadata.threadName === "string"
|
||||
? normalizeOptionalString(metadata.threadName)
|
||||
: undefined;
|
||||
const introText =
|
||||
typeof metadata.introText === "string"
|
||||
? normalizeOptionalString(metadata.introText)
|
||||
: undefined;
|
||||
const boundBy =
|
||||
typeof metadata.boundBy === "string"
|
||||
? normalizeOptionalString(metadata.boundBy)
|
||||
: undefined;
|
||||
const agentId =
|
||||
typeof metadata.agentId === "string"
|
||||
? normalizeOptionalString(metadata.agentId)
|
||||
: undefined;
|
||||
let threadId: string | undefined;
|
||||
let channelId: string | undefined;
|
||||
let createThread = false;
|
||||
|
||||
if (placement === "child") {
|
||||
createThread = true;
|
||||
channelId = normalizeChildBindingParentChannelId(input.conversation.parentConversationId);
|
||||
if (!channelId && conversationId) {
|
||||
const cfg = resolveCurrentCfg();
|
||||
channelId =
|
||||
(await resolveChannelIdForBinding({
|
||||
cfg,
|
||||
accountId,
|
||||
token: resolveCurrentToken(),
|
||||
threadId: conversationId,
|
||||
})) ?? undefined;
|
||||
}
|
||||
} else {
|
||||
threadId = conversationId || undefined;
|
||||
}
|
||||
const bound = await manager.bindTarget({
|
||||
threadId,
|
||||
channelId,
|
||||
createThread,
|
||||
threadName,
|
||||
targetKind: toThreadBindingTargetKind(input.targetKind),
|
||||
targetSessionKey,
|
||||
agentId,
|
||||
label,
|
||||
boundBy,
|
||||
introText,
|
||||
metadata,
|
||||
});
|
||||
return bound
|
||||
? toSessionBindingRecord(bound, {
|
||||
idleTimeoutMs,
|
||||
maxAgeMs,
|
||||
})
|
||||
: null;
|
||||
},
|
||||
listBySession: (targetSessionKey) =>
|
||||
manager
|
||||
.listBySessionKey(targetSessionKey)
|
||||
.map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs })),
|
||||
resolveByConversation: (ref) => {
|
||||
if (ref.channel !== "discord") {
|
||||
return null;
|
||||
}
|
||||
const binding = manager.getByThreadId(ref.conversationId);
|
||||
return binding ? toSessionBindingRecord(binding, { idleTimeoutMs, maxAgeMs }) : null;
|
||||
},
|
||||
touch: (bindingId, at) => {
|
||||
const threadId = resolveThreadBindingConversationIdFromBindingId({
|
||||
accountId,
|
||||
bindingId,
|
||||
});
|
||||
if (!threadId) {
|
||||
return;
|
||||
}
|
||||
manager.touchThread({ threadId, at, persist: true });
|
||||
},
|
||||
unbind: async (input) => {
|
||||
if (input.targetSessionKey?.trim()) {
|
||||
const removed = manager.unbindBySessionKey({
|
||||
targetSessionKey: input.targetSessionKey,
|
||||
reason: input.reason,
|
||||
});
|
||||
return removed.map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs }));
|
||||
}
|
||||
const threadId = resolveThreadBindingConversationIdFromBindingId({
|
||||
accountId,
|
||||
bindingId: input.bindingId,
|
||||
});
|
||||
if (!threadId) {
|
||||
return [];
|
||||
}
|
||||
const removed = manager.unbindThread({
|
||||
threadId,
|
||||
reason: input.reason,
|
||||
});
|
||||
return removed ? [toSessionBindingRecord(removed, { idleTimeoutMs, maxAgeMs })] : [];
|
||||
},
|
||||
};
|
||||
manager,
|
||||
defaults: { idleTimeoutMs, maxAgeMs },
|
||||
resolveCurrentCfg,
|
||||
resolveCurrentToken,
|
||||
});
|
||||
|
||||
registerSessionBindingAdapter(sessionBindingAdapter);
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import {
|
||||
resolveThreadBindingConversationIdFromBindingId,
|
||||
type BindingTargetKind,
|
||||
type SessionBindingAdapter,
|
||||
type SessionBindingRecord,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveDiscordChannelId } from "../target-parsing.js";
|
||||
import { resolveChannelIdForBinding } from "./thread-bindings.discord-api.js";
|
||||
import { resolveBindingRecordKey } from "./thread-bindings.state.js";
|
||||
import {
|
||||
resolveThreadBindingIdleTimeoutMs,
|
||||
resolveThreadBindingInactivityExpiresAt,
|
||||
resolveThreadBindingMaxAgeExpiresAt,
|
||||
resolveThreadBindingMaxAgeMs,
|
||||
} from "./thread-bindings.state.js";
|
||||
import type { ThreadBindingManager, ThreadBindingRecord } from "./thread-bindings.types.js";
|
||||
|
||||
type ThreadBindingDefaults = {
|
||||
idleTimeoutMs: number;
|
||||
maxAgeMs: number;
|
||||
};
|
||||
|
||||
function normalizeChildBindingParentChannelId(raw?: string | null): string | undefined {
|
||||
const trimmed = normalizeOptionalString(raw) ?? "";
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return resolveDiscordChannelId(trimmed);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function toSessionBindingTargetKind(raw: string): BindingTargetKind {
|
||||
return raw === "subagent" ? "subagent" : "session";
|
||||
}
|
||||
|
||||
function toThreadBindingTargetKind(raw: BindingTargetKind): "subagent" | "acp" {
|
||||
return raw === "subagent" ? "subagent" : "acp";
|
||||
}
|
||||
|
||||
function resolveEffectiveBindingExpiresAt(params: {
|
||||
record: ThreadBindingRecord;
|
||||
defaultIdleTimeoutMs: number;
|
||||
defaultMaxAgeMs: number;
|
||||
}): number | undefined {
|
||||
const inactivityExpiresAt = resolveThreadBindingInactivityExpiresAt({
|
||||
record: params.record,
|
||||
defaultIdleTimeoutMs: params.defaultIdleTimeoutMs,
|
||||
});
|
||||
const maxAgeExpiresAt = resolveThreadBindingMaxAgeExpiresAt({
|
||||
record: params.record,
|
||||
defaultMaxAgeMs: params.defaultMaxAgeMs,
|
||||
});
|
||||
if (inactivityExpiresAt != null && maxAgeExpiresAt != null) {
|
||||
return Math.min(inactivityExpiresAt, maxAgeExpiresAt);
|
||||
}
|
||||
return inactivityExpiresAt ?? maxAgeExpiresAt;
|
||||
}
|
||||
|
||||
export function toSessionBindingRecord(
|
||||
record: ThreadBindingRecord,
|
||||
defaults: ThreadBindingDefaults,
|
||||
): SessionBindingRecord {
|
||||
const bindingId =
|
||||
resolveBindingRecordKey({
|
||||
accountId: record.accountId,
|
||||
threadId: record.threadId,
|
||||
}) ?? `${record.accountId}:${record.threadId}`;
|
||||
return {
|
||||
bindingId,
|
||||
targetSessionKey: record.targetSessionKey,
|
||||
targetKind: toSessionBindingTargetKind(record.targetKind),
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: record.accountId,
|
||||
conversationId: record.threadId,
|
||||
parentConversationId: record.channelId,
|
||||
},
|
||||
status: "active",
|
||||
boundAt: record.boundAt,
|
||||
expiresAt: resolveEffectiveBindingExpiresAt({
|
||||
record,
|
||||
defaultIdleTimeoutMs: defaults.idleTimeoutMs,
|
||||
defaultMaxAgeMs: defaults.maxAgeMs,
|
||||
}),
|
||||
metadata: {
|
||||
agentId: record.agentId,
|
||||
label: record.label,
|
||||
webhookId: record.webhookId,
|
||||
webhookToken: record.webhookToken,
|
||||
boundBy: record.boundBy,
|
||||
lastActivityAt: record.lastActivityAt,
|
||||
idleTimeoutMs: resolveThreadBindingIdleTimeoutMs({
|
||||
record,
|
||||
defaultIdleTimeoutMs: defaults.idleTimeoutMs,
|
||||
}),
|
||||
maxAgeMs: resolveThreadBindingMaxAgeMs({
|
||||
record,
|
||||
defaultMaxAgeMs: defaults.maxAgeMs,
|
||||
}),
|
||||
...record.metadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createThreadBindingSessionAdapter(params: {
|
||||
accountId: string;
|
||||
manager: ThreadBindingManager;
|
||||
defaults: ThreadBindingDefaults;
|
||||
resolveCurrentCfg: () => OpenClawConfig;
|
||||
resolveCurrentToken: () => string | undefined;
|
||||
}): SessionBindingAdapter {
|
||||
const toRecord = (entry: ThreadBindingRecord) => toSessionBindingRecord(entry, params.defaults);
|
||||
|
||||
return {
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
capabilities: {
|
||||
placements: ["current", "child"],
|
||||
},
|
||||
bind: async (input) => {
|
||||
if (input.conversation.channel !== "discord") {
|
||||
return null;
|
||||
}
|
||||
const targetSessionKey = input.targetSessionKey.trim();
|
||||
if (!targetSessionKey) {
|
||||
return null;
|
||||
}
|
||||
const conversationId = normalizeOptionalString(input.conversation.conversationId) ?? "";
|
||||
const placement = input.placement === "child" ? "child" : "current";
|
||||
const metadata = input.metadata ?? {};
|
||||
const label = normalizeOptionalString(metadata.label);
|
||||
const threadName =
|
||||
typeof metadata.threadName === "string"
|
||||
? normalizeOptionalString(metadata.threadName)
|
||||
: undefined;
|
||||
const introText =
|
||||
typeof metadata.introText === "string"
|
||||
? normalizeOptionalString(metadata.introText)
|
||||
: undefined;
|
||||
const boundBy =
|
||||
typeof metadata.boundBy === "string"
|
||||
? normalizeOptionalString(metadata.boundBy)
|
||||
: undefined;
|
||||
const agentId =
|
||||
typeof metadata.agentId === "string"
|
||||
? normalizeOptionalString(metadata.agentId)
|
||||
: undefined;
|
||||
let threadId: string | undefined;
|
||||
let channelId: string | undefined;
|
||||
let createThread = false;
|
||||
|
||||
if (placement === "child") {
|
||||
createThread = true;
|
||||
channelId = normalizeChildBindingParentChannelId(input.conversation.parentConversationId);
|
||||
if (!channelId && conversationId) {
|
||||
channelId =
|
||||
(await resolveChannelIdForBinding({
|
||||
cfg: params.resolveCurrentCfg(),
|
||||
accountId: params.accountId,
|
||||
token: params.resolveCurrentToken(),
|
||||
threadId: conversationId,
|
||||
})) ?? undefined;
|
||||
}
|
||||
} else {
|
||||
threadId = conversationId || undefined;
|
||||
}
|
||||
|
||||
const bound = await params.manager.bindTarget({
|
||||
threadId,
|
||||
channelId,
|
||||
createThread,
|
||||
threadName,
|
||||
targetKind: toThreadBindingTargetKind(input.targetKind),
|
||||
targetSessionKey,
|
||||
agentId,
|
||||
label,
|
||||
boundBy,
|
||||
introText,
|
||||
metadata,
|
||||
});
|
||||
return bound ? toRecord(bound) : null;
|
||||
},
|
||||
listBySession: (targetSessionKey) =>
|
||||
params.manager.listBySessionKey(targetSessionKey).map(toRecord),
|
||||
resolveByConversation: (ref) => {
|
||||
if (ref.channel !== "discord") {
|
||||
return null;
|
||||
}
|
||||
const binding = params.manager.getByThreadId(ref.conversationId);
|
||||
return binding ? toRecord(binding) : null;
|
||||
},
|
||||
touch: (bindingId, at) => {
|
||||
const threadId = resolveThreadBindingConversationIdFromBindingId({
|
||||
accountId: params.accountId,
|
||||
bindingId,
|
||||
});
|
||||
if (!threadId) {
|
||||
return;
|
||||
}
|
||||
params.manager.touchThread({ threadId, at, persist: true });
|
||||
},
|
||||
unbind: async (input) => {
|
||||
if (input.targetSessionKey?.trim()) {
|
||||
const removed = params.manager.unbindBySessionKey({
|
||||
targetSessionKey: input.targetSessionKey,
|
||||
reason: input.reason,
|
||||
});
|
||||
return removed.map(toRecord);
|
||||
}
|
||||
const threadId = resolveThreadBindingConversationIdFromBindingId({
|
||||
accountId: params.accountId,
|
||||
bindingId: input.bindingId,
|
||||
});
|
||||
if (!threadId) {
|
||||
return [];
|
||||
}
|
||||
const removed = params.manager.unbindThread({
|
||||
threadId,
|
||||
reason: input.reason,
|
||||
});
|
||||
return removed ? [toRecord(removed)] : [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDiscordComponentCustomId,
|
||||
parseDiscordComponentCustomIdForInteraction,
|
||||
} from "./component-custom-id.js";
|
||||
|
||||
const API_SOURCE_PATH = resolve(dirname(fileURLToPath(import.meta.url)), "../api.ts");
|
||||
const itOnSupportedNode = Number(process.versions.node.split(".")[0]) >= 22 ? it : it.skip;
|
||||
|
||||
const FORMER_PUBLIC_API_EXPORTS = [
|
||||
"DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS",
|
||||
"DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS",
|
||||
"DISCORD_COMPONENT_ATTACHMENT_PREFIX",
|
||||
"DISCORD_COMPONENT_CUSTOM_ID_KEY",
|
||||
"DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS",
|
||||
"DISCORD_DEFAULT_LISTENER_TIMEOUT_MS",
|
||||
"DISCORD_MODAL_CUSTOM_ID_KEY",
|
||||
"DiscordApplicationSummary",
|
||||
"DiscordComponentBlock",
|
||||
"DiscordComponentBuildResult",
|
||||
"DiscordComponentButtonSpec",
|
||||
"DiscordComponentButtonStyle",
|
||||
"DiscordComponentEntry",
|
||||
"DiscordComponentMessageSpec",
|
||||
"DiscordComponentModalFieldType",
|
||||
"DiscordComponentSectionAccessory",
|
||||
"DiscordComponentSelectOption",
|
||||
"DiscordComponentSelectSpec",
|
||||
"DiscordComponentSelectType",
|
||||
"DiscordCredentialStatus",
|
||||
"DiscordFormModal",
|
||||
"DiscordInteractiveHandlerContext",
|
||||
"DiscordInteractiveHandlerRegistration",
|
||||
"DiscordModalEntry",
|
||||
"DiscordModalFieldDefinition",
|
||||
"DiscordModalFieldSpec",
|
||||
"DiscordModalSpec",
|
||||
"DiscordPluralKitConfig",
|
||||
"DiscordPrivilegedIntentStatus",
|
||||
"DiscordPrivilegedIntentsSummary",
|
||||
"DiscordProbe",
|
||||
"DiscordSendComponents",
|
||||
"DiscordSendEmbeds",
|
||||
"DiscordSendResult",
|
||||
"DiscordTarget",
|
||||
"DiscordTargetKind",
|
||||
"DiscordTargetParseOptions",
|
||||
"DiscordTokenResolution",
|
||||
"InspectedDiscordAccount",
|
||||
"PluralKitMemberInfo",
|
||||
"PluralKitMessageInfo",
|
||||
"PluralKitSystemInfo",
|
||||
"ResolvedDiscordAccount",
|
||||
"buildDiscordComponentCustomId",
|
||||
"buildDiscordComponentMessage",
|
||||
"buildDiscordComponentMessageFlags",
|
||||
"buildDiscordInteractiveComponents",
|
||||
"buildDiscordModalCustomId",
|
||||
"collectDiscordSecurityAuditFindings",
|
||||
"collectDiscordStatusIssues",
|
||||
"createDiscordActionGate",
|
||||
"createDiscordFormModal",
|
||||
"discordPlugin",
|
||||
"discordSetupPlugin",
|
||||
"fetchDiscordApplicationId",
|
||||
"fetchDiscordApplicationSummary",
|
||||
"fetchPluralKitMessageInfo",
|
||||
"formatDiscordComponentEventText",
|
||||
"getDiscordExecApprovalApprovers",
|
||||
"handleDiscordMessageAction",
|
||||
"handleDiscordSubagentDeliveryTarget",
|
||||
"handleDiscordSubagentEnded",
|
||||
"handleDiscordSubagentSpawning",
|
||||
"inspectDiscordAccount",
|
||||
"isDiscordExecApprovalApprover",
|
||||
"isDiscordExecApprovalClientEnabled",
|
||||
"listDiscordAccountIds",
|
||||
"listDiscordDirectoryGroupsFromConfig",
|
||||
"listDiscordDirectoryPeersFromConfig",
|
||||
"listEnabledDiscordAccounts",
|
||||
"looksLikeDiscordTargetId",
|
||||
"mergeDiscordAccountConfig",
|
||||
"normalizeDiscordMessagingTarget",
|
||||
"normalizeDiscordOutboundTarget",
|
||||
"normalizeExplicitDiscordSessionKey",
|
||||
"parseApplicationIdFromToken",
|
||||
"parseDiscordComponentCustomId",
|
||||
"parseDiscordComponentCustomIdForCarbon",
|
||||
"parseDiscordComponentCustomIdForInteraction",
|
||||
"parseDiscordModalCustomId",
|
||||
"parseDiscordModalCustomIdForCarbon",
|
||||
"parseDiscordModalCustomIdForInteraction",
|
||||
"parseDiscordTarget",
|
||||
"probeDiscord",
|
||||
"readDiscordComponentSpec",
|
||||
"resolveDefaultDiscordAccountId",
|
||||
"resolveDiscordAccount",
|
||||
"resolveDiscordAccountConfig",
|
||||
"resolveDiscordChannelId",
|
||||
"resolveDiscordComponentAttachmentName",
|
||||
"resolveDiscordGroupRequireMention",
|
||||
"resolveDiscordGroupToolPolicy",
|
||||
"resolveDiscordMaxLinesPerMessage",
|
||||
"resolveDiscordPrivilegedIntentsFromFlags",
|
||||
"resolveDiscordRuntimeGroupPolicy",
|
||||
"resolveDiscordTarget",
|
||||
"shouldSuppressLocalDiscordExecApprovalPrompt",
|
||||
"tryHandleDiscordMessageActionGuildAdmin",
|
||||
] as const;
|
||||
|
||||
function collectExportedNames(): Set<string> {
|
||||
const source = ts.createSourceFile(
|
||||
API_SOURCE_PATH,
|
||||
readFileSync(API_SOURCE_PATH, "utf8"),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const names = new Set<string>();
|
||||
for (const statement of source.statements) {
|
||||
if (
|
||||
ts.isVariableStatement(statement) &&
|
||||
statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
|
||||
) {
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
if (ts.isIdentifier(declaration.name)) {
|
||||
names.add(declaration.name.text);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!ts.isExportDeclaration(statement) || !statement.exportClause) {
|
||||
continue;
|
||||
}
|
||||
if (ts.isNamedExports(statement.exportClause)) {
|
||||
for (const element of statement.exportClause.elements) {
|
||||
names.add(element.name.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
describe("discord public API barrel", () => {
|
||||
it("keeps compatibility exports for existing @openclaw/discord/api.js consumers", () => {
|
||||
const exportedNames = collectExportedNames();
|
||||
|
||||
for (const exportName of FORMER_PUBLIC_API_EXPORTS) {
|
||||
expect(exportedNames).toContain(exportName);
|
||||
}
|
||||
});
|
||||
|
||||
itOnSupportedNode("links restored runtime compatibility exports", async () => {
|
||||
const api = await import("../api.js");
|
||||
|
||||
for (const exportName of [
|
||||
"DISCORD_COMPONENT_CUSTOM_ID_KEY",
|
||||
"buildDiscordComponentMessageFlags",
|
||||
"createDiscordFormModal",
|
||||
"handleDiscordSubagentSpawning",
|
||||
"listEnabledDiscordAccounts",
|
||||
"resolveDiscordRuntimeGroupPolicy",
|
||||
"tryHandleDiscordMessageActionGuildAdmin",
|
||||
]) {
|
||||
expect(api).toHaveProperty(exportName);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps legacy Carbon component parser aliases aligned with interaction parsers", () => {
|
||||
const exportedNames = collectExportedNames();
|
||||
const customId = buildDiscordComponentCustomId({
|
||||
componentId: "approve",
|
||||
modalId: "details",
|
||||
});
|
||||
|
||||
expect(exportedNames).toContain("parseDiscordComponentCustomIdForCarbon");
|
||||
expect(parseDiscordComponentCustomIdForInteraction(customId)).toEqual({
|
||||
key: "*",
|
||||
data: { cid: "approve", mid: "details" },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user