fix(gateway): re-assert server-undeafen+unmute before every video watch

The bot's own VOICE_STATE_UPDATE showed server-level deaf:true — a
server-deafened member is NOT sent the streamer's audiovisual RTP by Discord,
which is the likely reason no H264 arrives despite the DAVE watch reaching
Ready. The previous fire-and-forget forceSelfServerUnmuteUndeafen() ran once
after the first Ready join and silently reverted on reconnect/restart.

- Export forceSelfServerUnmuteUndeafen from recorder.ts; re-assert it (with
  read-back verification logging stillDeaf) at the START of every
  startStreamWatch() before STREAM_WATCH is sent (dynamic import avoids the
  recorder <-> videoRecorder <-> streamWatchReceiver module cycle).
- Re-assert it again after a successful voice reconnect.
- startStreamWatch() is now async; callers use void.
This commit is contained in:
asepharyana
2026-08-31 19:16:56 +07:00
parent 9606187861
commit 2b6a1eca19
3 changed files with 32 additions and 4 deletions
@@ -45,8 +45,14 @@ export function setEventBroadcaster(broadcaster: EventBroadcaster | undefined) {
* Requires the `MUTE_MEMBERS` + `DEAFEN_MEMBERS` permissions on the bot
* (user has granted them). Failures are logged, never fatal — an unmute
* failure must not break the voice join/recording.
*
* Exported so the video-receive path (videoRecorder.ts → streamWatch) can
* re-assert it right before requesting STREAM_WATCH — a server-deafened bot
* is NOT sent the streamer's audiovisual RTP, which is why no video arrives
* even when the DAVE watch reaches Ready. Server-deaf can silently revert on
* reconnect/restart, so this is re-invoked on every video watch attempt.
*/
async function forceSelfServerUnmuteUndeafen(
export async function forceSelfServerUnmuteUndeafen(
client: Client,
guild: Guild,
): Promise<void> {
@@ -54,7 +60,15 @@ async function forceSelfServerUnmuteUndeafen(
const selfId = client.user?.id;
if (!selfId) return;
await guild.members.edit(selfId, { mute: false, deaf: false });
logger.info({ guildId: guild.id }, "Forced bot server unmute + undeafen");
// Verify the PATCH actually landed — read back the live member voice state.
const fresh = guild.members.cache.get(selfId);
const stillDeaf = fresh?.voice?.deaf ?? false;
logger.info(
{ guildId: guild.id, stillDeaf },
stillDeaf
? "Forced bot server unmute + undeafen (but guild reports still deaf — will retry on next video)"
: "Forced bot server unmute + undeafen",
);
} catch (err) {
logger.warn(
{
@@ -225,6 +239,9 @@ export async function startRecording(
),
]);
// Reconnected successfully
// Server-deaf can silently revert across a reconnect — re-assert the bot
// is undeafened + unmuted so video receive keeps working.
void forceSelfServerUnmuteUndeafen(client, channel.guild);
} catch {
logger.error("Could not reconnect. Destroying connection");
connection.destroy();
@@ -159,13 +159,24 @@ function handleRaw(packet: unknown): void {
* waits for STREAM_CREATE / STREAM_SERVER_UPDATE (handled by handleRaw) to
* actually open the DAVE connection. Best-effort.
*/
export function startStreamWatch(channel: VoiceChannel, userId: string): void {
export async function startStreamWatch(
channel: VoiceChannel,
userId: string,
): Promise<void> {
if (!_client) {
logger.warn("startStreamWatch: no client set");
return;
}
const watchKey = `${channel.guild.id}:${userId}`;
if (watches.has(watchKey)) return; // already watching
// Re-assert the bot is server-undeafened + unmuted BEFORE requesting video.
// A server-deafened bot is NOT sent the streamer's audiovisual RTP by
// Discord, so no video would arrive even though the DAVE watch reaches
// Ready. Server-deaf can silently revert on reconnect/restart, so this is
// re-invoked on every watch attempt. Dynamic import avoids a static module
// cycle (recorder ⇄ videoRecorder ⇄ streamWatchReceiver).
const { forceSelfServerUnmuteUndeafen } = await import("./recorder.js");
void forceSelfServerUnmuteUndeafen(_client, channel.guild);
const sk = streamKeyFor(channel.id, channel.guild.id, userId);
logger.info(
{ userId, guildId: channel.guild.id, channelId: channel.id, streamKey: sk },
@@ -155,7 +155,7 @@ export function startVideoRecording(
try {
const selfId = _client?.user?.id;
if (selfId && userId === selfId) return;
startStreamWatch(channel, userId);
void startStreamWatch(channel, userId);
} catch (err) {
logger.warn(
{