Skip to content

Commit 488bab4

Browse files
committed
fix(voice): require admin for voice set
1 parent 6de357a commit 488bab4

2 files changed

Lines changed: 78 additions & 14 deletions

File tree

extensions/talk-voice/index.test.ts

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,19 @@ function createHarness(initialConfig: Record<string, unknown>) {
5050
return { command, runtime };
5151
}
5252

53-
function createCommandContext(args: string, channel = "discord", gatewayClientScopes?: string[]) {
53+
function createCommandContext(
54+
args: string,
55+
channel = "discord",
56+
gatewayClientScopes?: string[],
57+
senderIsOwner?: boolean,
58+
) {
5459
return {
5560
args,
5661
channel,
5762
channelId: channel,
5863
isAuthorizedSender: true,
5964
gatewayClientScopes,
65+
senderIsOwner,
6066
commandBody: args ? `/voice ${args}` : "/voice",
6167
config: {},
6268
requestConversationBinding: vi.fn(),
@@ -108,6 +114,12 @@ describe("talk-voice plugin", () => {
108114
});
109115
});
110116

117+
it("exposes owner status for mutating voice commands", () => {
118+
const { command } = createHarness({});
119+
120+
expect(command.exposeSenderIsOwner).toBe(true);
121+
});
122+
111123
it("lists voices from the active provider", async () => {
112124
const { command, runtime } = createHarness({
113125
talk: {
@@ -307,12 +319,36 @@ describe("talk-voice plugin", () => {
307319
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
308320
});
309321

310-
it("allows /voice set from non-gateway channels without operator.admin", async () => {
311-
const { runtime, run } = createElevenlabsVoiceSetHarness("telegram");
312-
const result = await run();
322+
it.each(["telegram", "discord"])(
323+
"rejects /voice set from %s channel without operator.admin",
324+
async (channel) => {
325+
const { runtime, run } = createElevenlabsVoiceSetHarness(channel);
326+
const result = await run();
313327

314-
expect(runtime.config.mutateConfigFile).toHaveBeenCalled();
315-
expect(result.text).toContain("voice-a");
328+
expect(result.text).toContain("requires operator.admin");
329+
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
330+
},
331+
);
332+
333+
it("keeps read-only voice commands available without operator.admin", async () => {
334+
const { command, runtime } = createHarness({
335+
talk: {
336+
provider: "elevenlabs",
337+
providers: {
338+
elevenlabs: {
339+
apiKey: "sk-eleven",
340+
},
341+
},
342+
},
343+
});
344+
vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]);
345+
346+
const status = await command.handler(createCommandContext("status", "telegram"));
347+
const list = await command.handler(createCommandContext("list", "telegram"));
348+
349+
expect(status.text).toContain("Talk voice status:");
350+
expect(list.text).toContain("ElevenLabs voices: 1");
351+
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
316352
});
317353

318354
it("allows /voice set when operator.admin is present on a non-webchat channel", async () => {
@@ -323,6 +359,27 @@ describe("talk-voice plugin", () => {
323359
expect(result.text).toContain("voice-a");
324360
});
325361

362+
it("allows /voice set from an owner non-gateway channel without scopes", async () => {
363+
const { command, runtime } = createHarness({
364+
talk: {
365+
provider: "elevenlabs",
366+
providers: {
367+
elevenlabs: {
368+
apiKey: "sk-eleven",
369+
},
370+
},
371+
},
372+
});
373+
vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]);
374+
375+
const result = await command.handler(
376+
createCommandContext("set Claudia", "telegram", undefined, true),
377+
);
378+
379+
expect(runtime.config.mutateConfigFile).toHaveBeenCalled();
380+
expect(result.text).toContain("voice-a");
381+
});
382+
326383
it("returns provider lookup errors cleanly", async () => {
327384
const { command, runtime } = createHarness({
328385
talk: {

extensions/talk-voice/index.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -113,14 +113,15 @@ function asProviderBaseUrl(value: unknown): string | undefined {
113113

114114
const TALK_ADMIN_SCOPE = "operator.admin";
115115

116-
function requiresAdminToSetVoice(
117-
channel: string,
118-
gatewayClientScopes?: readonly string[],
119-
): boolean {
116+
function requiresAdminToSetVoice(params: {
117+
senderIsOwner?: boolean;
118+
gatewayClientScopes?: readonly string[];
119+
}): boolean {
120+
const { senderIsOwner, gatewayClientScopes } = params;
120121
if (Array.isArray(gatewayClientScopes)) {
121122
return !gatewayClientScopes.includes(TALK_ADMIN_SCOPE);
122123
}
123-
return channel === "webchat";
124+
return senderIsOwner !== true;
124125
}
125126

126127
export default definePluginEntry({
@@ -135,6 +136,7 @@ export default definePluginEntry({
135136
},
136137
description: "List/set Talk provider voices (affects iOS Talk playback).",
137138
acceptsArgs: true,
139+
exposeSenderIsOwner: true,
138140
handler: async (ctx) => {
139141
const commandLabel = resolveCommandLabel(ctx.channel);
140142
const args = ctx.args?.trim() ?? "";
@@ -187,9 +189,14 @@ export default definePluginEntry({
187189
}
188190

189191
if (action === "set") {
190-
// Gateway callers can override messageChannel, so scope presence is
191-
// the reliable signal for internal admin-only mutations.
192-
if (requiresAdminToSetVoice(ctx.channel, ctx.gatewayClientScopes)) {
192+
// Persistent Talk voice changes are gateway config writes, so the
193+
// mutating subcommand requires explicit admin or owner authority.
194+
if (
195+
requiresAdminToSetVoice({
196+
senderIsOwner: ctx.senderIsOwner,
197+
gatewayClientScopes: ctx.gatewayClientScopes,
198+
})
199+
) {
193200
return { text: `⚠️ ${commandLabel} set requires operator.admin.` };
194201
}
195202

0 commit comments

Comments
 (0)