openclaw/src/auto-reply/reply/route-reply.ts
2026-01-07 05:02:34 +00:00

181 lines
5.0 KiB
TypeScript

/**
* Provider-agnostic reply router.
*
* Routes replies to the originating channel based on OriginatingChannel/OriginatingTo
* instead of using the session's lastChannel. This ensures replies go back to the
* provider where the message originated, even when the main session is shared
* across multiple providers.
*/
import type { ClawdbotConfig } from "../../config/config.js";
import { sendMessageDiscord } from "../../discord/send.js";
import { sendMessageIMessage } from "../../imessage/send.js";
import { sendMessageSignal } from "../../signal/send.js";
import { sendMessageSlack } from "../../slack/send.js";
import { sendMessageTelegram } from "../../telegram/send.js";
import { sendMessageWhatsApp } from "../../web/outbound.js";
import type { OriginatingChannelType } from "../templating.js";
import type { ReplyPayload } from "../types.js";
export type RouteReplyParams = {
/** The reply payload to send. */
payload: ReplyPayload;
/** The originating channel type (telegram, slack, etc). */
channel: OriginatingChannelType;
/** The destination chat/channel/user ID. */
to: string;
/** Provider account id (multi-account). */
accountId?: string;
/** Telegram message thread id (forum topics). */
threadId?: number;
/** Config for provider-specific settings. */
cfg: ClawdbotConfig;
};
export type RouteReplyResult = {
/** Whether the reply was sent successfully. */
ok: boolean;
/** Optional message ID from the provider. */
messageId?: string;
/** Error message if the send failed. */
error?: string;
};
/**
* Routes a reply payload to the specified channel.
*
* This function provides a unified interface for sending messages to any
* supported provider. It's used by the followup queue to route replies
* back to the originating channel when OriginatingChannel/OriginatingTo
* are set.
*/
export async function routeReply(
params: RouteReplyParams,
): Promise<RouteReplyResult> {
const { payload, channel, to, accountId, threadId } = params;
const text = payload.text ?? "";
const mediaUrls = (payload.mediaUrls?.filter(Boolean) ?? []).length
? (payload.mediaUrls?.filter(Boolean) as string[])
: payload.mediaUrl
? [payload.mediaUrl]
: [];
const replyToId = payload.replyToId;
// Skip empty replies.
if (!text.trim() && mediaUrls.length === 0) {
return { ok: true };
}
const sendOne = async (params: {
text: string;
mediaUrl?: string;
}): Promise<RouteReplyResult> => {
const { text, mediaUrl } = params;
switch (channel) {
case "telegram": {
const result = await sendMessageTelegram(to, text, {
mediaUrl,
messageThreadId: threadId,
});
return { ok: true, messageId: result.messageId };
}
case "slack": {
const result = await sendMessageSlack(to, text, {
mediaUrl,
threadTs: replyToId,
});
return { ok: true, messageId: result.messageId };
}
case "discord": {
const result = await sendMessageDiscord(to, text, {
mediaUrl,
replyTo: replyToId,
});
return { ok: true, messageId: result.messageId };
}
case "signal": {
const result = await sendMessageSignal(to, text, { mediaUrl });
return { ok: true, messageId: result.messageId };
}
case "imessage": {
const result = await sendMessageIMessage(to, text, { mediaUrl });
return { ok: true, messageId: result.messageId };
}
case "whatsapp": {
const result = await sendMessageWhatsApp(to, text, {
verbose: false,
mediaUrl,
accountId,
});
return { ok: true, messageId: result.messageId };
}
case "webchat": {
return {
ok: false,
error: `Webchat routing not supported for queued replies`,
};
}
default: {
const _exhaustive: never = channel;
return { ok: false, error: `Unknown channel: ${String(_exhaustive)}` };
}
}
};
try {
if (mediaUrls.length === 0) {
return await sendOne({ text });
}
let last: RouteReplyResult | undefined;
for (let i = 0; i < mediaUrls.length; i++) {
const mediaUrl = mediaUrls[i];
const caption = i === 0 ? text : "";
last = await sendOne({ text: caption, mediaUrl });
if (!last.ok) return last;
}
return last ?? { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
ok: false,
error: `Failed to route reply to ${channel}: ${message}`,
};
}
}
/**
* Checks if a channel type is routable via routeReply.
*
* Some channels (webchat) require special handling and cannot be routed through
* this generic interface.
*/
export function isRoutableChannel(
channel: OriginatingChannelType | undefined,
): channel is
| "telegram"
| "slack"
| "discord"
| "signal"
| "imessage"
| "whatsapp" {
if (!channel) return false;
return [
"telegram",
"slack",
"discord",
"signal",
"imessage",
"whatsapp",
].includes(channel);
}