mirror of https://github.com/openclaw/openclaw.git
399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
import type { RequestClient } from "@buape/carbon";
|
|
import { resolveAgentAvatar } from "openclaw/plugin-sdk/agent-runtime";
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
|
import type { MarkdownTableMode, ReplyToMode } from "openclaw/plugin-sdk/config-runtime";
|
|
import { createDiscordRetryRunner, type RetryRunner } from "openclaw/plugin-sdk/infra-runtime";
|
|
import {
|
|
resolveRetryConfig,
|
|
retryAsync,
|
|
type RetryConfig,
|
|
} from "openclaw/plugin-sdk/infra-runtime";
|
|
import {
|
|
resolveSendableOutboundReplyParts,
|
|
resolveTextChunksWithFallback,
|
|
sendMediaWithLeadingCaption,
|
|
} from "openclaw/plugin-sdk/reply-payload";
|
|
import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime";
|
|
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
|
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
|
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime";
|
|
import { resolveDiscordAccount } from "../accounts.js";
|
|
import { chunkDiscordTextWithMode } from "../chunk.js";
|
|
import { sendMessageDiscord, sendVoiceMessageDiscord, sendWebhookMessageDiscord } from "../send.js";
|
|
import { sendDiscordText } from "../send.shared.js";
|
|
|
|
export type DiscordThreadBindingLookupRecord = {
|
|
accountId: string;
|
|
threadId: string;
|
|
agentId: string;
|
|
label?: string;
|
|
webhookId?: string;
|
|
webhookToken?: string;
|
|
};
|
|
|
|
export type DiscordThreadBindingLookup = {
|
|
listBySessionKey: (targetSessionKey: string) => DiscordThreadBindingLookupRecord[];
|
|
touchThread?: (params: { threadId: string; at?: number; persist?: boolean }) => unknown;
|
|
};
|
|
|
|
type ResolvedRetryConfig = Required<RetryConfig>;
|
|
|
|
const DISCORD_DELIVERY_RETRY_DEFAULTS: ResolvedRetryConfig = {
|
|
attempts: 3,
|
|
minDelayMs: 1000,
|
|
maxDelayMs: 30_000,
|
|
jitter: 0,
|
|
};
|
|
|
|
function isRetryableDiscordError(err: unknown): boolean {
|
|
const status = (err as { status?: number }).status ?? (err as { statusCode?: number }).statusCode;
|
|
return status === 429 || (status !== undefined && status >= 500);
|
|
}
|
|
|
|
function getDiscordRetryAfterMs(err: unknown): number | undefined {
|
|
if (!err || typeof err !== "object") {
|
|
return undefined;
|
|
}
|
|
if (
|
|
"retryAfter" in err &&
|
|
typeof err.retryAfter === "number" &&
|
|
Number.isFinite(err.retryAfter)
|
|
) {
|
|
return err.retryAfter * 1000;
|
|
}
|
|
const retryAfterRaw = (err as { headers?: Record<string, string> }).headers?.["retry-after"];
|
|
if (!retryAfterRaw) {
|
|
return undefined;
|
|
}
|
|
const retryAfterMs = Number(retryAfterRaw) * 1000;
|
|
return Number.isFinite(retryAfterMs) ? retryAfterMs : undefined;
|
|
}
|
|
|
|
function resolveDeliveryRetryConfig(retry?: RetryConfig): ResolvedRetryConfig {
|
|
return resolveRetryConfig(DISCORD_DELIVERY_RETRY_DEFAULTS, retry);
|
|
}
|
|
|
|
async function sendWithRetry(
|
|
fn: () => Promise<unknown>,
|
|
retryConfig: ResolvedRetryConfig,
|
|
): Promise<void> {
|
|
await retryAsync(fn, {
|
|
...retryConfig,
|
|
shouldRetry: (err) => isRetryableDiscordError(err),
|
|
retryAfterMs: getDiscordRetryAfterMs,
|
|
});
|
|
}
|
|
|
|
function resolveTargetChannelId(target: string): string | undefined {
|
|
if (!target.startsWith("channel:")) {
|
|
return undefined;
|
|
}
|
|
const channelId = target.slice("channel:".length).trim();
|
|
return channelId || undefined;
|
|
}
|
|
|
|
function resolveBoundThreadBinding(params: {
|
|
threadBindings?: DiscordThreadBindingLookup;
|
|
sessionKey?: string;
|
|
target: string;
|
|
}): DiscordThreadBindingLookupRecord | undefined {
|
|
const sessionKey = params.sessionKey?.trim();
|
|
if (!params.threadBindings || !sessionKey) {
|
|
return undefined;
|
|
}
|
|
const bindings = params.threadBindings.listBySessionKey(sessionKey);
|
|
if (bindings.length === 0) {
|
|
return undefined;
|
|
}
|
|
const targetChannelId = resolveTargetChannelId(params.target);
|
|
if (!targetChannelId) {
|
|
return undefined;
|
|
}
|
|
return bindings.find((entry) => entry.threadId === targetChannelId);
|
|
}
|
|
|
|
function resolveBindingPersona(
|
|
cfg: OpenClawConfig,
|
|
binding: DiscordThreadBindingLookupRecord | undefined,
|
|
): {
|
|
username?: string;
|
|
avatarUrl?: string;
|
|
} {
|
|
if (!binding) {
|
|
return {};
|
|
}
|
|
const baseLabel = binding.label?.trim() || binding.agentId;
|
|
const username = (`🤖 ${baseLabel}`.trim() || "🤖 agent").slice(0, 80);
|
|
|
|
let avatarUrl: string | undefined;
|
|
try {
|
|
const avatar = resolveAgentAvatar(cfg, binding.agentId);
|
|
if (avatar.kind === "remote") {
|
|
avatarUrl = avatar.url;
|
|
}
|
|
} catch {
|
|
avatarUrl = undefined;
|
|
}
|
|
return { username, avatarUrl };
|
|
}
|
|
|
|
async function sendDiscordChunkWithFallback(params: {
|
|
cfg: OpenClawConfig;
|
|
target: string;
|
|
text: string;
|
|
token: string;
|
|
accountId?: string;
|
|
maxLinesPerMessage?: number;
|
|
rest?: RequestClient;
|
|
replyTo?: string;
|
|
binding?: DiscordThreadBindingLookupRecord;
|
|
chunkMode?: ChunkMode;
|
|
username?: string;
|
|
avatarUrl?: string;
|
|
/** Pre-resolved channel ID to bypass redundant resolution per chunk. */
|
|
channelId?: string;
|
|
/** Pre-created retry runner to avoid creating one per chunk. */
|
|
request?: RetryRunner;
|
|
/** Pre-resolved retry config (account-level). */
|
|
retryConfig: ResolvedRetryConfig;
|
|
}) {
|
|
if (!params.text.trim()) {
|
|
return;
|
|
}
|
|
const text = params.text;
|
|
const binding = params.binding;
|
|
if (binding?.webhookId && binding?.webhookToken) {
|
|
try {
|
|
await sendWebhookMessageDiscord(text, {
|
|
cfg: params.cfg,
|
|
webhookId: binding.webhookId,
|
|
webhookToken: binding.webhookToken,
|
|
accountId: binding.accountId,
|
|
threadId: binding.threadId,
|
|
replyTo: params.replyTo,
|
|
username: params.username,
|
|
avatarUrl: params.avatarUrl,
|
|
});
|
|
return;
|
|
} catch {
|
|
// Fall through to the standard bot sender path.
|
|
}
|
|
}
|
|
// When channelId and request are pre-resolved, send directly via sendDiscordText
|
|
// to avoid per-chunk overhead (channel-type GET, re-chunking, client creation)
|
|
// that can cause ordering issues under queue contention or rate limiting.
|
|
if (params.channelId && params.request && params.rest) {
|
|
const { channelId, request, rest } = params;
|
|
await sendWithRetry(
|
|
() =>
|
|
sendDiscordText(
|
|
rest,
|
|
channelId,
|
|
text,
|
|
params.replyTo,
|
|
request,
|
|
params.maxLinesPerMessage,
|
|
undefined,
|
|
undefined,
|
|
params.chunkMode,
|
|
),
|
|
params.retryConfig,
|
|
);
|
|
return;
|
|
}
|
|
await sendWithRetry(
|
|
() =>
|
|
sendMessageDiscord(params.target, text, {
|
|
cfg: params.cfg,
|
|
token: params.token,
|
|
rest: params.rest,
|
|
accountId: params.accountId,
|
|
replyTo: params.replyTo,
|
|
}),
|
|
params.retryConfig,
|
|
);
|
|
}
|
|
|
|
export async function deliverDiscordReply(params: {
|
|
cfg: OpenClawConfig;
|
|
replies: ReplyPayload[];
|
|
target: string;
|
|
token: string;
|
|
accountId?: string;
|
|
rest?: RequestClient;
|
|
runtime: RuntimeEnv;
|
|
textLimit: number;
|
|
maxLinesPerMessage?: number;
|
|
replyToId?: string;
|
|
replyToMode?: ReplyToMode;
|
|
tableMode?: MarkdownTableMode;
|
|
chunkMode?: ChunkMode;
|
|
sessionKey?: string;
|
|
threadBindings?: DiscordThreadBindingLookup;
|
|
mediaLocalRoots?: readonly string[];
|
|
}) {
|
|
const chunkLimit = Math.min(params.textLimit, 2000);
|
|
const replyTo = params.replyToId?.trim() || undefined;
|
|
const replyToMode = params.replyToMode ?? "all";
|
|
// replyToMode=first should only apply to the first physical send.
|
|
const replyOnce = replyToMode === "first";
|
|
let replyUsed = false;
|
|
const resolveReplyTo = () => {
|
|
if (!replyTo) {
|
|
return undefined;
|
|
}
|
|
if (!replyOnce) {
|
|
return replyTo;
|
|
}
|
|
if (replyUsed) {
|
|
return undefined;
|
|
}
|
|
replyUsed = true;
|
|
return replyTo;
|
|
};
|
|
const binding = resolveBoundThreadBinding({
|
|
threadBindings: params.threadBindings,
|
|
sessionKey: params.sessionKey,
|
|
target: params.target,
|
|
});
|
|
const persona = resolveBindingPersona(params.cfg, binding);
|
|
// Pre-resolve channel ID and retry runner once to avoid per-chunk overhead.
|
|
// This eliminates redundant channel-type GET requests and client creation that
|
|
// can cause ordering issues when multiple chunks share the RequestClient queue.
|
|
const channelId = resolveTargetChannelId(params.target);
|
|
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
|
|
const retryConfig = resolveDeliveryRetryConfig(account.config.retry);
|
|
const request: RetryRunner | undefined = channelId
|
|
? createDiscordRetryRunner({ configRetry: account.config.retry })
|
|
: undefined;
|
|
let deliveredAny = false;
|
|
for (const payload of params.replies) {
|
|
const tableMode = params.tableMode ?? "code";
|
|
const reply = resolveSendableOutboundReplyParts(payload, {
|
|
text: convertMarkdownTables(payload.text ?? "", tableMode),
|
|
});
|
|
if (!reply.hasContent) {
|
|
continue;
|
|
}
|
|
if (!reply.hasMedia) {
|
|
const mode = params.chunkMode ?? "length";
|
|
const chunks = resolveTextChunksWithFallback(
|
|
reply.text,
|
|
chunkDiscordTextWithMode(reply.text, {
|
|
maxChars: chunkLimit,
|
|
maxLines: params.maxLinesPerMessage,
|
|
chunkMode: mode,
|
|
}),
|
|
);
|
|
for (const chunk of chunks) {
|
|
if (!chunk.trim()) {
|
|
continue;
|
|
}
|
|
const replyTo = resolveReplyTo();
|
|
await sendDiscordChunkWithFallback({
|
|
cfg: params.cfg,
|
|
target: params.target,
|
|
text: chunk,
|
|
token: params.token,
|
|
rest: params.rest,
|
|
accountId: params.accountId,
|
|
maxLinesPerMessage: params.maxLinesPerMessage,
|
|
replyTo,
|
|
binding,
|
|
chunkMode: params.chunkMode,
|
|
username: persona.username,
|
|
avatarUrl: persona.avatarUrl,
|
|
channelId,
|
|
request,
|
|
retryConfig,
|
|
});
|
|
deliveredAny = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const firstMedia = reply.mediaUrls[0];
|
|
if (!firstMedia) {
|
|
continue;
|
|
}
|
|
// Voice message path: audioAsVoice flag routes through sendVoiceMessageDiscord.
|
|
if (payload.audioAsVoice) {
|
|
const replyTo = resolveReplyTo();
|
|
await sendVoiceMessageDiscord(params.target, firstMedia, {
|
|
cfg: params.cfg,
|
|
token: params.token,
|
|
rest: params.rest,
|
|
accountId: params.accountId,
|
|
replyTo,
|
|
});
|
|
deliveredAny = true;
|
|
// Voice messages cannot include text; send remaining text separately if present.
|
|
await sendDiscordChunkWithFallback({
|
|
cfg: params.cfg,
|
|
target: params.target,
|
|
text: reply.text,
|
|
token: params.token,
|
|
rest: params.rest,
|
|
accountId: params.accountId,
|
|
maxLinesPerMessage: params.maxLinesPerMessage,
|
|
replyTo: resolveReplyTo(),
|
|
binding,
|
|
chunkMode: params.chunkMode,
|
|
username: persona.username,
|
|
avatarUrl: persona.avatarUrl,
|
|
channelId,
|
|
request,
|
|
retryConfig,
|
|
});
|
|
// Additional media items are sent as regular attachments (voice is single-file only).
|
|
await sendMediaWithLeadingCaption({
|
|
mediaUrls: reply.mediaUrls.slice(1),
|
|
caption: "",
|
|
send: async ({ mediaUrl }) => {
|
|
const replyTo = resolveReplyTo();
|
|
await sendWithRetry(
|
|
() =>
|
|
sendMessageDiscord(params.target, "", {
|
|
cfg: params.cfg,
|
|
token: params.token,
|
|
rest: params.rest,
|
|
mediaUrl,
|
|
accountId: params.accountId,
|
|
mediaLocalRoots: params.mediaLocalRoots,
|
|
replyTo,
|
|
}),
|
|
retryConfig,
|
|
);
|
|
},
|
|
});
|
|
continue;
|
|
}
|
|
|
|
await sendMediaWithLeadingCaption({
|
|
mediaUrls: reply.mediaUrls,
|
|
caption: reply.text,
|
|
send: async ({ mediaUrl, caption }) => {
|
|
const replyTo = resolveReplyTo();
|
|
await sendWithRetry(
|
|
() =>
|
|
sendMessageDiscord(params.target, caption ?? "", {
|
|
cfg: params.cfg,
|
|
token: params.token,
|
|
rest: params.rest,
|
|
mediaUrl,
|
|
accountId: params.accountId,
|
|
mediaLocalRoots: params.mediaLocalRoots,
|
|
replyTo,
|
|
}),
|
|
retryConfig,
|
|
);
|
|
},
|
|
});
|
|
deliveredAny = true;
|
|
}
|
|
|
|
if (binding && deliveredAny) {
|
|
params.threadBindings?.touchThread?.({ threadId: binding.threadId });
|
|
}
|
|
}
|