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 0000000000..042553ede2 Binary files /dev/null and b/packages/frontend/assets/mi.png differ 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;