summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--locales/index.d.ts8
-rw-r--r--locales/ja-JP.yml2
-rw-r--r--packages/backend/src/core/WebhookTestService.ts17
-rw-r--r--packages/backend/src/models/SystemWebhook.ts4
-rw-r--r--packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts191
-rw-r--r--packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts168
-rw-r--r--packages/frontend/src/components/MkSystemWebhookEditor.vue18
-rw-r--r--packages/misskey-js/src/autogen/types.ts10
8 files changed, 388 insertions, 30 deletions
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 3ffb67f31c..b5af5909a3 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -9661,6 +9661,14 @@ export interface Locale extends ILocale {
* ユーザーが作成されたとき
*/
"userCreated": string;
+ /**
+ * モデレーターが一定期間非アクティブになったとき
+ */
+ "inactiveModeratorsWarning": string;
+ /**
+ * モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき
+ */
+ "inactiveModeratorsInvitationOnlyChanged": string;
};
/**
* Webhookを削除しますか?
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 919be1e3ec..c448d4d50a 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -2559,6 +2559,8 @@ _webhookSettings:
abuseReport: "ユーザーから通報があったとき"
abuseReportResolved: "ユーザーからの通報を処理したとき"
userCreated: "ユーザーが作成されたとき"
+ inactiveModeratorsWarning: "モデレーターが一定期間非アクティブになったとき"
+ inactiveModeratorsInvitationOnlyChanged: "モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき"
deleteConfirm: "Webhookを削除しますか?"
testRemarks: "スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。"
diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts
index 4c45b95a64..55c8a52705 100644
--- a/packages/backend/src/core/WebhookTestService.ts
+++ b/packages/backend/src/core/WebhookTestService.ts
@@ -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;
@@ -446,6 +447,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;
+ }
}
}
}
diff --git a/packages/backend/src/models/SystemWebhook.ts b/packages/backend/src/models/SystemWebhook.ts
index d6c27eae51..1a7ce4962b 100644
--- a/packages/backend/src/models/SystemWebhook.ts
+++ b/packages/backend/src/models/SystemWebhook.ts
@@ -14,6 +14,10 @@ export const systemWebhookEventTypes = [
'abuseReportResolved',
// ユーザが作成された時
'userCreated',
+ // モデレータが一定期間不在である警告
+ 'inactiveModeratorsWarning',
+ // モデレータが一定期間不在のためシステムにより招待制へと変更された
+ 'inactiveModeratorsInvitationOnlyChanged',
] as const;
export type SystemWebhookEventType = typeof systemWebhookEventTypes[number];
diff --git a/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts
index f2677f8e5c..87183cb342 100644
--- a/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts
+++ b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts
@@ -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.changeToInvitationOnly();
+ 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,
+ },
};
}
@@ -116,6 +213,74 @@ export class CheckModeratorsActivityProcessorService {
}
@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: モデレーター以外にも特別な権限を持つユーザーがいる場合は考慮する
return this.roleService.getModerators({
diff --git a/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts
index b783320aa0..1506283a3c 100644
--- a/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts
+++ b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts
@@ -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);
@@ -73,6 +99,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: ({
createSubLogger: () => ({
@@ -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);
});
});
});
diff --git a/packages/frontend/src/components/MkSystemWebhookEditor.vue b/packages/frontend/src/components/MkSystemWebhookEditor.vue
index a00cf0d9d3..485d003f93 100644
--- a/packages/frontend/src/components/MkSystemWebhookEditor.vue
+++ b/packages/frontend/src/components/MkSystemWebhookEditor.vue
@@ -55,6 +55,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
<MkButton v-show="mode === 'edit'" transparent :class="$style.testButton" :disabled="!(isActive && events.userCreated)" @click="test('userCreated')"><i class="ti ti-send"></i></MkButton>
</div>
+ <div :class="$style.switchBox">
+ <MkSwitch v-model="events.inactiveModeratorsWarning" :disabled="disabledEvents.inactiveModeratorsWarning">
+ <template #label>{{ i18n.ts._webhookSettings._systemEvents.inactiveModeratorsWarning }}</template>
+ </MkSwitch>
+ <MkButton v-show="mode === 'edit'" transparent :class="$style.testButton" :disabled="!(isActive && events.inactiveModeratorsWarning)" @click="test('inactiveModeratorsWarning')"><i class="ti ti-send"></i></MkButton>
+ </div>
+ <div :class="$style.switchBox">
+ <MkSwitch v-model="events.inactiveModeratorsInvitationOnlyChanged" :disabled="disabledEvents.inactiveModeratorsInvitationOnlyChanged">
+ <template #label>{{ i18n.ts._webhookSettings._systemEvents.inactiveModeratorsInvitationOnlyChanged }}</template>
+ </MkSwitch>
+ <MkButton v-show="mode === 'edit'" transparent :class="$style.testButton" :disabled="!(isActive && events.inactiveModeratorsInvitationOnlyChanged)" @click="test('inactiveModeratorsInvitationOnlyChanged')"><i class="ti ti-send"></i></MkButton>
+ </div>
</div>
<div v-show="mode === 'edit'" :class="$style.description">
@@ -100,6 +112,8 @@ type EventType = {
abuseReport: boolean;
abuseReportResolved: boolean;
userCreated: boolean;
+ inactiveModeratorsWarning: boolean;
+ inactiveModeratorsInvitationOnlyChanged: boolean;
}
const emit = defineEmits<{
@@ -123,6 +137,8 @@ const events = ref<EventType>({
abuseReport: true,
abuseReportResolved: true,
userCreated: true,
+ inactiveModeratorsWarning: true,
+ inactiveModeratorsInvitationOnlyChanged: true,
});
const isActive = ref<boolean>(true);
@@ -130,6 +146,8 @@ const disabledEvents = ref<EventType>({
abuseReport: false,
abuseReportResolved: false,
userCreated: false,
+ inactiveModeratorsWarning: false,
+ inactiveModeratorsInvitationOnlyChanged: false,
});
const disableSubmitButton = computed(() => {
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 6494614026..698c08826a 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -5048,7 +5048,7 @@ export type components = {
latestSentAt: string | null;
latestStatus: number | null;
name: string;
- on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
+ on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
url: string;
secret: string;
};
@@ -10249,7 +10249,7 @@ export type operations = {
'application/json': {
isActive: boolean;
name: string;
- on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
+ on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
url: string;
secret: string;
};
@@ -10359,7 +10359,7 @@ export type operations = {
content: {
'application/json': {
isActive?: boolean;
- on?: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
+ on?: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
};
};
};
@@ -10472,7 +10472,7 @@ export type operations = {
id: string;
isActive: boolean;
name: string;
- on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
+ on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
url: string;
secret: string;
};
@@ -10531,7 +10531,7 @@ export type operations = {
/** Format: misskey:id */
webhookId: string;
/** @enum {string} */
- type: 'abuseReport' | 'abuseReportResolved' | 'userCreated';
+ type: 'abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged';
override?: {
url?: string;
secret?: string;