Merge branch 'develop' into dev
This commit is contained in:
commit
f2396b9acb
39 changed files with 732 additions and 154 deletions
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class EnableStatsForFederatedInstances1727318020265 {
|
||||
name = 'EnableStatsForFederatedInstances1727318020265'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "enableStatsForFederatedInstances" boolean NOT NULL DEFAULT true`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableStatsForFederatedInstances"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class ProhibitedWordsForNameOfUser1728634286056 {
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "prohibitedWordsForNameOfUser" character varying(1024) array NOT NULL DEFAULT '{}'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "prohibitedWordsForNameOfUser"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -288,8 +288,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
|||
.log(updater, 'createAbuseReportNotificationRecipient', {
|
||||
recipientId: id,
|
||||
recipient: created,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
|
@ -327,8 +326,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
|||
recipientId: params.id,
|
||||
before: beforeEntity,
|
||||
after: afterEntity,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
|
||||
return afterEntity;
|
||||
}
|
||||
|
|
@ -349,8 +347,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
|||
.log(updater, 'deleteAbuseReportNotificationRecipient', {
|
||||
recipientId: id,
|
||||
recipient: entity,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -110,8 +110,7 @@ export class AbuseReportService {
|
|||
reportId: report.id,
|
||||
report: report,
|
||||
resolvedAs: ps.resolvedAs,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
}
|
||||
|
||||
return this.abuseUserReportsRepository.findBy({ id: In(reports.map(it => it.id)) })
|
||||
|
|
@ -148,8 +147,7 @@ export class AbuseReportService {
|
|||
.log(moderator, 'forwardAbuseReport', {
|
||||
reportId: report.id,
|
||||
report: report,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -274,13 +274,15 @@ export class AccountMoveService {
|
|||
}
|
||||
|
||||
// Update instance stats by decreasing remote followers count by the number of local followers who were following the old account.
|
||||
if (this.userEntityService.isRemoteUser(oldAccount)) {
|
||||
this.federatedInstanceService.fetch(oldAccount.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, false);
|
||||
}
|
||||
});
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
if (this.userEntityService.isRemoteUser(oldAccount)) {
|
||||
this.federatedInstanceService.fetchOrRegister(oldAccount.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: expensive?
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export class FederatedInstanceService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async fetch(host: string): Promise<MiInstance> {
|
||||
public async fetchOrRegister(host: string): Promise<MiInstance> {
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
||||
const cached = await this.federatedInstanceCache.get(host);
|
||||
|
|
@ -70,6 +70,24 @@ export class FederatedInstanceService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async fetch(host: string): Promise<MiInstance | null> {
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
||||
const cached = await this.federatedInstanceCache.get(host);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const index = await this.instancesRepository.findOneBy({ host });
|
||||
|
||||
if (index == null) {
|
||||
this.federatedInstanceCache.set(host, null);
|
||||
return null;
|
||||
} else {
|
||||
this.federatedInstanceCache.set(host, index);
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async update(id: MiInstance['id'], data: Partial<MiInstance>): Promise<void> {
|
||||
const result = await this.instancesRepository.createQueryBuilder().update()
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ export class FetchInstanceMetadataService {
|
|||
|
||||
try {
|
||||
if (!force) {
|
||||
const _instance = await this.federatedInstanceService.fetch(host);
|
||||
const _instance = await this.federatedInstanceService.fetchOrRegister(host);
|
||||
const now = Date.now();
|
||||
if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) {
|
||||
// unlock at the finally caluse
|
||||
|
|
|
|||
|
|
@ -511,13 +511,15 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
// Register host
|
||||
if (this.userEntityService.isRemoteUser(user)) {
|
||||
this.federatedInstanceService.fetch(user.host).then(async i => {
|
||||
this.updateNotesCountQueue.enqueue(i.id, 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateNote(i.host, note, true);
|
||||
}
|
||||
});
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
if (this.userEntityService.isRemoteUser(user)) {
|
||||
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
|
||||
this.updateNotesCountQueue.enqueue(i.id, 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateNote(i.host, note, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ハッシュタグ更新
|
||||
|
|
|
|||
|
|
@ -106,13 +106,15 @@ export class NoteDeleteService {
|
|||
this.perUserNotesChart.update(user, note, false);
|
||||
}
|
||||
|
||||
if (this.userEntityService.isRemoteUser(user)) {
|
||||
this.federatedInstanceService.fetch(user.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateNote(i.host, note, false);
|
||||
}
|
||||
});
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
if (this.userEntityService.isRemoteUser(user)) {
|
||||
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateNote(i.host, note, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -150,8 +150,8 @@ export class SignupService {
|
|||
}));
|
||||
});
|
||||
|
||||
this.usersChart.update(account, true).then();
|
||||
this.userService.notifySystemWebhook(account, 'userCreated').then();
|
||||
this.usersChart.update(account, true);
|
||||
this.userService.notifySystemWebhook(account, 'userCreated');
|
||||
|
||||
return { account, secret };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,8 +101,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
|
|||
.log(updater, 'createSystemWebhook', {
|
||||
systemWebhookId: webhook.id,
|
||||
webhook: webhook,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
|
||||
return webhook;
|
||||
}
|
||||
|
|
@ -139,8 +138,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
|
|||
systemWebhookId: beforeEntity.id,
|
||||
before: beforeEntity,
|
||||
after: afterEntity,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
|
||||
return afterEntity;
|
||||
}
|
||||
|
|
@ -158,8 +156,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
|
|||
.log(updater, 'deleteSystemWebhook', {
|
||||
systemWebhookId: webhook.id,
|
||||
webhook,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -305,20 +305,22 @@ export class UserFollowingService implements OnModuleInit {
|
|||
//#endregion
|
||||
|
||||
//#region Update instance stats
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
this.federatedInstanceService.fetch(follower.host).then(async i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowing(i.host, true);
|
||||
}
|
||||
});
|
||||
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
this.federatedInstanceService.fetch(followee.host).then(async i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, true);
|
||||
}
|
||||
});
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowing(i.host, true);
|
||||
}
|
||||
});
|
||||
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
|
|
@ -437,20 +439,22 @@ export class UserFollowingService implements OnModuleInit {
|
|||
//#endregion
|
||||
|
||||
//#region Update instance stats
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
this.federatedInstanceService.fetch(follower.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowing(i.host, false);
|
||||
}
|
||||
});
|
||||
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
this.federatedInstanceService.fetch(followee.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, false);
|
||||
}
|
||||
});
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowing(i.host, false);
|
||||
}
|
||||
});
|
||||
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { Packed } from '@/misc/json-schema.js';
|
|||
import { type WebhookEventTypes } from '@/models/Webhook.js';
|
||||
import { UserWebhookService } from '@/core/UserWebhookService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
|
||||
|
||||
const oneDayMillis = 24 * 60 * 60 * 1000;
|
||||
|
||||
|
|
@ -448,6 +449,22 @@ export class WebhookTestService {
|
|||
send(toPackedUserLite(dummyUser1));
|
||||
break;
|
||||
}
|
||||
case 'inactiveModeratorsWarning': {
|
||||
const dummyTime: ModeratorInactivityRemainingTime = {
|
||||
time: 100000,
|
||||
asDays: 1,
|
||||
asHours: 24,
|
||||
};
|
||||
|
||||
send({
|
||||
remainingTime: dummyTime,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'inactiveModeratorsInvitationOnlyChanged': {
|
||||
send({});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -408,13 +408,15 @@ export class ApPersonService implements OnModuleInit {
|
|||
this.cacheService.uriPersonCache.set(user.uri, user);
|
||||
|
||||
// Register host
|
||||
this.federatedInstanceService.fetch(host).then(i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.newUser(i.host);
|
||||
}
|
||||
});
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
this.federatedInstanceService.fetchOrRegister(host).then(i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.newUser(i.host);
|
||||
}
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
});
|
||||
}
|
||||
|
||||
this.usersChart.update(user, true);
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,11 @@ export class MiMeta {
|
|||
})
|
||||
public prohibitedWords: string[];
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024, array: true, default: '{}',
|
||||
})
|
||||
public prohibitedWordsForNameOfUser: string[];
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024, array: true, default: '{}',
|
||||
})
|
||||
|
|
@ -596,6 +601,11 @@ export class MiMeta {
|
|||
})
|
||||
public enableChartsForFederatedInstances: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: true,
|
||||
})
|
||||
public enableStatsForFederatedInstances: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ export const systemWebhookEventTypes = [
|
|||
'abuseReportResolved',
|
||||
// ユーザが作成された時
|
||||
'userCreated',
|
||||
// モデレータが一定期間不在である警告
|
||||
'inactiveModeratorsWarning',
|
||||
// モデレータが一定期間不在のためシステムにより招待制へと変更された
|
||||
'inactiveModeratorsInvitationOnlyChanged',
|
||||
] as const;
|
||||
export type SystemWebhookEventType = typeof systemWebhookEventTypes[number];
|
||||
|
||||
|
|
|
|||
|
|
@ -3,24 +3,110 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { EmailService } from '@/core/EmailService.js';
|
||||
import { MiUser, type UserProfilesRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||
import { AnnouncementService } from '@/core/AnnouncementService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
|
||||
// モデレーターが不在と判断する日付の閾値
|
||||
const MODERATOR_INACTIVITY_LIMIT_DAYS = 7;
|
||||
const ONE_DAY_MILLI_SEC = 1000 * 60 * 60 * 24;
|
||||
// 警告通知やログ出力を行う残日数の閾値
|
||||
const MODERATOR_INACTIVITY_WARNING_REMAINING_DAYS = 2;
|
||||
// 期限から6時間ごとに通知を行う
|
||||
const MODERATOR_INACTIVITY_WARNING_NOTIFY_INTERVAL_HOURS = 6;
|
||||
const ONE_HOUR_MILLI_SEC = 1000 * 60 * 60;
|
||||
const ONE_DAY_MILLI_SEC = ONE_HOUR_MILLI_SEC * 24;
|
||||
|
||||
export type ModeratorInactivityEvaluationResult = {
|
||||
isModeratorsInactive: boolean;
|
||||
inactiveModerators: MiUser[];
|
||||
remainingTime: ModeratorInactivityRemainingTime;
|
||||
}
|
||||
|
||||
export type ModeratorInactivityRemainingTime = {
|
||||
time: number;
|
||||
asHours: number;
|
||||
asDays: number;
|
||||
};
|
||||
|
||||
function generateModeratorInactivityMail(remainingTime: ModeratorInactivityRemainingTime) {
|
||||
const subject = 'Moderator Inactivity Warning / モデレーター不在の通知';
|
||||
|
||||
const timeVariant = remainingTime.asDays === 0 ? `${remainingTime.asHours} hours` : `${remainingTime.asDays} days`;
|
||||
const timeVariantJa = remainingTime.asDays === 0 ? `${remainingTime.asHours} 時間` : `${remainingTime.asDays} 日間`;
|
||||
const message = [
|
||||
'To Moderators,',
|
||||
'',
|
||||
`A moderator has been inactive for a period of time. If there are ${timeVariant} of inactivity left, it will switch to invitation only.`,
|
||||
'If you do not wish to move to invitation only, you must log into Misskey and update your last active date and time.',
|
||||
'',
|
||||
'---------------',
|
||||
'',
|
||||
'To モデレーター各位',
|
||||
'',
|
||||
`モデレーターが一定期間活動していないようです。あと${timeVariantJa}活動していない状態が続くと招待制に切り替わります。`,
|
||||
'招待制に切り替わることを望まない場合は、Misskeyにログインして最終アクティブ日時を更新してください。',
|
||||
'',
|
||||
];
|
||||
|
||||
const html = message.join('<br>');
|
||||
const text = message.join('\n');
|
||||
|
||||
return {
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
function generateInvitationOnlyChangedMail() {
|
||||
const subject = 'Change to Invitation-Only / 招待制に変更されました';
|
||||
|
||||
const message = [
|
||||
'To Moderators,',
|
||||
'',
|
||||
`Changed to invitation only because no moderator activity was detected for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days.`,
|
||||
'To cancel the invitation only, you need to access the control panel.',
|
||||
'',
|
||||
'---------------',
|
||||
'',
|
||||
'To モデレーター各位',
|
||||
'',
|
||||
`モデレーターの活動が${MODERATOR_INACTIVITY_LIMIT_DAYS}日間検出されなかったため、招待制に変更されました。`,
|
||||
'招待制を解除するには、コントロールパネルにアクセスする必要があります。',
|
||||
'',
|
||||
];
|
||||
|
||||
const html = message.join('<br>');
|
||||
const text = message.join('\n');
|
||||
|
||||
return {
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CheckModeratorsActivityProcessorService {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
private metaService: MetaService,
|
||||
private roleService: RoleService,
|
||||
private emailService: EmailService,
|
||||
private announcementService: AnnouncementService,
|
||||
private systemWebhookService: SystemWebhookService,
|
||||
private queueLoggerService: QueueLoggerService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger.createSubLogger('check-moderators-activity');
|
||||
|
|
@ -42,18 +128,23 @@ export class CheckModeratorsActivityProcessorService {
|
|||
|
||||
@bindThis
|
||||
private async processImpl() {
|
||||
const { isModeratorsInactive, inactivityLimitCountdown } = await this.evaluateModeratorsInactiveDays();
|
||||
if (isModeratorsInactive) {
|
||||
const evaluateResult = await this.evaluateModeratorsInactiveDays();
|
||||
if (evaluateResult.isModeratorsInactive) {
|
||||
this.logger.warn(`The moderator has been inactive for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days. We will move to invitation only.`);
|
||||
|
||||
await this.changeToInvitationOnly();
|
||||
|
||||
// TODO: モデレータに通知メール+Misskey通知
|
||||
// TODO: SystemWebhook通知
|
||||
await this.notifyChangeToInvitationOnly();
|
||||
} else {
|
||||
if (inactivityLimitCountdown <= 2) {
|
||||
this.logger.warn(`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.`);
|
||||
const remainingTime = evaluateResult.remainingTime;
|
||||
if (remainingTime.asDays <= MODERATOR_INACTIVITY_WARNING_REMAINING_DAYS) {
|
||||
const timeVariant = remainingTime.asDays === 0 ? `${remainingTime.asHours} hours` : `${remainingTime.asDays} days`;
|
||||
this.logger.warn(`A moderator has been inactive for a period of time. If you are inactive for an additional ${timeVariant}, it will switch to invitation only.`);
|
||||
|
||||
// TODO: 警告メール
|
||||
if (remainingTime.asHours % MODERATOR_INACTIVITY_WARNING_NOTIFY_INTERVAL_HOURS === 0) {
|
||||
// ジョブの実行頻度と同等だと通知が多すぎるため期限から6時間ごとに通知する
|
||||
// つまり、のこり2日を切ったら6時間ごとに通知が送られる
|
||||
await this.notifyInactiveModeratorsWarning(remainingTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -87,7 +178,7 @@ export class CheckModeratorsActivityProcessorService {
|
|||
* この場合、モデレータA, B, Cのアクティビティは判定基準日よりも古いため、モデレーターが不在と判断される。
|
||||
*/
|
||||
@bindThis
|
||||
public async evaluateModeratorsInactiveDays() {
|
||||
public async evaluateModeratorsInactiveDays(): Promise<ModeratorInactivityEvaluationResult> {
|
||||
const today = new Date();
|
||||
const inactivePeriod = new Date(today);
|
||||
inactivePeriod.setDate(today.getDate() - MODERATOR_INACTIVITY_LIMIT_DAYS);
|
||||
|
|
@ -101,12 +192,18 @@ export class CheckModeratorsActivityProcessorService {
|
|||
// 残りの猶予を示したいので、最終アクティブ日時が一番若いモデレータの日数を基準に猶予を計算する
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const newestLastActiveDate = new Date(Math.max(...moderators.map(it => it.lastActiveDate!.getTime())));
|
||||
const inactivityLimitCountdown = Math.floor((newestLastActiveDate.getTime() - inactivePeriod.getTime()) / ONE_DAY_MILLI_SEC);
|
||||
const remainingTime = newestLastActiveDate.getTime() - inactivePeriod.getTime();
|
||||
const remainingTimeAsDays = Math.floor(remainingTime / ONE_DAY_MILLI_SEC);
|
||||
const remainingTimeAsHours = Math.floor((remainingTime / ONE_HOUR_MILLI_SEC));
|
||||
|
||||
return {
|
||||
isModeratorsInactive: inactiveModerators.length === moderators.length,
|
||||
inactiveModerators,
|
||||
inactivityLimitCountdown,
|
||||
remainingTime: {
|
||||
time: remainingTime,
|
||||
asHours: remainingTimeAsHours,
|
||||
asDays: remainingTimeAsDays,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -115,6 +212,74 @@ export class CheckModeratorsActivityProcessorService {
|
|||
await this.metaService.update({ disableRegistration: true });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async notifyInactiveModeratorsWarning(remainingTime: ModeratorInactivityRemainingTime) {
|
||||
// -- モデレータへのメール送信
|
||||
|
||||
const moderators = await this.fetchModerators();
|
||||
const moderatorProfiles = await this.userProfilesRepository
|
||||
.findBy({ userId: In(moderators.map(it => it.id)) })
|
||||
.then(it => new Map(it.map(it => [it.userId, it])));
|
||||
|
||||
const mail = generateModeratorInactivityMail(remainingTime);
|
||||
for (const moderator of moderators) {
|
||||
const profile = moderatorProfiles.get(moderator.id);
|
||||
if (profile && profile.email && profile.emailVerified) {
|
||||
this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text);
|
||||
}
|
||||
}
|
||||
|
||||
// -- SystemWebhook
|
||||
|
||||
const systemWebhooks = await this.systemWebhookService.fetchActiveSystemWebhooks()
|
||||
.then(it => it.filter(it => it.on.includes('inactiveModeratorsWarning')));
|
||||
for (const systemWebhook of systemWebhooks) {
|
||||
this.systemWebhookService.enqueueSystemWebhook(
|
||||
systemWebhook,
|
||||
'inactiveModeratorsWarning',
|
||||
{ remainingTime: remainingTime },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async notifyChangeToInvitationOnly() {
|
||||
// -- モデレータへのメールとお知らせ(個人向け)送信
|
||||
|
||||
const moderators = await this.fetchModerators();
|
||||
const moderatorProfiles = await this.userProfilesRepository
|
||||
.findBy({ userId: In(moderators.map(it => it.id)) })
|
||||
.then(it => new Map(it.map(it => [it.userId, it])));
|
||||
|
||||
const mail = generateInvitationOnlyChangedMail();
|
||||
for (const moderator of moderators) {
|
||||
this.announcementService.create({
|
||||
title: mail.subject,
|
||||
text: mail.text,
|
||||
forExistingUsers: true,
|
||||
needConfirmationToRead: true,
|
||||
userId: moderator.id,
|
||||
});
|
||||
|
||||
const profile = moderatorProfiles.get(moderator.id);
|
||||
if (profile && profile.email && profile.emailVerified) {
|
||||
this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text);
|
||||
}
|
||||
}
|
||||
|
||||
// -- SystemWebhook
|
||||
|
||||
const systemWebhooks = await this.systemWebhookService.fetchActiveSystemWebhooks()
|
||||
.then(it => it.filter(it => it.on.includes('inactiveModeratorsInvitationOnlyChanged')));
|
||||
for (const systemWebhook of systemWebhooks) {
|
||||
this.systemWebhookService.enqueueSystemWebhook(
|
||||
systemWebhook,
|
||||
'inactiveModeratorsInvitationOnlyChanged',
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async fetchModerators() {
|
||||
// TODO: モデレーター以外にも特別な権限を持つユーザーがいる場合は考慮する
|
||||
|
|
|
|||
|
|
@ -74,8 +74,17 @@ export class DeliverProcessorService {
|
|||
try {
|
||||
await this.apRequestService.signedPost(job.data.user, job.data.to, job.data.content, job.data.digest);
|
||||
|
||||
// Update stats
|
||||
this.federatedInstanceService.fetch(host).then(i => {
|
||||
this.apRequestChart.deliverSucc();
|
||||
this.federationChart.deliverd(host, true);
|
||||
|
||||
// Update instance stats
|
||||
process.nextTick(async () => {
|
||||
const i = await (this.meta.enableStatsForFederatedInstances
|
||||
? this.federatedInstanceService.fetchOrRegister(host)
|
||||
: this.federatedInstanceService.fetch(host));
|
||||
|
||||
if (i == null) return;
|
||||
|
||||
if (i.isNotResponding) {
|
||||
this.federatedInstanceService.update(i.id, {
|
||||
isNotResponding: false,
|
||||
|
|
@ -83,9 +92,9 @@ export class DeliverProcessorService {
|
|||
});
|
||||
}
|
||||
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
this.apRequestChart.deliverSucc();
|
||||
this.federationChart.deliverd(i.host, true);
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
}
|
||||
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.requestSent(i.host, true);
|
||||
|
|
@ -94,8 +103,11 @@ export class DeliverProcessorService {
|
|||
|
||||
return 'Success';
|
||||
} catch (res) {
|
||||
// Update stats
|
||||
this.federatedInstanceService.fetch(host).then(i => {
|
||||
this.apRequestChart.deliverFail();
|
||||
this.federationChart.deliverd(host, false);
|
||||
|
||||
// Update instance stats
|
||||
this.federatedInstanceService.fetchOrRegister(host).then(i => {
|
||||
if (!i.isNotResponding) {
|
||||
this.federatedInstanceService.update(i.id, {
|
||||
isNotResponding: true,
|
||||
|
|
@ -116,9 +128,6 @@ export class DeliverProcessorService {
|
|||
});
|
||||
}
|
||||
|
||||
this.apRequestChart.deliverFail();
|
||||
this.federationChart.deliverd(i.host, false);
|
||||
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.requestSent(i.host, false);
|
||||
}
|
||||
|
|
@ -129,7 +138,7 @@ export class DeliverProcessorService {
|
|||
if (!res.isRetryable) {
|
||||
// 相手が閉鎖していることを明示しているため、配送停止する
|
||||
if (job.data.isSharedInbox && res.statusCode === 410) {
|
||||
this.federatedInstanceService.fetch(host).then(i => {
|
||||
this.federatedInstanceService.fetchOrRegister(host).then(i => {
|
||||
this.federatedInstanceService.update(i.id, {
|
||||
suspensionState: 'goneSuspended',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -192,21 +192,27 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
// Update stats
|
||||
this.federatedInstanceService.fetch(authUser.user.host).then(i => {
|
||||
this.apRequestChart.inbox();
|
||||
this.federationChart.inbox(authUser.user.host);
|
||||
|
||||
// Update instance stats
|
||||
process.nextTick(async () => {
|
||||
const i = await (this.meta.enableStatsForFederatedInstances
|
||||
? this.federatedInstanceService.fetchOrRegister(authUser.user.host)
|
||||
: this.federatedInstanceService.fetch(authUser.user.host));
|
||||
|
||||
if (i == null) return;
|
||||
|
||||
this.updateInstanceQueue.enqueue(i.id, {
|
||||
latestRequestReceivedAt: new Date(),
|
||||
shouldUnsuspend: i.suspensionState === 'autoSuspendedForNotResponding',
|
||||
});
|
||||
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
|
||||
this.apRequestChart.inbox();
|
||||
this.federationChart.inbox(i.host);
|
||||
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.requestReceived(i.host);
|
||||
}
|
||||
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
});
|
||||
|
||||
// アクティビティを処理
|
||||
|
|
|
|||
|
|
@ -181,6 +181,13 @@ export const meta = {
|
|||
type: 'string',
|
||||
},
|
||||
},
|
||||
prohibitedWordsForNameOfUser: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
bannedEmailDomains: {
|
||||
type: 'array',
|
||||
optional: true, nullable: false,
|
||||
|
|
@ -345,6 +352,10 @@ export const meta = {
|
|||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
enableStatsForFederatedInstances: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
enableServerMachineStats: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
|
|
@ -591,6 +602,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
mediaSilencedHosts: instance.mediaSilencedHosts,
|
||||
sensitiveWords: instance.sensitiveWords,
|
||||
prohibitedWords: instance.prohibitedWords,
|
||||
prohibitedWordsForNameOfUser: instance.prohibitedWordsForNameOfUser,
|
||||
preservedUsernames: instance.preservedUsernames,
|
||||
hcaptchaSecretKey: instance.hcaptchaSecretKey,
|
||||
mcaptchaSecretKey: instance.mcaptchaSecretKey,
|
||||
|
|
@ -632,6 +644,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
truemailAuthKey: instance.truemailAuthKey,
|
||||
enableChartsForRemoteUser: instance.enableChartsForRemoteUser,
|
||||
enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances,
|
||||
enableStatsForFederatedInstances: instance.enableStatsForFederatedInstances,
|
||||
enableServerMachineStats: instance.enableServerMachineStats,
|
||||
enableIdenticonGeneration: instance.enableIdenticonGeneration,
|
||||
bannedEmailDomains: instance.bannedEmailDomains,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@ export const paramDef = {
|
|||
type: 'string',
|
||||
},
|
||||
},
|
||||
prohibitedWordsForNameOfUser: {
|
||||
type: 'array', nullable: true, items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' },
|
||||
mascotImageUrl: { type: 'string', nullable: true },
|
||||
bannerUrl: { type: 'string', nullable: true },
|
||||
|
|
@ -144,6 +149,7 @@ export const paramDef = {
|
|||
truemailAuthKey: { type: 'string', nullable: true },
|
||||
enableChartsForRemoteUser: { type: 'boolean' },
|
||||
enableChartsForFederatedInstances: { type: 'boolean' },
|
||||
enableStatsForFederatedInstances: { type: 'boolean' },
|
||||
enableServerMachineStats: { type: 'boolean' },
|
||||
enableIdenticonGeneration: { type: 'boolean' },
|
||||
serverRules: { type: 'array', items: { type: 'string' } },
|
||||
|
|
@ -227,6 +233,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (Array.isArray(ps.prohibitedWords)) {
|
||||
set.prohibitedWords = ps.prohibitedWords.filter(Boolean);
|
||||
}
|
||||
if (Array.isArray(ps.prohibitedWordsForNameOfUser)) {
|
||||
set.prohibitedWordsForNameOfUser = ps.prohibitedWordsForNameOfUser.filter(Boolean);
|
||||
}
|
||||
if (Array.isArray(ps.silencedHosts)) {
|
||||
let lastValue = '';
|
||||
set.silencedHosts = ps.silencedHosts.sort().filter((h) => {
|
||||
|
|
@ -659,6 +668,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances;
|
||||
}
|
||||
|
||||
if (ps.enableStatsForFederatedInstances !== undefined) {
|
||||
set.enableStatsForFederatedInstances = ps.enableStatsForFederatedInstances;
|
||||
}
|
||||
|
||||
if (ps.enableServerMachineStats !== undefined) {
|
||||
set.enableServerMachineStats = ps.enableServerMachineStats;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { JSDOM } from 'jsdom';
|
|||
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
|
||||
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
||||
import * as Acct from '@/misc/acct.js';
|
||||
import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/_.js';
|
||||
import type { UsersRepository, DriveFilesRepository, MiMeta, UserProfilesRepository, PagesRepository } from '@/models/_.js';
|
||||
import type { MiLocalUser, MiUser } from '@/models/User.js';
|
||||
import { birthdaySchema, descriptionSchema, followedMessageSchema, locationSchema, nameSchema } from '@/models/User.js';
|
||||
import type { MiUserProfile } from '@/models/UserProfile.js';
|
||||
|
|
@ -22,6 +22,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
|||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
import { AccountUpdateService } from '@/core/AccountUpdateService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { HashtagService } from '@/core/HashtagService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RolePolicies, RoleService } from '@/core/RoleService.js';
|
||||
|
|
@ -114,6 +115,13 @@ export const meta = {
|
|||
code: 'RESTRICTED_BY_ROLE',
|
||||
id: '8feff0ba-5ab5-585b-31f4-4df816663fad',
|
||||
},
|
||||
|
||||
nameContainsProhibitedWords: {
|
||||
message: 'Your new name contains prohibited words.',
|
||||
code: 'YOUR_NAME_CONTAINS_PROHIBITED_WORDS',
|
||||
id: '0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191',
|
||||
httpStatusCode: 422,
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
|
|
@ -224,6 +232,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private instanceMeta: MiMeta,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
|
|
@ -248,6 +259,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private cacheService: CacheService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private avatarDecorationService: AvatarDecorationService,
|
||||
private utilityService: UtilityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, _user, token) => {
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: _user.id }) as MiLocalUser;
|
||||
|
|
@ -451,6 +463,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
const newFields = profileUpdates.fields === undefined ? profile.fields : profileUpdates.fields;
|
||||
|
||||
if (newName != null) {
|
||||
let hasProhibitedWords = false;
|
||||
if (!await this.roleService.isModerator(user)) {
|
||||
hasProhibitedWords = this.utilityService.isKeyWordIncluded(newName, this.instanceMeta.prohibitedWordsForNameOfUser);
|
||||
}
|
||||
if (hasProhibitedWords) {
|
||||
throw new ApiError(meta.errors.nameContainsProhibitedWords);
|
||||
}
|
||||
|
||||
const tokens = mfm.parseSimple(newName);
|
||||
emojis = emojis.concat(extractCustomEmojisFromMfm(tokens));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ process.env.NODE_ENV = 'test';
|
|||
import { jest } from '@jest/globals';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { Redis } from 'ioredis';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
|
|
@ -16,7 +17,6 @@ import { LoggerService } from '@/core/LoggerService.js';
|
|||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
|
||||
function mockRedis() {
|
||||
const hash = {} as any;
|
||||
|
|
@ -52,7 +52,7 @@ describe('FetchInstanceMetadataService', () => {
|
|||
if (token === HttpRequestService) {
|
||||
return { getJson: jest.fn(), getHtml: jest.fn(), send: jest.fn() };
|
||||
} else if (token === FederatedInstanceService) {
|
||||
return { fetch: jest.fn() };
|
||||
return { fetchOrRegister: jest.fn() };
|
||||
} else if (token === DI.redis) {
|
||||
return mockRedis;
|
||||
}
|
||||
|
|
@ -75,7 +75,7 @@ describe('FetchInstanceMetadataService', () => {
|
|||
test('Lock and update', async () => {
|
||||
redisClient.set = mockRedis();
|
||||
const now = Date.now();
|
||||
federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => { return now - 10 * 1000 * 60 * 60 * 24; } } } as any);
|
||||
federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => { return now - 10 * 1000 * 60 * 60 * 24; } } } as any);
|
||||
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
|
||||
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
|
||||
const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
|
||||
|
|
@ -83,14 +83,14 @@ describe('FetchInstanceMetadataService', () => {
|
|||
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
|
||||
expect(tryLockSpy).toHaveBeenCalledTimes(1);
|
||||
expect(unlockSpy).toHaveBeenCalledTimes(1);
|
||||
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(1);
|
||||
expect(httpRequestService.getJson).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Lock and don\'t update', async () => {
|
||||
redisClient.set = mockRedis();
|
||||
const now = Date.now();
|
||||
federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now } } as any);
|
||||
federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now } } as any);
|
||||
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
|
||||
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
|
||||
const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
|
||||
|
|
@ -98,14 +98,14 @@ describe('FetchInstanceMetadataService', () => {
|
|||
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
|
||||
expect(tryLockSpy).toHaveBeenCalledTimes(1);
|
||||
expect(unlockSpy).toHaveBeenCalledTimes(1);
|
||||
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(1);
|
||||
expect(httpRequestService.getJson).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test('Do nothing when lock not acquired', async () => {
|
||||
redisClient.set = mockRedis();
|
||||
const now = Date.now();
|
||||
federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
|
||||
federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
|
||||
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
|
||||
await fetchInstanceMetadataService.tryLock('example.com');
|
||||
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
|
||||
|
|
@ -114,14 +114,14 @@ describe('FetchInstanceMetadataService', () => {
|
|||
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
|
||||
expect(tryLockSpy).toHaveBeenCalledTimes(1);
|
||||
expect(unlockSpy).toHaveBeenCalledTimes(0);
|
||||
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0);
|
||||
expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(0);
|
||||
expect(httpRequestService.getJson).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test('Do when lock not acquired but forced', async () => {
|
||||
redisClient.set = mockRedis();
|
||||
const now = Date.now();
|
||||
federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
|
||||
federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
|
||||
httpRequestService.getJson.mockImplementation(() => { throw Error(); });
|
||||
await fetchInstanceMetadataService.tryLock('example.com');
|
||||
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
|
||||
|
|
@ -130,7 +130,7 @@ describe('FetchInstanceMetadataService', () => {
|
|||
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any, true);
|
||||
expect(tryLockSpy).toHaveBeenCalledTimes(0);
|
||||
expect(unlockSpy).toHaveBeenCalledTimes(1);
|
||||
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0);
|
||||
expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(0);
|
||||
expect(httpRequestService.getJson).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,13 +8,16 @@ import { Test, TestingModule } from '@nestjs/testing';
|
|||
import * as lolex from '@sinonjs/fake-timers';
|
||||
import { addHours, addSeconds, subDays, subHours, subSeconds } from 'date-fns';
|
||||
import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
|
||||
import { MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import { MiSystemWebhook, MiUser, MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { QueueLoggerService } from '@/queue/QueueLoggerService.js';
|
||||
import { EmailService } from '@/core/EmailService.js';
|
||||
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||
import { AnnouncementService } from '@/core/AnnouncementService.js';
|
||||
|
||||
const baseDate = new Date(Date.UTC(2000, 11, 15, 12, 0, 0));
|
||||
|
||||
|
|
@ -29,10 +32,17 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
|||
let userProfilesRepository: UserProfilesRepository;
|
||||
let idService: IdService;
|
||||
let roleService: jest.Mocked<RoleService>;
|
||||
let announcementService: jest.Mocked<AnnouncementService>;
|
||||
let emailService: jest.Mocked<EmailService>;
|
||||
let systemWebhookService: jest.Mocked<SystemWebhookService>;
|
||||
|
||||
let systemWebhook1: MiSystemWebhook;
|
||||
let systemWebhook2: MiSystemWebhook;
|
||||
let systemWebhook3: MiSystemWebhook;
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
|
||||
async function createUser(data: Partial<MiUser> = {}) {
|
||||
async function createUser(data: Partial<MiUser> = {}, profile: Partial<MiUserProfile> = {}): Promise<MiUser> {
|
||||
const id = idService.gen();
|
||||
const user = await usersRepository
|
||||
.insert({
|
||||
|
|
@ -45,11 +55,27 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
|||
|
||||
await userProfilesRepository.insert({
|
||||
userId: user.id,
|
||||
...profile,
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
function crateSystemWebhook(data: Partial<MiSystemWebhook> = {}): MiSystemWebhook {
|
||||
return {
|
||||
id: idService.gen(),
|
||||
isActive: true,
|
||||
updatedAt: new Date(),
|
||||
latestSentAt: null,
|
||||
latestStatus: null,
|
||||
name: 'test',
|
||||
url: 'https://example.com',
|
||||
secret: 'test',
|
||||
on: [],
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
function mockModeratorRole(users: MiUser[]) {
|
||||
roleService.getModerators.mockReset();
|
||||
roleService.getModerators.mockResolvedValue(users);
|
||||
|
|
@ -72,6 +98,18 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
|||
{
|
||||
provide: MetaService, useFactory: () => ({ fetch: jest.fn() }),
|
||||
},
|
||||
{
|
||||
provide: AnnouncementService, useFactory: () => ({ create: jest.fn() }),
|
||||
},
|
||||
{
|
||||
provide: EmailService, useFactory: () => ({ sendEmail: jest.fn() }),
|
||||
},
|
||||
{
|
||||
provide: SystemWebhookService, useFactory: () => ({
|
||||
fetchActiveSystemWebhooks: jest.fn(),
|
||||
enqueueSystemWebhook: jest.fn(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: QueueLoggerService, useFactory: () => ({
|
||||
logger: ({
|
||||
|
|
@ -93,6 +131,9 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
|||
service = app.get(CheckModeratorsActivityProcessorService);
|
||||
idService = app.get(IdService);
|
||||
roleService = app.get(RoleService) as jest.Mocked<RoleService>;
|
||||
announcementService = app.get(AnnouncementService) as jest.Mocked<AnnouncementService>;
|
||||
emailService = app.get(EmailService) as jest.Mocked<EmailService>;
|
||||
systemWebhookService = app.get(SystemWebhookService) as jest.Mocked<SystemWebhookService>;
|
||||
|
||||
app.enableShutdownHooks();
|
||||
});
|
||||
|
|
@ -102,6 +143,15 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
|||
now: new Date(baseDate),
|
||||
shouldClearNativeTimers: true,
|
||||
});
|
||||
|
||||
systemWebhook1 = crateSystemWebhook({ on: ['inactiveModeratorsWarning'] });
|
||||
systemWebhook2 = crateSystemWebhook({ on: ['inactiveModeratorsWarning', 'inactiveModeratorsInvitationOnlyChanged'] });
|
||||
systemWebhook3 = crateSystemWebhook({ on: ['abuseReport'] });
|
||||
|
||||
emailService.sendEmail.mockReturnValue(Promise.resolve());
|
||||
announcementService.create.mockReturnValue(Promise.resolve({} as never));
|
||||
systemWebhookService.fetchActiveSystemWebhooks.mockResolvedValue([systemWebhook1, systemWebhook2, systemWebhook3]);
|
||||
systemWebhookService.enqueueSystemWebhook.mockReturnValue(Promise.resolve({} as never));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -109,6 +159,9 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
|||
await usersRepository.delete({});
|
||||
await userProfilesRepository.delete({});
|
||||
roleService.getModerators.mockReset();
|
||||
announcementService.create.mockReset();
|
||||
emailService.sendEmail.mockReset();
|
||||
systemWebhookService.enqueueSystemWebhook.mockReset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
|
@ -152,7 +205,7 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
|||
expect(result.inactiveModerators).toEqual([user1]);
|
||||
});
|
||||
|
||||
test('[countdown] 猶予まで24時間ある場合、猶予1日として計算される', async () => {
|
||||
test('[remainingTime] 猶予まで24時間ある場合、猶予1日として計算される', async () => {
|
||||
const [user1, user2] = await Promise.all([
|
||||
createUser({ lastActiveDate: subDays(baseDate, 8) }),
|
||||
// 猶予はこのユーザ基準で計算される想定。
|
||||
|
|
@ -165,10 +218,11 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
|||
const result = await service.evaluateModeratorsInactiveDays();
|
||||
expect(result.isModeratorsInactive).toBe(false);
|
||||
expect(result.inactiveModerators).toEqual([user1]);
|
||||
expect(result.inactivityLimitCountdown).toBe(1);
|
||||
expect(result.remainingTime.asDays).toBe(1);
|
||||
expect(result.remainingTime.asHours).toBe(24);
|
||||
});
|
||||
|
||||
test('[countdown] 猶予まで25時間ある場合、猶予1日として計算される', async () => {
|
||||
test('[remainingTime] 猶予まで25時間ある場合、猶予1日として計算される', async () => {
|
||||
const [user1, user2] = await Promise.all([
|
||||
createUser({ lastActiveDate: subDays(baseDate, 8) }),
|
||||
// 猶予はこのユーザ基準で計算される想定。
|
||||
|
|
@ -181,10 +235,11 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
|||
const result = await service.evaluateModeratorsInactiveDays();
|
||||
expect(result.isModeratorsInactive).toBe(false);
|
||||
expect(result.inactiveModerators).toEqual([user1]);
|
||||
expect(result.inactivityLimitCountdown).toBe(1);
|
||||
expect(result.remainingTime.asDays).toBe(1);
|
||||
expect(result.remainingTime.asHours).toBe(25);
|
||||
});
|
||||
|
||||
test('[countdown] 猶予まで23時間ある場合、猶予0日として計算される', async () => {
|
||||
test('[remainingTime] 猶予まで23時間ある場合、猶予0日として計算される', async () => {
|
||||
const [user1, user2] = await Promise.all([
|
||||
createUser({ lastActiveDate: subDays(baseDate, 8) }),
|
||||
// 猶予はこのユーザ基準で計算される想定。
|
||||
|
|
@ -197,10 +252,11 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
|||
const result = await service.evaluateModeratorsInactiveDays();
|
||||
expect(result.isModeratorsInactive).toBe(false);
|
||||
expect(result.inactiveModerators).toEqual([user1]);
|
||||
expect(result.inactivityLimitCountdown).toBe(0);
|
||||
expect(result.remainingTime.asDays).toBe(0);
|
||||
expect(result.remainingTime.asHours).toBe(23);
|
||||
});
|
||||
|
||||
test('[countdown] 期限ちょうどの場合、猶予0日として計算される', async () => {
|
||||
test('[remainingTime] 期限ちょうどの場合、猶予0日として計算される', async () => {
|
||||
const [user1, user2] = await Promise.all([
|
||||
createUser({ lastActiveDate: subDays(baseDate, 8) }),
|
||||
// 猶予はこのユーザ基準で計算される想定。
|
||||
|
|
@ -213,10 +269,11 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
|||
const result = await service.evaluateModeratorsInactiveDays();
|
||||
expect(result.isModeratorsInactive).toBe(false);
|
||||
expect(result.inactiveModerators).toEqual([user1]);
|
||||
expect(result.inactivityLimitCountdown).toBe(0);
|
||||
expect(result.remainingTime.asDays).toBe(0);
|
||||
expect(result.remainingTime.asHours).toBe(0);
|
||||
});
|
||||
|
||||
test('[countdown] 期限より1時間超過している場合、猶予-1日として計算される', async () => {
|
||||
test('[remainingTime] 期限より1時間超過している場合、猶予-1日として計算される', async () => {
|
||||
const [user1, user2] = await Promise.all([
|
||||
createUser({ lastActiveDate: subDays(baseDate, 8) }),
|
||||
// 猶予はこのユーザ基準で計算される想定。
|
||||
|
|
@ -229,7 +286,94 @@ describe('CheckModeratorsActivityProcessorService', () => {
|
|||
const result = await service.evaluateModeratorsInactiveDays();
|
||||
expect(result.isModeratorsInactive).toBe(true);
|
||||
expect(result.inactiveModerators).toEqual([user1, user2]);
|
||||
expect(result.inactivityLimitCountdown).toBe(-1);
|
||||
expect(result.remainingTime.asDays).toBe(-1);
|
||||
expect(result.remainingTime.asHours).toBe(-1);
|
||||
});
|
||||
|
||||
test('[remainingTime] 期限より25時間超過している場合、猶予-2日として計算される', async () => {
|
||||
const [user1, user2] = await Promise.all([
|
||||
createUser({ lastActiveDate: subDays(baseDate, 10) }),
|
||||
// 猶予はこのユーザ基準で計算される想定。
|
||||
// 期限より1時間超過->猶予-1日として計算されるはずである
|
||||
createUser({ lastActiveDate: subDays(subHours(baseDate, 25), 7) }),
|
||||
]);
|
||||
|
||||
mockModeratorRole([user1, user2]);
|
||||
|
||||
const result = await service.evaluateModeratorsInactiveDays();
|
||||
expect(result.isModeratorsInactive).toBe(true);
|
||||
expect(result.inactiveModerators).toEqual([user1, user2]);
|
||||
expect(result.remainingTime.asDays).toBe(-2);
|
||||
expect(result.remainingTime.asHours).toBe(-25);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifyInactiveModeratorsWarning', () => {
|
||||
test('[notification + mail] 通知はモデレータ全員に発信され、メールはメールアドレスが存在+認証済みの場合のみ', async () => {
|
||||
const [user1, user2, user3, user4, root] = await Promise.all([
|
||||
createUser({}, { email: 'user1@example.com', emailVerified: true }),
|
||||
createUser({}, { email: 'user2@example.com', emailVerified: false }),
|
||||
createUser({}, { email: null, emailVerified: false }),
|
||||
createUser({}, { email: 'user4@example.com', emailVerified: true }),
|
||||
createUser({ isRoot: true }, { email: 'root@example.com', emailVerified: true }),
|
||||
]);
|
||||
|
||||
mockModeratorRole([user1, user2, user3, root]);
|
||||
await service.notifyInactiveModeratorsWarning({ time: 1, asDays: 0, asHours: 0 });
|
||||
|
||||
expect(emailService.sendEmail).toHaveBeenCalledTimes(2);
|
||||
expect(emailService.sendEmail.mock.calls[0][0]).toBe('user1@example.com');
|
||||
expect(emailService.sendEmail.mock.calls[1][0]).toBe('root@example.com');
|
||||
});
|
||||
|
||||
test('[systemWebhook] "inactiveModeratorsWarning"が有効なSystemWebhookに対して送信される', async () => {
|
||||
const [user1] = await Promise.all([
|
||||
createUser({}, { email: 'user1@example.com', emailVerified: true }),
|
||||
]);
|
||||
|
||||
mockModeratorRole([user1]);
|
||||
await service.notifyInactiveModeratorsWarning({ time: 1, asDays: 0, asHours: 0 });
|
||||
|
||||
expect(systemWebhookService.enqueueSystemWebhook).toHaveBeenCalledTimes(2);
|
||||
expect(systemWebhookService.enqueueSystemWebhook.mock.calls[0][0]).toEqual(systemWebhook1);
|
||||
expect(systemWebhookService.enqueueSystemWebhook.mock.calls[1][0]).toEqual(systemWebhook2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifyChangeToInvitationOnly', () => {
|
||||
test('[notification + mail] 通知はモデレータ全員に発信され、メールはメールアドレスが存在+認証済みの場合のみ', async () => {
|
||||
const [user1, user2, user3, user4, root] = await Promise.all([
|
||||
createUser({}, { email: 'user1@example.com', emailVerified: true }),
|
||||
createUser({}, { email: 'user2@example.com', emailVerified: false }),
|
||||
createUser({}, { email: null, emailVerified: false }),
|
||||
createUser({}, { email: 'user4@example.com', emailVerified: true }),
|
||||
createUser({ isRoot: true }, { email: 'root@example.com', emailVerified: true }),
|
||||
]);
|
||||
|
||||
mockModeratorRole([user1, user2, user3, root]);
|
||||
await service.notifyChangeToInvitationOnly();
|
||||
|
||||
expect(announcementService.create).toHaveBeenCalledTimes(4);
|
||||
expect(announcementService.create.mock.calls[0][0].userId).toBe(user1.id);
|
||||
expect(announcementService.create.mock.calls[1][0].userId).toBe(user2.id);
|
||||
expect(announcementService.create.mock.calls[2][0].userId).toBe(user3.id);
|
||||
expect(announcementService.create.mock.calls[3][0].userId).toBe(root.id);
|
||||
|
||||
expect(emailService.sendEmail).toHaveBeenCalledTimes(2);
|
||||
expect(emailService.sendEmail.mock.calls[0][0]).toBe('user1@example.com');
|
||||
expect(emailService.sendEmail.mock.calls[1][0]).toBe('root@example.com');
|
||||
});
|
||||
|
||||
test('[systemWebhook] "inactiveModeratorsInvitationOnlyChanged"が有効なSystemWebhookに対して送信される', async () => {
|
||||
const [user1] = await Promise.all([
|
||||
createUser({}, { email: 'user1@example.com', emailVerified: true }),
|
||||
]);
|
||||
|
||||
mockModeratorRole([user1]);
|
||||
await service.notifyChangeToInvitationOnly();
|
||||
|
||||
expect(systemWebhookService.enqueueSystemWebhook).toHaveBeenCalledTimes(1);
|
||||
expect(systemWebhookService.enqueueSystemWebhook.mock.calls[0][0]).toEqual(systemWebhook2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue