@@ -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 : {
0 commit comments