import { logVerbose } from "../../globals.js" ;
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "../../shared/string-coerce.js" ;
import {
canonicalizeSpeechProviderId,
getSpeechProvider,
listSpeechProviders,
} from "../../tts/provider-registry.js" ;
import {
getResolvedSpeechProviderConfig,
getLastTtsAttempt,
getTtsMaxLength,
getTtsProvider,
isSummarizationEnabled,
isTtsEnabled,
isTtsProviderConfigured,
resolveTtsConfig,
resolveTtsPrefsPath,
setLastTtsAttempt,
setSummarizationEnabled,
setTtsEnabled,
setTtsMaxLength,
setTtsProvider,
textToSpeech,
} from "../../tts/tts.js" ;
import type { ReplyPayload } from "../types.js" ;
import type { CommandHandler } from "./commands-types.js" ;
type ParsedTtsCommand = {
action: string;
args: string;
};
type TtsAttemptDetail = NonNullable<
NonNullable<ReturnType<typeof getLastTtsAttempt>>["attempts" ]
>[number];
function parseTtsCommand(normalized: string): ParsedTtsCommand | null {
// Accept `/tts` and `/tts <action> [args]` as a single control surface.
if (normalized === "/tts" ) {
return { action: "status" , args: "" };
}
if (!normalized.startsWith("/tts " )) {
return null ;
}
const rest = normalized.slice(5 ).trim();
if (!rest) {
return { action: "status" , args: "" };
}
const [action, ...tail] = rest.split(/\s+/);
return {
action: normalizeOptionalLowercaseString(action) ?? "" ,
args: normalizeOptionalString(tail.join(" " )) ?? "" ,
};
}
function formatAttemptDetails(attempts: TtsAttemptDetail[] | undefined): string | undefined {
if (!attempts || attempts.length === 0 ) {
return undefined;
}
return attempts
.map((attempt) => {
const reason = attempt.reasonCode === "success" ? "ok" : attempt.reasonCode;
const latency = Number.isFinite(attempt.latencyMs) ? ` ${attempt.latencyMs}ms` : "" ;
return `${attempt.provider}:${attempt.outcome}(${reason})${latency}`;
})
.join(", " );
}
function ttsUsage(): ReplyPayload {
// Keep usage in one place so help/validation stays consistent.
return {
text:
` **TTS (Text-to-Speech) Help**\n\n` +
`**Commands:**\n` +
`• /tts on — Enable automatic TTS for replies\n` +
`• /tts off — Disable TTS\n` +
`• /tts status — Show current settings\n` +
`• /tts provider [name] — View/change provider\n` +
`• /tts limit [number] — View/change text limit\n` +
`• /tts summary [on|off] — View/change auto-summary\n` +
`• /tts audio <text> — Generate audio from text\n\n` +
`**Providers:**\n` +
`Use /tts provider to list the registered speech providers and their status.\n\n` +
`**Text Limit (default : 1500 , max: 4096 ):**\n` +
`When text exceeds the limit:\n` +
`• Summary ON: AI summarizes, then generates audio\n` +
`• Summary OFF: Truncates text, then generates audio\n\n` +
`**Examples:**\n` +
`/tts provider <id>\n` +
`/tts limit 2000 \n` +
`/tts audio Hello, this is a test!`,
};
}
export const handleTtsCommands: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null ;
}
const parsed = parseTtsCommand(params.command.commandBodyNormalized);
if (!parsed) {
return null ;
}
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring TTS command from unauthorized sender: ${params.command.senderId || "<unknown>" }`,
);
return { shouldContinue: false };
}
const config = resolveTtsConfig(params.cfg);
const prefsPath = resolveTtsPrefsPath(config);
const action = parsed.action;
const args = parsed.args;
if (action === "help" ) {
return { shouldContinue: false , reply: ttsUsage() };
}
if (action === "on" ) {
setTtsEnabled(prefsPath, true );
return { shouldContinue: false , reply: { text: " TTS enabled." } };
}
if (action === "off" ) {
setTtsEnabled(prefsPath, false );
return { shouldContinue: false , reply: { text: " TTS disabled." } };
}
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!`,
},
};
}
const start = Date.now();
const result = await textToSpeech({
text: args,
cfg: params.cfg,
channel: params.command.channel,
prefsPath,
});
if (result.success && result.audioPath) {
// Store last attempt for `/tts status`.
setLastTtsAttempt({
timestamp: Date.now(),
success: true ,
textLength: args.length,
summarized: false ,
provider: result.provider,
fallbackFrom: result.fallbackFrom,
attemptedProviders: result.attemptedProviders,
attempts: result.attempts,
latencyMs: result.latencyMs,
});
const payload: ReplyPayload = {
mediaUrl: result.audioPath,
audioAsVoice: result.voiceCompatible === true ,
trustedLocalMedia: true ,
spokenText: args,
};
return { shouldContinue: false , reply: payload };
}
// Store failure details for `/tts status`.
setLastTtsAttempt({
timestamp: Date.now(),
success: false ,
textLength: args.length,
summarized: false ,
attemptedProviders: result.attemptedProviders,
attempts: result.attempts,
error: result.error,
latencyMs: Date.now() - start,
});
return {
shouldContinue: false ,
reply: { text: `❌ Error generating audio: ${result.error ?? "unknown error" }` },
};
}
if (action === "provider" ) {
const currentProvider = getTtsProvider(config, prefsPath);
if (!args.trim()) {
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>`,
},
};
}
const requested = normalizeOptionalLowercaseString(args) ?? "" ;
const resolvedProvider = getSpeechProvider(requested, params.cfg);
if (!resolvedProvider) {
return { shouldContinue: false , reply: ttsUsage() };
}
const nextProvider = canonicalizeSpeechProviderId(requested, params.cfg) ?? resolvedProvider.id;
setTtsProvider(prefsPath, nextProvider);
return {
shouldContinue: false ,
reply: { text: `✅ TTS provider set to ${nextProvider}.` },
};
}
if (action === "limit" ) {
if (!args.trim()) {
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 `,
},
};
}
const next = Number.parseInt(args.trim(), 10 );
if (!Number.isFinite(next) || next < 100 || next > 4096 ) {
return {
shouldContinue: false ,
reply: { text: "❌ Limit must be between 100 and 4096 characters." },
};
}
setTtsMaxLength(prefsPath, next);
return {
shouldContinue: false ,
reply: { text: `✅ TTS limit set to ${next} characters.` },
};
}
if (action === "summary" ) {
if (!args.trim()) {
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`,
},
};
}
const requested = normalizeOptionalLowercaseString(args) ?? "" ;
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." ,
},
};
}
if (action === "status" ) {
const enabled = isTtsEnabled(config, prefsPath);
const provider = getTtsProvider(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" }`,
`Provider: ${provider} (${hasKey ? "✅ configured" : "❌ not configured" })`,
`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.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 { shouldContinue: false , reply: ttsUsage() };
};
Messung V0.5 in Prozent C=100 H=98 G=98
¤ Dauer der Verarbeitung: 0.10 Sekunden
(vorverarbeitet am 2026-06-10)
¤
*© Formatika GbR, Deutschland