Files
moltbot/src/auto-reply/reply/route-reply.ts
Glucksberg df09e583aa feat(telegram-tts): add auto-TTS hook and provider switching
- Integrate message_sending hook into Telegram delivery path
- Send text first, then audio as voice message after
- Add /tts_provider command to switch between OpenAI and ElevenLabs
- Implement automatic fallback when primary provider fails
- Use gpt-4o-mini-tts as default OpenAI model
- Add hook integration to route-reply.ts for other channels

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 08:00:44 +00:00

197 lines
6.6 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 { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
import { normalizeChannelId } from "../../channels/plugins/index.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.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;
/** Thread id for replies (Telegram topic id or Matrix thread event id). */
threadId?: string | 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,
resolveSessionAgentId({
sessionKey: params.sessionKey,
config: cfg,
}),
).responsePrefix
: cfg.messages?.responsePrefix === "auto"
? undefined
: cfg.messages?.responsePrefix;
const normalized = normalizeReplyPayload(payload, {
responsePrefix,
});
if (!normalized) return { ok: true };
let text = normalized.text ?? "";
let mediaUrls = (normalized.mediaUrls?.filter(Boolean) ?? []).length
? (normalized.mediaUrls?.filter(Boolean) as string[])
: normalized.mediaUrl
? [normalized.mediaUrl]
: [];
const replyToId = normalized.replyToId;
// Run message_sending hook (allows plugins to modify or cancel)
const hookRunner = getGlobalHookRunner();
const normalizedChannel = normalizeChannelId(channel);
if (hookRunner && text.trim() && normalizedChannel) {
try {
const hookResult = await hookRunner.runMessageSending(
{
to,
content: text,
metadata: { channel, accountId, threadId },
},
{
channelId: normalizedChannel,
accountId: accountId ?? undefined,
conversationId: to,
},
);
// Check if hook wants to cancel the message
if (hookResult?.cancel) {
return { ok: true }; // Silently cancel
}
// Check if hook modified the content
if (hookResult?.content !== undefined) {
// Check if the modified content contains MEDIA: directive
const mediaMatch = hookResult.content.match(/^MEDIA:(.+)$/m);
if (mediaMatch) {
// Extract media path and add to mediaUrls
const mediaPath = mediaMatch[1].trim();
mediaUrls = [mediaPath];
// Remove MEDIA: directive from text (send audio only)
text = hookResult.content.replace(/^MEDIA:.+$/m, "").trim();
} else {
text = hookResult.content;
}
}
} catch {
// Hook errors shouldn't block message sending
}
}
// Skip empty replies.
if (!text.trim() && mediaUrls.length === 0) {
return { ok: true };
}
if (channel === INTERNAL_MESSAGE_CHANNEL) {
return {
ok: false,
error: "Webchat routing not supported for queued replies",
};
}
const channelId = normalizeChannelId(channel) ?? null;
if (!channelId) {
return { ok: false, error: `Unknown channel: ${String(channel)}` };
}
if (abortSignal?.aborted) {
return { ok: false, error: "Reply routing aborted" };
}
const resolvedReplyToId =
replyToId ??
(channelId === "slack" && threadId != null && threadId !== "" ? String(threadId) : undefined);
const resolvedThreadId = channelId === "slack" ? null : (threadId ?? null);
try {
// Provider docking: this is an execution boundary (we're about to send).
// Keep the module cheap to import by loading outbound plumbing lazily.
const { deliverOutboundPayloads } = await import("../../infra/outbound/deliver.js");
const results = await deliverOutboundPayloads({
cfg,
channel: channelId,
to,
accountId: accountId ?? undefined,
payloads: [normalized],
replyToId: resolvedReplyToId ?? null,
threadId: resolvedThreadId,
abortSignal,
mirror: params.sessionKey
? {
sessionKey: params.sessionKey,
agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: cfg }),
text,
mediaUrls,
}
: undefined,
});
const last = results.at(-1);
return { ok: true, messageId: last?.messageId };
} 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 Exclude<OriginatingChannelType, typeof INTERNAL_MESSAGE_CHANNEL> {
if (!channel || channel === INTERNAL_MESSAGE_CHANNEL) return false;
return normalizeChannelId(channel) !== null;
}