Skip to content

Commit

Permalink
feat: 運営のアクティビティが一定期間ない場合は通知+招待制に移行した際に通知 (#14757)
Browse files Browse the repository at this point in the history
* feat: 運営のアクティビティが一定期間ない場合は通知+招待制に移行した際に通知

* fix misskey-js.api.md

* Revert "feat: 運営のアクティビティが一定期間ない場合は通知+招待制に移行した際に通知"

This reverts commit 3ab953b.

* 通知をやめてユーザ単位でのお知らせ機能に変更

* テスト用実装を戻す

* Update packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts

Co-authored-by: syuilo <[email protected]>

* fix remove empty then

---------

Co-authored-by: syuilo <[email protected]>
  • Loading branch information
samunohito and syuilo authored Oct 13, 2024
1 parent 5229f5d commit 33b34ad
Show file tree
Hide file tree
Showing 8 changed files with 388 additions and 30 deletions.
8 changes: 8 additions & 0 deletions locales/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9661,6 +9661,14 @@ export interface Locale extends ILocale {
* ユーザーが作成されたとき
*/
"userCreated": string;
/**
* モデレーターが一定期間非アクティブになったとき
*/
"inactiveModeratorsWarning": string;
/**
* モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき
*/
"inactiveModeratorsInvitationOnlyChanged": string;
};
/**
* Webhookを削除しますか?
Expand Down
2 changes: 2 additions & 0 deletions locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2559,6 +2559,8 @@ _webhookSettings:
abuseReport: "ユーザーから通報があったとき"
abuseReportResolved: "ユーザーからの通報を処理したとき"
userCreated: "ユーザーが作成されたとき"
inactiveModeratorsWarning: "モデレーターが一定期間非アクティブになったとき"
inactiveModeratorsInvitationOnlyChanged: "モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき"
deleteConfirm: "Webhookを削除しますか?"
testRemarks: "スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。"

Expand Down
17 changes: 17 additions & 0 deletions packages/backend/src/core/WebhookTestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
}
}
}
4 changes: 4 additions & 0 deletions packages/backend/src/models/SystemWebhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export const systemWebhookEventTypes = [
'abuseReportResolved',
// ユーザが作成された時
'userCreated',
// モデレータが一定期間不在である警告
'inactiveModeratorsWarning',
// モデレータが一定期間不在のためシステムにより招待制へと変更された
'inactiveModeratorsInvitationOnlyChanged',
] as const;
export type SystemWebhookEventType = typeof systemWebhookEventTypes[number];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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);
}
}
}
}
Expand Down Expand Up @@ -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);
Expand All @@ -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,
},
};
}

Expand All @@ -115,6 +212,74 @@ export class CheckModeratorsActivityProcessorService {
await this.metaService.update({ disableRegistration: true });
}

@bindThis
public async notifyInactiveModeratorsWarning(remainingTime: ModeratorInactivityRemainingTime) {
// -- モデレータへのメール送信

const moderators = await this.fetchModerators();
const moderatorProfiles = await this.userProfilesRepository
.findBy({ userId: In(moderators.map(it => it.id)) })
.then(it => new Map(it.map(it => [it.userId, it])));

const mail = generateModeratorInactivityMail(remainingTime);
for (const moderator of moderators) {
const profile = moderatorProfiles.get(moderator.id);
if (profile && profile.email && profile.emailVerified) {
this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text);
}
}

// -- SystemWebhook

const systemWebhooks = await this.systemWebhookService.fetchActiveSystemWebhooks()
.then(it => it.filter(it => it.on.includes('inactiveModeratorsWarning')));
for (const systemWebhook of systemWebhooks) {
this.systemWebhookService.enqueueSystemWebhook(
systemWebhook,
'inactiveModeratorsWarning',
{ remainingTime: remainingTime },
);
}
}

@bindThis
public async notifyChangeToInvitationOnly() {
// -- モデレータへのメールとお知らせ(個人向け)送信

const moderators = await this.fetchModerators();
const moderatorProfiles = await this.userProfilesRepository
.findBy({ userId: In(moderators.map(it => it.id)) })
.then(it => new Map(it.map(it => [it.userId, it])));

const mail = generateInvitationOnlyChangedMail();
for (const moderator of moderators) {
this.announcementService.create({
title: mail.subject,
text: mail.text,
forExistingUsers: true,
needConfirmationToRead: true,
userId: moderator.id,
});

const profile = moderatorProfiles.get(moderator.id);
if (profile && profile.email && profile.emailVerified) {
this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text);
}
}

// -- SystemWebhook

const systemWebhooks = await this.systemWebhookService.fetchActiveSystemWebhooks()
.then(it => it.filter(it => it.on.includes('inactiveModeratorsInvitationOnlyChanged')));
for (const systemWebhook of systemWebhooks) {
this.systemWebhookService.enqueueSystemWebhook(
systemWebhook,
'inactiveModeratorsInvitationOnlyChanged',
{},
);
}
}

@bindThis
private async fetchModerators() {
// TODO: モデレーター以外にも特別な権限を持つユーザーがいる場合は考慮する
Expand Down
Loading

0 comments on commit 33b34ad

Please sign in to comment.