/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see .
*/
import {makeAutoObservable} from 'mobx';
import {GuildOperations, GuildVerificationLevel} from '~/Constants';
import type {GuildRecord} from '~/records/GuildRecord';
import GuildMemberStore from '~/stores/GuildMemberStore';
import GuildStore from '~/stores/GuildStore';
import UserStore from '~/stores/UserStore';
const FIVE_MINUTES_MS = 5 * 60 * 1000;
const TEN_MINUTES_MS = 10 * 60 * 1000;
export const VerificationFailureReason = {
UNCLAIMED_ACCOUNT: 'UNCLAIMED_ACCOUNT',
UNVERIFIED_EMAIL: 'UNVERIFIED_EMAIL',
ACCOUNT_TOO_NEW: 'ACCOUNT_TOO_NEW',
NOT_MEMBER_LONG_ENOUGH: 'NOT_MEMBER_LONG_ENOUGH',
NO_PHONE_NUMBER: 'NO_PHONE_NUMBER',
SEND_MESSAGE_DISABLED: 'SEND_MESSAGE_DISABLED',
TIMED_OUT: 'TIMED_OUT',
} as const;
export type VerificationFailureReason = (typeof VerificationFailureReason)[keyof typeof VerificationFailureReason];
interface VerificationStatus {
canAccess: boolean;
reason?: VerificationFailureReason;
timeRemaining?: number;
}
class GuildVerificationStore {
verificationStatus: Record = {};
private timers: Record = {};
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
handleConnectionOpen(): void {
this.recomputeAll();
}
handleGuildCreate(guild: {id: string}): void {
this.recomputeGuild(guild.id);
}
handleGuildUpdate(guild: {id: string}): void {
this.recomputeGuild(guild.id);
}
handleGuildDelete(guildId: string): void {
const newVerificationStatus = {...this.verificationStatus};
delete newVerificationStatus[guildId];
if (this.timers[guildId]) {
clearTimeout(this.timers[guildId]);
delete this.timers[guildId];
}
this.verificationStatus = Object.freeze(newVerificationStatus);
}
handleGuildMemberUpdate(guildId: string): void {
this.recomputeGuild(guildId);
}
handleUserUpdate(): void {
this.recomputeAll();
}
private recomputeAll(): void {
const guilds = GuildStore.getGuilds();
const newVerificationStatus: Record = {};
for (const timerId of Object.values(this.timers)) {
clearTimeout(timerId);
}
const newTimers: Record = {};
for (const guild of guilds) {
const status = this.computeVerificationStatus(guild);
newVerificationStatus[guild.id] = status;
if (!status.canAccess && status.timeRemaining && status.timeRemaining > 0) {
newTimers[guild.id] = setTimeout(() => {
this.recomputeGuild(guild.id);
}, status.timeRemaining);
}
}
this.verificationStatus = Object.freeze(newVerificationStatus);
this.timers = newTimers;
}
private recomputeGuild(guildId: string): void {
const guild = GuildStore.getGuild(guildId);
if (!guild) {
return;
}
const status = this.computeVerificationStatus(guild);
const newVerificationStatus = {
...this.verificationStatus,
[guildId]: status,
};
if (this.timers[guildId]) {
clearTimeout(this.timers[guildId]);
}
const newTimers = {...this.timers};
delete newTimers[guildId];
if (!status.canAccess && status.timeRemaining && status.timeRemaining > 0) {
newTimers[guildId] = setTimeout(() => {
this.recomputeGuild(guildId);
}, status.timeRemaining);
}
this.verificationStatus = Object.freeze(newVerificationStatus);
this.timers = newTimers;
}
private computeVerificationStatus(guild: GuildRecord): VerificationStatus {
const user = UserStore.getCurrentUser();
if (!user) {
return {canAccess: false, reason: VerificationFailureReason.UNCLAIMED_ACCOUNT};
}
const member = GuildMemberStore.getMember(guild.id, user.id);
const now = Date.now();
if (member?.communicationDisabledUntil) {
const timeoutUntil = member.communicationDisabledUntil;
const timeRemaining = timeoutUntil.getTime() - now;
if (timeRemaining > 0) {
return {
canAccess: false,
reason: VerificationFailureReason.TIMED_OUT,
timeRemaining,
};
}
}
if ((guild.disabledOperations & GuildOperations.SEND_MESSAGE) !== 0) {
return {canAccess: false, reason: VerificationFailureReason.SEND_MESSAGE_DISABLED};
}
if (user.id === guild.ownerId) {
return {canAccess: true};
}
const verificationLevel = guild.verificationLevel ?? GuildVerificationLevel.NONE;
if (verificationLevel === GuildVerificationLevel.NONE) {
return {canAccess: true};
}
if (member && member.roles.size > 0) {
return {canAccess: true};
}
if (!user.isClaimed()) {
return {canAccess: false, reason: VerificationFailureReason.UNCLAIMED_ACCOUNT};
}
if (verificationLevel >= GuildVerificationLevel.LOW) {
if (!user.verified) {
return {canAccess: false, reason: VerificationFailureReason.UNVERIFIED_EMAIL};
}
}
if (verificationLevel >= GuildVerificationLevel.MEDIUM) {
const accountAge = Date.now() - user.createdAt.getTime();
if (accountAge < FIVE_MINUTES_MS) {
const timeRemaining = FIVE_MINUTES_MS - accountAge;
return {canAccess: false, reason: VerificationFailureReason.ACCOUNT_TOO_NEW, timeRemaining};
}
}
if (verificationLevel >= GuildVerificationLevel.HIGH) {
if (member?.joinedAt) {
const membershipDuration = Date.now() - member.joinedAt.getTime();
if (membershipDuration < TEN_MINUTES_MS) {
const timeRemaining = TEN_MINUTES_MS - membershipDuration;
return {canAccess: false, reason: VerificationFailureReason.NOT_MEMBER_LONG_ENOUGH, timeRemaining};
}
}
}
if (verificationLevel >= GuildVerificationLevel.VERY_HIGH) {
if (!user.phone) {
return {canAccess: false, reason: VerificationFailureReason.NO_PHONE_NUMBER};
}
}
return {canAccess: true};
}
canAccessGuild(guildId: string): boolean {
const status = this.verificationStatus[guildId];
return status?.canAccess ?? true;
}
getVerificationStatus(guildId: string): VerificationStatus | null {
return this.verificationStatus[guildId] ?? null;
}
getFailureReason(guildId: string): VerificationFailureReason | null {
return this.verificationStatus[guildId]?.reason ?? null;
}
getTimeRemaining(guildId: string): number | null {
return this.verificationStatus[guildId]?.timeRemaining ?? null;
}
}
export default new GuildVerificationStore();