openclaw/extensions/discord/src/outbound-adapter.ts
2026-03-16 21:16:32 -07:00

225 lines
7.1 KiB
TypeScript

import {
resolvePayloadMediaUrls,
sendPayloadMediaSequence,
sendTextMediaPayload,
} from "openclaw/plugin-sdk/channel-runtime";
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime";
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime";
import type { DiscordComponentMessageSpec } from "./components.js";
import { getThreadBindingManager, type ThreadBindingRecord } from "./monitor/thread-bindings.js";
import { normalizeDiscordOutboundTarget } from "./normalize.js";
import {
sendDiscordComponentMessage,
sendMessageDiscord,
sendPollDiscord,
sendWebhookMessageDiscord,
} from "./send.js";
import { buildDiscordInteractiveComponents } from "./shared-interactive.js";
export const DISCORD_TEXT_CHUNK_LIMIT = 2000;
function resolveDiscordOutboundTarget(params: {
to: string;
threadId?: string | number | null;
}): string {
if (params.threadId == null) {
return params.to;
}
const threadId = String(params.threadId).trim();
if (!threadId) {
return params.to;
}
return `channel:${threadId}`;
}
function resolveDiscordWebhookIdentity(params: {
identity?: OutboundIdentity;
binding: ThreadBindingRecord;
}): { username?: string; avatarUrl?: string } {
const usernameRaw = params.identity?.name?.trim();
const fallbackUsername = params.binding.label?.trim() || params.binding.agentId;
const username = (usernameRaw || fallbackUsername || "").slice(0, 80) || undefined;
const avatarUrl = params.identity?.avatarUrl?.trim() || undefined;
return { username, avatarUrl };
}
async function maybeSendDiscordWebhookText(params: {
cfg?: OpenClawConfig;
text: string;
threadId?: string | number | null;
accountId?: string | null;
identity?: OutboundIdentity;
replyToId?: string | null;
}): Promise<{ messageId: string; channelId: string } | null> {
if (params.threadId == null) {
return null;
}
const threadId = String(params.threadId).trim();
if (!threadId) {
return null;
}
const manager = getThreadBindingManager(params.accountId ?? undefined);
if (!manager) {
return null;
}
const binding = manager.getByThreadId(threadId);
if (!binding?.webhookId || !binding?.webhookToken) {
return null;
}
const persona = resolveDiscordWebhookIdentity({
identity: params.identity,
binding,
});
const result = await sendWebhookMessageDiscord(params.text, {
webhookId: binding.webhookId,
webhookToken: binding.webhookToken,
accountId: binding.accountId,
threadId: binding.threadId,
cfg: params.cfg,
replyTo: params.replyToId ?? undefined,
username: persona.username,
avatarUrl: persona.avatarUrl,
});
return result;
}
export const discordOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: null,
textChunkLimit: DISCORD_TEXT_CHUNK_LIMIT,
pollMaxOptions: 10,
resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to),
sendPayload: async (ctx) => {
const payload = {
...ctx.payload,
text: ctx.payload.text ?? "",
};
const discordData = payload.channelData?.discord as
| { components?: DiscordComponentMessageSpec }
| undefined;
const rawComponentSpec =
discordData?.components ?? buildDiscordInteractiveComponents(payload.interactive);
const componentSpec = rawComponentSpec
? rawComponentSpec.text
? rawComponentSpec
: {
...rawComponentSpec,
text: payload.text?.trim() ? payload.text : undefined,
}
: undefined;
if (!componentSpec) {
return await sendTextMediaPayload({
channel: "discord",
ctx: {
...ctx,
payload,
},
adapter: discordOutbound,
});
}
const send =
resolveOutboundSendDep<typeof sendMessageDiscord>(ctx.deps, "discord") ?? sendMessageDiscord;
const target = resolveDiscordOutboundTarget({ to: ctx.to, threadId: ctx.threadId });
const mediaUrls = resolvePayloadMediaUrls(payload);
if (mediaUrls.length === 0) {
const result = await sendDiscordComponentMessage(target, componentSpec, {
replyTo: ctx.replyToId ?? undefined,
accountId: ctx.accountId ?? undefined,
silent: ctx.silent ?? undefined,
cfg: ctx.cfg,
});
return { channel: "discord", ...result };
}
const lastResult = await sendPayloadMediaSequence({
text: payload.text ?? "",
mediaUrls,
send: async ({ text, mediaUrl, isFirst }) => {
if (isFirst) {
return await sendDiscordComponentMessage(target, componentSpec, {
mediaUrl,
mediaLocalRoots: ctx.mediaLocalRoots,
replyTo: ctx.replyToId ?? undefined,
accountId: ctx.accountId ?? undefined,
silent: ctx.silent ?? undefined,
cfg: ctx.cfg,
});
}
return await send(target, text, {
verbose: false,
mediaUrl,
mediaLocalRoots: ctx.mediaLocalRoots,
replyTo: ctx.replyToId ?? undefined,
accountId: ctx.accountId ?? undefined,
silent: ctx.silent ?? undefined,
cfg: ctx.cfg,
});
},
});
return lastResult
? { channel: "discord", ...lastResult }
: { channel: "discord", messageId: "" };
},
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity, silent }) => {
if (!silent) {
const webhookResult = await maybeSendDiscordWebhookText({
cfg,
text,
threadId,
accountId,
identity,
replyToId,
}).catch(() => null);
if (webhookResult) {
return { channel: "discord", ...webhookResult };
}
}
const send =
resolveOutboundSendDep<typeof sendMessageDiscord>(deps, "discord") ?? sendMessageDiscord;
const target = resolveDiscordOutboundTarget({ to, threadId });
const result = await send(target, text, {
verbose: false,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
cfg,
});
return { channel: "discord", ...result };
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
deps,
replyToId,
threadId,
silent,
}) => {
const send =
resolveOutboundSendDep<typeof sendMessageDiscord>(deps, "discord") ?? sendMessageDiscord;
const target = resolveDiscordOutboundTarget({ to, threadId });
const result = await send(target, text, {
verbose: false,
mediaUrl,
mediaLocalRoots,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
cfg,
});
return { channel: "discord", ...result };
},
sendPoll: async ({ cfg, to, poll, accountId, threadId, silent }) => {
const target = resolveDiscordOutboundTarget({ to, threadId });
return await sendPollDiscord(target, poll, {
accountId: accountId ?? undefined,
silent: silent ?? undefined,
cfg,
});
},
};