239 lines
6.9 KiB
TypeScript
239 lines
6.9 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 { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
|
|
import type { ClawdbotConfig } from "../../config/config.js";
|
|
import { sendMessageDiscord } from "../../discord/send.js";
|
|
import { sendMessageIMessage } from "../../imessage/send.js";
|
|
import { sendMessageMSTeams } from "../../msteams/send.js";
|
|
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.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";
|
|
import { normalizeReplyPayload } from "./normalize-reply.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;
|
|
/** Session key for deriving agent identity defaults (multi-agent). */
|
|
sessionKey?: string;
|
|
/** Provider account id (multi-account). */
|
|
accountId?: string;
|
|
/** Telegram message thread id (forum topics). */
|
|
threadId?: number;
|
|
/** Config for provider-specific settings. */
|
|
cfg: ClawdbotConfig;
|
|
/** Optional abort signal for cooperative cancellation. */
|
|
abortSignal?: AbortSignal;
|
|
};
|
|
|
|
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, cfg, abortSignal } =
|
|
params;
|
|
|
|
// Debug: `pnpm test src/auto-reply/reply/route-reply.test.ts`
|
|
const responsePrefix = params.sessionKey
|
|
? resolveEffectiveMessagesConfig(
|
|
cfg,
|
|
resolveAgentIdFromSessionKey(params.sessionKey),
|
|
).responsePrefix
|
|
: cfg.messages?.responsePrefix;
|
|
const normalized = normalizeReplyPayload(payload, {
|
|
responsePrefix,
|
|
});
|
|
if (!normalized) return { ok: true };
|
|
|
|
const text = normalized.text ?? "";
|
|
const mediaUrls = (normalized.mediaUrls?.filter(Boolean) ?? []).length
|
|
? (normalized.mediaUrls?.filter(Boolean) as string[])
|
|
: normalized.mediaUrl
|
|
? [normalized.mediaUrl]
|
|
: [];
|
|
const replyToId = normalized.replyToId;
|
|
|
|
// Skip empty replies.
|
|
if (!text.trim() && mediaUrls.length === 0) {
|
|
return { ok: true };
|
|
}
|
|
|
|
const sendOne = async (params: {
|
|
text: string;
|
|
mediaUrl?: string;
|
|
}): Promise<RouteReplyResult> => {
|
|
if (abortSignal?.aborted) {
|
|
return { ok: false, error: "Reply routing aborted" };
|
|
}
|
|
const { text, mediaUrl } = params;
|
|
switch (channel) {
|
|
case "telegram": {
|
|
const replyToMessageId = replyToId
|
|
? Number.parseInt(replyToId, 10)
|
|
: undefined;
|
|
const resolvedReplyToMessageId = Number.isFinite(replyToMessageId)
|
|
? replyToMessageId
|
|
: undefined;
|
|
const result = await sendMessageTelegram(to, text, {
|
|
mediaUrl,
|
|
messageThreadId: threadId,
|
|
replyToMessageId: resolvedReplyToMessageId,
|
|
accountId,
|
|
});
|
|
return { ok: true, messageId: result.messageId };
|
|
}
|
|
|
|
case "slack": {
|
|
const result = await sendMessageSlack(to, text, {
|
|
mediaUrl,
|
|
threadTs: replyToId,
|
|
accountId,
|
|
});
|
|
return { ok: true, messageId: result.messageId };
|
|
}
|
|
|
|
case "discord": {
|
|
const result = await sendMessageDiscord(to, text, {
|
|
mediaUrl,
|
|
replyTo: replyToId,
|
|
accountId,
|
|
});
|
|
return { ok: true, messageId: result.messageId };
|
|
}
|
|
|
|
case "signal": {
|
|
const result = await sendMessageSignal(to, text, {
|
|
mediaUrl,
|
|
accountId,
|
|
});
|
|
return { ok: true, messageId: result.messageId };
|
|
}
|
|
|
|
case "imessage": {
|
|
const result = await sendMessageIMessage(to, text, {
|
|
mediaUrl,
|
|
accountId,
|
|
});
|
|
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`,
|
|
};
|
|
}
|
|
|
|
case "msteams": {
|
|
const result = await sendMessageMSTeams({
|
|
cfg,
|
|
to,
|
|
text,
|
|
mediaUrl,
|
|
});
|
|
return { ok: true, messageId: result.messageId };
|
|
}
|
|
|
|
default: {
|
|
const _exhaustive: never = channel;
|
|
return { ok: false, error: `Unknown channel: ${String(_exhaustive)}` };
|
|
}
|
|
}
|
|
};
|
|
|
|
try {
|
|
if (abortSignal?.aborted) {
|
|
return { ok: false, error: "Reply routing aborted" };
|
|
}
|
|
if (mediaUrls.length === 0) {
|
|
return await sendOne({ text });
|
|
}
|
|
|
|
let last: RouteReplyResult | undefined;
|
|
for (let i = 0; i < mediaUrls.length; i++) {
|
|
if (abortSignal?.aborted) {
|
|
return { ok: false, error: "Reply routing aborted" };
|
|
}
|
|
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"
|
|
| "msteams" {
|
|
if (!channel) return false;
|
|
return [
|
|
"telegram",
|
|
"slack",
|
|
"discord",
|
|
"signal",
|
|
"imessage",
|
|
"whatsapp",
|
|
"msteams",
|
|
].includes(channel);
|
|
}
|