mirror of https://github.com/openclaw/openclaw.git
363 lines
10 KiB
TypeScript
363 lines
10 KiB
TypeScript
import type { OpenClawConfig } from "../../config/config.js";
|
|
import { loadConfig } from "../../config/config.js";
|
|
import { callGatewayLeastPrivilege, randomIdempotencyKey } from "../../gateway/call.js";
|
|
import { resolveSendableOutboundReplyParts } from "../../plugin-sdk/reply-payload.js";
|
|
import type { PollInput } from "../../polls.js";
|
|
import { normalizePollInput } from "../../polls.js";
|
|
import {
|
|
GATEWAY_CLIENT_MODES,
|
|
GATEWAY_CLIENT_NAMES,
|
|
type GatewayClientMode,
|
|
type GatewayClientName,
|
|
} from "../../utils/message-channel.js";
|
|
import { resolveOutboundChannelPlugin } from "./channel-resolution.js";
|
|
import { resolveMessageChannelSelection } from "./channel-selection.js";
|
|
import {
|
|
deliverOutboundPayloads,
|
|
type OutboundDeliveryResult,
|
|
type OutboundSendDeps,
|
|
} from "./deliver.js";
|
|
import type { OutboundMirror } from "./mirror.js";
|
|
import { normalizeReplyPayloadsForDelivery } from "./payloads.js";
|
|
import { buildOutboundSessionContext } from "./session-context.js";
|
|
import { resolveOutboundTarget } from "./targets.js";
|
|
|
|
export type MessageGatewayOptions = {
|
|
url?: string;
|
|
token?: string;
|
|
timeoutMs?: number;
|
|
clientName?: GatewayClientName;
|
|
clientDisplayName?: string;
|
|
mode?: GatewayClientMode;
|
|
};
|
|
|
|
type MessageSendParams = {
|
|
to: string;
|
|
content: string;
|
|
/** Active agent id for per-agent outbound media root scoping. */
|
|
agentId?: string;
|
|
channel?: string;
|
|
mediaUrl?: string;
|
|
mediaUrls?: string[];
|
|
gifPlayback?: boolean;
|
|
forceDocument?: boolean;
|
|
accountId?: string;
|
|
replyToId?: string;
|
|
threadId?: string | number;
|
|
dryRun?: boolean;
|
|
bestEffort?: boolean;
|
|
deps?: OutboundSendDeps;
|
|
cfg?: OpenClawConfig;
|
|
gateway?: MessageGatewayOptions;
|
|
idempotencyKey?: string;
|
|
mirror?: OutboundMirror;
|
|
abortSignal?: AbortSignal;
|
|
silent?: boolean;
|
|
};
|
|
|
|
export type MessageSendResult = {
|
|
channel: string;
|
|
to: string;
|
|
via: "direct" | "gateway";
|
|
mediaUrl: string | null;
|
|
mediaUrls?: string[];
|
|
result?: OutboundDeliveryResult | { messageId: string };
|
|
dryRun?: boolean;
|
|
};
|
|
|
|
type MessagePollParams = {
|
|
to: string;
|
|
question: string;
|
|
options: string[];
|
|
maxSelections?: number;
|
|
durationSeconds?: number;
|
|
durationHours?: number;
|
|
channel?: string;
|
|
accountId?: string;
|
|
threadId?: string;
|
|
silent?: boolean;
|
|
isAnonymous?: boolean;
|
|
dryRun?: boolean;
|
|
cfg?: OpenClawConfig;
|
|
gateway?: MessageGatewayOptions;
|
|
idempotencyKey?: string;
|
|
};
|
|
|
|
export type MessagePollResult = {
|
|
channel: string;
|
|
to: string;
|
|
question: string;
|
|
options: string[];
|
|
maxSelections: number;
|
|
durationSeconds: number | null;
|
|
durationHours: number | null;
|
|
via: "gateway";
|
|
result?: {
|
|
messageId: string;
|
|
toJid?: string;
|
|
channelId?: string;
|
|
conversationId?: string;
|
|
pollId?: string;
|
|
};
|
|
dryRun?: boolean;
|
|
};
|
|
|
|
function buildMessagePollResult(params: {
|
|
channel: string;
|
|
to: string;
|
|
normalized: {
|
|
question: string;
|
|
options: string[];
|
|
maxSelections: number;
|
|
durationSeconds?: number | null;
|
|
durationHours?: number | null;
|
|
};
|
|
result?: MessagePollResult["result"];
|
|
dryRun?: boolean;
|
|
}): MessagePollResult {
|
|
return {
|
|
channel: params.channel,
|
|
to: params.to,
|
|
question: params.normalized.question,
|
|
options: params.normalized.options,
|
|
maxSelections: params.normalized.maxSelections,
|
|
durationSeconds: params.normalized.durationSeconds ?? null,
|
|
durationHours: params.normalized.durationHours ?? null,
|
|
via: "gateway",
|
|
...(params.dryRun ? { dryRun: true } : { result: params.result }),
|
|
};
|
|
}
|
|
|
|
async function resolveRequiredChannel(params: {
|
|
cfg: OpenClawConfig;
|
|
channel?: string;
|
|
}): Promise<string> {
|
|
return (
|
|
await resolveMessageChannelSelection({
|
|
cfg: params.cfg,
|
|
channel: params.channel,
|
|
})
|
|
).channel;
|
|
}
|
|
|
|
function resolveRequiredPlugin(channel: string, cfg: OpenClawConfig) {
|
|
const plugin = resolveOutboundChannelPlugin({ channel, cfg });
|
|
if (!plugin) {
|
|
throw new Error(`Unknown channel: ${channel}`);
|
|
}
|
|
return plugin;
|
|
}
|
|
|
|
function resolveGatewayOptions(opts?: MessageGatewayOptions) {
|
|
// Security: backend callers (tools/agents) must not accept user-controlled gateway URLs.
|
|
// Use config-derived gateway target only.
|
|
const url =
|
|
opts?.mode === GATEWAY_CLIENT_MODES.BACKEND ||
|
|
opts?.clientName === GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT
|
|
? undefined
|
|
: opts?.url;
|
|
return {
|
|
url,
|
|
token: opts?.token,
|
|
timeoutMs:
|
|
typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs)
|
|
? Math.max(1, Math.floor(opts.timeoutMs))
|
|
: 10_000,
|
|
clientName: opts?.clientName ?? GATEWAY_CLIENT_NAMES.CLI,
|
|
clientDisplayName: opts?.clientDisplayName,
|
|
mode: opts?.mode ?? GATEWAY_CLIENT_MODES.CLI,
|
|
};
|
|
}
|
|
|
|
async function callMessageGateway<T>(params: {
|
|
gateway?: MessageGatewayOptions;
|
|
method: string;
|
|
params: Record<string, unknown>;
|
|
}): Promise<T> {
|
|
const gateway = resolveGatewayOptions(params.gateway);
|
|
return await callGatewayLeastPrivilege<T>({
|
|
url: gateway.url,
|
|
token: gateway.token,
|
|
method: params.method,
|
|
params: params.params,
|
|
timeoutMs: gateway.timeoutMs,
|
|
clientName: gateway.clientName,
|
|
clientDisplayName: gateway.clientDisplayName,
|
|
mode: gateway.mode,
|
|
});
|
|
}
|
|
|
|
export async function sendMessage(params: MessageSendParams): Promise<MessageSendResult> {
|
|
const cfg = params.cfg ?? loadConfig();
|
|
const channel = await resolveRequiredChannel({ cfg, channel: params.channel });
|
|
const plugin = resolveRequiredPlugin(channel, cfg);
|
|
const deliveryMode = plugin.outbound?.deliveryMode ?? "direct";
|
|
const normalizedPayloads = normalizeReplyPayloadsForDelivery([
|
|
{
|
|
text: params.content,
|
|
mediaUrl: params.mediaUrl,
|
|
mediaUrls: params.mediaUrls,
|
|
},
|
|
]);
|
|
const mirrorText = normalizedPayloads
|
|
.map((payload) => payload.text)
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
const mirrorMediaUrls = normalizedPayloads.flatMap(
|
|
(payload) => resolveSendableOutboundReplyParts(payload).mediaUrls,
|
|
);
|
|
const primaryMediaUrl = mirrorMediaUrls[0] ?? params.mediaUrl ?? null;
|
|
|
|
if (params.dryRun) {
|
|
return {
|
|
channel,
|
|
to: params.to,
|
|
via: deliveryMode === "gateway" ? "gateway" : "direct",
|
|
mediaUrl: primaryMediaUrl,
|
|
mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : undefined,
|
|
dryRun: true,
|
|
};
|
|
}
|
|
|
|
if (deliveryMode !== "gateway") {
|
|
const outboundChannel = channel;
|
|
const resolvedTarget = resolveOutboundTarget({
|
|
channel: outboundChannel,
|
|
to: params.to,
|
|
cfg,
|
|
accountId: params.accountId,
|
|
mode: "explicit",
|
|
});
|
|
if (!resolvedTarget.ok) {
|
|
throw resolvedTarget.error;
|
|
}
|
|
|
|
const outboundSession = buildOutboundSessionContext({
|
|
cfg,
|
|
agentId: params.agentId,
|
|
sessionKey: params.mirror?.sessionKey,
|
|
});
|
|
const results = await deliverOutboundPayloads({
|
|
cfg,
|
|
channel: outboundChannel,
|
|
to: resolvedTarget.to,
|
|
session: outboundSession,
|
|
accountId: params.accountId,
|
|
payloads: normalizedPayloads,
|
|
replyToId: params.replyToId,
|
|
threadId: params.threadId,
|
|
gifPlayback: params.gifPlayback,
|
|
forceDocument: params.forceDocument,
|
|
deps: params.deps,
|
|
bestEffort: params.bestEffort,
|
|
abortSignal: params.abortSignal,
|
|
silent: params.silent,
|
|
mirror: params.mirror
|
|
? {
|
|
...params.mirror,
|
|
text: mirrorText || params.content,
|
|
mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : undefined,
|
|
idempotencyKey: params.mirror.idempotencyKey ?? params.idempotencyKey,
|
|
}
|
|
: undefined,
|
|
});
|
|
|
|
return {
|
|
channel,
|
|
to: params.to,
|
|
via: "direct",
|
|
mediaUrl: primaryMediaUrl,
|
|
mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : undefined,
|
|
result: results.at(-1),
|
|
};
|
|
}
|
|
|
|
const result = await callMessageGateway<{ messageId: string }>({
|
|
gateway: params.gateway,
|
|
method: "send",
|
|
params: {
|
|
to: params.to,
|
|
message: params.content,
|
|
mediaUrl: params.mediaUrl,
|
|
mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : params.mediaUrls,
|
|
gifPlayback: params.gifPlayback,
|
|
accountId: params.accountId,
|
|
agentId: params.agentId,
|
|
channel,
|
|
sessionKey: params.mirror?.sessionKey,
|
|
idempotencyKey: params.idempotencyKey ?? randomIdempotencyKey(),
|
|
},
|
|
});
|
|
|
|
return {
|
|
channel,
|
|
to: params.to,
|
|
via: "gateway",
|
|
mediaUrl: primaryMediaUrl,
|
|
mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : undefined,
|
|
result,
|
|
};
|
|
}
|
|
|
|
export async function sendPoll(params: MessagePollParams): Promise<MessagePollResult> {
|
|
const cfg = params.cfg ?? loadConfig();
|
|
const channel = await resolveRequiredChannel({ cfg, channel: params.channel });
|
|
|
|
const pollInput: PollInput = {
|
|
question: params.question,
|
|
options: params.options,
|
|
maxSelections: params.maxSelections,
|
|
durationSeconds: params.durationSeconds,
|
|
durationHours: params.durationHours,
|
|
};
|
|
const plugin = resolveRequiredPlugin(channel, cfg);
|
|
const outbound = plugin?.outbound;
|
|
if (!outbound?.sendPoll) {
|
|
throw new Error(`Unsupported poll channel: ${channel}`);
|
|
}
|
|
const normalized = outbound.pollMaxOptions
|
|
? normalizePollInput(pollInput, { maxOptions: outbound.pollMaxOptions })
|
|
: normalizePollInput(pollInput);
|
|
|
|
if (params.dryRun) {
|
|
return buildMessagePollResult({
|
|
channel,
|
|
to: params.to,
|
|
normalized,
|
|
dryRun: true,
|
|
});
|
|
}
|
|
|
|
const result = await callMessageGateway<{
|
|
messageId: string;
|
|
toJid?: string;
|
|
channelId?: string;
|
|
conversationId?: string;
|
|
pollId?: string;
|
|
}>({
|
|
gateway: params.gateway,
|
|
method: "poll",
|
|
params: {
|
|
to: params.to,
|
|
question: normalized.question,
|
|
options: normalized.options,
|
|
maxSelections: normalized.maxSelections,
|
|
durationSeconds: normalized.durationSeconds,
|
|
durationHours: normalized.durationHours,
|
|
threadId: params.threadId,
|
|
silent: params.silent,
|
|
isAnonymous: params.isAnonymous,
|
|
channel,
|
|
accountId: params.accountId,
|
|
idempotencyKey: params.idempotencyKey ?? randomIdempotencyKey(),
|
|
},
|
|
});
|
|
|
|
return buildMessagePollResult({
|
|
channel,
|
|
to: params.to,
|
|
normalized,
|
|
result,
|
|
});
|
|
}
|