mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-30 16:23:32 +00:00
fix(voice): require admin for voice set (#97874)
This commit is contained in:
@@ -50,13 +50,19 @@ function createHarness(initialConfig: Record<string, unknown>) {
|
||||
return { command, runtime };
|
||||
}
|
||||
|
||||
function createCommandContext(args: string, channel = "discord", gatewayClientScopes?: string[]) {
|
||||
function createCommandContext(
|
||||
args: string,
|
||||
channel = "discord",
|
||||
gatewayClientScopes?: string[],
|
||||
senderIsOwner?: boolean,
|
||||
) {
|
||||
return {
|
||||
args,
|
||||
channel,
|
||||
channelId: channel,
|
||||
isAuthorizedSender: true,
|
||||
gatewayClientScopes,
|
||||
senderIsOwner,
|
||||
commandBody: args ? `/voice ${args}` : "/voice",
|
||||
config: {},
|
||||
requestConversationBinding: vi.fn(),
|
||||
@@ -108,6 +114,12 @@ describe("talk-voice plugin", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes owner status for mutating voice commands", () => {
|
||||
const { command } = createHarness({});
|
||||
|
||||
expect(command.exposeSenderIsOwner).toBe(true);
|
||||
});
|
||||
|
||||
it("lists voices from the active provider", async () => {
|
||||
const { command, runtime } = createHarness({
|
||||
talk: {
|
||||
@@ -307,17 +319,62 @@ describe("talk-voice plugin", () => {
|
||||
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows /voice set from non-gateway channels without operator.admin", async () => {
|
||||
const { runtime, run } = createElevenlabsVoiceSetHarness("telegram");
|
||||
it.each(["telegram", "discord"])(
|
||||
"rejects /voice set from %s channel without operator.admin",
|
||||
async (channel) => {
|
||||
const { runtime, run } = createElevenlabsVoiceSetHarness(channel);
|
||||
const result = await run();
|
||||
|
||||
expect(result.text).toContain("requires operator.admin");
|
||||
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps read-only voice commands available without operator.admin", async () => {
|
||||
const { command, runtime } = createHarness({
|
||||
talk: {
|
||||
provider: "elevenlabs",
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
apiKey: "sk-eleven",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]);
|
||||
|
||||
const status = await command.handler(createCommandContext("status", "telegram"));
|
||||
const list = await command.handler(createCommandContext("list", "telegram"));
|
||||
|
||||
expect(status.text).toContain("Talk voice status:");
|
||||
expect(list.text).toContain("ElevenLabs voices: 1");
|
||||
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows /voice set when operator.admin is present on a non-webchat channel", async () => {
|
||||
const { runtime, run } = createElevenlabsVoiceSetHarness("telegram", ["operator.admin"]);
|
||||
const result = await run();
|
||||
|
||||
expect(runtime.config.mutateConfigFile).toHaveBeenCalled();
|
||||
expect(result.text).toContain("voice-a");
|
||||
});
|
||||
|
||||
it("allows /voice set when operator.admin is present on a non-webchat channel", async () => {
|
||||
const { runtime, run } = createElevenlabsVoiceSetHarness("telegram", ["operator.admin"]);
|
||||
const result = await run();
|
||||
it("allows /voice set from an owner non-gateway channel without scopes", async () => {
|
||||
const { command, runtime } = createHarness({
|
||||
talk: {
|
||||
provider: "elevenlabs",
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
apiKey: "sk-eleven",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]);
|
||||
|
||||
const result = await command.handler(
|
||||
createCommandContext("set Claudia", "telegram", undefined, true),
|
||||
);
|
||||
|
||||
expect(runtime.config.mutateConfigFile).toHaveBeenCalled();
|
||||
expect(result.text).toContain("voice-a");
|
||||
|
||||
@@ -113,14 +113,15 @@ function asProviderBaseUrl(value: unknown): string | undefined {
|
||||
|
||||
const TALK_ADMIN_SCOPE = "operator.admin";
|
||||
|
||||
function requiresAdminToSetVoice(
|
||||
channel: string,
|
||||
gatewayClientScopes?: readonly string[],
|
||||
): boolean {
|
||||
function requiresAdminToSetVoice(params: {
|
||||
senderIsOwner?: boolean;
|
||||
gatewayClientScopes?: readonly string[];
|
||||
}): boolean {
|
||||
const { senderIsOwner, gatewayClientScopes } = params;
|
||||
if (Array.isArray(gatewayClientScopes)) {
|
||||
return !gatewayClientScopes.includes(TALK_ADMIN_SCOPE);
|
||||
}
|
||||
return channel === "webchat";
|
||||
return senderIsOwner !== true;
|
||||
}
|
||||
|
||||
export default definePluginEntry({
|
||||
@@ -135,6 +136,7 @@ export default definePluginEntry({
|
||||
},
|
||||
description: "List/set Talk provider voices (affects iOS Talk playback).",
|
||||
acceptsArgs: true,
|
||||
exposeSenderIsOwner: true,
|
||||
handler: async (ctx) => {
|
||||
const commandLabel = resolveCommandLabel(ctx.channel);
|
||||
const args = ctx.args?.trim() ?? "";
|
||||
@@ -187,9 +189,14 @@ export default definePluginEntry({
|
||||
}
|
||||
|
||||
if (action === "set") {
|
||||
// Gateway callers can override messageChannel, so scope presence is
|
||||
// the reliable signal for internal admin-only mutations.
|
||||
if (requiresAdminToSetVoice(ctx.channel, ctx.gatewayClientScopes)) {
|
||||
// Persistent Talk voice changes are gateway config writes, so the
|
||||
// mutating subcommand requires explicit admin or owner authority.
|
||||
if (
|
||||
requiresAdminToSetVoice({
|
||||
senderIsOwner: ctx.senderIsOwner,
|
||||
gatewayClientScopes: ctx.gatewayClientScopes,
|
||||
})
|
||||
) {
|
||||
return { text: `⚠️ ${commandLabel} set requires operator.admin.` };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user