From 3ab953bdf87f28411a1a10bce787a23d238cda80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?= <46447427+samunohito@users.noreply.github.com> Date: Sat, 12 Oct 2024 12:02:41 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=81=8B=E5=96=B6=E3=81=AE=E3=82=A2?= =?UTF-8?q?=E3=82=AF=E3=83=86=E3=82=A3=E3=83=93=E3=83=86=E3=82=A3=E3=81=8C?= =?UTF-8?q?=E4=B8=80=E5=AE=9A=E6=9C=9F=E9=96=93=E3=81=AA=E3=81=84=E5=A0=B4?= =?UTF-8?q?=E5=90=88=E3=81=AF=E9=80=9A=E7=9F=A5=EF=BC=8B=E6=8B=9B=E5=BE=85?= =?UTF-8?q?=E5=88=B6=E3=81=AB=E7=A7=BB=E8=A1=8C=E3=81=97=E3=81=9F=E9=9A=9B?= =?UTF-8?q?=E3=81=AB=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- locales/index.d.ts | 20 ++ locales/ja-JP.yml | 5 + .../backend/src/core/WebhookTestService.ts | 17 ++ .../entities/NotificationEntityService.ts | 7 +- packages/backend/src/models/Notification.ts | 10 + packages/backend/src/models/SystemWebhook.ts | 4 + .../src/models/json-schema/notification.ts | 37 ++++ ...CheckModeratorsActivityProcessorService.ts | 172 +++++++++++++++-- packages/backend/src/types.ts | 4 + ...CheckModeratorsActivityProcessorService.ts | 173 ++++++++++++++++-- packages/frontend/assets/mi.png | Bin 0 -> 23198 bytes .../src/components/MkNotification.vue | 17 ++ .../src/components/MkSystemWebhookEditor.vue | 18 ++ packages/misskey-js/src/autogen/types.ts | 37 +++- packages/misskey-js/src/consts.ts | 2 +- 15 files changed, 486 insertions(+), 37 deletions(-) create mode 100644 packages/frontend/assets/mi.png diff --git a/locales/index.d.ts b/locales/index.d.ts index f0dead1245..24071b9a70 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -9326,6 +9326,18 @@ export interface Locale extends ILocale { * ログインがありました */ "login": string; + /** + * システムからの通知 + */ + "fromSystem": string; + /** + * モデレーターが一定期間非アクティブになっています。{timeVariant}まで非アクティブな状態が続くと招待制に切り替わります。 + */ + "adminInactiveModeratorsWarning": ParameterizedString<"timeVariant">; + /** + * モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されました。 + */ + "adminInactiveModeratorsInvitationOnlyChanged": string; "_types": { /** * すべて @@ -9633,6 +9645,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 0076c467ec..72e93bdb8f 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2462,6 +2462,9 @@ _notification: flushNotification: "通知の履歴をリセットする" exportOfXCompleted: "{x}のエクスポートが完了しました" login: "ログインがありました" + fromSystem: "システムからの通知" + adminInactiveModeratorsWarning: "モデレーターが一定期間非アクティブになっています。{timeVariant}まで非アクティブな状態が続くと招待制に切り替わります。" + adminInactiveModeratorsInvitationOnlyChanged: "モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されました。" _types: all: "すべて" @@ -2552,6 +2555,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/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index dff6968f9c..5763be6907 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -59,7 +59,7 @@ export class NotificationEntityService implements OnModuleInit { async #packInternal ( src: T, meId: MiUser['id'], - + options: { checkValidNotifier?: boolean; }, @@ -174,6 +174,9 @@ export class NotificationEntityService implements OnModuleInit { header: notification.customHeader, icon: notification.customIcon, } : {}), + ...(notification.type === 'adminInactiveModeratorsWarning' ? { + remainingTime: notification.remainingTime, + } : {}), }); } @@ -236,7 +239,7 @@ export class NotificationEntityService implements OnModuleInit { public async pack( src: MiNotification | MiGroupedNotification, meId: MiUser['id'], - + options: { checkValidNotifier?: boolean; }, diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts index b7f8e94d69..d77907e5a7 100644 --- a/packages/backend/src/models/Notification.ts +++ b/packages/backend/src/models/Notification.ts @@ -4,6 +4,7 @@ */ import { userExportableEntities } from '@/types.js'; +import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; import { MiUser } from './User.js'; import { MiNote } from './Note.js'; import { MiAccessToken } from './AccessToken.js'; @@ -116,6 +117,15 @@ export type MiNotification = { * アプリ通知のアプリ(のトークン) */ appAccessTokenId: MiAccessToken['id'] | null; +} | { + type: 'adminInactiveModeratorsWarning'; + id: string; + createdAt: string; + remainingTime: ModeratorInactivityRemainingTime; +} | { + type: 'adminInactiveModeratorsInvitationOnlyChanged'; + id: string; + createdAt: string; } | { type: 'test'; id: string; 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/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index cddaf4bc83..7e4b5a356e 100644 --- a/packages/backend/src/models/json-schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -412,6 +412,43 @@ export const packedNotificationSchema = { }, }, }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['adminInactiveModeratorsWarning'], + }, + remainingTime: { + type: 'object', + properties: { + time: { + type: 'number', + optional: false, nullable: false, + }, + asDays: { + type: 'number', + optional: false, nullable: false, + }, + asHours: { + type: 'number', + optional: false, nullable: false, + }, + }, + }, + }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['adminInactiveModeratorsInvitationOnlyChanged'], + }, + }, }, { type: 'object', properties: { diff --git a/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts index f2677f8e5c..f3d81d8835 100644 --- a/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts +++ b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts @@ -3,24 +3,91 @@ * 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 { NotificationService } from '@/core/NotificationService.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 { 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; +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 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.', + ]; + + const html = message.join('
'); + 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.', + ]; + + const html = message.join('
'); + 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 notificationService: NotificationService, + private emailService: EmailService, + private systemWebhookService: SystemWebhookService, private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('check-moderators-activity'); @@ -42,18 +109,19 @@ 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: 警告メール + await this.notifyInactiveModeratorsWarning(remainingTime); } } } @@ -87,7 +155,7 @@ export class CheckModeratorsActivityProcessorService { * この場合、モデレータA, B, Cのアクティビティは判定基準日よりも古いため、モデレーターが不在と判断される。 */ @bindThis - public async evaluateModeratorsInactiveDays() { + public async evaluateModeratorsInactiveDays(): Promise { const today = new Date(); const inactivePeriod = new Date(today); inactivePeriod.setDate(today.getDate() - MODERATOR_INACTIVITY_LIMIT_DAYS); @@ -101,12 +169,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 +189,78 @@ 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) { + this.notificationService.createNotification( + moderator.id, + 'adminInactiveModeratorsWarning', + { remainingTime: remainingTime }, + ); + + const profile = moderatorProfiles.get(moderator.id); + if (profile && profile.email && profile.emailVerified) { + this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text).then(); + } + } + + // -- 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 }, + ).then(); + } + } + + @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.notificationService.createNotification( + moderator.id, + 'adminInactiveModeratorsInvitationOnlyChanged', + {}, + ); + + const profile = moderatorProfiles.get(moderator.id); + if (profile && profile.email && profile.emailVerified) { + this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text).then(); + } + } + + // -- 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', + {}, + ).then(); + } + } + @bindThis private async fetchModerators() { // TODO: モデレーター以外にも特別な権限を持つユーザーがいる場合は考慮する diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index df3cfee171..2a233100a4 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -19,6 +19,8 @@ * exportCompleted - エクスポートが完了 * login - ログイン * app - アプリ通知 + * adminInactiveModeratorsWarning - [モデレータ以上向け] モデレータの不活性に対する警告 + * adminInactiveModeratorsInvitationOnlyChanged - [モデレータ以上向け] モデレータが不活性のため招待制に変更された * test - テスト通知(サーバー側) */ export const notificationTypes = [ @@ -37,6 +39,8 @@ export const notificationTypes = [ 'exportCompleted', 'login', 'app', + 'adminInactiveModeratorsWarning', + 'adminInactiveModeratorsInvitationOnlyChanged', 'test', ] as const; diff --git a/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts index b783320aa0..684d123855 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 { NotificationService } from '@/core/NotificationService.js'; +import { EmailService } from '@/core/EmailService.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.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; + let notificationService: jest.Mocked; + let emailService: jest.Mocked; + let systemWebhookService: jest.Mocked; + + let systemWebhook1: MiSystemWebhook; + let systemWebhook2: MiSystemWebhook; + let systemWebhook3: MiSystemWebhook; // -------------------------------------------------------------------------------------- - async function createUser(data: Partial = {}) { + async function createUser(data: Partial = {}, profile: Partial = {}): Promise { 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 { + 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: NotificationService, useFactory: () => ({ createNotification: 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; + notificationService = app.get(NotificationService) as jest.Mocked; + emailService = app.get(EmailService) as jest.Mocked; + systemWebhookService = app.get(SystemWebhookService) as jest.Mocked; app.enableShutdownHooks(); }); @@ -102,6 +143,14 @@ 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()); + systemWebhookService.fetchActiveSystemWebhooks.mockResolvedValue([systemWebhook1, systemWebhook2, systemWebhook3]); + systemWebhookService.enqueueSystemWebhook.mockReturnValue(Promise.resolve({} as never)); }); afterEach(async () => { @@ -109,6 +158,9 @@ describe('CheckModeratorsActivityProcessorService', () => { await usersRepository.delete({}); await userProfilesRepository.delete({}); roleService.getModerators.mockReset(); + notificationService.createNotification.mockReset(); + emailService.sendEmail.mockReset(); + systemWebhookService.enqueueSystemWebhook.mockReset(); }); afterAll(async () => { @@ -152,7 +204,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 +217,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 +234,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 +251,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 +268,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 +285,100 @@ 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(notificationService.createNotification).toHaveBeenCalledTimes(4); + expect(notificationService.createNotification.mock.calls[0][0]).toBe(user1.id); + expect(notificationService.createNotification.mock.calls[1][0]).toBe(user2.id); + expect(notificationService.createNotification.mock.calls[2][0]).toBe(user3.id); + expect(notificationService.createNotification.mock.calls[3][0]).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] "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(notificationService.createNotification).toHaveBeenCalledTimes(4); + expect(notificationService.createNotification.mock.calls[0][0]).toBe(user1.id); + expect(notificationService.createNotification.mock.calls[1][0]).toBe(user2.id); + expect(notificationService.createNotification.mock.calls[2][0]).toBe(user3.id); + expect(notificationService.createNotification.mock.calls[3][0]).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/assets/mi.png b/packages/frontend/assets/mi.png new file mode 100644 index 0000000000000000000000000000000000000000..042553ede2b7c089cb02234dc000b5bcd7764be0 GIT binary patch literal 23198 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 R|6-*d-Z#{)y8Arxe*jDQA&me4 literal 0 HcmV?d00001 diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index bef425097e..21cacbb05f 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -11,6 +11,8 @@ SPDX-License-Identifier: AGPL-3.0-only
+ + @@ -67,6 +69,8 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.tsx._notification.likedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }} {{ i18n.tsx._notification.reactedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }} {{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }} + {{ i18n.ts._notification.fromSystem }} + {{ i18n.ts._notification.fromSystem }} {{ notification.header }} @@ -130,6 +134,19 @@ SPDX-License-Identifier: AGPL-3.0-only + + {{ + i18n.tsx._notification.adminInactiveModeratorsWarning({ + timeVariant: notification.remainingTime.asDays === 0 + ? i18n.tsx._timeIn.hours({ n: notification.remainingTime.asHours }) + : i18n.tsx._timeIn.days({ n: notification.remainingTime.asDays }) + }) + }} + + + {{ i18n.ts._notification.adminInactiveModeratorsInvitationOnlyChanged }} + +
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
+
+ + + + +
+
+ + + + +
@@ -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({ abuseReport: true, abuseReportResolved: true, userCreated: true, + inactiveModeratorsWarning: true, + inactiveModeratorsInvitationOnlyChanged: true, }); const isActive = ref(true); @@ -130,6 +146,8 @@ const disabledEvents = ref({ 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 76ef7ea1fb..ba7410ccf3 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4347,6 +4347,25 @@ export type components = { type: 'renote:grouped'; note: components['schemas']['Note']; users: components['schemas']['UserLite'][]; + } | { + /** Format: id */ + id: string; + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + type: 'adminInactiveModeratorsWarning'; + remainingTime: { + time: number; + asDays: number; + asHours: number; + }; + } | { + /** Format: id */ + id: string; + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + type: 'adminInactiveModeratorsInvitationOnlyChanged'; } | { /** Format: id */ id: string; @@ -5047,7 +5066,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; }; @@ -10242,7 +10261,7 @@ export type operations = { 'application/json': { isActive: boolean; name: string; - on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[]; + on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[]; url: string; secret: string; }; @@ -10352,7 +10371,7 @@ export type operations = { content: { 'application/json': { isActive?: boolean; - on?: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[]; + on?: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[]; }; }; }; @@ -10465,7 +10484,7 @@ export type operations = { id: string; isActive: boolean; name: string; - on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[]; + on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[]; url: string; secret: string; }; @@ -10524,7 +10543,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; @@ -18686,8 +18705,8 @@ export type operations = { untilId?: string; /** @default true */ markAsRead?: boolean; - includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'pollVote' | 'groupInvited')[]; - excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'pollVote' | 'groupInvited')[]; + includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'adminInactiveModeratorsWarning' | 'adminInactiveModeratorsInvitationOnlyChanged' | 'test' | 'pollVote' | 'groupInvited')[]; + excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'adminInactiveModeratorsWarning' | 'adminInactiveModeratorsInvitationOnlyChanged' | 'test' | 'pollVote' | 'groupInvited')[]; }; }; }; @@ -18754,8 +18773,8 @@ export type operations = { untilId?: string; /** @default true */ markAsRead?: boolean; - includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[]; - excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[]; + includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'adminInactiveModeratorsWarning' | 'adminInactiveModeratorsInvitationOnlyChanged' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[]; + excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'login' | 'app' | 'adminInactiveModeratorsWarning' | 'adminInactiveModeratorsInvitationOnlyChanged' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[]; }; }; }; diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts index c5911a70eb..ce56f21746 100644 --- a/packages/misskey-js/src/consts.ts +++ b/packages/misskey-js/src/consts.ts @@ -16,7 +16,7 @@ import type { UserLite, } from './autogen/models.js'; -export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'roleAssigned', 'achievementEarned'] as const; +export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'roleAssigned', 'achievementEarned', 'adminInactiveModeratorsWarning', 'adminInactiveModeratorsInvitationOnlyChanged'] as const; export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;