refactor(tts): split complex command handlers (#105005)

This commit is contained in:
Vincent Koc
2026-07-12 13:23:27 +08:00
committed by GitHub
parent 36da65d050
commit f696430fd8
2 changed files with 229 additions and 248 deletions
+17 -1
View File
@@ -249,6 +249,22 @@ describe("handleTtsCommands status fallback reporting", () => {
expect(reply.text).toContain("TTS status");
});
it("keeps base status fields in display order", async () => {
const reply = expectReply(await handleTtsCommands(buildTtsParams("/tts status"), true));
expect(reply.text).toBe(
[
"📊 TTS status",
"State: ✅ enabled",
"Chat override: default",
`Provider: ${PRIMARY_TTS_PROVIDER} (✅ configured)`,
"Persona: none",
"Text limit: 1500 chars",
"Auto-summary: on",
].join("\n"),
);
});
it("resolves status config for the active agent", async () => {
const cfg = {
agents: { list: [{ id: "reader", tts: { provider: "elevenlabs" } }] },
@@ -364,7 +380,7 @@ describe("handleTtsCommands status fallback reporting", () => {
const beforeTtsRead = Date.now();
const result = await handleTtsCommands(
buildTtsParams("/tts latest", {}, undefined, { sessionEntry, sessionStore }),
buildTtsParams("/tts read latest", {}, undefined, { sessionEntry, sessionStore }),
true,
);
+212 -247
View File
@@ -37,7 +37,7 @@ import {
persistSessionEntry,
sessionEntryPersistenceConflictReply,
} from "./commands-session-store.js";
import type { CommandHandler } from "./commands-types.js";
import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
type ParsedTtsCommand = {
action: string;
@@ -48,6 +48,8 @@ type TtsAttemptDetail = NonNullable<
NonNullable<ReturnType<typeof getLastTtsAttempt>>["attempts"]
>[number];
type TtsCommandParams = Parameters<CommandHandler>[0];
function parseTtsCommand(normalized: string): ParsedTtsCommand | null {
// Accept `/tts` and `/tts <action> [args]` as a single control surface.
if (normalized === "/tts") {
@@ -84,6 +86,10 @@ function formatAttemptDetails(attempts: TtsAttemptDetail[] | undefined): string
.join(", ");
}
function stopWithText(text: string): CommandHandlerResult {
return { shouldContinue: false, reply: { text } };
}
function ttsUsage(): ReplyPayload {
// Keep usage in one place so help/validation stays consistent.
return {
@@ -126,7 +132,7 @@ async function buildTtsAudioReply(params: {
accountId?: string;
prefsPath: string;
agentId?: string;
}): Promise<{ reply: ReplyPayload; provider?: string; hash?: string } | { error: string }> {
}): Promise<{ reply: ReplyPayload } | { error: string }> {
const start = Date.now();
const result = await textToSpeech({
text: params.text,
@@ -151,7 +157,6 @@ async function buildTtsAudioReply(params: {
latencyMs: result.latencyMs,
});
return {
provider: result.provider,
reply: {
mediaUrl: result.audioPath,
audioAsVoice: result.audioAsVoice === true || result.voiceCompatible === true,
@@ -175,6 +180,139 @@ async function buildTtsAudioReply(params: {
return { error: result.error ?? "unknown error" };
}
async function handleTtsChatAction(
params: TtsCommandParams,
args: string,
): Promise<CommandHandlerResult> {
const requested = args.toLowerCase();
if (!params.sessionEntry || !params.sessionStore || !params.sessionKey) {
return stopWithText("🔇 No active chat session is available for a chat-scoped TTS override.");
}
if (!requested || requested === "status") {
return stopWithText(`🔊 Chat TTS override: ${params.sessionEntry.ttsAuto ?? "default"}.`);
}
let replyText: string;
if (requested === "on") {
params.sessionEntry.ttsAuto = "always";
replyText = "🔊 TTS enabled for this chat.";
} else if (requested === "off") {
params.sessionEntry.ttsAuto = "off";
replyText = "🔇 TTS disabled for this chat.";
} else if (requested === "default" || requested === "inherit" || requested === "clear") {
delete params.sessionEntry.ttsAuto;
replyText = "🔊 TTS chat override cleared.";
} else {
return { shouldContinue: false, reply: ttsUsage() };
}
if (!(await persistSessionEntry({ ...params, touchedFields: ["ttsAuto"] }))) {
return sessionEntryPersistenceConflictReply();
}
return stopWithText(replyText);
}
async function handleTtsLatestAction(
params: TtsCommandParams,
accountId: string | undefined,
prefsPath: string,
): Promise<CommandHandlerResult> {
if (!params.sessionEntry || !params.sessionStore || !params.sessionKey) {
return stopWithText("🎤 No active chat session is available for `/tts latest`.");
}
const latest = await readLatestAssistantTextFromSessionTranscript(
params.sessionEntry.sessionFile,
);
const latestText = latest?.text.trim();
if (!latestText || isSilentReplyPayloadText(latestText)) {
return stopWithText("🎤 No readable assistant reply was found in this chat yet.");
}
const hash = hashTtsReadLatestText(latestText);
if (params.sessionEntry.lastTtsReadLatestHash === hash) {
return stopWithText("🔊 Latest assistant reply was already sent as audio.");
}
const audio = await buildTtsAudioReply({
text: latestText,
cfg: params.cfg,
channel: params.command.channel,
accountId,
prefsPath,
agentId: params.agentId,
});
if ("error" in audio) {
return stopWithText(`❌ Error generating audio: ${audio.error}`);
}
params.sessionEntry.lastTtsReadLatestHash = hash;
params.sessionEntry.lastTtsReadLatestAt = Date.now();
if (
!(await persistSessionEntry({
...params,
touchedFields: ["lastTtsReadLatestHash", "lastTtsReadLatestAt"],
}))
) {
return sessionEntryPersistenceConflictReply();
}
return { shouldContinue: false, reply: audio.reply };
}
function handleTtsStatusAction(
params: TtsCommandParams,
config: ReturnType<typeof resolveTtsConfig>,
prefsPath: string,
): CommandHandlerResult {
const enabled = isTtsEnabled(config, prefsPath);
const provider = getTtsProvider(config, prefsPath);
const persona = getTtsPersona(config, prefsPath);
const hasKey = isTtsProviderConfigured(config, provider, params.cfg);
const maxLength = getTtsMaxLength(prefsPath);
const summarize = isSummarizationEnabled(prefsPath);
const last = getLastTtsAttempt();
const lines = [
"📊 TTS status",
`State: ${enabled ? "✅ enabled" : "❌ disabled"}`,
`Chat override: ${params.sessionEntry?.ttsAuto ?? "default"}`,
`Provider: ${provider} (${hasKey ? "✅ configured" : "❌ not configured"})`,
`Persona: ${persona?.id ?? "none"}`,
`Text limit: ${maxLength} chars`,
`Auto-summary: ${summarize ? "on" : "off"}`,
];
if (last) {
const timeAgo = Math.round((Date.now() - last.timestamp) / 1000);
lines.push("");
lines.push(`Last attempt (${timeAgo}s ago): ${last.success ? "✅" : "❌"}`);
lines.push(`Text: ${last.textLength} chars${last.summarized ? " (summarized)" : ""}`);
if (last.success) {
lines.push(`Provider: ${last.provider ?? "unknown"}`);
if (last.persona) {
lines.push(`Persona: ${last.persona}`);
}
if (last.fallbackFrom && last.provider && last.fallbackFrom !== last.provider) {
lines.push(`Fallback: ${last.fallbackFrom} -> ${last.provider}`);
}
if (last.attemptedProviders && last.attemptedProviders.length > 1) {
lines.push(`Attempts: ${last.attemptedProviders.join(" -> ")}`);
}
const details = formatAttemptDetails(last.attempts);
if (details) {
lines.push(`Attempt details: ${details}`);
}
lines.push(`Latency: ${last.latencyMs ?? 0}ms`);
} else if (last.error) {
lines.push(`Error: ${last.error}`);
if (last.attemptedProviders && last.attemptedProviders.length > 0) {
lines.push(`Attempts: ${last.attemptedProviders.join(" -> ")}`);
}
const details = formatAttemptDetails(last.attempts);
if (details) {
lines.push(`Attempt details: ${details}`);
}
}
}
return stopWithText(lines.join("\n"));
}
export const handleTtsCommands: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
@@ -207,119 +345,29 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
if (action === "on") {
setTtsEnabled(prefsPath, true);
return { shouldContinue: false, reply: { text: "🔊 TTS enabled." } };
return stopWithText("🔊 TTS enabled.");
}
if (action === "off") {
setTtsEnabled(prefsPath, false);
return { shouldContinue: false, reply: { text: "🔇 TTS disabled." } };
return stopWithText("🔇 TTS disabled.");
}
if (action === "chat") {
const requested = normalizeOptionalLowercaseString(args) ?? "";
if (!params.sessionEntry || !params.sessionStore || !params.sessionKey) {
return {
shouldContinue: false,
reply: { text: "🔇 No active chat session is available for a chat-scoped TTS override." },
};
}
if (!requested || requested === "status") {
return {
shouldContinue: false,
reply: { text: `🔊 Chat TTS override: ${params.sessionEntry.ttsAuto ?? "default"}.` },
};
}
if (requested === "on") {
params.sessionEntry.ttsAuto = "always";
if (!(await persistSessionEntry({ ...params, touchedFields: ["ttsAuto"] }))) {
return sessionEntryPersistenceConflictReply();
}
return { shouldContinue: false, reply: { text: "🔊 TTS enabled for this chat." } };
}
if (requested === "off") {
params.sessionEntry.ttsAuto = "off";
if (!(await persistSessionEntry({ ...params, touchedFields: ["ttsAuto"] }))) {
return sessionEntryPersistenceConflictReply();
}
return { shouldContinue: false, reply: { text: "🔇 TTS disabled for this chat." } };
}
if (requested === "default" || requested === "inherit" || requested === "clear") {
delete params.sessionEntry.ttsAuto;
if (!(await persistSessionEntry({ ...params, touchedFields: ["ttsAuto"] }))) {
return sessionEntryPersistenceConflictReply();
}
return { shouldContinue: false, reply: { text: "🔊 TTS chat override cleared." } };
}
return { shouldContinue: false, reply: ttsUsage() };
return handleTtsChatAction(params, args);
}
if (
action === "latest" ||
(action === "read" && normalizeOptionalLowercaseString(args) === "latest")
) {
if (!params.sessionEntry || !params.sessionStore || !params.sessionKey) {
return {
shouldContinue: false,
reply: { text: "🎤 No active chat session is available for `/tts latest`." },
};
}
const latest = await readLatestAssistantTextFromSessionTranscript(
params.sessionEntry.sessionFile,
);
const latestText = latest?.text.trim();
if (!latestText || isSilentReplyPayloadText(latestText)) {
return {
shouldContinue: false,
reply: { text: "🎤 No readable assistant reply was found in this chat yet." },
};
}
const hash = hashTtsReadLatestText(latestText);
if (params.sessionEntry.lastTtsReadLatestHash === hash) {
return {
shouldContinue: false,
reply: { text: "🔊 Latest assistant reply was already sent as audio." },
};
}
const audio = await buildTtsAudioReply({
text: latestText,
cfg: params.cfg,
channel: params.command.channel,
accountId,
prefsPath,
agentId: params.agentId,
});
if ("error" in audio) {
return {
shouldContinue: false,
reply: { text: `❌ Error generating audio: ${audio.error}` },
};
}
params.sessionEntry.lastTtsReadLatestHash = hash;
params.sessionEntry.lastTtsReadLatestAt = Date.now();
if (
!(await persistSessionEntry({
...params,
touchedFields: ["lastTtsReadLatestHash", "lastTtsReadLatestAt"],
}))
) {
return sessionEntryPersistenceConflictReply();
}
return { shouldContinue: false, reply: audio.reply };
if (action === "latest" || (action === "read" && args.toLowerCase() === "latest")) {
return handleTtsLatestAction(params, accountId, prefsPath);
}
if (action === "audio") {
if (!args.trim()) {
return {
shouldContinue: false,
reply: {
text:
`🎤 Generate audio from text.\n\n` +
`Usage: /tts audio <text>\n` +
`Example: /tts audio Hello, this is a test!`,
},
};
if (!args) {
return stopWithText(
`🎤 Generate audio from text.\n\n` +
`Usage: /tts audio <text>\n` +
`Example: /tts audio Hello, this is a test!`,
);
}
const audio = await buildTtsAudioReply({
@@ -333,46 +381,39 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
if (!("error" in audio)) {
return { shouldContinue: false, reply: audio.reply };
}
return {
shouldContinue: false,
reply: { text: `❌ Error generating audio: ${audio.error}` },
};
return stopWithText(`❌ Error generating audio: ${audio.error}`);
}
if (action === "provider") {
const currentProvider = getTtsProvider(config, prefsPath);
if (!args.trim()) {
if (!args) {
const providers = listSpeechProviders(params.cfg);
return {
shouldContinue: false,
reply: {
text:
`🎙️ TTS provider\n` +
`Primary: ${currentProvider}\n` +
providers
.map(
(provider) =>
`${provider.label}: ${
provider.isConfigured({
cfg: params.cfg,
providerConfig: getResolvedSpeechProviderConfig(
config,
provider.id,
params.cfg,
),
timeoutMs: config.timeoutMs,
})
? "✅"
: "❌"
}`,
)
.join("\n") +
`\nUsage: /tts provider <id>`,
},
};
return stopWithText(
`🎙️ TTS provider\n` +
`Primary: ${currentProvider}\n` +
providers
.map(
(provider) =>
`${provider.label}: ${
provider.isConfigured({
cfg: params.cfg,
providerConfig: getResolvedSpeechProviderConfig(
config,
provider.id,
params.cfg,
),
timeoutMs: config.timeoutMs,
})
? "✅"
: "❌"
}`,
)
.join("\n") +
`\nUsage: /tts provider <id>`,
);
}
const requested = normalizeOptionalLowercaseString(args) ?? "";
const requested = args.toLowerCase();
const resolvedProvider = getSpeechProvider(requested, params.cfg);
if (!resolvedProvider) {
return { shouldContinue: false, reply: ttsUsage() };
@@ -380,16 +421,13 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
const nextProvider = canonicalizeSpeechProviderId(requested, params.cfg) ?? resolvedProvider.id;
setTtsProvider(prefsPath, nextProvider);
return {
shouldContinue: false,
reply: { text: `✅ TTS provider set to ${nextProvider}.` },
};
return stopWithText(`✅ TTS provider set to ${nextProvider}.`);
}
if (action === "persona") {
const personas = listTtsPersonas(config);
const activePersona = getTtsPersona(config, prefsPath);
if (!args.trim()) {
if (!args) {
const lines = [
"🎭 TTS persona",
`Active: ${activePersona?.id ?? "none"}`,
@@ -404,141 +442,68 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
: "No personas configured.",
"Usage: /tts persona <id> | off",
];
return { shouldContinue: false, reply: { text: lines.join("\n") } };
return stopWithText(lines.join("\n"));
}
const requested = normalizeOptionalLowercaseString(args) ?? "";
const requested = args.toLowerCase();
if (requested === "off" || requested === "none" || requested === "default") {
setTtsPersona(prefsPath, null);
return { shouldContinue: false, reply: { text: "✅ TTS persona disabled." } };
return stopWithText("✅ TTS persona disabled.");
}
const persona = personas.find((entry) => entry.id === requested);
if (!persona) {
return {
shouldContinue: false,
reply: {
text:
`❌ Unknown TTS persona: ${requested || args}.\n` +
`Use /tts persona to list configured personas.`,
},
};
return stopWithText(
`❌ Unknown TTS persona: ${requested || args}.\n` +
`Use /tts persona to list configured personas.`,
);
}
setTtsPersona(prefsPath, persona.id);
return {
shouldContinue: false,
reply: { text: `✅ TTS persona set to ${persona.id}.` },
};
return stopWithText(`✅ TTS persona set to ${persona.id}.`);
}
if (action === "limit") {
if (!args.trim()) {
if (!args) {
const currentLimit = getTtsMaxLength(prefsPath);
return {
shouldContinue: false,
reply: {
text:
`📏 TTS limit: ${currentLimit} characters.\n\n` +
`Text longer than this triggers summary (if enabled).\n` +
`Range: 100-4096 chars (Telegram max).\n\n` +
`To change: /tts limit <number>\n` +
`Example: /tts limit 2000`,
},
};
return stopWithText(
`📏 TTS limit: ${currentLimit} characters.\n\n` +
`Text longer than this triggers summary (if enabled).\n` +
`Range: 100-4096 chars (Telegram max).\n\n` +
`To change: /tts limit <number>\n` +
`Example: /tts limit 2000`,
);
}
const trimmedLimit = args.trim();
const next = /^\d+$/.test(trimmedLimit) ? Number(trimmedLimit) : Number.NaN;
const next = /^\d+$/.test(args) ? Number(args) : Number.NaN;
if (!Number.isSafeInteger(next) || next < 100 || next > 4096) {
return {
shouldContinue: false,
reply: { text: "❌ Limit must be between 100 and 4096 characters." },
};
return stopWithText("❌ Limit must be between 100 and 4096 characters.");
}
setTtsMaxLength(prefsPath, next);
return {
shouldContinue: false,
reply: { text: `✅ TTS limit set to ${next} characters.` },
};
return stopWithText(`✅ TTS limit set to ${next} characters.`);
}
if (action === "summary") {
if (!args.trim()) {
if (!args) {
const enabled = isSummarizationEnabled(prefsPath);
const maxLen = getTtsMaxLength(prefsPath);
return {
shouldContinue: false,
reply: {
text:
`📝 TTS auto-summary: ${enabled ? "on" : "off"}.\n\n` +
`When text exceeds ${maxLen} chars:\n` +
`• ON: summarizes text, then generates audio\n` +
`• OFF: truncates text, then generates audio\n\n` +
`To change: /tts summary on | off`,
},
};
return stopWithText(
`📝 TTS auto-summary: ${enabled ? "on" : "off"}.\n\n` +
`When text exceeds ${maxLen} chars:\n` +
`• ON: summarizes text, then generates audio\n` +
`• OFF: truncates text, then generates audio\n\n` +
`To change: /tts summary on | off`,
);
}
const requested = normalizeOptionalLowercaseString(args) ?? "";
const requested = args.toLowerCase();
if (requested !== "on" && requested !== "off") {
return { shouldContinue: false, reply: ttsUsage() };
}
setSummarizationEnabled(prefsPath, requested === "on");
return {
shouldContinue: false,
reply: {
text: requested === "on" ? "✅ TTS auto-summary enabled." : "❌ TTS auto-summary disabled.",
},
};
return stopWithText(
requested === "on" ? "✅ TTS auto-summary enabled." : "❌ TTS auto-summary disabled.",
);
}
if (action === "status") {
const enabled = isTtsEnabled(config, prefsPath);
const provider = getTtsProvider(config, prefsPath);
const persona = getTtsPersona(config, prefsPath);
const hasKey = isTtsProviderConfigured(config, provider, params.cfg);
const maxLength = getTtsMaxLength(prefsPath);
const summarize = isSummarizationEnabled(prefsPath);
const last = getLastTtsAttempt();
const lines = [
"📊 TTS status",
`State: ${enabled ? "✅ enabled" : "❌ disabled"}`,
`Chat override: ${params.sessionEntry?.ttsAuto ?? "default"}`,
`Provider: ${provider} (${hasKey ? "✅ configured" : "❌ not configured"})`,
`Persona: ${persona?.id ?? "none"}`,
`Text limit: ${maxLength} chars`,
`Auto-summary: ${summarize ? "on" : "off"}`,
];
if (last) {
const timeAgo = Math.round((Date.now() - last.timestamp) / 1000);
lines.push("");
lines.push(`Last attempt (${timeAgo}s ago): ${last.success ? "✅" : "❌"}`);
lines.push(`Text: ${last.textLength} chars${last.summarized ? " (summarized)" : ""}`);
if (last.success) {
lines.push(`Provider: ${last.provider ?? "unknown"}`);
if (last.persona) {
lines.push(`Persona: ${last.persona}`);
}
if (last.fallbackFrom && last.provider && last.fallbackFrom !== last.provider) {
lines.push(`Fallback: ${last.fallbackFrom} -> ${last.provider}`);
}
if (last.attemptedProviders && last.attemptedProviders.length > 1) {
lines.push(`Attempts: ${last.attemptedProviders.join(" -> ")}`);
}
const details = formatAttemptDetails(last.attempts);
if (details) {
lines.push(`Attempt details: ${details}`);
}
lines.push(`Latency: ${last.latencyMs ?? 0}ms`);
} else if (last.error) {
lines.push(`Error: ${last.error}`);
if (last.attemptedProviders && last.attemptedProviders.length > 0) {
lines.push(`Attempts: ${last.attemptedProviders.join(" -> ")}`);
}
const details = formatAttemptDetails(last.attempts);
if (details) {
lines.push(`Attempt details: ${details}`);
}
}
}
return { shouldContinue: false, reply: { text: lines.join("\n") } };
return handleTtsStatusAction(params, config, prefsPath);
}
return { shouldContinue: false, reply: ttsUsage() };