diff --git a/src/lotus/lotusAudioInject.test.ts b/src/lotus/lotusAudioInject.test.ts index f919661f..9570df11 100644 --- a/src/lotus/lotusAudioInject.test.ts +++ b/src/lotus/lotusAudioInject.test.ts @@ -11,6 +11,7 @@ import { of } from "rxjs"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; import { + canPublishSoundboard, MAX_INJECT_BYTES, parseInjectSource, startLotusAudioInject, @@ -258,3 +259,17 @@ test("parseInjectSource rejects oversized or non-buffer audio", () => { expect(parseInjectSource({ audio: "not bytes" })).toBeNull(); expect(parseInjectSource({ audio: new Uint8Array(4) })).toBeNull(); }); + +test("canPublishSoundboard: refused by a microphone-only grant (EC #47)", () => { + expect(canPublishSoundboard(undefined)).toBe(true); + expect( + canPublishSoundboard({ canPublish: true, canPublishSources: [] }), + ).toBe(true); + expect( + canPublishSoundboard({ canPublish: true, canPublishSources: [2] }), + ).toBe(false); + expect( + canPublishSoundboard({ canPublish: true, canPublishSources: [2, 0] }), + ).toBe(true); + expect(canPublishSoundboard({ canPublish: false })).toBe(false); +}); diff --git a/src/lotus/lotusAudioInject.ts b/src/lotus/lotusAudioInject.ts index 59b2fb3a..19a16485 100644 --- a/src/lotus/lotusAudioInject.ts +++ b/src/lotus/lotusAudioInject.ts @@ -60,6 +60,22 @@ function acquireSharedAudio(): { * No effect unless the host sends the action. Returns a teardown function that * also aborts any clip still playing. */ +/** + * Pure: whether these LiveKit permissions allow publishing the soundboard's + * Unknown-source track (EC #47). No permissions or an empty source list + * means unrestricted; otherwise UNKNOWN (TrackSource 0) must be allowed. + */ +export function canPublishSoundboard( + perm: + | { canPublish?: boolean; canPublishSources?: readonly number[] } + | undefined, +): boolean { + if (!perm) return true; + if (perm.canPublish === false) return false; + const sources = perm.canPublishSources ?? []; + return sources.length === 0 || sources.includes(0); +} + export function startLotusAudioInject(vm: CallViewModel): () => void { const w = widget; if (!w) return () => undefined; @@ -116,6 +132,19 @@ export function startLotusAudioInject(vm: CallViewModel): () => void { return; } + // [EC #47] Clips publish as an Unknown-source track. Audio-only rooms get + // a token limited to the microphone source, where LiveKit refuses that + // publish *after* we replied; say so up front instead of failing silently. + if ( + rooms.some((r) => !canPublishSoundboard(r.localParticipant.permissions)) + ) { + w.api.transport.reply(ev.detail, { + played: false, + reason: "not_permitted", + }); + return; + } + w.api.transport.reply(ev.detail, {}); void playInjectedClip(clip, volume, rooms, activeClips).catch((e) => logger.warn("[lotus] inject_audio failed", e),