Skip to content

Commit 33b34ad

Browse files
samunohitosyuilo
andauthored
feat: 運営のアクティビティが一定期間ない場合は通知+招待制に移行した際に通知 (#14757)
* feat: 運営のアクティビティが一定期間ない場合は通知+招待制に移行した際に通知 * fix misskey-js.api.md * Revert "feat: 運営のアクティビティが一定期間ない場合は通知+招待制に移行した際に通知" This reverts commit 3ab953b. * 通知をやめてユーザ単位でのお知らせ機能に変更 * テスト用実装を戻す * Update packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> * fix remove empty then --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
1 parent 5229f5d commit 33b34ad

File tree

8 files changed

+388
-30
lines changed

8 files changed

+388
-30
lines changed

locales/index.d.ts

+8
Original file line numberDiff line numberDiff line change
@@ -9661,6 +9661,14 @@ export interface Locale extends ILocale {
96619661
* ユーザーが作成されたとき
96629662
*/
96639663
"userCreated": string;
9664+
/**
9665+
* モデレーターが一定期間非アクティブになったとき
9666+
*/
9667+
"inactiveModeratorsWarning": string;
9668+
/**
9669+
* モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき
9670+
*/
9671+
"inactiveModeratorsInvitationOnlyChanged": string;
96649672
};
96659673
/**
96669674
* Webhookを削除しますか?

locales/ja-JP.yml

+2
Original file line numberDiff line numberDiff line change
@@ -2559,6 +2559,8 @@ _webhookSettings:
25592559
abuseReport: "ユーザーから通報があったとき"
25602560
abuseReportResolved: "ユーザーからの通報を処理したとき"
25612561
userCreated: "ユーザーが作成されたとき"
2562+
inactiveModeratorsWarning: "モデレーターが一定期間非アクティブになったとき"
2563+
inactiveModeratorsInvitationOnlyChanged: "モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき"
25622564
deleteConfirm: "Webhookを削除しますか?"
25632565
testRemarks: "スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。"
25642566

packages/backend/src/core/WebhookTestService.ts

+17
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Packed } from '@/misc/json-schema.js';
1212
import { type WebhookEventTypes } from '@/models/Webhook.js';
1313
import { UserWebhookService } from '@/core/UserWebhookService.js';
1414
import { QueueService } from '@/core/QueueService.js';
15+
import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
1516

1617
const oneDayMillis = 24 * 60 * 60 * 1000;
1718

@@ -446,6 +447,22 @@ export class WebhookTestService {
446447
send(toPackedUserLite(dummyUser1));
447448
break;
448449
}
450+
case 'inactiveModeratorsWarning': {
451+
const dummyTime: ModeratorInactivityRemainingTime = {
452+
time: 100000,
453+
asDays: 1,
454+
asHours: 24,
455+
};
456+
457+
send({
458+
remainingTime: dummyTime,
459+
});
460+
break;
461+
}
462+
case 'inactiveModeratorsInvitationOnlyChanged': {
463+
send({});
464+
break;
465+
}
449466
}
450467
}
451468
}

packages/backend/src/models/SystemWebhook.ts

+4
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ export const systemWebhookEventTypes = [
1414
'abuseReportResolved',
1515
// ユーザが作成された時
1616
'userCreated',
17+
// モデレータが一定期間不在である警告
18+
'inactiveModeratorsWarning',
19+
// モデレータが一定期間不在のためシステムにより招待制へと変更された
20+
'inactiveModeratorsInvitationOnlyChanged',
1721
] as const;
1822
export type SystemWebhookEventType = typeof systemWebhookEventTypes[number];
1923

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

+178-13
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,110 @@
33
* SPDX-License-Identifier: AGPL-3.0-only
44
*/
55

6-
import { Injectable } from '@nestjs/common';
6+
import { Inject, Injectable } from '@nestjs/common';
7+
import { In } from 'typeorm';
78
import type Logger from '@/logger.js';
89
import { bindThis } from '@/decorators.js';
910
import { MetaService } from '@/core/MetaService.js';
1011
import { RoleService } from '@/core/RoleService.js';
12+
import { EmailService } from '@/core/EmailService.js';
13+
import { MiUser, type UserProfilesRepository } from '@/models/_.js';
14+
import { DI } from '@/di-symbols.js';
15+
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
16+
import { AnnouncementService } from '@/core/AnnouncementService.js';
1117
import { QueueLoggerService } from '../QueueLoggerService.js';
1218

1319
// モデレーターが不在と判断する日付の閾値
1420
const MODERATOR_INACTIVITY_LIMIT_DAYS = 7;
15-
const ONE_DAY_MILLI_SEC = 1000 * 60 * 60 * 24;
21+
// 警告通知やログ出力を行う残日数の閾値
22+
const MODERATOR_INACTIVITY_WARNING_REMAINING_DAYS = 2;
23+
// 期限から6時間ごとに通知を行う
24+
const MODERATOR_INACTIVITY_WARNING_NOTIFY_INTERVAL_HOURS = 6;
25+
const ONE_HOUR_MILLI_SEC = 1000 * 60 * 60;
26+
const ONE_DAY_MILLI_SEC = ONE_HOUR_MILLI_SEC * 24;
27+
28+
export type ModeratorInactivityEvaluationResult = {
29+
isModeratorsInactive: boolean;
30+
inactiveModerators: MiUser[];
31+
remainingTime: ModeratorInactivityRemainingTime;
32+
}
33+
34+
export type ModeratorInactivityRemainingTime = {
35+
time: number;
36+
asHours: number;
37+
asDays: number;
38+
};
39+
40+
function generateModeratorInactivityMail(remainingTime: ModeratorInactivityRemainingTime) {
41+
const subject = 'Moderator Inactivity Warning / モデレーター不在の通知';
42+
43+
const timeVariant = remainingTime.asDays === 0 ? `${remainingTime.asHours} hours` : `${remainingTime.asDays} days`;
44+
const timeVariantJa = remainingTime.asDays === 0 ? `${remainingTime.asHours} 時間` : `${remainingTime.asDays} 日間`;
45+
const message = [
46+
'To Moderators,',
47+
'',
48+
`A moderator has been inactive for a period of time. If there are ${timeVariant} of inactivity left, it will switch to invitation only.`,
49+
'If you do not wish to move to invitation only, you must log into Misskey and update your last active date and time.',
50+
'',
51+
'---------------',
52+
'',
53+
'To モデレーター各位',
54+
'',
55+
`モデレーターが一定期間活動していないようです。あと${timeVariantJa}活動していない状態が続くと招待制に切り替わります。`,
56+
'招待制に切り替わることを望まない場合は、Misskeyにログインして最終アクティブ日時を更新してください。',
57+
'',
58+
];
59+
60+
const html = message.join('<br>');
61+
const text = message.join('\n');
62+
63+
return {
64+
subject,
65+
html,
66+
text,
67+
};
68+
}
69+
70+
function generateInvitationOnlyChangedMail() {
71+
const subject = 'Change to Invitation-Only / 招待制に変更されました';
72+
73+
const message = [
74+
'To Moderators,',
75+
'',
76+
`Changed to invitation only because no moderator activity was detected for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days.`,
77+
'To cancel the invitation only, you need to access the control panel.',
78+
'',
79+
'---------------',
80+
'',
81+
'To モデレーター各位',
82+
'',
83+
`モデレーターの活動が${MODERATOR_INACTIVITY_LIMIT_DAYS}日間検出されなかったため、招待制に変更されました。`,
84+
'招待制を解除するには、コントロールパネルにアクセスする必要があります。',
85+
'',
86+
];
87+
88+
const html = message.join('<br>');
89+
const text = message.join('\n');
90+
91+
return {
92+
subject,
93+
html,
94+
text,
95+
};
96+
}
1697

1798
@Injectable()
1899
export class CheckModeratorsActivityProcessorService {
19100
private logger: Logger;
20101

21102
constructor(
103+
@Inject(DI.userProfilesRepository)
104+
private userProfilesRepository: UserProfilesRepository,
22105
private metaService: MetaService,
23106
private roleService: RoleService,
107+
private emailService: EmailService,
108+
private announcementService: AnnouncementService,
109+
private systemWebhookService: SystemWebhookService,
24110
private queueLoggerService: QueueLoggerService,
25111
) {
26112
this.logger = this.queueLoggerService.logger.createSubLogger('check-moderators-activity');
@@ -42,18 +128,23 @@ export class CheckModeratorsActivityProcessorService {
42128

43129
@bindThis
44130
private async processImpl() {
45-
const { isModeratorsInactive, inactivityLimitCountdown } = await this.evaluateModeratorsInactiveDays();
46-
if (isModeratorsInactive) {
131+
const evaluateResult = await this.evaluateModeratorsInactiveDays();
132+
if (evaluateResult.isModeratorsInactive) {
47133
this.logger.warn(`The moderator has been inactive for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days. We will move to invitation only.`);
48-
await this.changeToInvitationOnly();
49134

50-
// TODO: モデレータに通知メール+Misskey通知
51-
// TODO: SystemWebhook通知
135+
await this.changeToInvitationOnly();
136+
await this.notifyChangeToInvitationOnly();
52137
} else {
53-
if (inactivityLimitCountdown <= 2) {
54-
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.`);
138+
const remainingTime = evaluateResult.remainingTime;
139+
if (remainingTime.asDays <= MODERATOR_INACTIVITY_WARNING_REMAINING_DAYS) {
140+
const timeVariant = remainingTime.asDays === 0 ? `${remainingTime.asHours} hours` : `${remainingTime.asDays} days`;
141+
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.`);
55142

56-
// TODO: 警告メール
143+
if (remainingTime.asHours % MODERATOR_INACTIVITY_WARNING_NOTIFY_INTERVAL_HOURS === 0) {
144+
// ジョブの実行頻度と同等だと通知が多すぎるため期限から6時間ごとに通知する
145+
// つまり、のこり2日を切ったら6時間ごとに通知が送られる
146+
await this.notifyInactiveModeratorsWarning(remainingTime);
147+
}
57148
}
58149
}
59150
}
@@ -87,7 +178,7 @@ export class CheckModeratorsActivityProcessorService {
87178
* この場合、モデレータA, B, Cのアクティビティは判定基準日よりも古いため、モデレーターが不在と判断される。
88179
*/
89180
@bindThis
90-
public async evaluateModeratorsInactiveDays() {
181+
public async evaluateModeratorsInactiveDays(): Promise<ModeratorInactivityEvaluationResult> {
91182
const today = new Date();
92183
const inactivePeriod = new Date(today);
93184
inactivePeriod.setDate(today.getDate() - MODERATOR_INACTIVITY_LIMIT_DAYS);
@@ -101,12 +192,18 @@ export class CheckModeratorsActivityProcessorService {
101192
// 残りの猶予を示したいので、最終アクティブ日時が一番若いモデレータの日数を基準に猶予を計算する
102193
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
103194
const newestLastActiveDate = new Date(Math.max(...moderators.map(it => it.lastActiveDate!.getTime())));
104-
const inactivityLimitCountdown = Math.floor((newestLastActiveDate.getTime() - inactivePeriod.getTime()) / ONE_DAY_MILLI_SEC);
195+
const remainingTime = newestLastActiveDate.getTime() - inactivePeriod.getTime();
196+
const remainingTimeAsDays = Math.floor(remainingTime / ONE_DAY_MILLI_SEC);
197+
const remainingTimeAsHours = Math.floor((remainingTime / ONE_HOUR_MILLI_SEC));
105198

106199
return {
107200
isModeratorsInactive: inactiveModerators.length === moderators.length,
108201
inactiveModerators,
109-
inactivityLimitCountdown,
202+
remainingTime: {
203+
time: remainingTime,
204+
asHours: remainingTimeAsHours,
205+
asDays: remainingTimeAsDays,
206+
},
110207
};
111208
}
112209

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

215+
@bindThis
216+
public async notifyInactiveModeratorsWarning(remainingTime: ModeratorInactivityRemainingTime) {
217+
// -- モデレータへのメール送信
218+
219+
const moderators = await this.fetchModerators();
220+
const moderatorProfiles = await this.userProfilesRepository
221+
.findBy({ userId: In(moderators.map(it => it.id)) })
222+
.then(it => new Map(it.map(it => [it.userId, it])));
223+
224+
const mail = generateModeratorInactivityMail(remainingTime);
225+
for (const moderator of moderators) {
226+
const profile = moderatorProfiles.get(moderator.id);
227+
if (profile && profile.email && profile.emailVerified) {
228+
this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text);
229+
}
230+
}
231+
232+
// -- SystemWebhook
233+
234+
const systemWebhooks = await this.systemWebhookService.fetchActiveSystemWebhooks()
235+
.then(it => it.filter(it => it.on.includes('inactiveModeratorsWarning')));
236+
for (const systemWebhook of systemWebhooks) {
237+
this.systemWebhookService.enqueueSystemWebhook(
238+
systemWebhook,
239+
'inactiveModeratorsWarning',
240+
{ remainingTime: remainingTime },
241+
);
242+
}
243+
}
244+
245+
@bindThis
246+
public async notifyChangeToInvitationOnly() {
247+
// -- モデレータへのメールとお知らせ(個人向け)送信
248+
249+
const moderators = await this.fetchModerators();
250+
const moderatorProfiles = await this.userProfilesRepository
251+
.findBy({ userId: In(moderators.map(it => it.id)) })
252+
.then(it => new Map(it.map(it => [it.userId, it])));
253+
254+
const mail = generateInvitationOnlyChangedMail();
255+
for (const moderator of moderators) {
256+
this.announcementService.create({
257+
title: mail.subject,
258+
text: mail.text,
259+
forExistingUsers: true,
260+
needConfirmationToRead: true,
261+
userId: moderator.id,
262+
});
263+
264+
const profile = moderatorProfiles.get(moderator.id);
265+
if (profile && profile.email && profile.emailVerified) {
266+
this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text);
267+
}
268+
}
269+
270+
// -- SystemWebhook
271+
272+
const systemWebhooks = await this.systemWebhookService.fetchActiveSystemWebhooks()
273+
.then(it => it.filter(it => it.on.includes('inactiveModeratorsInvitationOnlyChanged')));
274+
for (const systemWebhook of systemWebhooks) {
275+
this.systemWebhookService.enqueueSystemWebhook(
276+
systemWebhook,
277+
'inactiveModeratorsInvitationOnlyChanged',
278+
{},
279+
);
280+
}
281+
}
282+
118283
@bindThis
119284
private async fetchModerators() {
120285
// TODO: モデレーター以外にも特別な権限を持つユーザーがいる場合は考慮する

0 commit comments

Comments
 (0)