mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
373 lines
12 KiB
TypeScript
373 lines
12 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import type {GuildReadyData} from '@app/features/gateway/types/GatewayGuildTypes';
|
|
import type {VoiceState} from '@app/features/gateway/types/GatewayVoiceTypes';
|
|
import {normalizeVoiceMediaGraphViewerStreamKeys} from '@app/features/voice/engine/VoiceMediaGraph';
|
|
import {ME} from '@fluxer/constants/src/AppConstants';
|
|
import {assign, initialTransition, type SnapshotFrom, setup, transition} from 'xstate';
|
|
|
|
export type NormalizedVoiceState = Omit<
|
|
VoiceState,
|
|
'guild_id' | 'channel_id' | 'connection_id' | 'viewer_stream_keys'
|
|
> & {
|
|
guild_id: string;
|
|
channel_id: string;
|
|
connection_id: string;
|
|
viewer_stream_keys: ReadonlyArray<string>;
|
|
};
|
|
|
|
export type VoiceGatewayVoiceStates = Record<string, Record<string, Record<string, NormalizedVoiceState>>>;
|
|
export type VoiceGatewayUserVoiceStates = Record<string, Record<string, Record<string, NormalizedVoiceState>>>;
|
|
export type VoiceGatewayConnectionVoiceStates = Record<string, NormalizedVoiceState>;
|
|
|
|
export interface VoiceGatewayStateContext {
|
|
voiceStates: VoiceGatewayVoiceStates;
|
|
userVoiceStates: VoiceGatewayUserVoiceStates;
|
|
connectionVoiceStates: VoiceGatewayConnectionVoiceStates;
|
|
}
|
|
|
|
type VoiceStateWithIndexedConnection = VoiceState & {
|
|
channel_id: string;
|
|
connection_id: string;
|
|
};
|
|
|
|
export type VoiceGatewayStateEvent =
|
|
| {type: 'voiceState.update'; guildId: string | null; voiceState: VoiceState}
|
|
| {type: 'voiceState.delete'; guildId: string; userId: string}
|
|
| {type: 'voiceState.removeConnection'; connectionId: string}
|
|
| {type: 'connection.open'; guilds: ReadonlyArray<GuildReadyData>}
|
|
| {type: 'guild.create'; guild: GuildReadyData}
|
|
| {type: 'guild.delete'; guildId: string}
|
|
| {type: 'clear.all'};
|
|
|
|
export function createEmptyVoiceGatewayStateContext(): VoiceGatewayStateContext {
|
|
return {
|
|
voiceStates: {},
|
|
userVoiceStates: {},
|
|
connectionVoiceStates: {},
|
|
};
|
|
}
|
|
|
|
function resolveGuildId(guildId: string | null): string {
|
|
return guildId ?? ME;
|
|
}
|
|
|
|
function hasIndexedVoiceConnection(voiceState: VoiceState): voiceState is VoiceStateWithIndexedConnection {
|
|
return Boolean(voiceState.channel_id && voiceState.connection_id);
|
|
}
|
|
|
|
function normalizeVoiceStateForGatewayIndex(
|
|
guildId: string,
|
|
voiceState: VoiceStateWithIndexedConnection,
|
|
): NormalizedVoiceState {
|
|
return {
|
|
...voiceState,
|
|
guild_id: guildId,
|
|
viewer_stream_keys: normalizeVoiceMediaGraphViewerStreamKeys(voiceState.viewer_stream_keys),
|
|
};
|
|
}
|
|
|
|
function removeConnectionFromVoiceStates(
|
|
voiceStates: VoiceGatewayVoiceStates,
|
|
guildId: string | null | undefined,
|
|
channelId: string | null | undefined,
|
|
connectionId: string,
|
|
): VoiceGatewayVoiceStates {
|
|
if (!guildId || !channelId) return voiceStates;
|
|
const guildStates = voiceStates[guildId];
|
|
const channelStates = guildStates?.[channelId];
|
|
if (!channelStates || !Object.hasOwn(channelStates, connectionId)) return voiceStates;
|
|
|
|
const nextVoiceStates = {...voiceStates};
|
|
const nextGuildStates = {...guildStates};
|
|
const nextChannelStates = {...channelStates};
|
|
delete nextChannelStates[connectionId];
|
|
if (Object.keys(nextChannelStates).length === 0) {
|
|
delete nextGuildStates[channelId];
|
|
} else {
|
|
nextGuildStates[channelId] = nextChannelStates;
|
|
}
|
|
if (Object.keys(nextGuildStates).length === 0) {
|
|
delete nextVoiceStates[guildId];
|
|
} else {
|
|
nextVoiceStates[guildId] = nextGuildStates;
|
|
}
|
|
return nextVoiceStates;
|
|
}
|
|
|
|
function removeConnectionFromUserVoiceStates(
|
|
userVoiceStates: VoiceGatewayUserVoiceStates,
|
|
userId: string | null | undefined,
|
|
guildId: string | null | undefined,
|
|
connectionId: string,
|
|
): VoiceGatewayUserVoiceStates {
|
|
if (!userId || !guildId) return userVoiceStates;
|
|
const userStates = userVoiceStates[userId];
|
|
const guildStates = userStates?.[guildId];
|
|
if (!guildStates || !Object.hasOwn(guildStates, connectionId)) return userVoiceStates;
|
|
|
|
const nextUserVoiceStates = {...userVoiceStates};
|
|
const nextUserStates = {...userStates};
|
|
const nextGuildStates = {...guildStates};
|
|
delete nextGuildStates[connectionId];
|
|
if (Object.keys(nextGuildStates).length === 0) {
|
|
delete nextUserStates[guildId];
|
|
} else {
|
|
nextUserStates[guildId] = nextGuildStates;
|
|
}
|
|
if (Object.keys(nextUserStates).length === 0) {
|
|
delete nextUserVoiceStates[userId];
|
|
} else {
|
|
nextUserVoiceStates[userId] = nextUserStates;
|
|
}
|
|
return nextUserVoiceStates;
|
|
}
|
|
|
|
function removeConnection(context: VoiceGatewayStateContext, connectionId: string): VoiceGatewayStateContext {
|
|
const existing = context.connectionVoiceStates[connectionId];
|
|
if (!existing) return context;
|
|
|
|
const nextConnectionVoiceStates = {...context.connectionVoiceStates};
|
|
delete nextConnectionVoiceStates[connectionId];
|
|
return {
|
|
voiceStates: removeConnectionFromVoiceStates(
|
|
context.voiceStates,
|
|
existing.guild_id,
|
|
existing.channel_id,
|
|
connectionId,
|
|
),
|
|
userVoiceStates: removeConnectionFromUserVoiceStates(
|
|
context.userVoiceStates,
|
|
existing.user_id,
|
|
existing.guild_id,
|
|
connectionId,
|
|
),
|
|
connectionVoiceStates: nextConnectionVoiceStates,
|
|
};
|
|
}
|
|
|
|
function addConnection(
|
|
context: VoiceGatewayStateContext,
|
|
guildId: string,
|
|
voiceState: VoiceState,
|
|
): VoiceGatewayStateContext {
|
|
if (!hasIndexedVoiceConnection(voiceState)) return context;
|
|
|
|
const channelId = voiceState.channel_id;
|
|
const connectionId = voiceState.connection_id;
|
|
const indexedVoiceState = normalizeVoiceStateForGatewayIndex(guildId, voiceState);
|
|
const guildStates = context.voiceStates[guildId] ?? {};
|
|
const channelStates = guildStates[channelId] ?? {};
|
|
const userStates = context.userVoiceStates[indexedVoiceState.user_id] ?? {};
|
|
const userGuildStates = userStates[guildId] ?? {};
|
|
return {
|
|
voiceStates: {
|
|
...context.voiceStates,
|
|
[guildId]: {
|
|
...guildStates,
|
|
[channelId]: {
|
|
...channelStates,
|
|
[connectionId]: indexedVoiceState,
|
|
},
|
|
},
|
|
},
|
|
userVoiceStates: {
|
|
...context.userVoiceStates,
|
|
[indexedVoiceState.user_id]: {
|
|
...userStates,
|
|
[guildId]: {
|
|
...userGuildStates,
|
|
[connectionId]: indexedVoiceState,
|
|
},
|
|
},
|
|
},
|
|
connectionVoiceStates: {
|
|
...context.connectionVoiceStates,
|
|
[connectionId]: indexedVoiceState,
|
|
},
|
|
};
|
|
}
|
|
|
|
function removeStaleSessionConnections(
|
|
context: VoiceGatewayStateContext,
|
|
guildId: string,
|
|
voiceState: VoiceStateWithIndexedConnection,
|
|
): VoiceGatewayStateContext {
|
|
if (!voiceState.session_id) return context;
|
|
const indexedGuildStates = context.userVoiceStates[voiceState.user_id]?.[guildId];
|
|
if (!indexedGuildStates) return context;
|
|
let next = context;
|
|
for (const [connectionId, existing] of Object.entries(indexedGuildStates)) {
|
|
if (connectionId === voiceState.connection_id) continue;
|
|
if (existing.session_id !== voiceState.session_id) continue;
|
|
if (existing.channel_id !== voiceState.channel_id) continue;
|
|
next = removeConnection(next, connectionId);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function upsertConnection(
|
|
context: VoiceGatewayStateContext,
|
|
guildId: string,
|
|
voiceState: VoiceState,
|
|
): VoiceGatewayStateContext {
|
|
const connectionId = voiceState.connection_id;
|
|
if (!connectionId) return context;
|
|
if (!voiceState.channel_id) return removeConnection(context, connectionId);
|
|
if (!hasIndexedVoiceConnection(voiceState)) return context;
|
|
const withoutCurrent = removeConnection(context, connectionId);
|
|
const withoutStaleSession = removeStaleSessionConnections(withoutCurrent, guildId, voiceState);
|
|
return addConnection(withoutStaleSession, guildId, voiceState);
|
|
}
|
|
|
|
function removeUserFromGuild(
|
|
context: VoiceGatewayStateContext,
|
|
guildId: string,
|
|
userId: string,
|
|
): VoiceGatewayStateContext {
|
|
const connectionIds = new Set<string>();
|
|
const indexedGuildStates = context.userVoiceStates[userId]?.[guildId];
|
|
if (indexedGuildStates) {
|
|
for (const connectionId of Object.keys(indexedGuildStates)) {
|
|
connectionIds.add(connectionId);
|
|
}
|
|
}
|
|
for (const [connectionId, voiceState] of Object.entries(context.connectionVoiceStates)) {
|
|
if (voiceState.guild_id === guildId && voiceState.user_id === userId) {
|
|
connectionIds.add(connectionId);
|
|
}
|
|
}
|
|
let next = context;
|
|
for (const connectionId of connectionIds) {
|
|
next = removeConnection(next, connectionId);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function removeGuildFromUserVoiceStates(
|
|
userVoiceStates: VoiceGatewayUserVoiceStates,
|
|
guildId: string,
|
|
): VoiceGatewayUserVoiceStates {
|
|
let nextUserVoiceStates = userVoiceStates;
|
|
for (const [userId, userStates] of Object.entries(userVoiceStates)) {
|
|
if (!userStates[guildId]) continue;
|
|
if (nextUserVoiceStates === userVoiceStates) {
|
|
nextUserVoiceStates = {...userVoiceStates};
|
|
}
|
|
const nextUserStates = {...userStates};
|
|
delete nextUserStates[guildId];
|
|
if (Object.keys(nextUserStates).length === 0) {
|
|
delete nextUserVoiceStates[userId];
|
|
} else {
|
|
nextUserVoiceStates[userId] = nextUserStates;
|
|
}
|
|
}
|
|
return nextUserVoiceStates;
|
|
}
|
|
|
|
function removeGuild(context: VoiceGatewayStateContext, guildId: string): VoiceGatewayStateContext {
|
|
let next = context;
|
|
for (const [connectionId, voiceState] of Object.entries(context.connectionVoiceStates)) {
|
|
if (voiceState.guild_id === guildId) {
|
|
next = removeConnection(next, connectionId);
|
|
}
|
|
}
|
|
|
|
const nextVoiceStates = Object.hasOwn(next.voiceStates, guildId) ? {...next.voiceStates} : next.voiceStates;
|
|
if (nextVoiceStates !== next.voiceStates) {
|
|
delete nextVoiceStates[guildId];
|
|
}
|
|
const nextUserVoiceStates = removeGuildFromUserVoiceStates(next.userVoiceStates, guildId);
|
|
if (nextVoiceStates === next.voiceStates && nextUserVoiceStates === next.userVoiceStates) return next;
|
|
return {
|
|
...next,
|
|
voiceStates: nextVoiceStates,
|
|
userVoiceStates: nextUserVoiceStates,
|
|
};
|
|
}
|
|
|
|
function replaceAllFromGuilds(guilds: ReadonlyArray<GuildReadyData>): VoiceGatewayStateContext {
|
|
let next = createEmptyVoiceGatewayStateContext();
|
|
for (const guild of guilds) {
|
|
for (const voiceState of guild.voice_states ?? []) {
|
|
next = upsertConnection(next, guild.id, voiceState);
|
|
}
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function replaceGuild(context: VoiceGatewayStateContext, guild: GuildReadyData): VoiceGatewayStateContext {
|
|
let next = removeGuild(context, guild.id);
|
|
for (const voiceState of guild.voice_states ?? []) {
|
|
next = upsertConnection(next, guild.id, voiceState);
|
|
}
|
|
if (Object.hasOwn(next.voiceStates, guild.id)) return next;
|
|
return {
|
|
...next,
|
|
voiceStates: {
|
|
...next.voiceStates,
|
|
[guild.id]: {},
|
|
},
|
|
};
|
|
}
|
|
|
|
export const voiceGatewayStateMachine = setup({
|
|
types: {} as {
|
|
context: VoiceGatewayStateContext;
|
|
events: VoiceGatewayStateEvent;
|
|
},
|
|
actions: {
|
|
updateVoiceState: assign(({context, event}) =>
|
|
event.type === 'voiceState.update'
|
|
? upsertConnection(context, resolveGuildId(event.guildId), event.voiceState)
|
|
: context,
|
|
),
|
|
deleteVoiceState: assign(({context, event}) =>
|
|
event.type === 'voiceState.delete' ? removeUserFromGuild(context, event.guildId, event.userId) : context,
|
|
),
|
|
removeVoiceStateConnection: assign(({context, event}) =>
|
|
event.type === 'voiceState.removeConnection' ? removeConnection(context, event.connectionId) : context,
|
|
),
|
|
openConnection: assign(({event}) =>
|
|
event.type === 'connection.open' ? replaceAllFromGuilds(event.guilds) : createEmptyVoiceGatewayStateContext(),
|
|
),
|
|
createGuild: assign(({context, event}) =>
|
|
event.type === 'guild.create' ? replaceGuild(context, event.guild) : context,
|
|
),
|
|
deleteGuild: assign(({context, event}) =>
|
|
event.type === 'guild.delete' ? removeGuild(context, event.guildId) : context,
|
|
),
|
|
clearAll: assign(() => createEmptyVoiceGatewayStateContext()),
|
|
},
|
|
}).createMachine({
|
|
id: 'voiceGatewayState',
|
|
context: () => createEmptyVoiceGatewayStateContext(),
|
|
initial: 'tracking',
|
|
states: {
|
|
tracking: {
|
|
on: {
|
|
'voiceState.update': {actions: 'updateVoiceState'},
|
|
'voiceState.delete': {actions: 'deleteVoiceState'},
|
|
'voiceState.removeConnection': {actions: 'removeVoiceStateConnection'},
|
|
'connection.open': {actions: 'openConnection'},
|
|
'guild.create': {actions: 'createGuild'},
|
|
'guild.delete': {actions: 'deleteGuild'},
|
|
'clear.all': {actions: 'clearAll'},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
export type VoiceGatewayStateSnapshot = SnapshotFrom<typeof voiceGatewayStateMachine>;
|
|
|
|
export function createVoiceGatewayStateSnapshot(): VoiceGatewayStateSnapshot {
|
|
return initialTransition(voiceGatewayStateMachine)[0];
|
|
}
|
|
|
|
export function transitionVoiceGatewayStateSnapshot(
|
|
snapshot: VoiceGatewayStateSnapshot,
|
|
event: VoiceGatewayStateEvent,
|
|
): VoiceGatewayStateSnapshot {
|
|
return transition(voiceGatewayStateMachine, snapshot, event)[0] as VoiceGatewayStateSnapshot;
|
|
}
|