fix(goLive): STREAM_CREATE handshake — self_video voice state + retry + send instrumentation

- signalStream: flip voice state to self_video:true/self_deaf:false before
  STREAM_CREATE (Discord silently ignores the request while video disabled)
- createStream: attach dispatch listeners before first signal (race), clean
  up listeners on timeout, retry STREAM_CREATE every 3s up to 4 attempts
  (upstream issue #217/#219 — Discord randomly drops the request)
- sendOpcode: direct [goLive:Streamer] log bypassing bootstrap debug filter
  (proves op 18 is actually broadcast)
This commit is contained in:
asepharyana
2026-08-11 20:45:00 +07:00
parent 10d7ecd405
commit 8615383829
+69 -17
View File
@@ -77,6 +77,11 @@ export class Streamer {
} }
sendOpcode(code: number, data: unknown): void { sendOpcode(code: number, data: unknown): void {
// Direct instrumentation — bypasses the bootstrap debug filter (which
// drops messages without [VOICE / [ffmpeg / error / stream).
console.log(
`[goLive:Streamer] sendOpcode op=${code} d=${JSON.stringify(data)}`,
);
this.client.ws.broadcast({ op: code, d: data }); this.client.ws.broadcast({ op: code, d: data });
} }
@@ -153,14 +158,6 @@ export class Streamer {
); );
return; return;
} }
this.signalStream();
const streamTimeout = setTimeout(() => {
reject(
new Error(
"Timed out waiting for STREAM_CREATE/STREAM_SERVER_UPDATE from Discord (stream handshake) — voice media session may not be active",
),
);
}, 12_000);
const { const {
guildId: clientGuildId, guildId: clientGuildId,
channelId: clientChannelId, channelId: clientChannelId,
@@ -175,13 +172,18 @@ export class Streamer {
clientChannelId, clientChannelId,
(conn) => { (conn) => {
clearTimeout(streamTimeout); clearTimeout(streamTimeout);
clearInterval(retryInterval);
resolve(conn); resolve(conn);
}, },
); );
this.voiceConnection.streamConnection = streamConn; this.voiceConnection.streamConnection = streamConn;
this._gatewayEmitter.on(
"STREAM_CREATE", // Attach listeners BEFORE the first signal so a fast dispatch can't
(d: { stream_key: string; rtc_server_id: string }) => { // be lost between signalStream() and listener registration.
const onStreamCreate = (d: {
stream_key: string;
rtc_server_id: string;
}) => {
const { channelId, guildId, userId } = parseStreamKey(d.stream_key); const { channelId, guildId, userId } = parseStreamKey(d.stream_key);
if ( if (
clientGuildId !== guildId || clientGuildId !== guildId ||
@@ -193,11 +195,12 @@ export class Streamer {
streamConn.serverId = d.rtc_server_id; streamConn.serverId = d.rtc_server_id;
streamConn.streamKey = d.stream_key; streamConn.streamKey = d.stream_key;
streamConn.setSession(session_id); streamConn.setSession(session_id);
}, };
); const onStreamServerUpdate = (d: {
this._gatewayEmitter.on( stream_key: string;
"STREAM_SERVER_UPDATE", endpoint: string;
(d: { stream_key: string; endpoint: string; token: string }) => { token: string;
}) => {
const { channelId, guildId, userId } = parseStreamKey(d.stream_key); const { channelId, guildId, userId } = parseStreamKey(d.stream_key);
if ( if (
clientGuildId !== guildId || clientGuildId !== guildId ||
@@ -207,8 +210,46 @@ export class Streamer {
return; return;
} }
streamConn.setTokens(d.endpoint, d.token); streamConn.setTokens(d.endpoint, d.token);
}, };
this._gatewayEmitter.on("STREAM_CREATE", onStreamCreate);
this._gatewayEmitter.on("STREAM_SERVER_UPDATE", onStreamServerUpdate);
const cleanup = () => {
clearTimeout(streamTimeout);
clearInterval(retryInterval);
this._gatewayEmitter.removeListener("STREAM_CREATE", onStreamCreate);
this._gatewayEmitter.removeListener(
"STREAM_SERVER_UPDATE",
onStreamServerUpdate,
); );
};
const streamTimeout = setTimeout(() => {
cleanup();
reject(
new Error(
"Timed out waiting for STREAM_CREATE/STREAM_SERVER_UPDATE from Discord (stream handshake) — voice media session may not be active",
),
);
}, 12_000);
// Discord sometimes drops the STREAM_CREATE request silently (upstream
// issue #217/#219) — resend a few times instead of giving up after one.
let attempt = 0;
const retryInterval = setInterval(() => {
attempt += 1;
if (attempt >= 4) {
clearInterval(retryInterval);
return;
}
console.log(
`[goLive:Streamer] createStream: retrying STREAM_CREATE (attempt ${attempt + 1}/4)`,
);
this.signalStream();
}, 3_000);
console.log(
`[goLive:Streamer] createStream: sending STREAM_CREATE (attempt 1/4)`,
);
this.signalStream();
}); });
} }
@@ -261,6 +302,17 @@ export class Streamer {
channelId: channel_id, channelId: channel_id,
botId: user_id, botId: user_id,
} = this.voiceConnection; } = this.voiceConnection;
// Mimic the real Discord client when starting Go Live: flip the voice
// state to video-enabled (and un-deafen) BEFORE requesting the stream.
// Discord's gateway silently ignores STREAM_CREATE while the user's
// voice state still has self_video: false.
this.sendOpcode(GatewayOpCodes.VOICE_STATE_UPDATE, {
guild_id,
channel_id,
self_mute: false,
self_deaf: false,
self_video: true,
});
this.sendOpcode(GatewayOpCodes.STREAM_CREATE, { this.sendOpcode(GatewayOpCodes.STREAM_CREATE, {
type, type,
guild_id, guild_id,