mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
536 lines
16 KiB
TypeScript
536 lines
16 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
|
import type {GuildReadyData} from '@app/features/gateway/types/GatewayGuildTypes';
|
|
import {GuildMember} from '@app/features/member/models/GuildMember';
|
|
import SelectedGuild from '@app/features/navigation/state/SelectedGuild';
|
|
import {Logger} from '@app/features/platform/utils/AppLogger';
|
|
import Users from '@app/features/user/state/Users';
|
|
import type {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
|
import type {User as WireUser} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
|
import {makeAutoObservable} from 'mobx';
|
|
|
|
type Members = Record<string, GuildMember>;
|
|
|
|
interface PendingMemberRequest {
|
|
guildId: string;
|
|
resolve: (members: Array<GuildMember>) => void;
|
|
reject: (error: Error) => void;
|
|
members: Array<GuildMember>;
|
|
receivedChunks: number;
|
|
requestedUserIds?: Set<string>;
|
|
markMissingAsNonMembers: boolean;
|
|
timeoutId: ReturnType<typeof setTimeout>;
|
|
}
|
|
|
|
const MEMBER_REQUEST_TIMEOUT = 30000;
|
|
const MEMBER_NONCE_LENGTH = 32;
|
|
const MEMBER_NONCE_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
const MAX_USER_IDS_PER_REQUEST = 100;
|
|
const logger = new Logger('GuildMembers');
|
|
|
|
function generateMemberNonce(): string {
|
|
let nonce = '';
|
|
const charsLength = MEMBER_NONCE_CHARS.length;
|
|
for (let i = 0; i < MEMBER_NONCE_LENGTH; i += 1) {
|
|
nonce += MEMBER_NONCE_CHARS[Math.floor(Math.random() * charsLength)];
|
|
}
|
|
return nonce;
|
|
}
|
|
|
|
function addVoiceStateMembers(guild: GuildReadyData, members: Members): void {
|
|
if (!guild.voice_states) {
|
|
return;
|
|
}
|
|
const voiceStateMembers: Array<GuildMemberData> = [];
|
|
for (const voiceState of guild.voice_states) {
|
|
if (!voiceState.member) {
|
|
continue;
|
|
}
|
|
voiceStateMembers.push(voiceState.member);
|
|
}
|
|
cacheGuildMemberUsers(voiceStateMembers);
|
|
for (const member of voiceStateMembers) {
|
|
members[member.user.id] = new GuildMember(guild.id, member, {cacheUser: false});
|
|
}
|
|
}
|
|
|
|
function cacheGuildMemberUsers(members: ReadonlyArray<GuildMemberData>): void {
|
|
if (members.length === 0) {
|
|
return;
|
|
}
|
|
Users.cacheUsers(members.map((member) => member.user));
|
|
}
|
|
|
|
function getMissingVoiceStateMemberUserIds(guild: GuildReadyData, members: Members): Array<string> {
|
|
if (!guild.voice_states || guild.voice_states.length === 0) {
|
|
return [];
|
|
}
|
|
const missingUserIds = new Set<string>();
|
|
for (const voiceState of guild.voice_states) {
|
|
if (voiceState.member) {
|
|
continue;
|
|
}
|
|
if (members[voiceState.user_id]) {
|
|
continue;
|
|
}
|
|
missingUserIds.add(voiceState.user_id);
|
|
}
|
|
return Array.from(missingUserIds);
|
|
}
|
|
|
|
class GuildMembers {
|
|
members: Record<string, Members> = {};
|
|
nonMembers: Record<string, Set<string>> = {};
|
|
pendingRequests: Map<string, PendingMemberRequest> = new Map();
|
|
loadedGuilds: Set<string> = new Set();
|
|
private pendingMessageMemberHydration: Map<string, Set<string>> = new Map();
|
|
private inFlightMessageMembers: Map<string, Set<string>> = new Map();
|
|
private messageMemberRequestGeneration: number = 0;
|
|
|
|
constructor() {
|
|
makeAutoObservable<
|
|
this,
|
|
'pendingMessageMemberHydration' | 'inFlightMessageMembers' | 'messageMemberRequestGeneration'
|
|
>(
|
|
this,
|
|
{
|
|
pendingMessageMemberHydration: false,
|
|
inFlightMessageMembers: false,
|
|
messageMemberRequestGeneration: false,
|
|
},
|
|
{autoBind: true},
|
|
);
|
|
}
|
|
|
|
getMember(guildId: string, userId?: string | null): GuildMember | null {
|
|
if (!userId) {
|
|
return null;
|
|
}
|
|
return this.members[guildId]?.[userId] ?? null;
|
|
}
|
|
|
|
isMembershipKnown(guildId: string, userId?: string | null): boolean {
|
|
if (!userId) {
|
|
return false;
|
|
}
|
|
if (this.members[guildId]?.[userId]) {
|
|
return true;
|
|
}
|
|
return this.nonMembers[guildId]?.has(userId) ?? false;
|
|
}
|
|
|
|
isUserTimedOut(guildId: string | null, userId?: string | null): boolean {
|
|
return this.getCommunicationDisabledUntil(guildId, userId) !== null;
|
|
}
|
|
|
|
getCommunicationDisabledUntil(guildId: string | null, userId?: string | null): Date | null {
|
|
if (!guildId || !userId) {
|
|
return null;
|
|
}
|
|
const until = this.members[guildId]?.[userId]?.communicationDisabledUntil ?? null;
|
|
if (!until || until.getTime() <= Date.now()) {
|
|
return null;
|
|
}
|
|
return until;
|
|
}
|
|
|
|
getMembers(guildId: string): Array<GuildMember> {
|
|
return Object.values(this.members[guildId] ?? {});
|
|
}
|
|
|
|
getMemberCount(guildId: string): number {
|
|
return Object.keys(this.members[guildId] ?? {}).length;
|
|
}
|
|
|
|
handleGatewayReady(guilds: Array<GuildReadyData>): void {
|
|
this.members = {};
|
|
this.nonMembers = {};
|
|
this.loadedGuilds.clear();
|
|
this.resetMessageMemberRequests();
|
|
const availableGuildIds = new Set(guilds.map((guild) => guild.id));
|
|
for (const guildId of Array.from(this.pendingMessageMemberHydration.keys())) {
|
|
if (!availableGuildIds.has(guildId)) {
|
|
this.pendingMessageMemberHydration.delete(guildId);
|
|
}
|
|
}
|
|
for (const guild of guilds) {
|
|
this.handleGuildCreate(guild);
|
|
}
|
|
}
|
|
|
|
handleGuildCreate(guild: GuildReadyData, options?: {synced?: boolean}): void {
|
|
if (guild.unavailable) {
|
|
return;
|
|
}
|
|
if (!this.members[guild.id]) {
|
|
this.members[guild.id] = {};
|
|
}
|
|
delete this.nonMembers[guild.id];
|
|
const members = this.members[guild.id];
|
|
cacheGuildMemberUsers(guild.members);
|
|
for (const member of guild.members) {
|
|
members[member.user.id] = new GuildMember(guild.id, member, {cacheUser: false});
|
|
}
|
|
addVoiceStateMembers(guild, members);
|
|
const missingVoiceStateMemberUserIds = getMissingVoiceStateMemberUserIds(guild, members);
|
|
if (missingVoiceStateMemberUserIds.length > 0 && GatewayConnection.socket) {
|
|
void this.ensureMembersLoaded(guild.id, missingVoiceStateMemberUserIds).catch((error: unknown) => {
|
|
logger.warn('Failed to fetch missing voice members after guild create', {
|
|
guildId: guild.id,
|
|
userIds: missingVoiceStateMemberUserIds,
|
|
error,
|
|
});
|
|
});
|
|
}
|
|
if (options?.synced || GatewayConnection.hasCompletedGuildSync(guild.id)) {
|
|
this.loadedGuilds.add(guild.id);
|
|
void this.requestPendingMessageMembers(guild.id);
|
|
}
|
|
}
|
|
|
|
handleGuildDelete(guildId: string): void {
|
|
delete this.members[guildId];
|
|
delete this.nonMembers[guildId];
|
|
this.pendingMessageMemberHydration.delete(guildId);
|
|
this.inFlightMessageMembers.delete(guildId);
|
|
this.loadedGuilds.delete(guildId);
|
|
}
|
|
|
|
handleMemberAdd(guildId: string, member: GuildMemberData): void {
|
|
if (!this.members[guildId]) {
|
|
this.members[guildId] = {};
|
|
}
|
|
this.members[guildId][member.user.id] = new GuildMember(guildId, member);
|
|
this.nonMembers[guildId]?.delete(member.user.id);
|
|
}
|
|
|
|
hydrateIfMissing(guildId: string, member: GuildMemberData): void {
|
|
if (this.members[guildId]?.[member.user.id]) {
|
|
return;
|
|
}
|
|
if (!this.members[guildId]) {
|
|
this.members[guildId] = {};
|
|
}
|
|
this.members[guildId][member.user.id] = new GuildMember(guildId, member);
|
|
this.nonMembers[guildId]?.delete(member.user.id);
|
|
}
|
|
|
|
hydrateOrRefresh(guildId: string, member: GuildMemberData): void {
|
|
const existing = this.members[guildId]?.[member.user.id];
|
|
if (!existing) {
|
|
this.hydrateIfMissing(guildId, member);
|
|
return;
|
|
}
|
|
Users.handleUserUpdate(member.user as WireUser);
|
|
if (existing.user === Users.getUser(member.user.id) && existing.matches(member)) {
|
|
return;
|
|
}
|
|
this.members[guildId][member.user.id] = new GuildMember(guildId, member);
|
|
}
|
|
|
|
handleMemberRemove(guildId: string, userId: string): void {
|
|
this.markAsNonMember(guildId, userId);
|
|
const existingMembers = this.members[guildId];
|
|
if (!existingMembers) {
|
|
return;
|
|
}
|
|
delete existingMembers[userId];
|
|
if (Object.keys(existingMembers).length === 0) {
|
|
delete this.members[guildId];
|
|
}
|
|
}
|
|
|
|
handleGuildRoleDelete(guildId: string, roleId: string): void {
|
|
const existingMembers = this.members[guildId];
|
|
if (!existingMembers) {
|
|
return;
|
|
}
|
|
for (const memberId of Object.keys(existingMembers)) {
|
|
const member = existingMembers[memberId];
|
|
if (member.roles.has(roleId)) {
|
|
const newRoles = new Set(member.roles);
|
|
newRoles.delete(roleId);
|
|
existingMembers[memberId] = new GuildMember(guildId, {
|
|
...member.toJSON(),
|
|
roles: Array.from(newRoles),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
handleMembersChunk(params: {
|
|
guildId: string;
|
|
members: Array<GuildMemberData>;
|
|
chunkIndex: number;
|
|
chunkCount: number;
|
|
nonce?: string;
|
|
}): void {
|
|
const {guildId, members, chunkCount, nonce} = params;
|
|
const newMembers: Array<GuildMember> = [];
|
|
if (!this.members[guildId]) {
|
|
this.members[guildId] = {};
|
|
}
|
|
const guildMembers = this.members[guildId];
|
|
const negativeCache = this.nonMembers[guildId];
|
|
for (const member of members) {
|
|
const record = new GuildMember(guildId, member);
|
|
newMembers.push(record);
|
|
guildMembers[member.user.id] = record;
|
|
negativeCache?.delete(member.user.id);
|
|
}
|
|
if (nonce) {
|
|
const pending = this.pendingRequests.get(nonce);
|
|
if (pending) {
|
|
pending.members.push(...newMembers);
|
|
pending.receivedChunks++;
|
|
if (pending.receivedChunks >= chunkCount) {
|
|
clearTimeout(pending.timeoutId);
|
|
this.markNotFoundAsNonMembers(pending);
|
|
pending.resolve(pending.members);
|
|
this.pendingRequests.delete(nonce);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private markNotFoundAsNonMembers(pending: PendingMemberRequest): void {
|
|
if (!pending.markMissingAsNonMembers) {
|
|
return;
|
|
}
|
|
const requested = pending.requestedUserIds;
|
|
if (!requested || requested.size === 0) {
|
|
return;
|
|
}
|
|
const returnedIds = new Set(pending.members.map((record) => record.user.id));
|
|
for (const id of requested) {
|
|
if (!returnedIds.has(id)) {
|
|
this.markAsNonMember(pending.guildId, id);
|
|
}
|
|
}
|
|
}
|
|
|
|
private markAsNonMember(guildId: string, userId: string): void {
|
|
if (!this.nonMembers[guildId]) {
|
|
this.nonMembers[guildId] = new Set();
|
|
}
|
|
this.nonMembers[guildId].add(userId);
|
|
}
|
|
|
|
async fetchMembers(
|
|
guildId: string,
|
|
options?: {
|
|
query?: string;
|
|
limit?: number;
|
|
userIds?: Array<string>;
|
|
presences?: boolean;
|
|
markMissingAsNonMembers?: boolean;
|
|
},
|
|
): Promise<Array<GuildMember>> {
|
|
const userIds = options?.userIds;
|
|
if (userIds && userIds.length > MAX_USER_IDS_PER_REQUEST) {
|
|
const batches: Array<Promise<Array<GuildMember>>> = [];
|
|
for (let i = 0; i < userIds.length; i += MAX_USER_IDS_PER_REQUEST) {
|
|
const slice = userIds.slice(i, i + MAX_USER_IDS_PER_REQUEST);
|
|
batches.push(this.fetchMembers(guildId, {...options, userIds: slice}));
|
|
}
|
|
const results = await Promise.all(batches);
|
|
return results.flat();
|
|
}
|
|
const nonce = generateMemberNonce();
|
|
return new Promise((resolve, reject) => {
|
|
const timeoutId = setTimeout(() => {
|
|
if (this.pendingRequests.has(nonce)) {
|
|
this.pendingRequests.delete(nonce);
|
|
reject(new Error('Request timed out'));
|
|
}
|
|
}, MEMBER_REQUEST_TIMEOUT);
|
|
this.pendingRequests.set(nonce, {
|
|
guildId,
|
|
resolve,
|
|
reject,
|
|
members: [],
|
|
receivedChunks: 0,
|
|
requestedUserIds: userIds && userIds.length > 0 ? new Set(userIds) : undefined,
|
|
markMissingAsNonMembers: options?.markMissingAsNonMembers ?? false,
|
|
timeoutId,
|
|
});
|
|
const socket = GatewayConnection.socket;
|
|
const requestOptions: {
|
|
guildId: string;
|
|
nonce: string;
|
|
query?: string;
|
|
limit?: number;
|
|
userIds?: Array<string>;
|
|
presences?: boolean;
|
|
} = {
|
|
guildId,
|
|
nonce,
|
|
presences: options?.presences ?? true,
|
|
};
|
|
if (options?.query) {
|
|
requestOptions.query = options.query;
|
|
}
|
|
if (options?.limit !== undefined) {
|
|
requestOptions.limit = options.limit;
|
|
}
|
|
if (userIds && userIds.length > 0) {
|
|
requestOptions.userIds = userIds;
|
|
}
|
|
socket?.requestGuildMembers(requestOptions);
|
|
});
|
|
}
|
|
|
|
requestMembersInBackground(options: {
|
|
guildIds: Array<string>;
|
|
query?: string;
|
|
limit?: number;
|
|
presences?: boolean;
|
|
}): void {
|
|
const socket = GatewayConnection.socket;
|
|
if (!socket) {
|
|
return;
|
|
}
|
|
const guildIds = [...new Set(options.guildIds.filter((guildId) => guildId.length > 0))];
|
|
if (guildIds.length === 0) {
|
|
return;
|
|
}
|
|
socket.requestGuildMembers({
|
|
guildIds,
|
|
...(options.query !== undefined && {query: options.query}),
|
|
...(options.limit !== undefined && {limit: options.limit}),
|
|
...(options.presences !== undefined && {presences: options.presences}),
|
|
});
|
|
}
|
|
|
|
async ensureMembersLoaded(guildId: string, userIds: Array<string>): Promise<void> {
|
|
const missingIds = this.getMissingMemberIds(guildId, userIds);
|
|
if (missingIds.length === 0) {
|
|
return;
|
|
}
|
|
await this.fetchMembers(guildId, {userIds: missingIds, markMissingAsNonMembers: true});
|
|
}
|
|
|
|
async ensureMembersLoadedForMessages(guildId: string, userIds: Array<string>): Promise<void> {
|
|
const missingIds = this.getMissingMemberIds(guildId, userIds);
|
|
if (missingIds.length === 0) {
|
|
return;
|
|
}
|
|
this.queuePendingMessageMemberHydration(guildId, missingIds);
|
|
if (!GatewayConnection.hasCompletedGuildSync(guildId) && SelectedGuild.selectedGuildId === guildId) {
|
|
GatewayConnection.syncGuildIfNeeded(guildId, 'message-member-hydration');
|
|
}
|
|
await this.requestPendingMessageMembers(guildId);
|
|
}
|
|
|
|
handleConnectionResumed(): void {
|
|
this.resetMessageMemberRequests();
|
|
for (const guildId of Array.from(this.pendingMessageMemberHydration.keys())) {
|
|
void this.requestPendingMessageMembers(guildId);
|
|
}
|
|
}
|
|
|
|
private resetMessageMemberRequests(): void {
|
|
this.messageMemberRequestGeneration += 1;
|
|
this.inFlightMessageMembers.clear();
|
|
}
|
|
|
|
private getMissingMemberIds(guildId: string, userIds: Array<string>): Array<string> {
|
|
const known = this.members[guildId];
|
|
const negativeCache = this.nonMembers[guildId];
|
|
return [...new Set(userIds)].filter((id) => !known?.[id] && !negativeCache?.has(id));
|
|
}
|
|
|
|
private queuePendingMessageMemberHydration(guildId: string, userIds: Array<string>): void {
|
|
let pending = this.pendingMessageMemberHydration.get(guildId);
|
|
if (!pending) {
|
|
pending = new Set();
|
|
this.pendingMessageMemberHydration.set(guildId, pending);
|
|
}
|
|
for (const userId of userIds) {
|
|
pending.add(userId);
|
|
}
|
|
}
|
|
|
|
private async requestPendingMessageMembers(guildId: string): Promise<void> {
|
|
if (!GatewayConnection.hasCompletedGuildSync(guildId)) {
|
|
return;
|
|
}
|
|
const pending = this.pendingMessageMemberHydration.get(guildId);
|
|
if (!pending || pending.size === 0) {
|
|
return;
|
|
}
|
|
const stillMissing = new Set(this.getMissingMemberIds(guildId, Array.from(pending)));
|
|
for (const userId of Array.from(pending)) {
|
|
if (!stillMissing.has(userId)) {
|
|
pending.delete(userId);
|
|
}
|
|
}
|
|
if (pending.size === 0) {
|
|
this.pendingMessageMemberHydration.delete(guildId);
|
|
return;
|
|
}
|
|
let inFlight = this.inFlightMessageMembers.get(guildId);
|
|
if (!inFlight) {
|
|
inFlight = new Set();
|
|
this.inFlightMessageMembers.set(guildId, inFlight);
|
|
}
|
|
const userIds = Array.from(pending).filter((userId) => !inFlight.has(userId));
|
|
if (userIds.length === 0) {
|
|
return;
|
|
}
|
|
for (const userId of userIds) {
|
|
inFlight.add(userId);
|
|
}
|
|
const generation = this.messageMemberRequestGeneration;
|
|
try {
|
|
await this.fetchMembers(guildId, {userIds, markMissingAsNonMembers: true});
|
|
if (generation === this.messageMemberRequestGeneration) {
|
|
this.forgetPendingMessageMembers(guildId, userIds);
|
|
}
|
|
} catch (error) {
|
|
logger.warn('Failed to hydrate message author members, will retry on the next load or reconnect', {
|
|
guildId,
|
|
userIds,
|
|
error,
|
|
});
|
|
} finally {
|
|
if (generation === this.messageMemberRequestGeneration) {
|
|
this.releaseInFlightMessageMembers(guildId, userIds);
|
|
}
|
|
}
|
|
}
|
|
|
|
private forgetPendingMessageMembers(guildId: string, userIds: Array<string>): void {
|
|
const pending = this.pendingMessageMemberHydration.get(guildId);
|
|
if (!pending) {
|
|
return;
|
|
}
|
|
for (const userId of userIds) {
|
|
pending.delete(userId);
|
|
}
|
|
if (pending.size === 0) {
|
|
this.pendingMessageMemberHydration.delete(guildId);
|
|
}
|
|
}
|
|
|
|
private releaseInFlightMessageMembers(guildId: string, userIds: Array<string>): void {
|
|
const inFlight = this.inFlightMessageMembers.get(guildId);
|
|
if (!inFlight) {
|
|
return;
|
|
}
|
|
for (const userId of userIds) {
|
|
inFlight.delete(userId);
|
|
}
|
|
if (inFlight.size === 0) {
|
|
this.inFlightMessageMembers.delete(guildId);
|
|
}
|
|
}
|
|
|
|
isGuildFullyLoaded(guildId: string): boolean {
|
|
return this.loadedGuilds.has(guildId);
|
|
}
|
|
}
|
|
|
|
export default new GuildMembers();
|