diff --git a/apps/bot/src/metame-owner-voice-bridge.ts b/apps/bot/src/metame-owner-voice-bridge.ts index 374743e..5db17a5 100644 --- a/apps/bot/src/metame-owner-voice-bridge.ts +++ b/apps/bot/src/metame-owner-voice-bridge.ts @@ -10,6 +10,7 @@ import { type RepoDiscordIdentityRegistry, WeksaSpeechClient, } from "@voidbot/core"; +import { ChannelType } from "discord.js"; import type { Message } from "discord.js"; const METAME_VOICE_DESIGN_PROMPT = [ @@ -53,6 +54,12 @@ export async function maybeMirrorOwnerAquariumMessageAsMetameVoice(input: { console.warn(`Skipped Metame owner voice bridge: ${metame.id} is not allowed in channel ${message.channelId}.`); return; } + if (!(await hasHumanListenerInAquariumVoice(message, config.repoFaceDiscordVoice.channelId))) { + console.log( + `Skipped Metame owner voice bridge for message ${message.id}: no human listeners in configured Aquarium voice channel.`, + ); + return; + } await appendMetameOwnerVoiceState({ statePath: config.metameOwnerVoice.statePath, @@ -170,3 +177,23 @@ async function appendMetameOwnerVoiceState(input: { ].join("\n"); await appendFile(input.statePath, `${header}${entry}`, "utf8"); } + +async function hasHumanListenerInAquariumVoice( + message: Message, + voiceChannelId: string | undefined, +): Promise { + if (!message.guild || !voiceChannelId) { + return false; + } + + const channel = await message.guild.channels.fetch(voiceChannelId).catch((error) => { + const detail = error instanceof Error ? error.message : String(error); + console.warn(`Could not inspect Aquarium voice channel ${voiceChannelId}: ${detail}`); + return null; + }); + if (!channel || (channel.type !== ChannelType.GuildVoice && channel.type !== ChannelType.GuildStageVoice)) { + return false; + } + + return channel.members.some((member) => !member.user.bot); +} diff --git a/apps/bot/src/repo-face-voice-playback.ts b/apps/bot/src/repo-face-voice-playback.ts index e980d2e..546cd6e 100644 --- a/apps/bot/src/repo-face-voice-playback.ts +++ b/apps/bot/src/repo-face-voice-playback.ts @@ -10,7 +10,7 @@ import { type AudioPlayer, type VoiceConnection, } from "@discordjs/voice"; -import { ChannelType, type Client, type GuildBasedChannel } from "discord.js"; +import { ChannelType, type Client, type VoiceBasedChannel } from "discord.js"; import { appendRepoFaceVoicePlayedEntry, @@ -88,6 +88,12 @@ class RepoFaceVoicePlaybackRuntime { this.active = true; try { const channel = await this.resolveVoiceChannel(); + if (!hasHumanListener(channel)) { + console.log( + `Skipped repo Face voice playback for ${entry.id}: no human listeners in channel ${this.options.channelId}.`, + ); + return; + } const connection = await this.ensureConnection(channel); const resource = createAudioResource(entry.audioPath, { metadata: entry, @@ -108,7 +114,7 @@ class RepoFaceVoicePlaybackRuntime { } } - private async resolveVoiceChannel(): Promise { + private async resolveVoiceChannel(): Promise { const channel = await this.client.channels.fetch(this.options.channelId!); if (!channel || !("guild" in channel)) { throw new Error(`Configured voice channel ${this.options.channelId} was not found in a guild.`); @@ -119,7 +125,7 @@ class RepoFaceVoicePlaybackRuntime { return channel; } - private async ensureConnection(channel: GuildBasedChannel): Promise { + private async ensureConnection(channel: VoiceBasedChannel): Promise { if ( this.connection && this.connection.joinConfig.channelId === channel.id && @@ -136,7 +142,17 @@ class RepoFaceVoicePlaybackRuntime { adapterCreator: channel.guild.voiceAdapterCreator, selfDeaf: false, }); + this.connection.on("error", (error) => { + console.warn(`Repo Face voice connection error in channel ${channel.id}: ${error.message}`); + this.connection?.destroy(); + this.connection = undefined; + this.active = false; + }); await entersState(this.connection, VoiceConnectionStatus.Ready, 30_000); return this.connection; } } + +function hasHumanListener(channel: VoiceBasedChannel): boolean { + return channel.members.some((member) => !member.user.bot); +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index b4eeed2..8ce5ed8 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -357,3 +357,4 @@ {"ts":"2026-06-12T18:12:19Z","type":"implementation","status":"ok","note":"Added owner-authorized Aquarium-to-Metame voice bridge: owner messages in Aquarium can be mirrored exactly through shared Weksa/MiMo speech rendering, persisted to a Weksa-readable Metame voice-sync projection, and queued into the existing Discord voice outbox."} {"ts":"2026-06-13T08:34:42Z","type":"implementation","status":"ok","note":"Created native Moobs Persona CultCache state at .voidbot/private/personas/moobs/moobs.cc from D:/Downloads/Moobs.txt; Moobs remains outside repo Face/swarm registries and is for iVixey's separate Discord."} {"ts":"2026-06-13T12:09:58Z","type":"verification","status":"ok","note":"2026-06-13 local moderation heartbeat was re-enabled by setting VOIDBOT_MODERATION_HEARTBEAT_ENABLED=true while VOID_MODERATION_ENFORCEMENT_MODE=policy. A direct ModerationHeartbeatOnly pass completed ok in policy mode, observed 0 new messages past cursor 2026-06-12T18:40:31.845Z, proposed/applied 0 operations, and left openCaseCount=0."} +{"ts":"2026-06-13T19:11:52Z","type":"implementation","status":"ok","note":"Disabled Metame owner voice locally and hardened the voice path: when re-enabled, owner Aquarium text only renders through Weksa if the configured Aquarium voice channel currently has a non-bot listener; playback also skips empty voice channels and logs connection errors instead of crashing the bot."}