feat(backend): 7日間運営のアクティビティがないサーバを自動的に招待制にする

This commit is contained in:
おさむのひと 2024-10-10 19:19:37 +09:00
parent 1c99785e7e
commit 8868ada741
5 changed files with 149 additions and 15 deletions

View File

@ -93,6 +93,13 @@ export class QueueService {
repeat: { pattern: '0 0 * * *' }, repeat: { pattern: '0 0 * * *' },
removeOnComplete: true, removeOnComplete: true,
}); });
this.systemQueue.add('checkModeratorsActivity', {
}, {
// 毎時30分に起動
repeat: { pattern: '30 * * * *' },
removeOnComplete: true,
});
} }
@bindThis @bindThis

View File

@ -101,6 +101,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
@Injectable() @Injectable()
export class RoleService implements OnApplicationShutdown, OnModuleInit { export class RoleService implements OnApplicationShutdown, OnModuleInit {
private rootUserIdCache: MemorySingleCache<MiUser['id']>;
private rolesCache: MemorySingleCache<MiRole[]>; private rolesCache: MemorySingleCache<MiRole[]>;
private roleAssignmentByUserIdCache: MemoryKVCache<MiRoleAssignment[]>; private roleAssignmentByUserIdCache: MemoryKVCache<MiRoleAssignment[]>;
private notificationService: NotificationService; private notificationService: NotificationService;
@ -136,6 +137,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
private moderationLogService: ModerationLogService, private moderationLogService: ModerationLogService,
private fanoutTimelineService: FanoutTimelineService, private fanoutTimelineService: FanoutTimelineService,
) { ) {
this.rootUserIdCache = new MemorySingleCache<MiUser['id']>(1000 * 60 * 60 * 24 * 7); // 1week. rootユーザのIDは不変なので長めに
this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m
@ -416,7 +418,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
} }
@bindThis @bindThis
public async isExplorable(role: { id: MiRole['id']} | null): Promise<boolean> { public async isExplorable(role: { id: MiRole['id'] } | null): Promise<boolean> {
if (role == null) return false; if (role == null) return false;
const check = await this.rolesRepository.findOneBy({ id: role.id }); const check = await this.rolesRepository.findOneBy({ id: role.id });
if (check == null) return false; if (check == null) return false;
@ -430,22 +432,32 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
? roles.filter(r => r.isModerator || r.isAdministrator) ? roles.filter(r => r.isModerator || r.isAdministrator)
: roles.filter(r => r.isModerator); : roles.filter(r => r.isModerator);
// TODO: isRootなアカウントも含める
const assigns = moderatorRoles.length > 0 const assigns = moderatorRoles.length > 0
? await this.roleAssignmentsRepository.findBy({ roleId: In(moderatorRoles.map(r => r.id)) }) ? await this.roleAssignmentsRepository.findBy({ roleId: In(moderatorRoles.map(r => r.id)) })
: []; : [];
const rootUserId = await this.rootUserIdCache.fetch(async () => {
const it = await this.usersRepository.createQueryBuilder('users')
.select('id')
.where({ isRoot: true })
.getOneOrFail();
return it.id;
});
const now = Date.now(); const now = Date.now();
const result = [ const result = [
// Setを経由して重複を除去ユーザIDは重複する可能性があるので // Setを経由して重複を除去ユーザIDは重複する可能性があるので
...new Set( ...new Set(
assigns [
.filter(it => ...assigns
(excludeExpire) .filter(it =>
? (it.expiresAt == null || it.expiresAt.getTime() > now) (excludeExpire)
: true, ? (it.expiresAt == null || it.expiresAt.getTime() > now)
) : true,
.map(a => a.userId), )
.map(a => a.userId),
rootUserId,
],
), ),
]; ];
@ -453,12 +465,13 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
} }
@bindThis @bindThis
public async getModerators(includeAdmins = true): Promise<MiUser[]> { public async getModerators(includeAdmins = true, excludeExpire = false): Promise<MiUser[]> {
const ids = await this.getModeratorIds(includeAdmins); const ids = await this.getModeratorIds(includeAdmins, excludeExpire);
const users = ids.length > 0 ? await this.usersRepository.findBy({ return ids.length > 0
id: In(ids), ? await this.usersRepository.findBy({
}) : []; id: In(ids),
return users; })
: [];
} }
@bindThis @bindThis

View File

@ -80,6 +80,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
DeliverProcessorService, DeliverProcessorService,
InboxProcessorService, InboxProcessorService,
AggregateRetentionProcessorService, AggregateRetentionProcessorService,
CheckExpiredMutingsProcessorService,
QueueProcessorService, QueueProcessorService,
], ],
exports: [ exports: [

View File

@ -10,6 +10,7 @@ import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
@ -120,6 +121,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private aggregateRetentionProcessorService: AggregateRetentionProcessorService, private aggregateRetentionProcessorService: AggregateRetentionProcessorService,
private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService, private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService,
private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService, private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService,
private checkModeratorsActivityProcessorService: CheckModeratorsActivityProcessorService,
private cleanProcessorService: CleanProcessorService, private cleanProcessorService: CleanProcessorService,
) { ) {
this.logger = this.queueLoggerService.logger; this.logger = this.queueLoggerService.logger;
@ -150,6 +152,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
case 'aggregateRetention': return this.aggregateRetentionProcessorService.process(); case 'aggregateRetention': return this.aggregateRetentionProcessorService.process();
case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process(); case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process();
case 'bakeBufferedReactions': return this.bakeBufferedReactionsProcessorService.process(); case 'bakeBufferedReactions': return this.bakeBufferedReactionsProcessorService.process();
case 'checkModeratorsActivity': return this.checkModeratorsActivityProcessorService.process();
case 'clean': return this.cleanProcessorService.process(); case 'clean': return this.cleanProcessorService.process();
default: throw new Error(`unrecognized job type ${job.name} for system`); default: throw new Error(`unrecognized job type ${job.name} for system`);
} }

View File

@ -0,0 +1,110 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
import { RoleService } from '@/core/RoleService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
// モデレーターが不在と判断する日付の閾値
const MODERATOR_INACTIVITY_LIMIT_DAYS = 7;
const ONE_DAY_MILLI_SEC = 1000 * 60 * 60 * 24;
@Injectable()
export class CheckModeratorsActivityProcessorService {
private logger: Logger;
constructor(
private metaService: MetaService,
private roleService: RoleService,
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('check-moderators-activity');
}
@bindThis
public async process(): Promise<void> {
this.logger.info('start.');
const meta = await this.metaService.fetch(false);
if (!meta.disableRegistration) {
await this.processImpl();
} else {
this.logger.warn('is already invitation only.');
}
this.logger.succ('finish.');
}
@bindThis
private async processImpl() {
const { isModeratorsInactive, inactivityLimitCountdown } = await this.evaluateModeratorsInactiveDays();
if (isModeratorsInactive) {
this.logger.warn(`The moderator has been inactive for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days. We will move to invitation only.`);
await this.changeToInvitationOnly();
} else {
if (inactivityLimitCountdown <= 2) {
this.logger.info(`A moderator has been inactive for a period of time. If you are inactive for an additional ${inactivityLimitCountdown} days, it will switch to invitation only.`);
}
}
}
/**
* trueの場合はモデレーターが不在である
* isModerator, isAdministrator, isRootのいずれかがtrueのユーザを対象に
* {@link MiUser.lastActiveDate}{@link MODERATOR_INACTIVITY_LIMIT_DAYS}
* {@link MiUser.lastActiveDate}nullの場合は
*
* -----
*
* ###
* - 実行日時: 2022-01-30 12:00:00
* - 判定基準: 2022-01-23 12:00:00{@link MODERATOR_INACTIVITY_LIMIT_DAYS}
*
* ####
* - モデレータA: lastActiveDate = 2022-01-20 00:00:00
* - モデレータB: lastActiveDate = 2022-01-23 12:00:00 0
* - モデレータC: lastActiveDate = 2022-01-23 11:59:59 -1
* - モデレータD: lastActiveDate = null
*
* Bのアクティビティのみ判定基準日よりも古くないため
*
* ####
* - モデレータA: lastActiveDate = 2022-01-20 00:00:00
* - モデレータB: lastActiveDate = 2022-01-22 12:00:00 -1
* - モデレータC: lastActiveDate = 2022-01-23 11:59:59 -1
* - モデレータC: lastActiveDate = null
*
* A, B, Cのアクティビティは判定基準日よりも古いため
*/
@bindThis
public async evaluateModeratorsInactiveDays() {
const today = new Date();
const inactivePeriod = new Date(today);
inactivePeriod.setDate(today.getDate() - MODERATOR_INACTIVITY_LIMIT_DAYS);
// TODO: モデレーター以外にも特別な権限を持つユーザーがいる場合は考慮する
const moderators = await this.roleService.getModerators(true, true);
const inactiveModeratorCount = moderators
.map(it => it.lastActiveDate)
.filter(it => it != null)
.filter(it => it.getTime() < inactivePeriod.getTime())
.length;
return {
isModeratorsInactive: inactiveModeratorCount !== moderators.length,
inactivityLimitCountdown: MODERATOR_INACTIVITY_LIMIT_DAYS - Math.floor((today.getTime() - inactivePeriod.getTime()) / ONE_DAY_MILLI_SEC),
};
}
@bindThis
public async changeToInvitationOnly() {
const meta = await this.metaService.fetch(true);
meta.disableRegistration = true;
await this.metaService.update(meta);
}
}