fix: avoid Discord /tts conflict

This commit is contained in:
Peter Steinberger
2026-01-24 09:58:06 +00:00
parent cfdd5a8c2e
commit c8c58c0537
8 changed files with 52 additions and 14 deletions

View File

@@ -68,6 +68,7 @@ Text + native (when enabled):
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
- `/tts on|off|status|provider|limit|summary|audio` (control TTS; see [/tts](/tts))
- Discord: native command is `/voice` (Discord reserves `/tts`); text `/tts` still works.
- `/stop`
- `/restart`
- `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram)

View File

@@ -260,6 +260,9 @@ Reply -> TTS enabled?
There is a single command: `/tts`.
See [Slash commands](/tools/slash-commands) for enablement details.
Discord note: `/tts` is a built-in Discord command, so Clawdbot registers
`/voice` as the native command there. Text `/tts ...` still works.
```
/tts on
/tts off

View File

@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
buildCommandText,
buildCommandTextFromArgs,
findCommandByNativeName,
getCommandDetection,
listChatCommands,
listChatCommandsForConfig,
@@ -85,6 +86,16 @@ describe("commands registry", () => {
expect(native.find((spec) => spec.name === "demo_skill")).toBeTruthy();
});
it("applies provider-specific native names", () => {
const native = listNativeCommandSpecsForConfig(
{ commands: { native: true } },
{ provider: "discord" },
);
expect(native.find((spec) => spec.name === "voice")).toBeTruthy();
expect(findCommandByNativeName("voice", "discord")?.key).toBe("tts");
expect(findCommandByNativeName("tts", "discord")).toBeUndefined();
});
it("detects known text commands", () => {
const detection = getCommandDetection();
expect(detection.exact.has("/commands")).toBe(true);

View File

@@ -105,13 +105,29 @@ export function listChatCommandsForConfig(
return [...base, ...buildSkillCommandDefinitions(params.skillCommands)];
}
const NATIVE_NAME_OVERRIDES: Record<string, Record<string, string>> = {
discord: {
tts: "voice",
},
};
function resolveNativeName(command: ChatCommandDefinition, provider?: string): string | undefined {
if (!command.nativeName) return undefined;
if (provider) {
const override = NATIVE_NAME_OVERRIDES[provider]?.[command.key];
if (override) return override;
}
return command.nativeName;
}
export function listNativeCommandSpecs(params?: {
skillCommands?: SkillCommandSpec[];
provider?: string;
}): NativeCommandSpec[] {
return listChatCommands({ skillCommands: params?.skillCommands })
.filter((command) => command.scope !== "text" && command.nativeName)
.map((command) => ({
name: command.nativeName ?? command.key,
name: resolveNativeName(command, params?.provider) ?? command.key,
description: command.description,
acceptsArgs: Boolean(command.acceptsArgs),
args: command.args,
@@ -120,22 +136,27 @@ export function listNativeCommandSpecs(params?: {
export function listNativeCommandSpecsForConfig(
cfg: ClawdbotConfig,
params?: { skillCommands?: SkillCommandSpec[] },
params?: { skillCommands?: SkillCommandSpec[]; provider?: string },
): NativeCommandSpec[] {
return listChatCommandsForConfig(cfg, params)
.filter((command) => command.scope !== "text" && command.nativeName)
.map((command) => ({
name: command.nativeName ?? command.key,
name: resolveNativeName(command, params?.provider) ?? command.key,
description: command.description,
acceptsArgs: Boolean(command.acceptsArgs),
args: command.args,
}));
}
export function findCommandByNativeName(name: string): ChatCommandDefinition | undefined {
export function findCommandByNativeName(
name: string,
provider?: string,
): ChatCommandDefinition | undefined {
const normalized = name.trim().toLowerCase();
return getChatCommands().find(
(command) => command.scope !== "text" && command.nativeName?.toLowerCase() === normalized,
(command) =>
command.scope !== "text" &&
resolveNativeName(command, provider)?.toLowerCase() === normalized,
);
}

View File

@@ -254,7 +254,7 @@ async function handleDiscordCommandArgInteraction(
return;
}
const commandDefinition =
findCommandByNativeName(parsed.command) ??
findCommandByNativeName(parsed.command, "discord") ??
listChatCommands().find((entry) => entry.key === parsed.command);
if (!commandDefinition) {
await safeDiscordInteractionCall("command arg update", () =>
@@ -395,7 +395,7 @@ export function createDiscordNativeCommand(params: {
}) {
const { command, cfg, discordConfig, accountId, sessionPrefix, ephemeralDefault } = params;
const commandDefinition =
findCommandByNativeName(command.name) ??
findCommandByNativeName(command.name, "discord") ??
({
key: command.name,
nativeName: command.name,

View File

@@ -346,11 +346,13 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const maxDiscordCommands = 100;
let skillCommands =
nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : [];
let commandSpecs = nativeEnabled ? listNativeCommandSpecsForConfig(cfg, { skillCommands }) : [];
let commandSpecs = nativeEnabled
? listNativeCommandSpecsForConfig(cfg, { skillCommands, provider: "discord" })
: [];
const initialCommandCount = commandSpecs.length;
if (nativeEnabled && nativeSkillsEnabled && commandSpecs.length > maxDiscordCommands) {
skillCommands = [];
commandSpecs = listNativeCommandSpecsForConfig(cfg, { skillCommands: [] });
commandSpecs = listNativeCommandSpecsForConfig(cfg, { skillCommands: [], provider: "discord" });
runtime.log?.(
warn(
`discord: ${initialCommandCount} commands exceeds limit; removing per-skill commands and keeping /skill.`,

View File

@@ -476,14 +476,14 @@ export function registerSlackMonitorSlashCommands(params: {
const skillCommands =
nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : [];
const nativeCommands = nativeEnabled
? listNativeCommandSpecsForConfig(cfg, { skillCommands })
? listNativeCommandSpecsForConfig(cfg, { skillCommands, provider: "slack" })
: [];
if (nativeCommands.length > 0) {
for (const command of nativeCommands) {
ctx.app.command(
`/${command.name}`,
async ({ command: cmd, ack, respond }: SlackCommandMiddlewareArgs) => {
const commandDefinition = findCommandByNativeName(command.name);
const commandDefinition = findCommandByNativeName(command.name, "slack");
const rawText = cmd.text?.trim() ?? "";
const commandArgs = commandDefinition
? parseCommandArgs(commandDefinition, rawText)
@@ -557,7 +557,7 @@ export function registerSlackMonitorSlashCommands(params: {
});
return;
}
const commandDefinition = findCommandByNativeName(parsed.command);
const commandDefinition = findCommandByNativeName(parsed.command, "slack");
const commandArgs: CommandArgs = {
values: { [parsed.arg]: parsed.value },
};

View File

@@ -85,7 +85,7 @@ export const registerTelegramNativeCommands = ({
const skillCommands =
nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : [];
const nativeCommands = nativeEnabled
? listNativeCommandSpecsForConfig(cfg, { skillCommands })
? listNativeCommandSpecsForConfig(cfg, { skillCommands, provider: "telegram" })
: [];
const reservedCommands = new Set(
listNativeCommandSpecs().map((command) => command.name.toLowerCase()),
@@ -216,7 +216,7 @@ export const registerTelegramNativeCommands = ({
return;
}
const commandDefinition = findCommandByNativeName(command.name);
const commandDefinition = findCommandByNativeName(command.name, "telegram");
const rawText = ctx.match?.trim() ?? "";
const commandArgs = commandDefinition
? parseCommandArgs(commandDefinition, rawText)