From 12bc671511f301598d57635a7125157b963a1973 Mon Sep 17 00:00:00 2001
From: FineArchs <133759614+FineArchs@users.noreply.github.com>
Date: Fri, 11 Oct 2024 17:17:45 +0900
Subject: [PATCH 01/34] =?UTF-8?q?fix:=20admin/emoji/update=20=E3=81=A7?=
 =?UTF-8?q?=E4=B8=8D=E6=AD=A3=E3=81=AA=E3=82=A8=E3=83=A9=E3=83=BC=E3=81=8C?=
 =?UTF-8?q?=E7=99=BA=E7=94=9F=E3=81=99=E3=82=8B=20(#14750)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* fix emoji updating bug

* update changelog

* type fix

* " -> '

* conprehensiveness check

* lint

* undefined -> null
---
 CHANGELOG.md                                  |  3 ++
 .../backend/src/core/CustomEmojiService.ts    | 29 ++++++++++++----
 .../api/endpoints/admin/emoji/update.ts       | 33 +++++++++----------
 3 files changed, 40 insertions(+), 25 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index ee37bb3c6f..b449a1b91e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,9 @@
 - Enhance: l10nの更新
 - Fix: メールアドレス不要でCaptchaが有効な場合にアカウント登録完了後自動でのログインに失敗する問題を修正
 
+### Server
+- Fix: `admin/emoji/update`エンドポイントのidのみ指定した時不正なエラーが発生するバグを修正
+
 ## 2024.10.0
 
 ### Note
diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts
index 5db3c5b980..4566113449 100644
--- a/packages/backend/src/core/CustomEmojiService.ts
+++ b/packages/backend/src/core/CustomEmojiService.ts
@@ -103,19 +103,33 @@ export class CustomEmojiService implements OnApplicationShutdown {
 	}
 
 	@bindThis
-	public async update(id: MiEmoji['id'], data: {
+	public async update(data: (
+		{ id: MiEmoji['id'], name?: string; } | { name: string; id?: MiEmoji['id'], }
+	) & {
 		driveFile?: MiDriveFile;
-		name?: string;
 		category?: string | null;
 		aliases?: string[];
 		license?: string | null;
 		isSensitive?: boolean;
 		localOnly?: boolean;
 		roleIdsThatCanBeUsedThisEmojiAsReaction?: MiRole['id'][];
-	}, moderator?: MiUser): Promise<void> {
-		const emoji = await this.emojisRepository.findOneByOrFail({ id: id });
-		const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() });
-		if (sameNameEmoji != null && sameNameEmoji.id !== id) throw new Error('name already exists');
+	}, moderator?: MiUser): Promise<
+		null
+		| 'NO_SUCH_EMOJI'
+		| 'SAME_NAME_EMOJI_EXISTS'
+	> {
+		const emoji = data.id
+			? await this.getEmojiById(data.id)
+			: await this.getEmojiByName(data.name!);
+		if (emoji === null) return 'NO_SUCH_EMOJI';
+		const id = emoji.id;
+
+		// IDと絵文字名が両方指定されている場合は絵文字名の変更を行うため重複チェックが必要
+		const doNameUpdate = data.id && data.name && (data.name !== emoji.name);
+		if (doNameUpdate) {
+			const isDuplicate = await this.checkDuplicate(data.name!);
+			if (isDuplicate) return 'SAME_NAME_EMOJI_EXISTS';
+		}
 
 		await this.emojisRepository.update(emoji.id, {
 			updatedAt: new Date(),
@@ -135,7 +149,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
 
 		const packed = await this.emojiEntityService.packDetailed(emoji.id);
 
-		if (emoji.name === data.name) {
+		if (!doNameUpdate) {
 			this.globalEventService.publishBroadcastStream('emojiUpdated', {
 				emojis: [packed],
 			});
@@ -157,6 +171,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
 				after: updated,
 			});
 		}
+		return null;
 	}
 
 	@bindThis
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts
index 22609a16a3..212cba5c5d 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts
@@ -6,7 +6,7 @@
 import { Inject, Injectable } from '@nestjs/common';
 import { Endpoint } from '@/server/api/endpoint-base.js';
 import { CustomEmojiService } from '@/core/CustomEmojiService.js';
-import type { DriveFilesRepository } from '@/models/_.js';
+import type { DriveFilesRepository, MiEmoji } from '@/models/_.js';
 import { DI } from '@/di-symbols.js';
 import { ApiError } from '../../../error.js';
 
@@ -78,25 +78,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
 			}
 
-			let emojiId;
-			if (ps.id) {
-				emojiId = ps.id;
-				const emoji = await this.customEmojiService.getEmojiById(ps.id);
-				if (!emoji) throw new ApiError(meta.errors.noSuchEmoji);
-				if (ps.name && (ps.name !== emoji.name)) {
-					const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name);
-					if (isDuplicate) throw new ApiError(meta.errors.sameNameEmojiExists);
-				}
-			} else {
-				if (!ps.name) throw new Error('Invalid Params unexpectedly passed. This is a BUG. Please report it to the development team.');
-				const emoji = await this.customEmojiService.getEmojiByName(ps.name);
-				if (!emoji) throw new ApiError(meta.errors.noSuchEmoji);
-				emojiId = emoji.id;
-			}
+			// JSON schemeのanyOfの型変換がうまくいっていないらしい
+			const required = { id: ps.id, name: ps.name } as 
+				| { id: MiEmoji['id']; name?: string }
+				| { id?: MiEmoji['id']; name: string };
 
-			await this.customEmojiService.update(emojiId, {
+			const error = await this.customEmojiService.update({
+				...required,
 				driveFile,
-				name: ps.name,
 				category: ps.category,
 				aliases: ps.aliases,
 				license: ps.license,
@@ -104,6 +93,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				localOnly: ps.localOnly,
 				roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction,
 			}, me);
+
+			switch (error) {
+				case null: return;
+				case 'NO_SUCH_EMOJI': throw new ApiError(meta.errors.noSuchEmoji);
+				case 'SAME_NAME_EMOJI_EXISTS': throw new ApiError(meta.errors.sameNameEmojiExists);
+			}
+			// 網羅性チェック
+			const mustBeNever: never = error;
 		});
 	}
 }

From a2cd6a7709ffacfabb738deac22cb0fd1eb7d493 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: Fri, 11 Oct 2024 20:59:36 +0900
Subject: [PATCH 02/34] =?UTF-8?q?feat(backend):=207=E6=97=A5=E9=96=93?=
 =?UTF-8?q?=E9=81=8B=E5=96=B6=E3=81=AE=E3=82=A2=E3=82=AF=E3=83=86=E3=82=A3?=
 =?UTF-8?q?=E3=83=93=E3=83=86=E3=82=A3=E3=81=8C=E3=81=AA=E3=81=84=E3=82=B5?=
 =?UTF-8?q?=E3=83=BC=E3=83=90=E3=82=92=E8=87=AA=E5=8B=95=E7=9A=84=E3=81=AB?=
 =?UTF-8?q?=E6=8B=9B=E5=BE=85=E5=88=B6=E3=81=AB=E3=81=99=E3=82=8B=20(#1474?=
 =?UTF-8?q?6)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* feat(backend): 7日間運営のアクティビティがないサーバを自動的に招待制にする

* fix RoleService.

* fix

* fix

* fix

* add test and fix

* fix

* fix CHANGELOG.md

* fix test
---
 CHANGELOG.md                                  |   5 +
 .../core/AbuseReportNotificationService.ts    |  10 +-
 packages/backend/src/core/QueueService.ts     |   7 +
 packages/backend/src/core/RoleService.ts      |  75 ++++--
 .../backend/src/queue/QueueProcessorModule.ts |   3 +
 .../src/queue/QueueProcessorService.ts        |   3 +
 ...CheckModeratorsActivityProcessorService.ts | 127 ++++++++++
 .../server/api/endpoints/admin/show-users.ts  |   4 +-
 packages/backend/test/unit/RoleService.ts     | 150 +++++++++--
 ...CheckModeratorsActivityProcessorService.ts | 235 ++++++++++++++++++
 10 files changed, 575 insertions(+), 44 deletions(-)
 create mode 100644 packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts
 create mode 100644 packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b449a1b91e..030dbfda28 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,10 +1,15 @@
 ## 2024.10.1
+### Note
+- 悪質なユーザからサーバを守る措置の一環として、モデレータ権限を持つユーザの最終アクティブ日時を確認し、 
+7日間活動していない場合は自動的に招待制へと移行(コントロールパネル -> モデレーション -> "誰でも新規登録できるようにする"をオフに変更)するようになりました。  
+詳細な経緯は https://github.com/misskey-dev/misskey/issues/13437 をご確認ください。
 
 ### Client
 - Enhance: l10nの更新
 - Fix: メールアドレス不要でCaptchaが有効な場合にアカウント登録完了後自動でのログインに失敗する問題を修正
 
 ### Server
+- Feat: モデレータ権限を持つユーザが全員7日間活動しなかった場合は自動的に招待制へと移行するように ( #13437 )
 - Fix: `admin/emoji/update`エンドポイントのidのみ指定した時不正なエラーが発生するバグを修正
 
 ## 2024.10.0
diff --git a/packages/backend/src/core/AbuseReportNotificationService.ts b/packages/backend/src/core/AbuseReportNotificationService.ts
index fb7c7bd2c3..7d030f2f16 100644
--- a/packages/backend/src/core/AbuseReportNotificationService.ts
+++ b/packages/backend/src/core/AbuseReportNotificationService.ts
@@ -61,7 +61,10 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
 			return;
 		}
 
-		const moderatorIds = await this.roleService.getModeratorIds(true, true);
+		const moderatorIds = await this.roleService.getModeratorIds({
+			includeAdmins: true,
+			excludeExpire: true,
+		});
 
 		for (const moderatorId of moderatorIds) {
 			for (const abuseReport of abuseReports) {
@@ -370,7 +373,10 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
 		}
 
 		// モデレータ権限の有無で通知先設定を振り分ける
-		const authorizedUserIds = await this.roleService.getModeratorIds(true, true);
+		const authorizedUserIds = await this.roleService.getModeratorIds({
+			includeAdmins: true,
+			excludeExpire: true,
+		});
 		const authorizedUserRecipients = Array.of<MiAbuseReportNotificationRecipient>();
 		const unauthorizedUserRecipients = Array.of<MiAbuseReportNotificationRecipient>();
 		for (const recipient of userRecipients) {
diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts
index f35e456556..37028026cc 100644
--- a/packages/backend/src/core/QueueService.ts
+++ b/packages/backend/src/core/QueueService.ts
@@ -93,6 +93,13 @@ export class QueueService {
 			repeat: { pattern: '0 0 * * *' },
 			removeOnComplete: true,
 		});
+
+		this.systemQueue.add('checkModeratorsActivity', {
+		}, {
+			// 毎時30分に起動
+			repeat: { pattern: '30 * * * *' },
+			removeOnComplete: true,
+		});
 	}
 
 	@bindThis
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 583eea1a34..5af6b05942 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -101,6 +101,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
 
 @Injectable()
 export class RoleService implements OnApplicationShutdown, OnModuleInit {
+	private rootUserIdCache: MemorySingleCache<MiUser['id']>;
 	private rolesCache: MemorySingleCache<MiRole[]>;
 	private roleAssignmentByUserIdCache: MemoryKVCache<MiRoleAssignment[]>;
 	private notificationService: NotificationService;
@@ -136,6 +137,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
 		private moderationLogService: ModerationLogService,
 		private fanoutTimelineService: FanoutTimelineService,
 	) {
+		this.rootUserIdCache = new MemorySingleCache<MiUser['id']>(1000 * 60 * 60 * 24 * 7); // 1week. rootユーザのIDは不変なので長めに
 		this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h
 		this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m
 
@@ -416,49 +418,78 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
 	}
 
 	@bindThis
-	public async isExplorable(role: { id: MiRole['id']} | null): Promise<boolean> {
+	public async isExplorable(role: { id: MiRole['id'] } | null): Promise<boolean> {
 		if (role == null) return false;
 		const check = await this.rolesRepository.findOneBy({ id: role.id });
 		if (check == null) return false;
 		return check.isExplorable;
 	}
 
+	/**
+	 * モデレーター権限のロールが割り当てられているユーザID一覧を取得する.
+	 *
+	 * @param opts.includeAdmins 管理者権限も含めるか(デフォルト: true)
+	 * @param opts.includeRoot rootユーザも含めるか(デフォルト: false)
+	 * @param opts.excludeExpire 期限切れのロールを除外するか(デフォルト: false)
+	 */
 	@bindThis
-	public async getModeratorIds(includeAdmins = true, excludeExpire = false): Promise<MiUser['id'][]> {
+	public async getModeratorIds(opts?: {
+		includeAdmins?: boolean,
+		includeRoot?: boolean,
+		excludeExpire?: boolean,
+	}): Promise<MiUser['id'][]> {
+		const includeAdmins = opts?.includeAdmins ?? true;
+		const includeRoot = opts?.includeRoot ?? false;
+		const excludeExpire = opts?.excludeExpire ?? false;
+
 		const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
 		const moderatorRoles = includeAdmins
 			? roles.filter(r => r.isModerator || r.isAdministrator)
 			: roles.filter(r => r.isModerator);
 
-		// TODO: isRootなアカウントも含める
 		const assigns = moderatorRoles.length > 0
 			? await this.roleAssignmentsRepository.findBy({ roleId: In(moderatorRoles.map(r => r.id)) })
 			: [];
 
+		// Setを経由して重複を除去(ユーザIDは重複する可能性があるので)
 		const now = Date.now();
-		const result = [
-			// Setを経由して重複を除去(ユーザIDは重複する可能性があるので)
-			...new Set(
-				assigns
-					.filter(it =>
-						(excludeExpire)
-							? (it.expiresAt == null || it.expiresAt.getTime() > now)
-							: true,
-					)
-					.map(a => a.userId),
-			),
-		];
+		const resultSet = new Set(
+			assigns
+				.filter(it =>
+					(excludeExpire)
+						? (it.expiresAt == null || it.expiresAt.getTime() > now)
+						: true,
+				)
+				.map(a => a.userId),
+		);
 
-		return result.sort((x, y) => x.localeCompare(y));
+		if (includeRoot) {
+			const rootUserId = await this.rootUserIdCache.fetch(async () => {
+				const it = await this.usersRepository.createQueryBuilder('users')
+					.select('id')
+					.where({ isRoot: true })
+					.getRawOne<{ id: string }>();
+				// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+				return it!.id;
+			});
+			resultSet.add(rootUserId);
+		}
+
+		return [...resultSet].sort((x, y) => x.localeCompare(y));
 	}
 
 	@bindThis
-	public async getModerators(includeAdmins = true): Promise<MiUser[]> {
-		const ids = await this.getModeratorIds(includeAdmins);
-		const users = ids.length > 0 ? await this.usersRepository.findBy({
-			id: In(ids),
-		}) : [];
-		return users;
+	public async getModerators(opts?: {
+		includeAdmins?: boolean,
+		includeRoot?: boolean,
+		excludeExpire?: boolean,
+	}): Promise<MiUser[]> {
+		const ids = await this.getModeratorIds(opts);
+		return ids.length > 0
+			? await this.usersRepository.findBy({
+				id: In(ids),
+			})
+			: [];
 	}
 
 	@bindThis
diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts
index 0027b5ef3d..9044285bf6 100644
--- a/packages/backend/src/queue/QueueProcessorModule.ts
+++ b/packages/backend/src/queue/QueueProcessorModule.ts
@@ -6,6 +6,7 @@
 import { Module } from '@nestjs/common';
 import { CoreModule } from '@/core/CoreModule.js';
 import { GlobalModule } from '@/GlobalModule.js';
+import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
 import { QueueLoggerService } from './QueueLoggerService.js';
 import { QueueProcessorService } from './QueueProcessorService.js';
 import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
@@ -80,6 +81,8 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
 		DeliverProcessorService,
 		InboxProcessorService,
 		AggregateRetentionProcessorService,
+		CheckExpiredMutingsProcessorService,
+		CheckModeratorsActivityProcessorService,
 		QueueProcessorService,
 	],
 	exports: [
diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts
index e9e1c45224..85e148e900 100644
--- a/packages/backend/src/queue/QueueProcessorService.ts
+++ b/packages/backend/src/queue/QueueProcessorService.ts
@@ -10,6 +10,7 @@ import type { Config } from '@/config.js';
 import { DI } from '@/di-symbols.js';
 import type Logger from '@/logger.js';
 import { bindThis } from '@/decorators.js';
+import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
 import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
 import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
 import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
@@ -120,6 +121,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
 		private aggregateRetentionProcessorService: AggregateRetentionProcessorService,
 		private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService,
 		private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService,
+		private checkModeratorsActivityProcessorService: CheckModeratorsActivityProcessorService,
 		private cleanProcessorService: CleanProcessorService,
 	) {
 		this.logger = this.queueLoggerService.logger;
@@ -150,6 +152,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
 					case 'aggregateRetention': return this.aggregateRetentionProcessorService.process();
 					case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process();
 					case 'bakeBufferedReactions': return this.bakeBufferedReactionsProcessorService.process();
+					case 'checkModeratorsActivity': return this.checkModeratorsActivityProcessorService.process();
 					case 'clean': return this.cleanProcessorService.process();
 					default: throw new Error(`unrecognized job type ${job.name} for system`);
 				}
diff --git a/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts
new file mode 100644
index 0000000000..f2677f8e5c
--- /dev/null
+++ b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts
@@ -0,0 +1,127 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import type Logger from '@/logger.js';
+import { bindThis } from '@/decorators.js';
+import { MetaService } from '@/core/MetaService.js';
+import { RoleService } from '@/core/RoleService.js';
+import { QueueLoggerService } from '../QueueLoggerService.js';
+
+// モデレーターが不在と判断する日付の閾値
+const MODERATOR_INACTIVITY_LIMIT_DAYS = 7;
+const ONE_DAY_MILLI_SEC = 1000 * 60 * 60 * 24;
+
+@Injectable()
+export class CheckModeratorsActivityProcessorService {
+	private logger: Logger;
+
+	constructor(
+		private metaService: MetaService,
+		private roleService: RoleService,
+		private queueLoggerService: QueueLoggerService,
+	) {
+		this.logger = this.queueLoggerService.logger.createSubLogger('check-moderators-activity');
+	}
+
+	@bindThis
+	public async process(): Promise<void> {
+		this.logger.info('start.');
+
+		const meta = await this.metaService.fetch(false);
+		if (!meta.disableRegistration) {
+			await this.processImpl();
+		} else {
+			this.logger.info('is already invitation only.');
+		}
+
+		this.logger.succ('finish.');
+	}
+
+	@bindThis
+	private async processImpl() {
+		const { isModeratorsInactive, inactivityLimitCountdown } = await this.evaluateModeratorsInactiveDays();
+		if (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通知
+		} 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.`);
+
+				// TODO: 警告メール
+			}
+		}
+	}
+
+	/**
+	 * モデレーターが不在であるかどうかを確認する。trueの場合はモデレーターが不在である。
+	 * isModerator, isAdministrator, isRootのいずれかがtrueのユーザを対象に、
+	 * {@link MiUser.lastActiveDate}の値が実行日時の{@link MODERATOR_INACTIVITY_LIMIT_DAYS}日前よりも古いユーザがいるかどうかを確認する。
+	 * {@link MiUser.lastActiveDate}がnullの場合は、そのユーザは確認の対象外とする。
+	 *
+	 * -----
+	 *
+	 * ### サンプルパターン
+	 * - 実行日時: 2022-01-30 12:00:00
+	 * - 判定基準: 2022-01-23 12:00:00(実行日時の{@link MODERATOR_INACTIVITY_LIMIT_DAYS}日前)
+	 *
+	 * #### パターン①
+	 * - モデレータA: lastActiveDate = 2022-01-20 00:00:00 ※アウト
+	 * - モデレータB: lastActiveDate = 2022-01-23 12:00:00 ※セーフ(判定基準と同値なのでギリギリ残り0日)
+	 * - モデレータC: lastActiveDate = 2022-01-23 11:59:59 ※アウト(残り-1日)
+	 * - モデレータD: lastActiveDate = null
+	 *
+	 * この場合、モデレータBのアクティビティのみ判定基準日よりも古くないため、モデレーターが在席と判断される。
+	 *
+	 * #### パターン②
+	 * - モデレータA: lastActiveDate = 2022-01-20 00:00:00 ※アウト
+	 * - モデレータB: lastActiveDate = 2022-01-22 12:00:00 ※アウト(残り-1日)
+	 * - モデレータC: lastActiveDate = 2022-01-23 11:59:59 ※アウト(残り-1日)
+	 * - モデレータD: lastActiveDate = null
+	 *
+	 * この場合、モデレータA, B, Cのアクティビティは判定基準日よりも古いため、モデレーターが不在と判断される。
+	 */
+	@bindThis
+	public async evaluateModeratorsInactiveDays() {
+		const today = new Date();
+		const inactivePeriod = new Date(today);
+		inactivePeriod.setDate(today.getDate() - MODERATOR_INACTIVITY_LIMIT_DAYS);
+
+		const moderators = await this.fetchModerators()
+			.then(it => it.filter(it => it.lastActiveDate != null));
+		const inactiveModerators = moderators
+			// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+			.filter(it => it.lastActiveDate!.getTime() < inactivePeriod.getTime());
+
+		// 残りの猶予を示したいので、最終アクティブ日時が一番若いモデレータの日数を基準に猶予を計算する
+		// 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);
+
+		return {
+			isModeratorsInactive: inactiveModerators.length === moderators.length,
+			inactiveModerators,
+			inactivityLimitCountdown,
+		};
+	}
+
+	@bindThis
+	private async changeToInvitationOnly() {
+		await this.metaService.update({ disableRegistration: true });
+	}
+
+	@bindThis
+	private async fetchModerators() {
+		// TODO: モデレーター以外にも特別な権限を持つユーザーがいる場合は考慮する
+		return this.roleService.getModerators({
+			includeAdmins: true,
+			includeRoot: true,
+			excludeExpire: true,
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/admin/show-users.ts b/packages/backend/src/server/api/endpoints/admin/show-users.ts
index 2fef9abbf9..2b2c8c60ab 100644
--- a/packages/backend/src/server/api/endpoints/admin/show-users.ts
+++ b/packages/backend/src/server/api/endpoints/admin/show-users.ts
@@ -71,13 +71,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 					break;
 				}
 				case 'moderator': {
-					const moderatorIds = await this.roleService.getModeratorIds(false);
+					const moderatorIds = await this.roleService.getModeratorIds({ includeAdmins: false });
 					if (moderatorIds.length === 0) return [];
 					query.where('user.id IN (:...moderatorIds)', { moderatorIds: moderatorIds });
 					break;
 				}
 				case 'adminOrModerator': {
-					const adminOrModeratorIds = await this.roleService.getModeratorIds();
+					const adminOrModeratorIds = await this.roleService.getModeratorIds({ includeAdmins: true });
 					if (adminOrModeratorIds.length === 0) return [];
 					query.where('user.id IN (:...adminOrModeratorIds)', { adminOrModeratorIds: adminOrModeratorIds });
 					break;
diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts
index ef80d25f81..9c1b1008d6 100644
--- a/packages/backend/test/unit/RoleService.ts
+++ b/packages/backend/test/unit/RoleService.ts
@@ -10,6 +10,8 @@ import { jest } from '@jest/globals';
 import { ModuleMocker } from 'jest-mock';
 import { Test } from '@nestjs/testing';
 import * as lolex from '@sinonjs/fake-timers';
+import type { TestingModule } from '@nestjs/testing';
+import type { MockFunctionMetadata } from 'jest-mock';
 import { GlobalModule } from '@/GlobalModule.js';
 import { RoleService } from '@/core/RoleService.js';
 import {
@@ -31,8 +33,6 @@ import { secureRndstr } from '@/misc/secure-rndstr.js';
 import { NotificationService } from '@/core/NotificationService.js';
 import { RoleCondFormulaValue } from '@/models/Role.js';
 import { UserEntityService } from '@/core/entities/UserEntityService.js';
-import type { TestingModule } from '@nestjs/testing';
-import type { MockFunctionMetadata } from 'jest-mock';
 
 const moduleMocker = new ModuleMocker(global);
 
@@ -277,9 +277,9 @@ describe('RoleService', () => {
 	});
 
 	describe('getModeratorIds', () => {
-		test('includeAdmins = false, excludeExpire = false', async () => {
-			const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([
-				createUser(), createUser(), createUser(), createUser(), createUser(), createUser(),
+		test('includeAdmins = false, includeRoot = false, excludeExpire = false', async () => {
+			const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
+				createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }),
 			]);
 
 			const role1 = await createRole({ name: 'admin', isAdministrator: true });
@@ -295,13 +295,17 @@ describe('RoleService', () => {
 				assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }),
 			]);
 
-			const result = await roleService.getModeratorIds(false, false);
+			const result = await roleService.getModeratorIds({
+				includeAdmins: false,
+				includeRoot: false,
+				excludeExpire: false,
+			});
 			expect(result).toEqual([modeUser1.id, modeUser2.id]);
 		});
 
-		test('includeAdmins = false, excludeExpire = true', async () => {
-			const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([
-				createUser(), createUser(), createUser(), createUser(), createUser(), createUser(),
+		test('includeAdmins = false, includeRoot = false, excludeExpire = true', async () => {
+			const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
+				createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }),
 			]);
 
 			const role1 = await createRole({ name: 'admin', isAdministrator: true });
@@ -317,13 +321,17 @@ describe('RoleService', () => {
 				assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }),
 			]);
 
-			const result = await roleService.getModeratorIds(false, true);
+			const result = await roleService.getModeratorIds({
+				includeAdmins: false,
+				includeRoot: false,
+				excludeExpire: true,
+			});
 			expect(result).toEqual([modeUser1.id]);
 		});
 
-		test('includeAdmins = true, excludeExpire = false', async () => {
-			const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([
-				createUser(), createUser(), createUser(), createUser(), createUser(), createUser(),
+		test('includeAdmins = true, includeRoot = false, excludeExpire = false', async () => {
+			const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
+				createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }),
 			]);
 
 			const role1 = await createRole({ name: 'admin', isAdministrator: true });
@@ -339,13 +347,17 @@ describe('RoleService', () => {
 				assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }),
 			]);
 
-			const result = await roleService.getModeratorIds(true, false);
+			const result = await roleService.getModeratorIds({
+				includeAdmins: true,
+				includeRoot: false,
+				excludeExpire: false,
+			});
 			expect(result).toEqual([adminUser1.id, adminUser2.id, modeUser1.id, modeUser2.id]);
 		});
 
-		test('includeAdmins = true, excludeExpire = true', async () => {
-			const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([
-				createUser(), createUser(), createUser(), createUser(), createUser(), createUser(),
+		test('includeAdmins = true, includeRoot = false, excludeExpire = true', async () => {
+			const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
+				createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }),
 			]);
 
 			const role1 = await createRole({ name: 'admin', isAdministrator: true });
@@ -361,9 +373,111 @@ describe('RoleService', () => {
 				assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }),
 			]);
 
-			const result = await roleService.getModeratorIds(true, true);
+			const result = await roleService.getModeratorIds({
+				includeAdmins: true,
+				includeRoot: false,
+				excludeExpire: true,
+			});
 			expect(result).toEqual([adminUser1.id, modeUser1.id]);
 		});
+
+		test('includeAdmins = false, includeRoot = true, excludeExpire = false', async () => {
+			const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([
+				createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createUser({ isRoot: true }),
+			]);
+
+			const role1 = await createRole({ name: 'admin', isAdministrator: true });
+			const role2 = await createRole({ name: 'moderator', isModerator: true });
+			const role3 = await createRole({ name: 'normal' });
+
+			await Promise.all([
+				assignRole({ userId: adminUser1.id, roleId: role1.id }),
+				assignRole({ userId: adminUser2.id, roleId: role1.id, expiresAt: new Date(Date.now() - 1000) }),
+				assignRole({ userId: modeUser1.id, roleId: role2.id }),
+				assignRole({ userId: modeUser2.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }),
+				assignRole({ userId: normalUser1.id, roleId: role3.id }),
+				assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }),
+			]);
+
+			const result = await roleService.getModeratorIds({
+				includeAdmins: false,
+				includeRoot: true,
+				excludeExpire: false,
+			});
+			expect(result).toEqual([modeUser1.id, modeUser2.id, rootUser.id]);
+		});
+
+		test('root has moderator role', async () => {
+			const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([
+				createUser(), createUser(), createUser(), createUser({ isRoot: true }),
+			]);
+
+			const role1 = await createRole({ name: 'admin', isAdministrator: true });
+			const role2 = await createRole({ name: 'moderator', isModerator: true });
+			const role3 = await createRole({ name: 'normal' });
+
+			await Promise.all([
+				assignRole({ userId: adminUser1.id, roleId: role1.id }),
+				assignRole({ userId: modeUser1.id, roleId: role2.id }),
+				assignRole({ userId: rootUser.id, roleId: role2.id }),
+				assignRole({ userId: normalUser1.id, roleId: role3.id }),
+			]);
+
+			const result = await roleService.getModeratorIds({
+				includeAdmins: false,
+				includeRoot: true,
+				excludeExpire: false,
+			});
+			expect(result).toEqual([modeUser1.id, rootUser.id]);
+		});
+
+		test('root has administrator role', async () => {
+			const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([
+				createUser(), createUser(), createUser(), createUser({ isRoot: true }),
+			]);
+
+			const role1 = await createRole({ name: 'admin', isAdministrator: true });
+			const role2 = await createRole({ name: 'moderator', isModerator: true });
+			const role3 = await createRole({ name: 'normal' });
+
+			await Promise.all([
+				assignRole({ userId: adminUser1.id, roleId: role1.id }),
+				assignRole({ userId: rootUser.id, roleId: role1.id }),
+				assignRole({ userId: modeUser1.id, roleId: role2.id }),
+				assignRole({ userId: normalUser1.id, roleId: role3.id }),
+			]);
+
+			const result = await roleService.getModeratorIds({
+				includeAdmins: true,
+				includeRoot: true,
+				excludeExpire: false,
+			});
+			expect(result).toEqual([adminUser1.id, modeUser1.id, rootUser.id]);
+		});
+
+		test('root has moderator role(expire)', async () => {
+			const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([
+				createUser(), createUser(), createUser(), createUser({ isRoot: true }),
+			]);
+
+			const role1 = await createRole({ name: 'admin', isAdministrator: true });
+			const role2 = await createRole({ name: 'moderator', isModerator: true });
+			const role3 = await createRole({ name: 'normal' });
+
+			await Promise.all([
+				assignRole({ userId: adminUser1.id, roleId: role1.id }),
+				assignRole({ userId: modeUser1.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }),
+				assignRole({ userId: rootUser.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }),
+				assignRole({ userId: normalUser1.id, roleId: role3.id }),
+			]);
+
+			const result = await roleService.getModeratorIds({
+				includeAdmins: false,
+				includeRoot: true,
+				excludeExpire: true,
+			});
+			expect(result).toEqual([rootUser.id]);
+		});
 	});
 
 	describe('conditional role', () => {
diff --git a/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts
new file mode 100644
index 0000000000..b783320aa0
--- /dev/null
+++ b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts
@@ -0,0 +1,235 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { jest } from '@jest/globals';
+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 { 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';
+
+const baseDate = new Date(Date.UTC(2000, 11, 15, 12, 0, 0));
+
+describe('CheckModeratorsActivityProcessorService', () => {
+	let app: TestingModule;
+	let clock: lolex.InstalledClock;
+	let service: CheckModeratorsActivityProcessorService;
+
+	// --------------------------------------------------------------------------------------
+
+	let usersRepository: UsersRepository;
+	let userProfilesRepository: UserProfilesRepository;
+	let idService: IdService;
+	let roleService: jest.Mocked<RoleService>;
+
+	// --------------------------------------------------------------------------------------
+
+	async function createUser(data: Partial<MiUser> = {}) {
+		const id = idService.gen();
+		const user = await usersRepository
+			.insert({
+				id: id,
+				username: `user_${id}`,
+				usernameLower: `user_${id}`.toLowerCase(),
+				...data,
+			})
+			.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
+
+		await userProfilesRepository.insert({
+			userId: user.id,
+		});
+
+		return user;
+	}
+
+	function mockModeratorRole(users: MiUser[]) {
+		roleService.getModerators.mockReset();
+		roleService.getModerators.mockResolvedValue(users);
+	}
+
+	// --------------------------------------------------------------------------------------
+
+	beforeAll(async () => {
+		app = await Test
+			.createTestingModule({
+				imports: [
+					GlobalModule,
+				],
+				providers: [
+					CheckModeratorsActivityProcessorService,
+					IdService,
+					{
+						provide: RoleService, useFactory: () => ({ getModerators: jest.fn() }),
+					},
+					{
+						provide: MetaService, useFactory: () => ({ fetch: jest.fn() }),
+					},
+					{
+						provide: QueueLoggerService, useFactory: () => ({
+							logger: ({
+								createSubLogger: () => ({
+									info: jest.fn(),
+									warn: jest.fn(),
+									succ: jest.fn(),
+								}),
+							}),
+						}),
+					},
+				],
+			})
+			.compile();
+
+		usersRepository = app.get(DI.usersRepository);
+		userProfilesRepository = app.get(DI.userProfilesRepository);
+
+		service = app.get(CheckModeratorsActivityProcessorService);
+		idService = app.get(IdService);
+		roleService = app.get(RoleService) as jest.Mocked<RoleService>;
+
+		app.enableShutdownHooks();
+	});
+
+	beforeEach(async () => {
+		clock = lolex.install({
+			now: new Date(baseDate),
+			shouldClearNativeTimers: true,
+		});
+	});
+
+	afterEach(async () => {
+		clock.uninstall();
+		await usersRepository.delete({});
+		await userProfilesRepository.delete({});
+		roleService.getModerators.mockReset();
+	});
+
+	afterAll(async () => {
+		await app.close();
+	});
+
+	// --------------------------------------------------------------------------------------
+
+	describe('evaluateModeratorsInactiveDays', () => {
+		test('[isModeratorsInactive] inactiveなモデレーターがいても他のモデレーターがアクティブなら"運営が非アクティブ"としてみなされない', async () => {
+			const [user1, user2, user3, user4] = await Promise.all([
+				// 期限よりも1秒新しいタイミングでアクティブ化(セーフ)
+				createUser({ lastActiveDate: subDays(addSeconds(baseDate, 1), 7) }),
+				// 期限ちょうどにアクティブ化(セーフ)
+				createUser({ lastActiveDate: subDays(baseDate, 7) }),
+				// 期限よりも1秒古いタイミングでアクティブ化(アウト)
+				createUser({ lastActiveDate: subDays(subSeconds(baseDate, 1), 7) }),
+				// 対象外
+				createUser({ lastActiveDate: null }),
+			]);
+
+			mockModeratorRole([user1, user2, user3, user4]);
+
+			const result = await service.evaluateModeratorsInactiveDays();
+			expect(result.isModeratorsInactive).toBe(false);
+			expect(result.inactiveModerators).toEqual([user3]);
+		});
+
+		test('[isModeratorsInactive] 全員非アクティブなら"運営が非アクティブ"としてみなされる', async () => {
+			const [user1, user2] = await Promise.all([
+				// 期限よりも1秒古いタイミングでアクティブ化(アウト)
+				createUser({ lastActiveDate: subDays(subSeconds(baseDate, 1), 7) }),
+				// 対象外
+				createUser({ lastActiveDate: null }),
+			]);
+
+			mockModeratorRole([user1, user2]);
+
+			const result = await service.evaluateModeratorsInactiveDays();
+			expect(result.isModeratorsInactive).toBe(true);
+			expect(result.inactiveModerators).toEqual([user1]);
+		});
+
+		test('[countdown] 猶予まで24時間ある場合、猶予1日として計算される', async () => {
+			const [user1, user2] = await Promise.all([
+				createUser({ lastActiveDate: subDays(baseDate, 8) }),
+				// 猶予はこのユーザ基準で計算される想定。
+				// 期限まで残り24時間->猶予1日として計算されるはずである
+				createUser({ lastActiveDate: subDays(baseDate, 6) }),
+			]);
+
+			mockModeratorRole([user1, user2]);
+
+			const result = await service.evaluateModeratorsInactiveDays();
+			expect(result.isModeratorsInactive).toBe(false);
+			expect(result.inactiveModerators).toEqual([user1]);
+			expect(result.inactivityLimitCountdown).toBe(1);
+		});
+
+		test('[countdown] 猶予まで25時間ある場合、猶予1日として計算される', async () => {
+			const [user1, user2] = await Promise.all([
+				createUser({ lastActiveDate: subDays(baseDate, 8) }),
+				// 猶予はこのユーザ基準で計算される想定。
+				// 期限まで残り25時間->猶予1日として計算されるはずである
+				createUser({ lastActiveDate: subDays(addHours(baseDate, 1), 6) }),
+			]);
+
+			mockModeratorRole([user1, user2]);
+
+			const result = await service.evaluateModeratorsInactiveDays();
+			expect(result.isModeratorsInactive).toBe(false);
+			expect(result.inactiveModerators).toEqual([user1]);
+			expect(result.inactivityLimitCountdown).toBe(1);
+		});
+
+		test('[countdown] 猶予まで23時間ある場合、猶予0日として計算される', async () => {
+			const [user1, user2] = await Promise.all([
+				createUser({ lastActiveDate: subDays(baseDate, 8) }),
+				// 猶予はこのユーザ基準で計算される想定。
+				// 期限まで残り23時間->猶予0日として計算されるはずである
+				createUser({ lastActiveDate: subDays(subHours(baseDate, 1), 6) }),
+			]);
+
+			mockModeratorRole([user1, user2]);
+
+			const result = await service.evaluateModeratorsInactiveDays();
+			expect(result.isModeratorsInactive).toBe(false);
+			expect(result.inactiveModerators).toEqual([user1]);
+			expect(result.inactivityLimitCountdown).toBe(0);
+		});
+
+		test('[countdown] 期限ちょうどの場合、猶予0日として計算される', async () => {
+			const [user1, user2] = await Promise.all([
+				createUser({ lastActiveDate: subDays(baseDate, 8) }),
+				// 猶予はこのユーザ基準で計算される想定。
+				// 期限ちょうど->猶予0日として計算されるはずである
+				createUser({ lastActiveDate: subDays(baseDate, 7) }),
+			]);
+
+			mockModeratorRole([user1, user2]);
+
+			const result = await service.evaluateModeratorsInactiveDays();
+			expect(result.isModeratorsInactive).toBe(false);
+			expect(result.inactiveModerators).toEqual([user1]);
+			expect(result.inactivityLimitCountdown).toBe(0);
+		});
+
+		test('[countdown] 期限より1時間超過している場合、猶予-1日として計算される', async () => {
+			const [user1, user2] = await Promise.all([
+				createUser({ lastActiveDate: subDays(baseDate, 8) }),
+				// 猶予はこのユーザ基準で計算される想定。
+				// 期限より1時間超過->猶予-1日として計算されるはずである
+				createUser({ lastActiveDate: subDays(subHours(baseDate, 1), 7) }),
+			]);
+
+			mockModeratorRole([user1, user2]);
+
+			const result = await service.evaluateModeratorsInactiveDays();
+			expect(result.isModeratorsInactive).toBe(true);
+			expect(result.inactiveModerators).toEqual([user1, user2]);
+			expect(result.inactivityLimitCountdown).toBe(-1);
+		});
+	});
+});

From c397b42242a34b85de1c183d86ee78c5cd50e161 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Fri, 11 Oct 2024 21:01:50 +0900
Subject: [PATCH 03/34] chore: add description

---
 locales/ja-JP.yml                                | 1 +
 packages/frontend/src/pages/admin/moderation.vue | 1 +
 2 files changed, 2 insertions(+)

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 0076c467ec..48a670ce50 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1440,6 +1440,7 @@ _serverSettings:
   reactionsBufferingDescription: "有効にすると、リアクション作成時のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。"
   inquiryUrl: "問い合わせ先URL"
   inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。"
+  thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。"
 
 _accountMigration:
   moveFrom: "別のアカウントからこのアカウントに移行"
diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue
index 54eb95cd51..04d23b1358 100644
--- a/packages/frontend/src/pages/admin/moderation.vue
+++ b/packages/frontend/src/pages/admin/moderation.vue
@@ -12,6 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<div class="_gaps_m">
 					<MkSwitch v-model="enableRegistration" @change="onChange_enableRegistration">
 						<template #label>{{ i18n.ts.enableRegistration }}</template>
+						<template #caption>{{ i18n.ts._serverSettings.thisSettingWillAutomaticallyOffWhenModeratorsInactive }}</template>
 					</MkSwitch>
 
 					<MkSwitch v-model="emailRequiredForSignup" @change="onChange_emailRequiredForSignup">

From af1cbc131fc9e045692f9f9def708c0978817fff Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Fri, 11 Oct 2024 21:05:53 +0900
Subject: [PATCH 04/34] wip (#14745)

---
 locales/index.d.ts                            |   4 +++
 locales/ja-JP.yml                             |   1 +
 .../migration/1728550878802-testcaptcha.js    |  16 ++++++++++
 packages/backend/src/core/CaptchaService.ts   |  13 ++++++++
 .../src/core/entities/MetaEntityService.ts    |   1 +
 packages/backend/src/models/Meta.ts           |   5 ++++
 .../backend/src/models/json-schema/meta.ts    |   4 +++
 .../src/server/api/ApiServerService.ts        |   2 ++
 .../src/server/api/SigninApiService.ts        |   7 +++++
 .../src/server/api/SignupApiService.ts        |   7 +++++
 .../src/server/api/endpoints/admin/meta.ts    |   5 ++++
 .../server/api/endpoints/admin/update-meta.ts |   5 ++++
 packages/frontend/assets/testcaptcha.png      | Bin 0 -> 2634 bytes
 .../frontend/src/components/MkCaptcha.vue     |  28 ++++++++++++++++--
 .../src/components/MkSignin.password.vue      |   9 +++++-
 packages/frontend/src/components/MkSignin.vue |   6 ++--
 .../src/components/MkSignupDialog.form.vue    |   6 ++++
 .../src/pages/admin/bot-protection.vue        |  15 +++++++++-
 packages/misskey-js/src/autogen/types.ts      |   3 ++
 19 files changed, 130 insertions(+), 7 deletions(-)
 create mode 100644 packages/backend/migration/1728550878802-testcaptcha.js
 create mode 100644 packages/frontend/assets/testcaptcha.png

diff --git a/locales/index.d.ts b/locales/index.d.ts
index f0dead1245..dab8eb0361 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -5166,6 +5166,10 @@ export interface Locale extends ILocale {
      * 対象
      */
     "target": string;
+    /**
+     * CAPTCHAのテストを目的とした機能です。<strong>本番環境で使用しないでください。</strong>
+     */
+    "testCaptchaWarning": string;
     "_abuseUserReport": {
         /**
          * 転送
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 48a670ce50..440ffa9306 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1287,6 +1287,7 @@ passkeyVerificationFailed: "パスキーの検証に失敗しました。"
 passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。"
 messageToFollower: "フォロワーへのメッセージ"
 target: "対象"
+testCaptchaWarning: "CAPTCHAのテストを目的とした機能です。<strong>本番環境で使用しないでください。</strong>"
 
 _abuseUserReport:
   forward: "転送"
diff --git a/packages/backend/migration/1728550878802-testcaptcha.js b/packages/backend/migration/1728550878802-testcaptcha.js
new file mode 100644
index 0000000000..d8d987c0c1
--- /dev/null
+++ b/packages/backend/migration/1728550878802-testcaptcha.js
@@ -0,0 +1,16 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class Testcaptcha1728550878802 {
+    name = 'Testcaptcha1728550878802'
+
+    async up(queryRunner) {
+			await queryRunner.query(`ALTER TABLE "meta" ADD "enableTestcaptcha" boolean NOT NULL DEFAULT false`);
+    }
+
+    async down(queryRunner) {
+			await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableTestcaptcha"`);
+    }
+}
diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts
index f6b7955cd2..206d0dbe0a 100644
--- a/packages/backend/src/core/CaptchaService.ts
+++ b/packages/backend/src/core/CaptchaService.ts
@@ -119,5 +119,18 @@ export class CaptchaService {
 			throw new Error(`turnstile-failed: ${errorCodes}`);
 		}
 	}
+
+	@bindThis
+	public async verifyTestcaptcha(response: string | null | undefined): Promise<void> {
+		if (response == null) {
+			throw new Error('testcaptcha-failed: no response provided');
+		}
+
+		const success = response === 'testcaptcha-passed';
+
+		if (!success) {
+			throw new Error('testcaptcha-failed');
+		}
+	}
 }
 
diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts
index fbd982eb34..409dca3426 100644
--- a/packages/backend/src/core/entities/MetaEntityService.ts
+++ b/packages/backend/src/core/entities/MetaEntityService.ts
@@ -96,6 +96,7 @@ export class MetaEntityService {
 			recaptchaSiteKey: instance.recaptchaSiteKey,
 			enableTurnstile: instance.enableTurnstile,
 			turnstileSiteKey: instance.turnstileSiteKey,
+			enableTestcaptcha: instance.enableTestcaptcha,
 			swPublickey: instance.swPublicKey,
 			themeColor: instance.themeColor,
 			mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png',
diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts
index d29689f907..fd007de6c6 100644
--- a/packages/backend/src/models/Meta.ts
+++ b/packages/backend/src/models/Meta.ts
@@ -258,6 +258,11 @@ export class MiMeta {
 	})
 	public turnstileSecretKey: string | null;
 
+	@Column('boolean', {
+		default: false,
+	})
+	public enableTestcaptcha: boolean;
+
 	// chaptcha系を追加した際にはnodeinfoのレスポンスに追加するのを忘れないようにすること
 
 	@Column('enum', {
diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts
index 99feeaa7d7..e3fd63464a 100644
--- a/packages/backend/src/models/json-schema/meta.ts
+++ b/packages/backend/src/models/json-schema/meta.ts
@@ -115,6 +115,10 @@ export const packedMetaLiteSchema = {
 			type: 'string',
 			optional: false, nullable: true,
 		},
+		enableTestcaptcha: {
+			type: 'boolean',
+			optional: false, nullable: false,
+		},
 		swPublickey: {
 			type: 'string',
 			optional: false, nullable: true,
diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts
index be63635efe..3a8cb19f01 100644
--- a/packages/backend/src/server/api/ApiServerService.ts
+++ b/packages/backend/src/server/api/ApiServerService.ts
@@ -119,6 +119,7 @@ export class ApiServerService {
 				'g-recaptcha-response'?: string;
 				'turnstile-response'?: string;
 				'm-captcha-response'?: string;
+				'testcaptcha-response'?: string;
 			}
 		}>('/signup', (request, reply) => this.signupApiService.signup(request, reply));
 
@@ -132,6 +133,7 @@ export class ApiServerService {
 				'g-recaptcha-response'?: string;
 				'turnstile-response'?: string;
 				'm-captcha-response'?: string;
+				'testcaptcha-response'?: string;
 			};
 		}>('/signin-flow', (request, reply) => this.signinApiService.signin(request, reply));
 
diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts
index 0d24ffa56a..1d983ca4bc 100644
--- a/packages/backend/src/server/api/SigninApiService.ts
+++ b/packages/backend/src/server/api/SigninApiService.ts
@@ -71,6 +71,7 @@ export class SigninApiService {
 				'g-recaptcha-response'?: string;
 				'turnstile-response'?: string;
 				'm-captcha-response'?: string;
+				'testcaptcha-response'?: string;
 			};
 		}>,
 		reply: FastifyReply,
@@ -194,6 +195,12 @@ export class SigninApiService {
 						throw new FastifyReplyError(400, err);
 					});
 				}
+
+				if (this.meta.enableTestcaptcha) {
+					await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => {
+						throw new FastifyReplyError(400, err);
+					});
+				}
 			}
 
 			if (same) {
diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts
index c499638018..3ec5e5d3e6 100644
--- a/packages/backend/src/server/api/SignupApiService.ts
+++ b/packages/backend/src/server/api/SignupApiService.ts
@@ -67,6 +67,7 @@ export class SignupApiService {
 				'g-recaptcha-response'?: string;
 				'turnstile-response'?: string;
 				'm-captcha-response'?: string;
+				'testcaptcha-response'?: string;
 			}
 		}>,
 		reply: FastifyReply,
@@ -99,6 +100,12 @@ export class SignupApiService {
 					throw new FastifyReplyError(400, err);
 				});
 			}
+
+			if (this.meta.enableTestcaptcha) {
+				await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => {
+					throw new FastifyReplyError(400, err);
+				});
+			}
 		}
 
 		const username = body['username'];
diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts
index b76ed5c524..abb3c17be3 100644
--- a/packages/backend/src/server/api/endpoints/admin/meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/meta.ts
@@ -69,6 +69,10 @@ export const meta = {
 				type: 'string',
 				optional: false, nullable: true,
 			},
+			enableTestcaptcha: {
+				type: 'boolean',
+				optional: false, nullable: false,
+			},
 			swPublickey: {
 				type: 'string',
 				optional: false, nullable: true,
@@ -555,6 +559,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				recaptchaSiteKey: instance.recaptchaSiteKey,
 				enableTurnstile: instance.enableTurnstile,
 				turnstileSiteKey: instance.turnstileSiteKey,
+				enableTestcaptcha: instance.enableTestcaptcha,
 				swPublickey: instance.swPublicKey,
 				themeColor: instance.themeColor,
 				mascotImageUrl: instance.mascotImageUrl,
diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
index 9ffae840b6..e97ac4e2b9 100644
--- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
@@ -78,6 +78,7 @@ export const paramDef = {
 		enableTurnstile: { type: 'boolean' },
 		turnstileSiteKey: { type: 'string', nullable: true },
 		turnstileSecretKey: { type: 'string', nullable: true },
+		enableTestcaptcha: { type: 'boolean' },
 		sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] },
 		sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] },
 		setSensitiveFlagAutomatically: { type: 'boolean' },
@@ -357,6 +358,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				set.turnstileSecretKey = ps.turnstileSecretKey;
 			}
 
+			if (ps.enableTestcaptcha !== undefined) {
+				set.enableTestcaptcha = ps.enableTestcaptcha;
+			}
+
 			if (ps.sensitiveMediaDetection !== undefined) {
 				set.sensitiveMediaDetection = ps.sensitiveMediaDetection;
 			}
diff --git a/packages/frontend/assets/testcaptcha.png b/packages/frontend/assets/testcaptcha.png
new file mode 100644
index 0000000000000000000000000000000000000000..9bfd252b51c6057f99ff1012897c4d9e6971f897
GIT binary patch
literal 2634
zcmV-Q3bpl#P)<h;3K|Lk000e1NJLTq003kF003kN1^@s6aN?Cz00001b5ch_0Itp)
z=>Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D3ExRXK~#8N?VR6>
zRaF$nd+f2^dhAc=u{V3DPy#E6ems;9Q4|IF6hRL%qcn+?4+DxQiG|GMKr$!{X2V22
zm`o}$rHJ7W7GsJzIL0JeC%>1wY|V1*z1Kc_pL_SY%@@8n_x#vrpY_>$?Y+;r*ZZnf
z6{S@mg=rN?VQLkSso(ypUi@~kdicvL9X~U(SdGmuclQp4S_5R`>{4~#XO~n1%%G?h
zK+p>`5Zr?Tr4>LBYz=}mj$6L{Pxq{Lsue&Unz*b2(7g8RYu&Tjsa61a?5jW2;Je)B
z^wk$2e72+oRQkd3-_`9tw-rjyfW$($$Db?P0&e4&(i0%QDQlE#Kxx~U(m0T8FvvA~
zN?X(@knAw-I(|&qe&)|^t;$woK$?Tmb!1O@<nYyQ&B|6IKrAckez*;41PBIFEg<>4
zaL4g!i|fSfVl^~!p?c@tbJhF9KUdS=l+lB-18F=})c^rwx=kA0b+KBr_WOGbIQ<5b
z6-b>_^}zV$>W%NNSJ!T?TrXh#@ZPB<_SmFeuOOqLKnS=7gZqUIbA9VIS%Ji)DzsP$
z!KJrPTvyAmnqLcn)*!fy<9n%WKw?r=42+Zsg4X+<dhWCuc%OR-B@2+4pdkfWVL({a
zY2}3Mg8sz%Q)<vd?iG|QKw^SMx!fwWa;+SneLWBZw-#`VdjTa25Npt4dk1v?{<wiW
zln(cQv7iRq>ZYM21BppjvAqMbz6){9)}(IU{JVN{<5@M>RyPSH8HhDq#SG+JBXc*@
z^0;Hm29#Z&{#rfz(hq9DEp7@*G7xLJssjVUdgRfmt7@6nUg1(In2Ce=AIBsE(E=rn
z7MvovNK6HxsZJ_;`L!RrXXjH-fYcc~`k^{da;L7I0Lj^sn^qktIa60M5X-c*ZHk5R
z>RV^JXQnF|NN8G`I)3y^u~0~<kM)y*gr=o!89y`u3R=GgKpJvA){hxyl7aZ9rFGXH
zCCR$7|KOtw>UOeqoJj@(^<rAuFQJ!c0hWI9Y5zk@>o}7I$RLPvKVDAB7gTkh5KCK>
z4aoWP=c|c{iE8`y?H!Meja74VbKSjV%a!|K49*)~|4H2!Ym2e~nVOoaHf`FZ8emG6
z<&|zOTa*mM2ZLNP6r?6Cc)5)<Xi*Xn3k-6pP*R@w#u#bQqS`<X9z3W9-sj`Tk9W^5
zM=5U*tw!%yx^cUEMZanRsRIKZ9UbkKP>xLNjCIO3qhnw|(LiF-l;+ZUNu7s@7USD3
zACLYL1p|r#a`x<5HOMh8v6lc!>Knv0YfpCmtqX!=29nw{xJw*Mprqci?qqa&(qsaH
z1)dhzy56~SXSd|?R1eC@>iJjFVL&Dzn6hetO>xOGr?5MN{p0ITRUdvbp9KbF0x~i(
zq6Rg^ZBPgd9vCiMy4gKCk4!)|Z{Dm1wXX53t8JP*e0c>J4BP;gTP7fT_wH2#Ti2K%
ztV)*&$gyL`)WFs;ddKo|{r^&lo+_h}wCRbmRVE;}Zr!SO@80bU@Qxij)P2%>_UuW5
z;hK7v{zN%OCLj=?96frp+O}<5CkPnm4;?zx39z*XrHmb+bx^5mn^>8F_yky3TWEoj
zMr+3LF|sU5OJ3W=C<_oMW}HF#v*!Mo2bqJQY(OAuFCm9|OYBqSK~Pp8DYG&89zGUE
z8`n0PvI2o(dr2-2%GxG7keq84t5erDB`G@)-<qWf4-yP0I}k{&S(-3k2Mv-M0YXBs
znix~iE3RE@_y0cXQlmhi+$n63VL*)ofpVp=L5BhL1PIr{!b0b1Z7iJs0}QC=K%}Vm
z_})qa;GhlOE;RyVety0aXsb@2KHYK5IkAeZQ@@lI2yPhPyF#l*i(gvu|C|jFkcy}$
z;GEgnS#RKQ&dkirU57x%)~Vmh3IxHV;KEq7YvMuDtOd?@jxW%zI+faEvI0Si<u^|)
zn785(3^IplImZP>)(NkEOYSj^0@=EC>)pR`^NWj%CKxcvf~){xtw+i_NxOP+ztkuY
z>GAP#bvqur%f%xBLGR+*$#`rczn*mQ;=RAY2-S(MQ;K>DWO8y+-L9Deg_NRwFAO~n
z(_#FLi2=R{t|?R}4PrmlGaz{JB=;kz{4+NJfv}hieOV7>X)`W)O^hMf2IDJ5rKwRM
zbn|rMa=(^#$g<^HpulpVme<7RlGP{>1P3sv)oQ`Ha^*_rUv5{+1r&b1%Y2|ld3+x}
zm#nNn5D0$rWNNiCIJ`9@-4GBh^~B7!c+llF`MwgB6^Ls9h5<_h1}IvfW$JOwwlwo}
zCxOUvOH)=LkPHAB+kL<^^VDNG7hiOc=}v0|yxh{19f&J%)M~ARGb{&oZM-47#vQFb
z=p}TXmLB`5>_8wHI2kM-6tr^foD`uv?ONoL(pV!vEQ*<3rOJ}-N=ak2fbbcSbZIGo
z6iSt)tk|P~Sf`$$6p;Gpo6eUFBh(mNV^C8)vyRL_tT4zL6kZ1Q<2M~KJ&K<{G&Hp0
zdSsN_R4?TXz;X>&!}B#r6A&JV7|XmYUlx(AgR1L%&DJtW2(T;2uKRaggCEb2ac$=^
z0dWO_W<+F}qh(&kF?>I_C4j#3HEU(^R!-)@mgj^Tqjn9~TT0h<t{@Osg|r{s@Eon>
zWo(z_aEE3(@_aOaBS(&Owz~#wDl-&VZe9;duaxzc^~7i2cAmn5K(q?sDQz9e3dwOG
z)Jt&V{CJpxL5)Fzp_@j};M;xnJ$lg`$^=vgxgB4Bk|`_*M5|EUDWWV(`~ACYZCt3Z
zE8q}X8UGdw->;pk84Zx6^(r*R*i1lmz?)INeRz0y#b?}mG?2QzL%TVo8yTcfFF~u~
z$Kxg`I9f`gU_f%(D+2`c2BBpKUY<)hhMzM%J#9*tI4&B9r9zrqHz&)d?JjA@`|Rt@
zp&qAQ%aR@Bd0Vz@S@GFr)TL)Yw41{_cJvZCmY`y;UZR!bvgMb)vd8iJlh?9l2W_l#
zt(&C#3dCnu>avz&{n@qe{(Sp<(t6&$efw5?b~ze|7ATtAyB1cEEXQJPfOnO{*F-Uo
zLS%XPO!Dp1#HX+F+1a^s=k9@|IS54Set?3?!E&%lZQ0yf0Ax9ssii!NlI8Jh%6+bT
z;}aHs3{4`ae)L(JU6O-9wC)$OY}wiji+>%5eBi)=6~|}+z;XN-d`^+CJXd3+I#Fhj
z_onr1k`@c@AP^|#EgvjrBHE%%3#1kRd7LkRb>u70)ffTA7gS%JLMwk05Xb^Wd#4R)
zH>OP=wd3%a_YxUER~oU(2Ly_3jIeKNtTj4512Y4G<id(Ol*dD>Tap$49(0_~_rbmt
s5t1wqpQU1;gl2cL(c$?2Vlz|y3vYW%ba;w#qW}N^07*qoM6N<$g0}PD7ytkO

literal 0
HcmV?d00001

diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue
index c5b6e0caed..82fc89e51c 100644
--- a/packages/frontend/src/components/MkCaptcha.vue
+++ b/packages/frontend/src/components/MkCaptcha.vue
@@ -10,6 +10,17 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<div id="mcaptcha__widget-container" class="m-captcha-style"></div>
 		<div ref="captchaEl"></div>
 	</div>
+	<div v-if="props.provider == 'testcaptcha'" style="background: #eee; border: solid 1px #888; padding: 8px; color: #000; max-width: 320px; display: flex; gap: 10px; align-items: center; box-shadow: 2px 2px 6px #0004; border-radius: 4px;">
+		<img src="/client-assets/testcaptcha.png" style="width: 60px; height: 60px; "/>
+		<div v-if="testcaptchaPassed">
+			<div style="color: green;">Test captcha passed!</div>
+		</div>
+		<div v-else>
+			<div style="font-size: 13px; margin-bottom: 4px;">Type "ai-chan-kawaii" to pass captcha</div>
+			<input v-model="testcaptchaInput" data-cy-testcaptcha-input/>
+			<button type="button" data-cy-testcaptcha-submit @click="testcaptchaSubmit">Submit</button>
+		</div>
+	</div>
 	<div v-else ref="captchaEl"></div>
 </div>
 </template>
@@ -29,7 +40,7 @@ export type Captcha = {
 	getResponse(id: string): string;
 };
 
-export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha';
+export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'mcaptcha' | 'testcaptcha';
 
 type CaptchaContainer = {
 	readonly [_ in CaptchaProvider]?: Captcha;
@@ -54,12 +65,16 @@ const available = ref(false);
 
 const captchaEl = shallowRef<HTMLDivElement | undefined>();
 
+const testcaptchaInput = ref('');
+const testcaptchaPassed = ref(false);
+
 const variable = computed(() => {
 	switch (props.provider) {
 		case 'hcaptcha': return 'hcaptcha';
 		case 'recaptcha': return 'grecaptcha';
 		case 'turnstile': return 'turnstile';
 		case 'mcaptcha': return 'mcaptcha';
+		case 'testcaptcha': return 'testcaptcha';
 	}
 });
 
@@ -71,6 +86,7 @@ const src = computed(() => {
 		case 'recaptcha': return 'https://www.recaptcha.net/recaptcha/api.js?render=explicit';
 		case 'turnstile': return 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
 		case 'mcaptcha': return null;
+		case 'testcaptcha': return null;
 	}
 });
 
@@ -78,7 +94,7 @@ const scriptId = computed(() => `script-${props.provider}`);
 
 const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha);
 
-if (loaded || props.provider === 'mcaptcha') {
+if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') {
 	available.value = true;
 } else if (src.value !== null) {
 	(document.getElementById(scriptId.value) ?? document.head.appendChild(Object.assign(document.createElement('script'), {
@@ -91,6 +107,8 @@ if (loaded || props.provider === 'mcaptcha') {
 
 function reset() {
 	if (captcha.value.reset) captcha.value.reset();
+	testcaptchaPassed.value = false;
+	testcaptchaInput.value = '';
 }
 
 async function requestRender() {
@@ -127,6 +145,12 @@ function onReceivedMessage(message: MessageEvent) {
 	}
 }
 
+function testcaptchaSubmit() {
+	testcaptchaPassed.value = testcaptchaInput.value === 'ai-chan-kawaii';
+	callback(testcaptchaPassed.value ? 'testcaptcha-passed' : undefined);
+	if (!testcaptchaPassed.value) testcaptchaInput.value = '';
+}
+
 onMounted(() => {
 	if (available.value) {
 		window.addEventListener('message', onReceivedMessage);
diff --git a/packages/frontend/src/components/MkSignin.password.vue b/packages/frontend/src/components/MkSignin.password.vue
index f30bf5f861..5608122a39 100644
--- a/packages/frontend/src/components/MkSignin.password.vue
+++ b/packages/frontend/src/components/MkSignin.password.vue
@@ -28,6 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/>
 				<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
 				<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
+				<MkCaptcha v-if="instance.enableTestcaptcha" ref="testcaptcha" v-model="testcaptchaResponse" :class="$style.captcha" provider="testcaptcha"/>
 			</div>
 
 			<MkButton type="submit" :disabled="needCaptcha && captchaFailed" large primary rounded style="margin: 0 auto;" data-cy-signin-page-password-continue>{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
@@ -44,6 +45,7 @@ export type PwResponse = {
 		mCaptchaResponse: string | null;
 		reCaptchaResponse: string | null;
 		turnstileResponse: string | null;
+		testcaptchaResponse: string | null;
 	};
 };
 </script>
@@ -75,18 +77,21 @@ const hCaptcha = useTemplateRef('hcaptcha');
 const mCaptcha = useTemplateRef('mcaptcha');
 const reCaptcha = useTemplateRef('recaptcha');
 const turnstile = useTemplateRef('turnstile');
+const testcaptcha = useTemplateRef('testcaptcha');
 
 const hCaptchaResponse = ref<string | null>(null);
 const mCaptchaResponse = ref<string | null>(null);
 const reCaptchaResponse = ref<string | null>(null);
 const turnstileResponse = ref<string | null>(null);
+const testcaptchaResponse = ref<string | null>(null);
 
 const captchaFailed = computed((): boolean => {
 	return (
 		(instance.enableHcaptcha && !hCaptchaResponse.value) ||
 		(instance.enableMcaptcha && !mCaptchaResponse.value) ||
 		(instance.enableRecaptcha && !reCaptchaResponse.value) ||
-		(instance.enableTurnstile && !turnstileResponse.value)
+		(instance.enableTurnstile && !turnstileResponse.value) ||
+		(instance.enableTestcaptcha && !testcaptchaResponse.value)
 	);
 });
 
@@ -104,6 +109,7 @@ function onSubmit() {
 			mCaptchaResponse: mCaptchaResponse.value,
 			reCaptchaResponse: reCaptchaResponse.value,
 			turnstileResponse: turnstileResponse.value,
+			testcaptchaResponse: testcaptchaResponse.value,
 		},
 	});
 }
@@ -113,6 +119,7 @@ function resetCaptcha() {
 	mCaptcha.value?.reset();
 	reCaptcha.value?.reset();
 	turnstile.value?.reset();
+	testcaptcha.value?.reset();
 }
 
 defineExpose({
diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue
index a773cefdab..776ee20e36 100644
--- a/packages/frontend/src/components/MkSignin.vue
+++ b/packages/frontend/src/components/MkSignin.vue
@@ -68,6 +68,8 @@ import { nextTick, onBeforeUnmount, ref, shallowRef, useTemplateRef } from 'vue'
 import * as Misskey from 'misskey-js';
 import { supported as webAuthnSupported, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill';
 
+import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill';
+import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
 import { login } from '@/account.js';
@@ -79,9 +81,6 @@ import XPassword, { type PwResponse } from '@/components/MkSignin.password.vue';
 import XTotp from '@/components/MkSignin.totp.vue';
 import XPasskey from '@/components/MkSignin.passkey.vue';
 
-import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill';
-import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
-
 const emit = defineEmits<{
 	(ev: 'login', v: Misskey.entities.SigninFlowResponse & { finished: true }): void;
 }>();
@@ -188,6 +187,7 @@ async function onPasswordSubmitted(pw: PwResponse) {
 			'm-captcha-response': pw.captcha.mCaptchaResponse,
 			'g-recaptcha-response': pw.captcha.reCaptchaResponse,
 			'turnstile-response': pw.captcha.turnstileResponse,
+			'testcaptcha-response': pw.captcha.testcaptchaResponse,
 		});
 	}
 }
diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue
index ffb5551ff3..3d1c44fc90 100644
--- a/packages/frontend/src/components/MkSignupDialog.form.vue
+++ b/packages/frontend/src/components/MkSignupDialog.form.vue
@@ -66,6 +66,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/>
 			<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
 			<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
+			<MkCaptcha v-if="instance.enableTestcaptcha" ref="testcaptcha" v-model="testcaptchaResponse" :class="$style.captcha" provider="testcaptcha"/>
 			<MkButton type="submit" :disabled="shouldDisableSubmitting" large gradate rounded data-cy-signup-submit style="margin: 0 auto;">
 				<template v-if="submitting">
 					<MkLoading :em="true" :colored="false"/>
@@ -108,6 +109,7 @@ const hcaptcha = ref<Captcha | undefined>();
 const mcaptcha = ref<Captcha | undefined>();
 const recaptcha = ref<Captcha | undefined>();
 const turnstile = ref<Captcha | undefined>();
+const testcaptcha = ref<Captcha | undefined>();
 
 const username = ref<string>('');
 const password = ref<string>('');
@@ -123,6 +125,7 @@ const hCaptchaResponse = ref<string | null>(null);
 const mCaptchaResponse = ref<string | null>(null);
 const reCaptchaResponse = ref<string | null>(null);
 const turnstileResponse = ref<string | null>(null);
+const testcaptchaResponse = ref<string | null>(null);
 const usernameAbortController = ref<null | AbortController>(null);
 const emailAbortController = ref<null | AbortController>(null);
 
@@ -132,6 +135,7 @@ const shouldDisableSubmitting = computed((): boolean => {
 		instance.enableMcaptcha && !mCaptchaResponse.value ||
 		instance.enableRecaptcha && !reCaptchaResponse.value ||
 		instance.enableTurnstile && !turnstileResponse.value ||
+		instance.enableTestcaptcha && !testcaptchaResponse.value ||
 		instance.emailRequiredForSignup && emailState.value !== 'ok' ||
 		usernameState.value !== 'ok' ||
 		passwordRetypeState.value !== 'match';
@@ -259,6 +263,7 @@ async function onSubmit(): Promise<void> {
 		'm-captcha-response': mCaptchaResponse.value,
 		'g-recaptcha-response': reCaptchaResponse.value,
 		'turnstile-response': turnstileResponse.value,
+		'testcaptcha-response': testcaptchaResponse.value,
 	};
 
 	const res = await fetch(`${config.apiUrl}/signup`, {
@@ -301,6 +306,7 @@ function onSignupApiError() {
 	mcaptcha.value?.reset?.();
 	recaptcha.value?.reset?.();
 	turnstile.value?.reset?.();
+	testcaptcha.value?.reset?.();
 
 	os.alert({
 		type: 'error',
diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue
index b34592cd6a..d07add4408 100644
--- a/packages/frontend/src/pages/admin/bot-protection.vue
+++ b/packages/frontend/src/pages/admin/bot-protection.vue
@@ -11,6 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	<template v-else-if="botProtectionForm.savedState.provider === 'mcaptcha'" #suffix>mCaptcha</template>
 	<template v-else-if="botProtectionForm.savedState.provider === 'recaptcha'" #suffix>reCAPTCHA</template>
 	<template v-else-if="botProtectionForm.savedState.provider === 'turnstile'" #suffix>Turnstile</template>
+	<template v-else-if="botProtectionForm.savedState.provider === 'testcaptcha'" #suffix>testCaptcha</template>
 	<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
 	<template v-if="botProtectionForm.modified.value" #footer>
 		<MkFormFooter :form="botProtectionForm"/>
@@ -23,6 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<option value="mcaptcha">mCaptcha</option>
 			<option value="recaptcha">reCAPTCHA</option>
 			<option value="turnstile">Turnstile</option>
+			<option value="testcaptcha">testCaptcha</option>
 		</MkRadios>
 
 		<template v-if="botProtectionForm.state.provider === 'hcaptcha'">
@@ -85,6 +87,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<MkCaptcha provider="turnstile" :sitekey="botProtectionForm.state.turnstileSiteKey || '1x00000000000000000000AA'"/>
 			</FormSlot>
 		</template>
+		<template v-else-if="botProtectionForm.state.provider === 'testcaptcha'">
+			<MkInfo warn><span v-html="i18n.ts.testCaptchaWarning"></span></MkInfo>
+			<FormSlot>
+				<template #label>{{ i18n.ts.preview }}</template>
+				<MkCaptcha provider="testcaptcha"/>
+			</FormSlot>
+		</template>
 	</div>
 </MkFolder>
 </template>
@@ -101,6 +110,7 @@ import { i18n } from '@/i18n.js';
 import { useForm } from '@/scripts/use-form.js';
 import MkFormFooter from '@/components/MkFormFooter.vue';
 import MkFolder from '@/components/MkFolder.vue';
+import MkInfo from '@/components/MkInfo.vue';
 
 const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue'));
 
@@ -115,7 +125,9 @@ const botProtectionForm = useForm({
 				? 'turnstile'
 				: meta.enableMcaptcha
 					? 'mcaptcha'
-					: null,
+					: meta.enableTestcaptcha
+						? 'testcaptcha'
+						: null,
 	hcaptchaSiteKey: meta.hcaptchaSiteKey,
 	hcaptchaSecretKey: meta.hcaptchaSecretKey,
 	mcaptchaSiteKey: meta.mcaptchaSiteKey,
@@ -140,6 +152,7 @@ const botProtectionForm = useForm({
 		enableTurnstile: state.provider === 'turnstile',
 		turnstileSiteKey: state.turnstileSiteKey,
 		turnstileSecretKey: state.turnstileSecretKey,
+		enableTestcaptcha: state.provider === 'testcaptcha',
 	});
 	fetchInstance(true);
 });
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 76ef7ea1fb..e40cb050fd 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -4972,6 +4972,7 @@ export type components = {
       recaptchaSiteKey: string | null;
       enableTurnstile: boolean;
       turnstileSiteKey: string | null;
+      enableTestcaptcha: boolean;
       swPublickey: string | null;
       /** @default /assets/ai.png */
       mascotImageUrl: string;
@@ -5102,6 +5103,7 @@ export type operations = {
             recaptchaSiteKey: string | null;
             enableTurnstile: boolean;
             turnstileSiteKey: string | null;
+            enableTestcaptcha: boolean;
             swPublickey: string | null;
             /** @default /assets/ai.png */
             mascotImageUrl: string | null;
@@ -9491,6 +9493,7 @@ export type operations = {
           enableTurnstile?: boolean;
           turnstileSiteKey?: string | null;
           turnstileSecretKey?: string | null;
+          enableTestcaptcha?: boolean;
           /** @enum {string} */
           sensitiveMediaDetection?: 'none' | 'all' | 'local' | 'remote';
           /** @enum {string} */

From 777804605eb056c6f139ad07827413467b7cee9a Mon Sep 17 00:00:00 2001
From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com>
Date: Fri, 11 Oct 2024 12:13:47 +0000
Subject: [PATCH 05/34] Bump version to 2024.10.1-beta.3

---
 package.json                     | 2 +-
 packages/misskey-js/package.json | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/package.json b/package.json
index 31d46d79f8..2c84c55303 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "misskey",
-	"version": "2024.10.1-beta.2",
+	"version": "2024.10.1-beta.3",
 	"codename": "nasubi",
 	"repository": {
 		"type": "git",
diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json
index 268eab7978..e5f4b7b9dd 100644
--- a/packages/misskey-js/package.json
+++ b/packages/misskey-js/package.json
@@ -1,7 +1,7 @@
 {
 	"type": "module",
 	"name": "misskey-js",
-	"version": "2024.10.1-beta.2",
+	"version": "2024.10.1-beta.3",
 	"description": "Misskey SDK for JavaScript",
 	"license": "MIT",
 	"main": "./built/index.js",

From 2f09d69773dcc6c4607b2c9e1f5c86cc1337ece1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?=
 <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Fri, 11 Oct 2024 21:29:03 +0900
Subject: [PATCH 06/34] =?UTF-8?q?fix(backend):=20=E3=82=AD=E3=83=A5?=
 =?UTF-8?q?=E3=83=BC=E3=81=AE=E3=82=A8=E3=83=A9=E3=83=BC=E3=83=AD=E3=82=B0?=
 =?UTF-8?q?=E3=82=92=E7=B0=A1=E7=95=A5=E5=8C=96=E3=81=99=E3=82=8B=E3=82=88?=
 =?UTF-8?q?=E3=81=86=E3=81=AB=20(#14748)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* reduce federation log spam

* Don't record stack trace for unrecoverable errors.
* Avoid logging duplicate stace traces.

(cherry picked from commit ed0570110bf8cb8e8959591dccfa3c35999106ce)

* improve error summaries

(cherry picked from commit 20dd66f735d9778df0371001e303549dce619260)

* fix lint errors

(cherry picked from commit 83869e1c470b12b3bf4b23d885514d926620662a)

* condense job info

(cherry picked from commit 786702e076ad1af14538849512ad31c0ced7afe6)

* fix maxAttempts calculation

(cherry picked from commit b4d10aa8f821e594ec9c907eb2a5bdb3c73c67d5)

* condense error info

(cherry picked from commit f62cd8941ced74a4865aa5eae4f4a1c7aa1d30f1)

* normalize ID logging

(cherry picked from commit d8e1e4890d28347239162e26235eb68b1ff96654)

* further condense error details

(cherry picked from commit d867c2089b3b24680df0713a2aa0914789e45670)

* collapse AbortErrors

(cherry picked from commit 5171ba7113ebc7242527768afb9ab4cec534e3b3)

* don't log job name unless it has one

(cherry picked from commit a5316c06ed770b60f7b4c7ff5aa8c71cc0558db7)

* Update Changelog

* Record origin

---------

Co-authored-by: Hazel K <acomputerdog@gmail.com>
---
 CHANGELOG.md                                  |  4 +
 .../src/queue/QueueProcessorService.ts        | 86 +++++++++++--------
 2 files changed, 52 insertions(+), 38 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 030dbfda28..143b63f7b2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,10 @@
 - Feat: モデレータ権限を持つユーザが全員7日間活動しなかった場合は自動的に招待制へと移行するように ( #13437 )
 - Fix: `admin/emoji/update`エンドポイントのidのみ指定した時不正なエラーが発生するバグを修正
 
+### Server
+- Fix: キューのエラーログを簡略化するように  
+  (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/649)
+
 ## 2024.10.0
 
 ### Note
diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts
index 85e148e900..6940e1c188 100644
--- a/packages/backend/src/queue/QueueProcessorService.ts
+++ b/packages/backend/src/queue/QueueProcessorService.ts
@@ -67,7 +67,7 @@ function getJobInfo(job: Bull.Job | undefined, increment = false): string {
 
 	// onActiveとかonCompletedのattemptsMadeがなぜか0始まりなのでインクリメントする
 	const currentAttempts = job.attemptsMade + (increment ? 1 : 0);
-	const maxAttempts = job.opts ? job.opts.attempts : 0;
+	const maxAttempts = job.opts.attempts ?? 0;
 
 	return `id=${job.id} attempts=${currentAttempts}/${maxAttempts} age=${formated}`;
 }
@@ -126,20 +126,30 @@ export class QueueProcessorService implements OnApplicationShutdown {
 	) {
 		this.logger = this.queueLoggerService.logger;
 
-		function renderError(e: Error): any {
-			if (e) { // 何故かeがundefinedで来ることがある
-				return {
-					stack: e.stack,
-					message: e.message,
-					name: e.name,
-				};
-			} else {
-				return {
-					stack: '?',
-					message: '?',
-					name: '?',
-				};
+		function renderError(e?: Error) {
+			// 何故かeがundefinedで来ることがある
+			if (!e) return '?';
+
+			if (e instanceof Bull.UnrecoverableError || e.name === 'AbortError') {
+				return `${e.name}: ${e.message}`;
 			}
+
+			return {
+				stack: e.stack,
+				message: e.message,
+				name: e.name,
+			};
+		}
+
+		function renderJob(job?: Bull.Job) {
+			if (!job) return '?';
+
+			return {
+				name: job.name || undefined,
+				info: getJobInfo(job),
+				failedReason: job.failedReason || undefined,
+				data: job.data,
+			};
 		}
 
 		//#region system
@@ -175,15 +185,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
 				.on('active', (job) => logger.debug(`active id=${job.id}`))
 				.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
 				.on('failed', (job, err: Error) => {
-					logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) });
+					logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
 					if (config.sentryForBackend) {
-						Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.message}`, {
+						Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
 							level: 'error',
 							extra: { job, err },
 						});
 					}
 				})
-				.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
+				.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
 				.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
 		}
 		//#endregion
@@ -232,15 +242,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
 				.on('active', (job) => logger.debug(`active id=${job.id}`))
 				.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
 				.on('failed', (job, err) => {
-					logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) });
+					logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
 					if (config.sentryForBackend) {
-						Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.message}`, {
+						Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
 							level: 'error',
 							extra: { job, err },
 						});
 					}
 				})
-				.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
+				.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
 				.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
 		}
 		//#endregion
@@ -272,15 +282,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
 				.on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
 				.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
 				.on('failed', (job, err) => {
-					logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
+					logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
 					if (config.sentryForBackend) {
-						Sentry.captureMessage(`Queue: Deliver: ${err.message}`, {
+						Sentry.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, {
 							level: 'error',
 							extra: { job, err },
 						});
 					}
 				})
-				.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
+				.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
 				.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
 		}
 		//#endregion
@@ -312,15 +322,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
 				.on('active', (job) => logger.debug(`active ${getJobInfo(job, true)}`))
 				.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`))
 				.on('failed', (job, err) => {
-					logger.error(`failed(${err.stack}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job, e: renderError(err) });
+					logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job: renderJob(job), e: renderError(err) });
 					if (config.sentryForBackend) {
-						Sentry.captureMessage(`Queue: Inbox: ${err.message}`, {
+						Sentry.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, {
 							level: 'error',
 							extra: { job, err },
 						});
 					}
 				})
-				.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
+				.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
 				.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
 		}
 		//#endregion
@@ -352,15 +362,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
 				.on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
 				.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
 				.on('failed', (job, err) => {
-					logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
+					logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
 					if (config.sentryForBackend) {
-						Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.message}`, {
+						Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, {
 							level: 'error',
 							extra: { job, err },
 						});
 					}
 				})
-				.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
+				.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
 				.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
 		}
 		//#endregion
@@ -392,15 +402,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
 				.on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`))
 				.on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`))
 				.on('failed', (job, err) => {
-					logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
+					logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`);
 					if (config.sentryForBackend) {
-						Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.message}`, {
+						Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, {
 							level: 'error',
 							extra: { job, err },
 						});
 					}
 				})
-				.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
+				.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
 				.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
 		}
 		//#endregion
@@ -439,15 +449,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
 				.on('active', (job) => logger.debug(`active id=${job.id}`))
 				.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
 				.on('failed', (job, err) => {
-					logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) });
+					logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
 					if (config.sentryForBackend) {
-						Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.message}`, {
+						Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
 							level: 'error',
 							extra: { job, err },
 						});
 					}
 				})
-				.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
+				.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
 				.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
 		}
 		//#endregion
@@ -480,15 +490,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
 				.on('active', (job) => logger.debug(`active id=${job.id}`))
 				.on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`))
 				.on('failed', (job, err) => {
-					logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) });
+					logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) });
 					if (config.sentryForBackend) {
-						Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.message}`, {
+						Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, {
 							level: 'error',
 							extra: { job, err },
 						});
 					}
 				})
-				.on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) }))
+				.on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) }))
 				.on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`));
 		}
 		//#endregion

From a87a18f40d6b8c8ff44a0bccc7fadb34029f3812 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Sat, 12 Oct 2024 10:11:55 +0900
Subject: [PATCH 07/34] Update about-misskey.vue

---
 packages/frontend/src/pages/about-misskey.vue | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue
index 891489f1a1..68b98c2ab7 100644
--- a/packages/frontend/src/pages/about-misskey.vue
+++ b/packages/frontend/src/pages/about-misskey.vue
@@ -266,6 +266,9 @@ const patronsWithIcon = [{
 }, {
 	name: 'なっかあ',
 	icon: 'https://assets.misskey-hub.net/patrons/c2f5f3e394e74a64912284a2f4ca710e.jpg',
+}, {
+	name: '如月ユカ',
+	icon: 'https://assets.misskey-hub.net/patrons/f24a042076a041b6811a2f124eb620ca.jpg',
 }];
 
 const patrons = [

From ef90f83917c61afef607c67b16adbabea12b78a2 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Sat, 12 Oct 2024 10:31:40 +0900
Subject: [PATCH 08/34] Update index.d.ts

---
 locales/index.d.ts | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/locales/index.d.ts b/locales/index.d.ts
index dab8eb0361..2dca73bfa6 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -5700,6 +5700,10 @@ export interface Locale extends ILocale {
          * サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。
          */
         "inquiryUrlDescription": string;
+        /**
+         * 一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。
+         */
+        "thisSettingWillAutomaticallyOffWhenModeratorsInactive": string;
     };
     "_accountMigration": {
         /**

From 824c51a19f8cf3f4b6ad9bff56a5449d1b216ba1 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Sat, 12 Oct 2024 10:32:00 +0900
Subject: [PATCH 09/34] fix(frontend): fix style

Fix #14754
---
 packages/frontend/src/components/MkWindow.vue | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue
index a5f7a2e9e5..056b6a37ed 100644
--- a/packages/frontend/src/components/MkWindow.vue
+++ b/packages/frontend/src/components/MkWindow.vue
@@ -54,9 +54,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 
 <script lang="ts" setup>
 import { onBeforeUnmount, onMounted, provide, shallowRef, ref } from 'vue';
+import type { MenuItem } from '@/types/menu.js';
 import contains from '@/scripts/contains.js';
 import * as os from '@/os.js';
-import type { MenuItem } from '@/types/menu.js';
 import { i18n } from '@/i18n.js';
 import { defaultStore } from '@/store.js';
 
@@ -484,6 +484,10 @@ defineExpose({
 }
 
 .root {
+	// universal.vueとかで直接--MI-stickyBottomが定義されていたりするのでリセット
+	--MI-stickyTop: 0;
+	--MI-stickyBottom: 0;
+
 	position: fixed;
 	top: 0;
 	left: 0;

From 85bb1ff1db5f2156b88a588477efc137323e1333 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Sat, 12 Oct 2024 11:18:26 +0900
Subject: [PATCH 10/34] :art:

---
 packages/frontend/src/components/global/MkAd.vue | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue
index 646304fb06..1eded847ef 100644
--- a/packages/frontend/src/components/global/MkAd.vue
+++ b/packages/frontend/src/components/global/MkAd.vue
@@ -124,7 +124,7 @@ function reduceFrequency(): void {
 <style lang="scss" module>
 .root {
 	background-size: auto auto;
-	background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--bg) 8px, var(--bg) 14px );
+	background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px );
 }
 
 .main {

From ee08e9f51e5079a0a1ba1ff2f109ae72c3f19dd7 Mon Sep 17 00:00:00 2001
From: FineArchs <133759614+FineArchs@users.noreply.github.com>
Date: Sat, 12 Oct 2024 11:20:55 +0900
Subject: [PATCH 11/34] =?UTF-8?q?refactor:=20MkStickyContainer=E3=81=A7<st?=
 =?UTF-8?q?yle=20/>=E3=82=92=E4=BD=BF=E3=81=86=20(#14755)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* remove rootEL ref

* use css module

* use v-bind in css

* --MI prefix

* remove unused ref

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
---
 .../components/global/MkStickyContainer.vue   | 56 +++++++++----------
 1 file changed, 25 insertions(+), 31 deletions(-)

diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue
index cb21dafd2b..2763ecadd6 100644
--- a/packages/frontend/src/components/global/MkStickyContainer.vue
+++ b/packages/frontend/src/components/global/MkStickyContainer.vue
@@ -4,19 +4,18 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div ref="rootEl">
-	<div ref="headerEl">
+<div>
+	<div ref="headerEl" :class="$style.header">
 		<slot name="header"></slot>
 	</div>
 	<div
-		ref="bodyEl"
+		:class="$style.body"
 		:data-sticky-container-header-height="headerHeight"
 		:data-sticky-container-footer-height="footerHeight"
-		style="position: relative; z-index: 0;"
 	>
 		<slot></slot>
 	</div>
-	<div ref="footerEl">
+	<div ref="footerEl" :class="$style.footer">
 		<slot name="footer"></slot>
 	</div>
 </div>
@@ -27,10 +26,8 @@ import { onMounted, onUnmounted, provide, inject, Ref, ref, watch, shallowRef }
 
 import { CURRENT_STICKY_BOTTOM, CURRENT_STICKY_TOP } from '@@/js/const.js';
 
-const rootEl = shallowRef<HTMLElement>();
 const headerEl = shallowRef<HTMLElement>();
 const footerEl = shallowRef<HTMLElement>();
-const bodyEl = shallowRef<HTMLElement>();
 
 const headerHeight = ref<string | undefined>();
 const childStickyTop = ref(0);
@@ -67,31 +64,11 @@ onMounted(() => {
 
 	watch([parentStickyTop, parentStickyBottom], calc);
 
-	watch(childStickyTop, () => {
-		if (bodyEl.value == null) return;
-		bodyEl.value.style.setProperty('--MI-stickyTop', `${childStickyTop.value}px`);
-	}, {
-		immediate: true,
-	});
-
-	watch(childStickyBottom, () => {
-		if (bodyEl.value == null) return;
-		bodyEl.value.style.setProperty('--MI-stickyBottom', `${childStickyBottom.value}px`);
-	}, {
-		immediate: true,
-	});
-
 	if (headerEl.value != null) {
-		headerEl.value.style.position = 'sticky';
-		headerEl.value.style.top = 'var(--MI-stickyTop, 0)';
-		headerEl.value.style.zIndex = '1';
 		observer.observe(headerEl.value);
 	}
 
 	if (footerEl.value != null) {
-		footerEl.value.style.position = 'sticky';
-		footerEl.value.style.bottom = 'var(--MI-stickyBottom, 0)';
-		footerEl.value.style.zIndex = '1';
 		observer.observe(footerEl.value);
 	}
 });
@@ -99,8 +76,25 @@ onMounted(() => {
 onUnmounted(() => {
 	observer.disconnect();
 });
-
-defineExpose({
-	rootEl: rootEl,
-});
 </script>
+
+<style lang='scss' module>
+.body {
+	position: relative;
+	z-index: 0;
+	--MI-stickyTop: v-bind("childStickyTop + 'px'");
+	--MI-stickyBottom: v-bind("childStickyBottom + 'px'");
+}
+
+.header {
+	position: sticky;
+	top: var(--MI-stickyTop, 0);
+	z-index: 1;
+}
+
+.footer {
+	position: sticky;
+	bottom: var(--MI-stickyBottom, 0);
+	z-index: 1;
+}
+</style>

From c4c69cd267012158d456be0852e9e51e62874848 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Sat, 12 Oct 2024 11:28:58 +0900
Subject: [PATCH 12/34] :art:

---
 .../src/components/MkDateSeparatedList.vue     |  6 ++++--
 .../frontend/src/components/global/MkAd.vue    | 18 ++++++------------
 2 files changed, 10 insertions(+), 14 deletions(-)

diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue
index a8a32e8bc7..5976aa02f5 100644
--- a/packages/frontend/src/components/MkDateSeparatedList.vue
+++ b/packages/frontend/src/components/MkDateSeparatedList.vue
@@ -100,10 +100,12 @@ export default defineComponent({
 				return [el, separator];
 			} else {
 				if (props.ad && item._shouldInsertAd_) {
-					return [h(MkAd, {
+					return [h('div', {
 						key: item.id + ':ad',
+						style: 'padding: 8px; background-size: auto auto; background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px );',
+					}, [h(MkAd, {
 						prefer: ['horizontal', 'horizontal-big'],
-					}), el];
+					})]), el];
 				} else {
 					return el;
 				}
diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue
index 1eded847ef..792a087148 100644
--- a/packages/frontend/src/components/global/MkAd.vue
+++ b/packages/frontend/src/components/global/MkAd.vue
@@ -30,12 +30,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 		</component>
 	</div>
 	<div v-else :class="$style.menu">
-		<div :class="$style.menuContainer">
-			<div>Ads by {{ host }}</div>
-			<!--<MkButton class="button" primary>{{ i18n.ts._ad.like }}</MkButton>-->
-			<MkButton v-if="chosen.ratio !== 0" :class="$style.menuButton" @click="reduceFrequency">{{ i18n.ts._ad.reduceFrequencyOfThisAd }}</MkButton>
-			<button class="_textButton" @click="toggleMenu">{{ i18n.ts._ad.back }}</button>
-		</div>
+		<div>Ads by {{ host }}</div>
+		<!--<MkButton class="button" primary>{{ i18n.ts._ad.like }}</MkButton>-->
+		<MkButton v-if="chosen.ratio !== 0" :class="$style.menuButton" @click="reduceFrequency">{{ i18n.ts._ad.reduceFrequencyOfThisAd }}</MkButton>
+		<button class="_textButton" @click="toggleMenu">{{ i18n.ts._ad.back }}</button>
 	</div>
 </div>
 <div v-else></div>
@@ -123,8 +121,7 @@ function reduceFrequency(): void {
 
 <style lang="scss" module>
 .root {
-	background-size: auto auto;
-	background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px );
+
 }
 
 .main {
@@ -202,14 +199,11 @@ function reduceFrequency(): void {
 }
 
 .menu {
-	padding: 8px;
 	text-align: center;
-}
-
-.menuContainer {
 	padding: 8px;
 	margin: 0 auto;
 	max-width: 400px;
+	background: var(--MI_THEME-panel);
 	border: solid 1px var(--MI_THEME-divider);
 }
 

From 45d42b8641585cbe582e4c2a95e03ef511df00be Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?=
 <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Sun, 13 Oct 2024 20:21:25 +0900
Subject: [PATCH 13/34] =?UTF-8?q?feat:=20=E3=83=A6=E3=83=BC=E3=82=B6?=
 =?UTF-8?q?=E3=83=BC=E3=81=AE=E5=90=8D=E5=89=8D=E3=81=AB=E7=A6=81=E6=AD=A2?=
 =?UTF-8?q?=E3=83=AF=E3=83=BC=E3=83=89=E3=82=92=E8=A8=AD=E5=AE=9A=E3=81=A7?=
 =?UTF-8?q?=E3=81=8D=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=20(#14756)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* wip

* :art:

* Enhance: モデレーター以上は制限の影響を受けないように

* refactor

* better error handling

* fix

* Revert "better error handling"

This reverts commit 5670b29cfa18a3894d0c2abfe0e5ef862e3b9ffa.

* error handling

* エラーが出ないのを修正

* translation

* Update Changelog

* status code

* :v:

* モデレーター以上は影響ないことを明記

* :art:

* update changelog

* spdx

* Update update.ts

* refactor

* eliminate `screen name`

* remove untracked file

---------

Co-authored-by: KanariKanaru <93921745+kanarikanaru@users.noreply.github.com>
---
 CHANGELOG.md                                  |  3 +++
 locales/index.d.ts                            | 16 +++++++++++++
 locales/ja-JP.yml                             |  4 ++++
 ...8634286056-prohibitedWordsForNameOfUser.js | 14 +++++++++++
 packages/backend/src/models/Meta.ts           |  5 ++++
 .../src/server/api/endpoints/admin/meta.ts    |  8 +++++++
 .../server/api/endpoints/admin/update-meta.ts |  8 +++++++
 .../src/server/api/endpoints/i/update.ts      | 22 ++++++++++++++++-
 .../components/MkUserSetupDialog.Profile.vue  |  5 ++++
 packages/frontend/src/os.ts                   |  8 +++++--
 .../frontend/src/pages/admin/moderation.vue   | 24 ++++++++++++++++++-
 .../frontend/src/pages/settings/profile.vue   | 11 ++++++++-
 .../frontend/src/scripts/get-note-menu.ts     | 11 ++++-----
 packages/misskey-js/src/autogen/types.ts      |  2 ++
 14 files changed, 129 insertions(+), 12 deletions(-)
 create mode 100644 packages/backend/migration/1728634286056-prohibitedWordsForNameOfUser.js

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 143b63f7b2..130eb00b77 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,9 @@
 7日間活動していない場合は自動的に招待制へと移行(コントロールパネル -> モデレーション -> "誰でも新規登録できるようにする"をオフに変更)するようになりました。  
 詳細な経緯は https://github.com/misskey-dev/misskey/issues/13437 をご確認ください。
 
+### General
+- Feat: ユーザーの名前に禁止ワードを設定できるように
+
 ### Client
 - Enhance: l10nの更新
 - Fix: メールアドレス不要でCaptchaが有効な場合にアカウント登録完了後自動でのログインに失敗する問題を修正
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 2dca73bfa6..7585410291 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -5170,6 +5170,22 @@ export interface Locale extends ILocale {
      * CAPTCHAのテストを目的とした機能です。<strong>本番環境で使用しないでください。</strong>
      */
     "testCaptchaWarning": string;
+    /**
+     * 禁止ワード(ユーザーの名前)
+     */
+    "prohibitedWordsForNameOfUser": string;
+    /**
+     * このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。
+     */
+    "prohibitedWordsForNameOfUserDescription": string;
+    /**
+     * 変更しようとした名前に禁止された文字列が含まれています
+     */
+    "yourNameContainsProhibitedWords": string;
+    /**
+     * 名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。
+     */
+    "yourNameContainsProhibitedWordsDescription": string;
     "_abuseUserReport": {
         /**
          * 転送
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 440ffa9306..330f7ef473 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1288,6 +1288,10 @@ passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証
 messageToFollower: "フォロワーへのメッセージ"
 target: "対象"
 testCaptchaWarning: "CAPTCHAのテストを目的とした機能です。<strong>本番環境で使用しないでください。</strong>"
+prohibitedWordsForNameOfUser: "禁止ワード(ユーザーの名前)"
+prohibitedWordsForNameOfUserDescription: "このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。"
+yourNameContainsProhibitedWords: "変更しようとした名前に禁止された文字列が含まれています"
+yourNameContainsProhibitedWordsDescription: "名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。"
 
 _abuseUserReport:
   forward: "転送"
diff --git a/packages/backend/migration/1728634286056-prohibitedWordsForNameOfUser.js b/packages/backend/migration/1728634286056-prohibitedWordsForNameOfUser.js
new file mode 100644
index 0000000000..36e698d120
--- /dev/null
+++ b/packages/backend/migration/1728634286056-prohibitedWordsForNameOfUser.js
@@ -0,0 +1,14 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class ProhibitedWordsForNameOfUser1728634286056 {
+		async up(queryRunner) {
+			await queryRunner.query(`ALTER TABLE "meta" ADD "prohibitedWordsForNameOfUser" character varying(1024) array NOT NULL DEFAULT '{}'`);
+		}
+
+		async down(queryRunner) {
+			await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "prohibitedWordsForNameOfUser"`);
+		}
+}
diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts
index fd007de6c6..5ceee1c3f5 100644
--- a/packages/backend/src/models/Meta.ts
+++ b/packages/backend/src/models/Meta.ts
@@ -81,6 +81,11 @@ export class MiMeta {
 	})
 	public prohibitedWords: string[];
 
+	@Column('varchar', {
+		length: 1024, array: true, default: '{}',
+	})
+	public prohibitedWordsForNameOfUser: string[];
+
 	@Column('varchar', {
 		length: 1024, array: true, default: '{}',
 	})
diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts
index abb3c17be3..16cbbc9aa4 100644
--- a/packages/backend/src/server/api/endpoints/admin/meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/meta.ts
@@ -177,6 +177,13 @@ export const meta = {
 					type: 'string',
 				},
 			},
+			prohibitedWordsForNameOfUser: {
+				type: 'array',
+				optional: false, nullable: false,
+				items: {
+					type: 'string',
+				},
+			},
 			bannedEmailDomains: {
 				type: 'array',
 				optional: true, nullable: false,
@@ -586,6 +593,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				mediaSilencedHosts: instance.mediaSilencedHosts,
 				sensitiveWords: instance.sensitiveWords,
 				prohibitedWords: instance.prohibitedWords,
+				prohibitedWordsForNameOfUser: instance.prohibitedWordsForNameOfUser,
 				preservedUsernames: instance.preservedUsernames,
 				hcaptchaSecretKey: instance.hcaptchaSecretKey,
 				mcaptchaSecretKey: instance.mcaptchaSecretKey,
diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
index e97ac4e2b9..536645e0d7 100644
--- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
@@ -46,6 +46,11 @@ export const paramDef = {
 				type: 'string',
 			},
 		},
+		prohibitedWordsForNameOfUser: {
+			type: 'array', nullable: true, items: {
+				type: 'string',
+			},
+		},
 		themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' },
 		mascotImageUrl: { type: 'string', nullable: true },
 		bannerUrl: { type: 'string', nullable: true },
@@ -214,6 +219,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 			if (Array.isArray(ps.prohibitedWords)) {
 				set.prohibitedWords = ps.prohibitedWords.filter(Boolean);
 			}
+			if (Array.isArray(ps.prohibitedWordsForNameOfUser)) {
+				set.prohibitedWordsForNameOfUser = ps.prohibitedWordsForNameOfUser.filter(Boolean);
+			}
 			if (Array.isArray(ps.silencedHosts)) {
 				let lastValue = '';
 				set.silencedHosts = ps.silencedHosts.sort().filter((h) => {
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index 798bd98cf1..0b35005a87 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -11,7 +11,7 @@ import { JSDOM } from 'jsdom';
 import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
 import { extractHashtags } from '@/misc/extract-hashtags.js';
 import * as Acct from '@/misc/acct.js';
-import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/_.js';
+import type { UsersRepository, DriveFilesRepository, MiMeta, UserProfilesRepository, PagesRepository } from '@/models/_.js';
 import type { MiLocalUser, MiUser } from '@/models/User.js';
 import { birthdaySchema, descriptionSchema, followedMessageSchema, locationSchema, nameSchema } from '@/models/User.js';
 import type { MiUserProfile } from '@/models/UserProfile.js';
@@ -22,6 +22,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
 import { GlobalEventService } from '@/core/GlobalEventService.js';
 import { UserFollowingService } from '@/core/UserFollowingService.js';
 import { AccountUpdateService } from '@/core/AccountUpdateService.js';
+import { UtilityService } from '@/core/UtilityService.js';
 import { HashtagService } from '@/core/HashtagService.js';
 import { DI } from '@/di-symbols.js';
 import { RolePolicies, RoleService } from '@/core/RoleService.js';
@@ -114,6 +115,13 @@ export const meta = {
 			code: 'RESTRICTED_BY_ROLE',
 			id: '8feff0ba-5ab5-585b-31f4-4df816663fad',
 		},
+
+		nameContainsProhibitedWords: {
+			message: 'Your new name contains prohibited words.',
+			code: 'YOUR_NAME_CONTAINS_PROHIBITED_WORDS',
+			id: '0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191',
+			httpStatusCode: 422,
+		},
 	},
 
 	res: {
@@ -223,6 +231,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 		@Inject(DI.config)
 		private config: Config,
 
+		@Inject(DI.meta)
+		private instanceMeta: MiMeta,
+
 		@Inject(DI.usersRepository)
 		private usersRepository: UsersRepository,
 
@@ -247,6 +258,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 		private cacheService: CacheService,
 		private httpRequestService: HttpRequestService,
 		private avatarDecorationService: AvatarDecorationService,
+		private utilityService: UtilityService,
 	) {
 		super(meta, paramDef, async (ps, _user, token) => {
 			const user = await this.usersRepository.findOneByOrFail({ id: _user.id }) as MiLocalUser;
@@ -449,6 +461,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 			const newFields = profileUpdates.fields === undefined ? profile.fields : profileUpdates.fields;
 
 			if (newName != null) {
+				let hasProhibitedWords = false;
+				if (!await this.roleService.isModerator(user)) {
+					hasProhibitedWords = this.utilityService.isKeyWordIncluded(newName, this.instanceMeta.prohibitedWordsForNameOfUser);
+				}
+				if (hasProhibitedWords) {
+					throw new ApiError(meta.errors.nameContainsProhibitedWords);
+				}
+
 				const tokens = mfm.parseSimple(newName);
 				emojis = emojis.concat(extractCustomEmojisFromMfm(tokens));
 			}
diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue
index 3194641cdb..7cb48f6afb 100644
--- a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue
+++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue
@@ -51,6 +51,11 @@ watch(name, () => {
 		// 空文字列をnullにしたいので??は使うな
 		// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
 		name: name.value || null,
+	}, undefined, {
+		'0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191': {
+			title: i18n.ts.yourNameContainsProhibitedWords,
+			text: i18n.ts.yourNameContainsProhibitedWordsDescription,
+		},
 	});
 });
 
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index 60e4218a48..4d41cf5bc0 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -10,6 +10,7 @@ import { EventEmitter } from 'eventemitter3';
 import * as Misskey from 'misskey-js';
 import type { ComponentProps as CP } from 'vue-component-type-helpers';
 import type { Form, GetFormResultType } from '@/scripts/form.js';
+import type { MenuItem } from '@/types/menu.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { defaultStore } from '@/store.js';
 import { i18n } from '@/i18n.js';
@@ -22,7 +23,6 @@ import MkPasswordDialog from '@/components/MkPasswordDialog.vue';
 import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue';
 import MkPopupMenu from '@/components/MkPopupMenu.vue';
 import MkContextMenu from '@/components/MkContextMenu.vue';
-import type { MenuItem } from '@/types/menu.js';
 import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
 import { pleaseLogin } from '@/scripts/please-login.js';
 import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
@@ -35,6 +35,7 @@ export const apiWithDialog = (<E extends keyof Misskey.Endpoints = keyof Misskey
 	endpoint: E,
 	data: P = {} as any,
 	token?: string | null | undefined,
+	customErrors?: Record<string, { title?: string; text: string; }>,
 ) => {
 	const promise = misskeyApi(endpoint, data, token);
 	promiseDialog(promise, null, async (err) => {
@@ -77,6 +78,9 @@ export const apiWithDialog = (<E extends keyof Misskey.Endpoints = keyof Misskey
 		} else if (err.message.startsWith('Unexpected token')) {
 			title = i18n.ts.gotInvalidResponseError;
 			text = i18n.ts.gotInvalidResponseErrorDescription;
+		} else if (customErrors && customErrors[err.id] != null) {
+			title = customErrors[err.id].title;
+			text = customErrors[err.id].text;
 		}
 		alert({
 			type: 'error',
@@ -86,7 +90,7 @@ export const apiWithDialog = (<E extends keyof Misskey.Endpoints = keyof Misskey
 	});
 
 	return promise;
-}) as typeof misskeyApi;
+});
 
 export function promiseDialog<T extends Promise<any>>(
 	promise: T,
diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue
index 04d23b1358..5d8a581b2e 100644
--- a/packages/frontend/src/pages/admin/moderation.vue
+++ b/packages/frontend/src/pages/admin/moderation.vue
@@ -57,6 +57,18 @@ SPDX-License-Identifier: AGPL-3.0-only
 						</div>
 					</MkFolder>
 
+					<MkFolder>
+						<template #icon><i class="ti ti-user-x"></i></template>
+						<template #label>{{ i18n.ts.prohibitedWordsForNameOfUser }}</template>
+
+						<div class="_gaps">
+							<MkTextarea v-model="prohibitedWordsForNameOfUser">
+								<template #caption>{{ i18n.ts.prohibitedWordsForNameOfUserDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template>
+							</MkTextarea>
+							<MkButton primary @click="save_prohibitedWordsForNameOfUser">{{ i18n.ts.save }}</MkButton>
+						</div>
+					</MkFolder>
+
 					<MkFolder>
 						<template #icon><i class="ti ti-eye-off"></i></template>
 						<template #label>{{ i18n.ts.hiddenTags }}</template>
@@ -131,6 +143,7 @@ const enableRegistration = ref<boolean>(false);
 const emailRequiredForSignup = ref<boolean>(false);
 const sensitiveWords = ref<string>('');
 const prohibitedWords = ref<string>('');
+const prohibitedWordsForNameOfUser = ref<string>('');
 const hiddenTags = ref<string>('');
 const preservedUsernames = ref<string>('');
 const blockedHosts = ref<string>('');
@@ -143,10 +156,11 @@ async function init() {
 	emailRequiredForSignup.value = meta.emailRequiredForSignup;
 	sensitiveWords.value = meta.sensitiveWords.join('\n');
 	prohibitedWords.value = meta.prohibitedWords.join('\n');
+	prohibitedWordsForNameOfUser.value = meta.prohibitedWordsForNameOfUser.join('\n');
 	hiddenTags.value = meta.hiddenTags.join('\n');
 	preservedUsernames.value = meta.preservedUsernames.join('\n');
 	blockedHosts.value = meta.blockedHosts.join('\n');
-	silencedHosts.value = meta.silencedHosts.join('\n');
+	silencedHosts.value = meta.silencedHosts?.join('\n') ?? '';
 	mediaSilencedHosts.value = meta.mediaSilencedHosts.join('\n');
 }
 
@@ -190,6 +204,14 @@ function save_prohibitedWords() {
 	});
 }
 
+function save_prohibitedWordsForNameOfUser() {
+	os.apiWithDialog('admin/update-meta', {
+		prohibitedWordsForNameOfUser: prohibitedWordsForNameOfUser.value.split('\n'),
+	}).then(() => {
+		fetchInstance(true);
+	});
+}
+
 function save_hiddenTags() {
 	os.apiWithDialog('admin/update-meta', {
 		hiddenTags: hiddenTags.value.split('\n'),
diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue
index 0d61f8d851..561894d2b7 100644
--- a/packages/frontend/src/pages/settings/profile.vue
+++ b/packages/frontend/src/pages/settings/profile.vue
@@ -142,13 +142,17 @@ const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.d
 
 const reactionAcceptance = computed(defaultStore.makeGetterSetter('reactionAcceptance'));
 
+function assertVaildLang(lang: string | null): lang is keyof typeof langmap {
+	return lang != null && lang in langmap;
+}
+
 const profile = reactive({
 	name: $i.name,
 	description: $i.description,
 	followedMessage: $i.followedMessage,
 	location: $i.location,
 	birthday: $i.birthday,
-	lang: $i.lang,
+	lang: assertVaildLang($i.lang) ? $i.lang : null,
 	isBot: $i.isBot ?? false,
 	isCat: $i.isCat ?? false,
 });
@@ -202,6 +206,11 @@ function save() {
 		lang: profile.lang || null,
 		isBot: !!profile.isBot,
 		isCat: !!profile.isCat,
+	}, undefined, {
+		'0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191': {
+			title: i18n.ts.yourNameContainsProhibitedWords,
+			text: i18n.ts.yourNameContainsProhibitedWordsDescription,
+		},
 	});
 	globalEvents.emit('requestClearPageCache');
 	claimAchievement('profileFilled');
diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts
index 4ffa0ab94d..c1846b0589 100644
--- a/packages/frontend/src/scripts/get-note-menu.ts
+++ b/packages/frontend/src/scripts/get-note-menu.ts
@@ -245,13 +245,10 @@ export function getNoteMenu(props: {
 	function togglePin(pin: boolean): void {
 		os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
 			noteId: appearNote.id,
-		}, undefined, null, res => {
-			if (res.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
-				os.alert({
-					type: 'error',
-					text: i18n.ts.pinLimitExceeded,
-				});
-			}
+		}, undefined, {
+			'72dab508-c64d-498f-8740-a8eec1ba385a': {
+				text: i18n.ts.pinLimitExceeded,
+			},
 		});
 	}
 
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index e40cb050fd..f61d72e280 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -5124,6 +5124,7 @@ export type operations = {
             blockedHosts: string[];
             sensitiveWords: string[];
             prohibitedWords: string[];
+            prohibitedWordsForNameOfUser: string[];
             bannedEmailDomains?: string[];
             preservedUsernames: string[];
             hcaptchaSecretKey: string | null;
@@ -9461,6 +9462,7 @@ export type operations = {
           blockedHosts?: string[] | null;
           sensitiveWords?: string[] | null;
           prohibitedWords?: string[] | null;
+          prohibitedWordsForNameOfUser?: string[] | null;
           themeColor?: string | null;
           mascotImageUrl?: string | null;
           bannerUrl?: string | null;

From ff47fef5725ba31efc7016534c2d9db8b0ad242a Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Sun, 13 Oct 2024 20:22:16 +0900
Subject: [PATCH 14/34] =?UTF-8?q?feat:=20=E3=83=AA=E3=83=A2=E3=83=BC?=
 =?UTF-8?q?=E3=83=88=E3=82=B5=E3=83=BC=E3=83=90=E3=83=BC=E3=81=AE=E3=82=B5?=
 =?UTF-8?q?=E3=83=BC=E3=83=90=E3=83=BC=E6=83=85=E5=A0=B1=E3=82=92=E5=8F=8E?=
 =?UTF-8?q?=E9=9B=86=E3=81=97=E3=81=AA=E3=81=84=E3=82=AA=E3=83=97=E3=82=B7?=
 =?UTF-8?q?=E3=83=A7=E3=83=B3=20(#14634)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* wip

* wip

* Update FetchInstanceMetadataService.ts

* Update FetchInstanceMetadataService.ts

* Update types.ts
---
 locales/index.d.ts                            |  4 ++
 locales/ja-JP.yml                             |  1 +
 ...020265-enableStatsForFederatedInstances.js | 16 +++++
 .../backend/src/core/AccountMoveService.ts    | 16 ++---
 .../src/core/FederatedInstanceService.ts      | 20 ++++++-
 .../src/core/FetchInstanceMetadataService.ts  |  2 +-
 .../backend/src/core/NoteCreateService.ts     | 16 ++---
 .../backend/src/core/NoteDeleteService.ts     | 16 ++---
 .../backend/src/core/UserFollowingService.ts  | 60 ++++++++++---------
 .../activitypub/models/ApPersonService.ts     | 16 ++---
 packages/backend/src/models/Meta.ts           |  5 ++
 .../processors/DeliverProcessorService.ts     | 31 ++++++----
 .../queue/processors/InboxProcessorService.ts | 20 ++++---
 .../src/server/api/endpoints/admin/meta.ts    |  5 ++
 .../server/api/endpoints/admin/update-meta.ts |  5 ++
 .../test/unit/FetchInstanceMetadataService.ts | 20 +++----
 .../frontend/src/pages/admin/performance.vue  | 16 +++++
 packages/misskey-js/src/autogen/types.ts      |  2 +
 18 files changed, 185 insertions(+), 86 deletions(-)
 create mode 100644 packages/backend/migration/1727318020265-enableStatsForFederatedInstances.js

diff --git a/locales/index.d.ts b/locales/index.d.ts
index 7585410291..3ffb67f31c 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -4366,6 +4366,10 @@ export interface Locale extends ILocale {
      * リモートサーバーのチャートを生成
      */
     "enableChartsForFederatedInstances": string;
+    /**
+     * リモートサーバーの情報を取得
+     */
+    "enableStatsForFederatedInstances": string;
     /**
      * ノートのアクションにクリップを追加
      */
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 330f7ef473..919be1e3ec 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1087,6 +1087,7 @@ retryAllQueuesConfirmTitle: "今すぐ再試行しますか?"
 retryAllQueuesConfirmText: "一時的にサーバーの負荷が増大することがあります。"
 enableChartsForRemoteUser: "リモートユーザーのチャートを生成"
 enableChartsForFederatedInstances: "リモートサーバーのチャートを生成"
+enableStatsForFederatedInstances: "リモートサーバーの情報を取得"
 showClipButtonInNoteFooter: "ノートのアクションにクリップを追加"
 reactionsDisplaySize: "リアクションの表示サイズ"
 limitWidthOfReaction: "リアクションの最大横幅を制限し、縮小して表示する"
diff --git a/packages/backend/migration/1727318020265-enableStatsForFederatedInstances.js b/packages/backend/migration/1727318020265-enableStatsForFederatedInstances.js
new file mode 100644
index 0000000000..4ff520172b
--- /dev/null
+++ b/packages/backend/migration/1727318020265-enableStatsForFederatedInstances.js
@@ -0,0 +1,16 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class EnableStatsForFederatedInstances1727318020265 {
+    name = 'EnableStatsForFederatedInstances1727318020265'
+
+    async up(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "meta" ADD "enableStatsForFederatedInstances" boolean NOT NULL DEFAULT true`);
+    }
+
+    async down(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableStatsForFederatedInstances"`);
+    }
+}
diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts
index 6e3125044c..24d11f29ff 100644
--- a/packages/backend/src/core/AccountMoveService.ts
+++ b/packages/backend/src/core/AccountMoveService.ts
@@ -274,13 +274,15 @@ export class AccountMoveService {
 		}
 
 		// Update instance stats by decreasing remote followers count by the number of local followers who were following the old account.
-		if (this.userEntityService.isRemoteUser(oldAccount)) {
-			this.federatedInstanceService.fetch(oldAccount.host).then(async i => {
-				this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length);
-				if (this.meta.enableChartsForFederatedInstances) {
-					this.instanceChart.updateFollowers(i.host, false);
-				}
-			});
+		if (this.meta.enableStatsForFederatedInstances) {
+			if (this.userEntityService.isRemoteUser(oldAccount)) {
+				this.federatedInstanceService.fetchOrRegister(oldAccount.host).then(async i => {
+					this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length);
+					if (this.meta.enableChartsForFederatedInstances) {
+						this.instanceChart.updateFollowers(i.host, false);
+					}
+				});
+			}
 		}
 
 		// FIXME: expensive?
diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts
index 7aeeb78178..73bbf03b26 100644
--- a/packages/backend/src/core/FederatedInstanceService.ts
+++ b/packages/backend/src/core/FederatedInstanceService.ts
@@ -47,7 +47,7 @@ export class FederatedInstanceService implements OnApplicationShutdown {
 	}
 
 	@bindThis
-	public async fetch(host: string): Promise<MiInstance> {
+	public async fetchOrRegister(host: string): Promise<MiInstance> {
 		host = this.utilityService.toPuny(host);
 
 		const cached = await this.federatedInstanceCache.get(host);
@@ -70,6 +70,24 @@ export class FederatedInstanceService implements OnApplicationShutdown {
 		}
 	}
 
+	@bindThis
+	public async fetch(host: string): Promise<MiInstance | null> {
+		host = this.utilityService.toPuny(host);
+
+		const cached = await this.federatedInstanceCache.get(host);
+		if (cached !== undefined) return cached;
+
+		const index = await this.instancesRepository.findOneBy({ host });
+
+		if (index == null) {
+			this.federatedInstanceCache.set(host, null);
+			return null;
+		} else {
+			this.federatedInstanceCache.set(host, index);
+			return index;
+		}
+	}
+
 	@bindThis
 	public async update(id: MiInstance['id'], data: Partial<MiInstance>): Promise<void> {
 		const result = await this.instancesRepository.createQueryBuilder().update()
diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts
index aa16468ecb..987999bce7 100644
--- a/packages/backend/src/core/FetchInstanceMetadataService.ts
+++ b/packages/backend/src/core/FetchInstanceMetadataService.ts
@@ -82,7 +82,7 @@ export class FetchInstanceMetadataService {
 
 		try {
 			if (!force) {
-				const _instance = await this.federatedInstanceService.fetch(host);
+				const _instance = await this.federatedInstanceService.fetchOrRegister(host);
 				const now = Date.now();
 				if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) {
 					// unlock at the finally caluse
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index 0ce57f16e6..3647fa7231 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -511,13 +511,15 @@ export class NoteCreateService implements OnApplicationShutdown {
 		}
 
 		// Register host
-		if (this.userEntityService.isRemoteUser(user)) {
-			this.federatedInstanceService.fetch(user.host).then(async i => {
-				this.updateNotesCountQueue.enqueue(i.id, 1);
-				if (this.meta.enableChartsForFederatedInstances) {
-					this.instanceChart.updateNote(i.host, note, true);
-				}
-			});
+		if (this.meta.enableStatsForFederatedInstances) {
+			if (this.userEntityService.isRemoteUser(user)) {
+				this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
+					this.updateNotesCountQueue.enqueue(i.id, 1);
+					if (this.meta.enableChartsForFederatedInstances) {
+						this.instanceChart.updateNote(i.host, note, true);
+					}
+				});
+			}
 		}
 
 		// ハッシュタグ更新
diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts
index f9f8ace386..4ecd2592b2 100644
--- a/packages/backend/src/core/NoteDeleteService.ts
+++ b/packages/backend/src/core/NoteDeleteService.ts
@@ -106,13 +106,15 @@ export class NoteDeleteService {
 				this.perUserNotesChart.update(user, note, false);
 			}
 
-			if (this.userEntityService.isRemoteUser(user)) {
-				this.federatedInstanceService.fetch(user.host).then(async i => {
-					this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
-					if (this.meta.enableChartsForFederatedInstances) {
-						this.instanceChart.updateNote(i.host, note, false);
-					}
-				});
+			if (this.meta.enableStatsForFederatedInstances) {
+				if (this.userEntityService.isRemoteUser(user)) {
+					this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
+						this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
+						if (this.meta.enableChartsForFederatedInstances) {
+							this.instanceChart.updateNote(i.host, note, false);
+						}
+					});
+				}
 			}
 		}
 
diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts
index 77e7b60bea..8963003057 100644
--- a/packages/backend/src/core/UserFollowingService.ts
+++ b/packages/backend/src/core/UserFollowingService.ts
@@ -305,20 +305,22 @@ export class UserFollowingService implements OnModuleInit {
 			//#endregion
 
 			//#region Update instance stats
-			if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
-				this.federatedInstanceService.fetch(follower.host).then(async i => {
-					this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
-					if (this.meta.enableChartsForFederatedInstances) {
-						this.instanceChart.updateFollowing(i.host, true);
-					}
-				});
-			} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
-				this.federatedInstanceService.fetch(followee.host).then(async i => {
-					this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
-					if (this.meta.enableChartsForFederatedInstances) {
-						this.instanceChart.updateFollowers(i.host, true);
-					}
-				});
+			if (this.meta.enableStatsForFederatedInstances) {
+				if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
+					this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => {
+						this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
+						if (this.meta.enableChartsForFederatedInstances) {
+							this.instanceChart.updateFollowing(i.host, true);
+						}
+					});
+				} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
+					this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => {
+						this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
+						if (this.meta.enableChartsForFederatedInstances) {
+							this.instanceChart.updateFollowers(i.host, true);
+						}
+					});
+				}
 			}
 			//#endregion
 
@@ -437,20 +439,22 @@ export class UserFollowingService implements OnModuleInit {
 			//#endregion
 
 			//#region Update instance stats
-			if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
-				this.federatedInstanceService.fetch(follower.host).then(async i => {
-					this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
-					if (this.meta.enableChartsForFederatedInstances) {
-						this.instanceChart.updateFollowing(i.host, false);
-					}
-				});
-			} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
-				this.federatedInstanceService.fetch(followee.host).then(async i => {
-					this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
-					if (this.meta.enableChartsForFederatedInstances) {
-						this.instanceChart.updateFollowers(i.host, false);
-					}
-				});
+			if (this.meta.enableStatsForFederatedInstances) {
+				if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
+					this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => {
+						this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
+						if (this.meta.enableChartsForFederatedInstances) {
+							this.instanceChart.updateFollowing(i.host, false);
+						}
+					});
+				} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
+					this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => {
+						this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
+						if (this.meta.enableChartsForFederatedInstances) {
+							this.instanceChart.updateFollowers(i.host, false);
+						}
+					});
+				}
 			}
 			//#endregion
 
diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts
index e042a85782..73281078e5 100644
--- a/packages/backend/src/core/activitypub/models/ApPersonService.ts
+++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts
@@ -408,13 +408,15 @@ export class ApPersonService implements OnModuleInit {
 		this.cacheService.uriPersonCache.set(user.uri, user);
 
 		// Register host
-		this.federatedInstanceService.fetch(host).then(i => {
-			this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
-			this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
-			if (this.meta.enableChartsForFederatedInstances) {
-				this.instanceChart.newUser(i.host);
-			}
-		});
+		if (this.meta.enableStatsForFederatedInstances) {
+			this.federatedInstanceService.fetchOrRegister(host).then(i => {
+				this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
+				if (this.meta.enableChartsForFederatedInstances) {
+					this.instanceChart.newUser(i.host);
+				}
+				this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
+			});
+		}
 
 		this.usersChart.update(user, true);
 
diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts
index 5ceee1c3f5..ad5e31ad6f 100644
--- a/packages/backend/src/models/Meta.ts
+++ b/packages/backend/src/models/Meta.ts
@@ -529,6 +529,11 @@ export class MiMeta {
 	})
 	public enableChartsForFederatedInstances: boolean;
 
+	@Column('boolean', {
+		default: true,
+	})
+	public enableStatsForFederatedInstances: boolean;
+
 	@Column('boolean', {
 		default: false,
 	})
diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts
index 9590a4fe71..5a16496011 100644
--- a/packages/backend/src/queue/processors/DeliverProcessorService.ts
+++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts
@@ -74,8 +74,17 @@ export class DeliverProcessorService {
 		try {
 			await this.apRequestService.signedPost(job.data.user, job.data.to, job.data.content, job.data.digest);
 
-			// Update stats
-			this.federatedInstanceService.fetch(host).then(i => {
+			this.apRequestChart.deliverSucc();
+			this.federationChart.deliverd(host, true);
+
+			// Update instance stats
+			process.nextTick(async () => {
+				const i = await (this.meta.enableStatsForFederatedInstances
+					? this.federatedInstanceService.fetchOrRegister(host)
+					: this.federatedInstanceService.fetch(host));
+
+				if (i == null) return;
+
 				if (i.isNotResponding) {
 					this.federatedInstanceService.update(i.id, {
 						isNotResponding: false,
@@ -83,9 +92,9 @@ export class DeliverProcessorService {
 					});
 				}
 
-				this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
-				this.apRequestChart.deliverSucc();
-				this.federationChart.deliverd(i.host, true);
+				if (this.meta.enableStatsForFederatedInstances) {
+					this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
+				}
 
 				if (this.meta.enableChartsForFederatedInstances) {
 					this.instanceChart.requestSent(i.host, true);
@@ -94,8 +103,11 @@ export class DeliverProcessorService {
 
 			return 'Success';
 		} catch (res) {
-			// Update stats
-			this.federatedInstanceService.fetch(host).then(i => {
+			this.apRequestChart.deliverFail();
+			this.federationChart.deliverd(host, false);
+
+			// Update instance stats
+			this.federatedInstanceService.fetchOrRegister(host).then(i => {
 				if (!i.isNotResponding) {
 					this.federatedInstanceService.update(i.id, {
 						isNotResponding: true,
@@ -116,9 +128,6 @@ export class DeliverProcessorService {
 					});
 				}
 
-				this.apRequestChart.deliverFail();
-				this.federationChart.deliverd(i.host, false);
-
 				if (this.meta.enableChartsForFederatedInstances) {
 					this.instanceChart.requestSent(i.host, false);
 				}
@@ -129,7 +138,7 @@ export class DeliverProcessorService {
 				if (!res.isRetryable) {
 					// 相手が閉鎖していることを明示しているため、配送停止する
 					if (job.data.isSharedInbox && res.statusCode === 410) {
-						this.federatedInstanceService.fetch(host).then(i => {
+						this.federatedInstanceService.fetchOrRegister(host).then(i => {
 							this.federatedInstanceService.update(i.id, {
 								suspensionState: 'goneSuspended',
 							});
diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts
index a77c968395..95d764e4d8 100644
--- a/packages/backend/src/queue/processors/InboxProcessorService.ts
+++ b/packages/backend/src/queue/processors/InboxProcessorService.ts
@@ -192,21 +192,27 @@ export class InboxProcessorService implements OnApplicationShutdown {
 			}
 		}
 
-		// Update stats
-		this.federatedInstanceService.fetch(authUser.user.host).then(i => {
+		this.apRequestChart.inbox();
+		this.federationChart.inbox(authUser.user.host);
+
+		// Update instance stats
+		process.nextTick(async () => {
+			const i = await (this.meta.enableStatsForFederatedInstances
+				? this.federatedInstanceService.fetchOrRegister(authUser.user.host)
+				: this.federatedInstanceService.fetch(authUser.user.host));
+
+			if (i == null) return;
+
 			this.updateInstanceQueue.enqueue(i.id, {
 				latestRequestReceivedAt: new Date(),
 				shouldUnsuspend: i.suspensionState === 'autoSuspendedForNotResponding',
 			});
 
-			this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
-
-			this.apRequestChart.inbox();
-			this.federationChart.inbox(i.host);
-
 			if (this.meta.enableChartsForFederatedInstances) {
 				this.instanceChart.requestReceived(i.host);
 			}
+
+			this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
 		});
 
 		// アクティビティを処理
diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts
index 16cbbc9aa4..64e3cc33bd 100644
--- a/packages/backend/src/server/api/endpoints/admin/meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/meta.ts
@@ -348,6 +348,10 @@ export const meta = {
 				type: 'boolean',
 				optional: false, nullable: false,
 			},
+			enableStatsForFederatedInstances: {
+				type: 'boolean',
+				optional: false, nullable: false,
+			},
 			enableServerMachineStats: {
 				type: 'boolean',
 				optional: false, nullable: false,
@@ -635,6 +639,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				truemailAuthKey: instance.truemailAuthKey,
 				enableChartsForRemoteUser: instance.enableChartsForRemoteUser,
 				enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances,
+				enableStatsForFederatedInstances: instance.enableStatsForFederatedInstances,
 				enableServerMachineStats: instance.enableServerMachineStats,
 				enableIdenticonGeneration: instance.enableIdenticonGeneration,
 				bannedEmailDomains: instance.bannedEmailDomains,
diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
index 536645e0d7..38ef0d1de8 100644
--- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
@@ -136,6 +136,7 @@ export const paramDef = {
 		truemailAuthKey: { type: 'string', nullable: true },
 		enableChartsForRemoteUser: { type: 'boolean' },
 		enableChartsForFederatedInstances: { type: 'boolean' },
+		enableStatsForFederatedInstances: { type: 'boolean' },
 		enableServerMachineStats: { type: 'boolean' },
 		enableIdenticonGeneration: { type: 'boolean' },
 		serverRules: { type: 'array', items: { type: 'string' } },
@@ -578,6 +579,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances;
 			}
 
+			if (ps.enableStatsForFederatedInstances !== undefined) {
+				set.enableStatsForFederatedInstances = ps.enableStatsForFederatedInstances;
+			}
+
 			if (ps.enableServerMachineStats !== undefined) {
 				set.enableServerMachineStats = ps.enableServerMachineStats;
 			}
diff --git a/packages/backend/test/unit/FetchInstanceMetadataService.ts b/packages/backend/test/unit/FetchInstanceMetadataService.ts
index bf8f3ab0e3..1e3605aafc 100644
--- a/packages/backend/test/unit/FetchInstanceMetadataService.ts
+++ b/packages/backend/test/unit/FetchInstanceMetadataService.ts
@@ -8,6 +8,7 @@ process.env.NODE_ENV = 'test';
 import { jest } from '@jest/globals';
 import { Test } from '@nestjs/testing';
 import { Redis } from 'ioredis';
+import type { TestingModule } from '@nestjs/testing';
 import { GlobalModule } from '@/GlobalModule.js';
 import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
 import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
@@ -16,7 +17,6 @@ import { LoggerService } from '@/core/LoggerService.js';
 import { UtilityService } from '@/core/UtilityService.js';
 import { IdService } from '@/core/IdService.js';
 import { DI } from '@/di-symbols.js';
-import type { TestingModule } from '@nestjs/testing';
 
 function mockRedis() {
 	const hash = {} as any;
@@ -52,7 +52,7 @@ describe('FetchInstanceMetadataService', () => {
 				if (token === HttpRequestService) {
 					return { getJson: jest.fn(), getHtml: jest.fn(), send: jest.fn() };
 				} else if (token === FederatedInstanceService) {
-					return { fetch: jest.fn() };
+					return { fetchOrRegister: jest.fn() };
 				} else if (token === DI.redis) {
 					return mockRedis;
 				}
@@ -75,7 +75,7 @@ describe('FetchInstanceMetadataService', () => {
 	test('Lock and update', async () => {
 		redisClient.set = mockRedis();
 		const now = Date.now();
-		federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => { return now - 10 * 1000 * 60 * 60 * 24; } } } as any);
+		federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => { return now - 10 * 1000 * 60 * 60 * 24; } } } as any);
 		httpRequestService.getJson.mockImplementation(() => { throw Error(); });
 		const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
 		const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
@@ -83,14 +83,14 @@ describe('FetchInstanceMetadataService', () => {
 		await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
 		expect(tryLockSpy).toHaveBeenCalledTimes(1);
 		expect(unlockSpy).toHaveBeenCalledTimes(1);
-		expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
+		expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(1);
 		expect(httpRequestService.getJson).toHaveBeenCalled();
 	});
 
 	test('Lock and don\'t update', async () => {
 		redisClient.set = mockRedis();
 		const now = Date.now();
-		federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now } } as any);
+		federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now } } as any);
 		httpRequestService.getJson.mockImplementation(() => { throw Error(); });
 		const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
 		const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
@@ -98,14 +98,14 @@ describe('FetchInstanceMetadataService', () => {
 		await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
 		expect(tryLockSpy).toHaveBeenCalledTimes(1);
 		expect(unlockSpy).toHaveBeenCalledTimes(1);
-		expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
+		expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(1);
 		expect(httpRequestService.getJson).toHaveBeenCalledTimes(0);
 	});
 
 	test('Do nothing when lock not acquired', async () => {
 		redisClient.set = mockRedis();
 		const now = Date.now();
-		federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
+		federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
 		httpRequestService.getJson.mockImplementation(() => { throw Error(); });
 		await fetchInstanceMetadataService.tryLock('example.com');
 		const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
@@ -114,14 +114,14 @@ describe('FetchInstanceMetadataService', () => {
 		await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
 		expect(tryLockSpy).toHaveBeenCalledTimes(1);
 		expect(unlockSpy).toHaveBeenCalledTimes(0);
-		expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0);
+		expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(0);
 		expect(httpRequestService.getJson).toHaveBeenCalledTimes(0);
 	});
 
 	test('Do when lock not acquired but forced', async () => {
 		redisClient.set = mockRedis();
 		const now = Date.now();
-		federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
+		federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any);
 		httpRequestService.getJson.mockImplementation(() => { throw Error(); });
 		await fetchInstanceMetadataService.tryLock('example.com');
 		const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
@@ -130,7 +130,7 @@ describe('FetchInstanceMetadataService', () => {
 		await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any, true);
 		expect(tryLockSpy).toHaveBeenCalledTimes(0);
 		expect(unlockSpy).toHaveBeenCalledTimes(1);
-		expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0);
+		expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(0);
 		expect(httpRequestService.getJson).toHaveBeenCalled();
 	});
 });
diff --git a/packages/frontend/src/pages/admin/performance.vue b/packages/frontend/src/pages/admin/performance.vue
index 7e0a932f82..12338f0bf9 100644
--- a/packages/frontend/src/pages/admin/performance.vue
+++ b/packages/frontend/src/pages/admin/performance.vue
@@ -29,6 +29,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 				</MkSwitch>
 			</div>
 
+			<div class="_panel" style="padding: 16px;">
+				<MkSwitch v-model="enableStatsForFederatedInstances" @change="onChange_enableStatsForFederatedInstances">
+					<template #label>{{ i18n.ts.enableStatsForFederatedInstances }}</template>
+					<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
+				</MkSwitch>
+			</div>
+
 			<div class="_panel" style="padding: 16px;">
 				<MkSwitch v-model="enableChartsForFederatedInstances" @change="onChange_enableChartsForFederatedInstances">
 					<template #label>{{ i18n.ts.enableChartsForFederatedInstances }}</template>
@@ -120,6 +127,7 @@ const meta = await misskeyApi('admin/meta');
 const enableServerMachineStats = ref(meta.enableServerMachineStats);
 const enableIdenticonGeneration = ref(meta.enableIdenticonGeneration);
 const enableChartsForRemoteUser = ref(meta.enableChartsForRemoteUser);
+const enableStatsForFederatedInstances = ref(meta.enableStatsForFederatedInstances);
 const enableChartsForFederatedInstances = ref(meta.enableChartsForFederatedInstances);
 
 function onChange_enableServerMachineStats(value: boolean) {
@@ -146,6 +154,14 @@ function onChange_enableChartsForRemoteUser(value: boolean) {
 	});
 }
 
+function onChange_enableStatsForFederatedInstances(value: boolean) {
+	os.apiWithDialog('admin/update-meta', {
+		enableStatsForFederatedInstances: value,
+	}).then(() => {
+		fetchInstance(true);
+	});
+}
+
 function onChange_enableChartsForFederatedInstances(value: boolean) {
 	os.apiWithDialog('admin/update-meta', {
 		enableChartsForFederatedInstances: value,
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index f61d72e280..6494614026 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -5165,6 +5165,7 @@ export type operations = {
             truemailAuthKey: string | null;
             enableChartsForRemoteUser: boolean;
             enableChartsForFederatedInstances: boolean;
+            enableStatsForFederatedInstances: boolean;
             enableServerMachineStats: boolean;
             enableIdenticonGeneration: boolean;
             manifestJsonOverride: string;
@@ -9547,6 +9548,7 @@ export type operations = {
           truemailAuthKey?: string | null;
           enableChartsForRemoteUser?: boolean;
           enableChartsForFederatedInstances?: boolean;
+          enableStatsForFederatedInstances?: boolean;
           enableServerMachineStats?: boolean;
           enableIdenticonGeneration?: boolean;
           serverRules?: string[];

From 5229f5de4d9ef7cd75d32466d29d672193adaf45 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Sun, 13 Oct 2024 20:32:02 +0900
Subject: [PATCH 15/34] refactor(backend): remove unnecessary .then

---
 .../backend/src/core/AbuseReportNotificationService.ts   | 9 +++------
 packages/backend/src/core/AbuseReportService.ts          | 6 ++----
 packages/backend/src/core/SignupService.ts               | 4 ++--
 packages/backend/src/core/SystemWebhookService.ts        | 9 +++------
 4 files changed, 10 insertions(+), 18 deletions(-)

diff --git a/packages/backend/src/core/AbuseReportNotificationService.ts b/packages/backend/src/core/AbuseReportNotificationService.ts
index 7d030f2f16..25e265f2b1 100644
--- a/packages/backend/src/core/AbuseReportNotificationService.ts
+++ b/packages/backend/src/core/AbuseReportNotificationService.ts
@@ -288,8 +288,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
 			.log(updater, 'createAbuseReportNotificationRecipient', {
 				recipientId: id,
 				recipient: created,
-			})
-			.then();
+			});
 
 		return created;
 	}
@@ -327,8 +326,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
 				recipientId: params.id,
 				before: beforeEntity,
 				after: afterEntity,
-			})
-			.then();
+			});
 
 		return afterEntity;
 	}
@@ -349,8 +347,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
 			.log(updater, 'deleteAbuseReportNotificationRecipient', {
 				recipientId: id,
 				recipient: entity,
-			})
-			.then();
+			});
 	}
 
 	/**
diff --git a/packages/backend/src/core/AbuseReportService.ts b/packages/backend/src/core/AbuseReportService.ts
index 73baad5499..0b022d3b08 100644
--- a/packages/backend/src/core/AbuseReportService.ts
+++ b/packages/backend/src/core/AbuseReportService.ts
@@ -110,8 +110,7 @@ export class AbuseReportService {
 					reportId: report.id,
 					report: report,
 					resolvedAs: ps.resolvedAs,
-				})
-				.then();
+				});
 		}
 
 		return this.abuseUserReportsRepository.findBy({ id: In(reports.map(it => it.id)) })
@@ -148,8 +147,7 @@ export class AbuseReportService {
 			.log(moderator, 'forwardAbuseReport', {
 				reportId: report.id,
 				report: report,
-			})
-			.then();
+			});
 	}
 
 	@bindThis
diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts
index cc8a3d6461..3865392b7f 100644
--- a/packages/backend/src/core/SignupService.ts
+++ b/packages/backend/src/core/SignupService.ts
@@ -150,8 +150,8 @@ export class SignupService {
 			}));
 		});
 
-		this.usersChart.update(account, true).then();
-		this.userService.notifySystemWebhook(account, 'userCreated').then();
+		this.usersChart.update(account, true);
+		this.userService.notifySystemWebhook(account, 'userCreated');
 
 		return { account, secret };
 	}
diff --git a/packages/backend/src/core/SystemWebhookService.ts b/packages/backend/src/core/SystemWebhookService.ts
index bb7c6b8c0e..db6407dcb3 100644
--- a/packages/backend/src/core/SystemWebhookService.ts
+++ b/packages/backend/src/core/SystemWebhookService.ts
@@ -101,8 +101,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
 			.log(updater, 'createSystemWebhook', {
 				systemWebhookId: webhook.id,
 				webhook: webhook,
-			})
-			.then();
+			});
 
 		return webhook;
 	}
@@ -139,8 +138,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
 				systemWebhookId: beforeEntity.id,
 				before: beforeEntity,
 				after: afterEntity,
-			})
-			.then();
+			});
 
 		return afterEntity;
 	}
@@ -158,8 +156,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
 			.log(updater, 'deleteSystemWebhook', {
 				systemWebhookId: webhook.id,
 				webhook,
-			})
-			.then();
+			});
 	}
 
 	/**

From 33b34ad7b8248b4d5ddc37b986ffcf4dff6a37c4 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: Sun, 13 Oct 2024 20:32:12 +0900
Subject: [PATCH 16/34] =?UTF-8?q?feat:=20=E9=81=8B=E5=96=B6=E3=81=AE?=
 =?UTF-8?q?=E3=82=A2=E3=82=AF=E3=83=86=E3=82=A3=E3=83=93=E3=83=86=E3=82=A3?=
 =?UTF-8?q?=E3=81=8C=E4=B8=80=E5=AE=9A=E6=9C=9F=E9=96=93=E3=81=AA=E3=81=84?=
 =?UTF-8?q?=E5=A0=B4=E5=90=88=E3=81=AF=E9=80=9A=E7=9F=A5=EF=BC=8B=E6=8B=9B?=
 =?UTF-8?q?=E5=BE=85=E5=88=B6=E3=81=AB=E7=A7=BB=E8=A1=8C=E3=81=97=E3=81=9F?=
 =?UTF-8?q?=E9=9A=9B=E3=81=AB=E9=80=9A=E7=9F=A5=20(#14757)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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

* fix misskey-js.api.md

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

This reverts commit 3ab953bdf87f28411a1a10bce787a23d238cda80.

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

* テスト用実装を戻す

* 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>
---
 locales/index.d.ts                            |   8 +
 locales/ja-JP.yml                             |   2 +
 .../backend/src/core/WebhookTestService.ts    |  17 ++
 packages/backend/src/models/SystemWebhook.ts  |   4 +
 ...CheckModeratorsActivityProcessorService.ts | 191 ++++++++++++++++--
 ...CheckModeratorsActivityProcessorService.ts | 168 +++++++++++++--
 .../src/components/MkSystemWebhookEditor.vue  |  18 ++
 packages/misskey-js/src/autogen/types.ts      |  10 +-
 8 files changed, 388 insertions(+), 30 deletions(-)

diff --git a/locales/index.d.ts b/locales/index.d.ts
index 3ffb67f31c..b5af5909a3 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -9661,6 +9661,14 @@ export interface Locale extends ILocale {
              * ユーザーが作成されたとき
              */
             "userCreated": string;
+            /**
+             * モデレーターが一定期間非アクティブになったとき
+             */
+            "inactiveModeratorsWarning": string;
+            /**
+             * モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき
+             */
+            "inactiveModeratorsInvitationOnlyChanged": string;
         };
         /**
          * Webhookを削除しますか?
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 919be1e3ec..c448d4d50a 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -2559,6 +2559,8 @@ _webhookSettings:
     abuseReport: "ユーザーから通報があったとき"
     abuseReportResolved: "ユーザーからの通報を処理したとき"
     userCreated: "ユーザーが作成されたとき"
+    inactiveModeratorsWarning: "モデレーターが一定期間非アクティブになったとき"
+    inactiveModeratorsInvitationOnlyChanged: "モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき"
   deleteConfirm: "Webhookを削除しますか?"
   testRemarks: "スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。"
 
diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts
index 4c45b95a64..55c8a52705 100644
--- a/packages/backend/src/core/WebhookTestService.ts
+++ b/packages/backend/src/core/WebhookTestService.ts
@@ -12,6 +12,7 @@ import { Packed } from '@/misc/json-schema.js';
 import { type WebhookEventTypes } from '@/models/Webhook.js';
 import { UserWebhookService } from '@/core/UserWebhookService.js';
 import { QueueService } from '@/core/QueueService.js';
+import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
 
 const oneDayMillis = 24 * 60 * 60 * 1000;
 
@@ -446,6 +447,22 @@ export class WebhookTestService {
 				send(toPackedUserLite(dummyUser1));
 				break;
 			}
+			case 'inactiveModeratorsWarning': {
+				const dummyTime: ModeratorInactivityRemainingTime = {
+					time: 100000,
+					asDays: 1,
+					asHours: 24,
+				};
+
+				send({
+					remainingTime: dummyTime,
+				});
+				break;
+			}
+			case 'inactiveModeratorsInvitationOnlyChanged': {
+				send({});
+				break;
+			}
 		}
 	}
 }
diff --git a/packages/backend/src/models/SystemWebhook.ts b/packages/backend/src/models/SystemWebhook.ts
index d6c27eae51..1a7ce4962b 100644
--- a/packages/backend/src/models/SystemWebhook.ts
+++ b/packages/backend/src/models/SystemWebhook.ts
@@ -14,6 +14,10 @@ export const systemWebhookEventTypes = [
 	'abuseReportResolved',
 	// ユーザが作成された時
 	'userCreated',
+	// モデレータが一定期間不在である警告
+	'inactiveModeratorsWarning',
+	// モデレータが一定期間不在のためシステムにより招待制へと変更された
+	'inactiveModeratorsInvitationOnlyChanged',
 ] as const;
 export type SystemWebhookEventType = typeof systemWebhookEventTypes[number];
 
diff --git a/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts
index f2677f8e5c..87183cb342 100644
--- a/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts
+++ b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts
@@ -3,24 +3,110 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { Injectable } from '@nestjs/common';
+import { Inject, Injectable } from '@nestjs/common';
+import { In } from 'typeorm';
 import type Logger from '@/logger.js';
 import { bindThis } from '@/decorators.js';
 import { MetaService } from '@/core/MetaService.js';
 import { RoleService } from '@/core/RoleService.js';
+import { EmailService } from '@/core/EmailService.js';
+import { MiUser, type UserProfilesRepository } from '@/models/_.js';
+import { DI } from '@/di-symbols.js';
+import { SystemWebhookService } from '@/core/SystemWebhookService.js';
+import { AnnouncementService } from '@/core/AnnouncementService.js';
 import { QueueLoggerService } from '../QueueLoggerService.js';
 
 // モデレーターが不在と判断する日付の閾値
 const MODERATOR_INACTIVITY_LIMIT_DAYS = 7;
-const ONE_DAY_MILLI_SEC = 1000 * 60 * 60 * 24;
+// 警告通知やログ出力を行う残日数の閾値
+const MODERATOR_INACTIVITY_WARNING_REMAINING_DAYS = 2;
+// 期限から6時間ごとに通知を行う
+const MODERATOR_INACTIVITY_WARNING_NOTIFY_INTERVAL_HOURS = 6;
+const ONE_HOUR_MILLI_SEC = 1000 * 60 * 60;
+const ONE_DAY_MILLI_SEC = ONE_HOUR_MILLI_SEC * 24;
+
+export type ModeratorInactivityEvaluationResult = {
+	isModeratorsInactive: boolean;
+	inactiveModerators: MiUser[];
+	remainingTime: ModeratorInactivityRemainingTime;
+}
+
+export type ModeratorInactivityRemainingTime = {
+	time: number;
+	asHours: number;
+	asDays: number;
+};
+
+function generateModeratorInactivityMail(remainingTime: ModeratorInactivityRemainingTime) {
+	const subject = 'Moderator Inactivity Warning / モデレーター不在の通知';
+
+	const timeVariant = remainingTime.asDays === 0 ? `${remainingTime.asHours} hours` : `${remainingTime.asDays} days`;
+	const timeVariantJa = remainingTime.asDays === 0 ? `${remainingTime.asHours} 時間` : `${remainingTime.asDays} 日間`;
+	const message = [
+		'To Moderators,',
+		'',
+		`A moderator has been inactive for a period of time. If there are ${timeVariant} of inactivity left, it will switch to invitation only.`,
+		'If you do not wish to move to invitation only, you must log into Misskey and update your last active date and time.',
+		'',
+		'---------------',
+		'',
+		'To モデレーター各位',
+		'',
+		`モデレーターが一定期間活動していないようです。あと${timeVariantJa}活動していない状態が続くと招待制に切り替わります。`,
+		'招待制に切り替わることを望まない場合は、Misskeyにログインして最終アクティブ日時を更新してください。',
+		'',
+	];
+
+	const html = message.join('<br>');
+	const text = message.join('\n');
+
+	return {
+		subject,
+		html,
+		text,
+	};
+}
+
+function generateInvitationOnlyChangedMail() {
+	const subject = 'Change to Invitation-Only / 招待制に変更されました';
+
+	const message = [
+		'To Moderators,',
+		'',
+		`Changed to invitation only because no moderator activity was detected for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days.`,
+		'To cancel the invitation only, you need to access the control panel.',
+		'',
+		'---------------',
+		'',
+		'To モデレーター各位',
+		'',
+		`モデレーターの活動が${MODERATOR_INACTIVITY_LIMIT_DAYS}日間検出されなかったため、招待制に変更されました。`,
+		'招待制を解除するには、コントロールパネルにアクセスする必要があります。',
+		'',
+	];
+
+	const html = message.join('<br>');
+	const text = message.join('\n');
+
+	return {
+		subject,
+		html,
+		text,
+	};
+}
 
 @Injectable()
 export class CheckModeratorsActivityProcessorService {
 	private logger: Logger;
 
 	constructor(
+		@Inject(DI.userProfilesRepository)
+		private userProfilesRepository: UserProfilesRepository,
 		private metaService: MetaService,
 		private roleService: RoleService,
+		private emailService: EmailService,
+		private announcementService: AnnouncementService,
+		private systemWebhookService: SystemWebhookService,
 		private queueLoggerService: QueueLoggerService,
 	) {
 		this.logger = this.queueLoggerService.logger.createSubLogger('check-moderators-activity');
@@ -42,18 +128,23 @@ export class CheckModeratorsActivityProcessorService {
 
 	@bindThis
 	private async processImpl() {
-		const { isModeratorsInactive, inactivityLimitCountdown } = await this.evaluateModeratorsInactiveDays();
-		if (isModeratorsInactive) {
+		const evaluateResult = await this.evaluateModeratorsInactiveDays();
+		if (evaluateResult.isModeratorsInactive) {
 			this.logger.warn(`The moderator has been inactive for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days. We will move to invitation only.`);
+
 			await this.changeToInvitationOnly();
-
-			// TODO: モデレータに通知メール+Misskey通知
-			// TODO: SystemWebhook通知
+			await this.notifyChangeToInvitationOnly();
 		} else {
-			if (inactivityLimitCountdown <= 2) {
-				this.logger.warn(`A moderator has been inactive for a period of time. If you are inactive for an additional ${inactivityLimitCountdown} days, it will switch to invitation only.`);
+			const remainingTime = evaluateResult.remainingTime;
+			if (remainingTime.asDays <= MODERATOR_INACTIVITY_WARNING_REMAINING_DAYS) {
+				const timeVariant = remainingTime.asDays === 0 ? `${remainingTime.asHours} hours` : `${remainingTime.asDays} days`;
+				this.logger.warn(`A moderator has been inactive for a period of time. If you are inactive for an additional ${timeVariant}, it will switch to invitation only.`);
 
-				// TODO: 警告メール
+				if (remainingTime.asHours % MODERATOR_INACTIVITY_WARNING_NOTIFY_INTERVAL_HOURS === 0) {
+					// ジョブの実行頻度と同等だと通知が多すぎるため期限から6時間ごとに通知する
+					// つまり、のこり2日を切ったら6時間ごとに通知が送られる
+					await this.notifyInactiveModeratorsWarning(remainingTime);
+				}
 			}
 		}
 	}
@@ -87,7 +178,7 @@ export class CheckModeratorsActivityProcessorService {
 	 * この場合、モデレータA, B, Cのアクティビティは判定基準日よりも古いため、モデレーターが不在と判断される。
 	 */
 	@bindThis
-	public async evaluateModeratorsInactiveDays() {
+	public async evaluateModeratorsInactiveDays(): Promise<ModeratorInactivityEvaluationResult> {
 		const today = new Date();
 		const inactivePeriod = new Date(today);
 		inactivePeriod.setDate(today.getDate() - MODERATOR_INACTIVITY_LIMIT_DAYS);
@@ -101,12 +192,18 @@ export class CheckModeratorsActivityProcessorService {
 		// 残りの猶予を示したいので、最終アクティブ日時が一番若いモデレータの日数を基準に猶予を計算する
 		// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
 		const newestLastActiveDate = new Date(Math.max(...moderators.map(it => it.lastActiveDate!.getTime())));
-		const inactivityLimitCountdown = Math.floor((newestLastActiveDate.getTime() - inactivePeriod.getTime()) / ONE_DAY_MILLI_SEC);
+		const remainingTime = newestLastActiveDate.getTime() - inactivePeriod.getTime();
+		const remainingTimeAsDays = Math.floor(remainingTime / ONE_DAY_MILLI_SEC);
+		const remainingTimeAsHours = Math.floor((remainingTime / ONE_HOUR_MILLI_SEC));
 
 		return {
 			isModeratorsInactive: inactiveModerators.length === moderators.length,
 			inactiveModerators,
-			inactivityLimitCountdown,
+			remainingTime: {
+				time: remainingTime,
+				asHours: remainingTimeAsHours,
+				asDays: remainingTimeAsDays,
+			},
 		};
 	}
 
@@ -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: モデレーター以外にも特別な権限を持つユーザーがいる場合は考慮する
diff --git a/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts
index b783320aa0..1506283a3c 100644
--- a/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts
+++ b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts
@@ -8,13 +8,16 @@ import { Test, TestingModule } from '@nestjs/testing';
 import * as lolex from '@sinonjs/fake-timers';
 import { addHours, addSeconds, subDays, subHours, subSeconds } from 'date-fns';
 import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
-import { MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js';
+import { MiSystemWebhook, MiUser, MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js';
 import { IdService } from '@/core/IdService.js';
 import { RoleService } from '@/core/RoleService.js';
 import { GlobalModule } from '@/GlobalModule.js';
 import { MetaService } from '@/core/MetaService.js';
 import { DI } from '@/di-symbols.js';
 import { QueueLoggerService } from '@/queue/QueueLoggerService.js';
+import { EmailService } from '@/core/EmailService.js';
+import { SystemWebhookService } from '@/core/SystemWebhookService.js';
+import { AnnouncementService } from '@/core/AnnouncementService.js';
 
 const baseDate = new Date(Date.UTC(2000, 11, 15, 12, 0, 0));
 
@@ -29,10 +32,17 @@ describe('CheckModeratorsActivityProcessorService', () => {
 	let userProfilesRepository: UserProfilesRepository;
 	let idService: IdService;
 	let roleService: jest.Mocked<RoleService>;
+	let announcementService: jest.Mocked<AnnouncementService>;
+	let emailService: jest.Mocked<EmailService>;
+	let systemWebhookService: jest.Mocked<SystemWebhookService>;
+
+	let systemWebhook1: MiSystemWebhook;
+	let systemWebhook2: MiSystemWebhook;
+	let systemWebhook3: MiSystemWebhook;
 
 	// --------------------------------------------------------------------------------------
 
-	async function createUser(data: Partial<MiUser> = {}) {
+	async function createUser(data: Partial<MiUser> = {}, profile: Partial<MiUserProfile> = {}): Promise<MiUser> {
 		const id = idService.gen();
 		const user = await usersRepository
 			.insert({
@@ -45,11 +55,27 @@ describe('CheckModeratorsActivityProcessorService', () => {
 
 		await userProfilesRepository.insert({
 			userId: user.id,
+			...profile,
 		});
 
 		return user;
 	}
 
+	function crateSystemWebhook(data: Partial<MiSystemWebhook> = {}): MiSystemWebhook {
+		return {
+			id: idService.gen(),
+			isActive: true,
+			updatedAt: new Date(),
+			latestSentAt: null,
+			latestStatus: null,
+			name: 'test',
+			url: 'https://example.com',
+			secret: 'test',
+			on: [],
+			...data,
+		};
+	}
+
 	function mockModeratorRole(users: MiUser[]) {
 		roleService.getModerators.mockReset();
 		roleService.getModerators.mockResolvedValue(users);
@@ -72,6 +98,18 @@ describe('CheckModeratorsActivityProcessorService', () => {
 					{
 						provide: MetaService, useFactory: () => ({ fetch: jest.fn() }),
 					},
+					{
+						provide: AnnouncementService, useFactory: () => ({ create: jest.fn() }),
+					},
+					{
+						provide: EmailService, useFactory: () => ({ sendEmail: jest.fn() }),
+					},
+					{
+						provide: SystemWebhookService, useFactory: () => ({
+							fetchActiveSystemWebhooks: jest.fn(),
+							enqueueSystemWebhook: jest.fn(),
+						}),
+					},
 					{
 						provide: QueueLoggerService, useFactory: () => ({
 							logger: ({
@@ -93,6 +131,9 @@ describe('CheckModeratorsActivityProcessorService', () => {
 		service = app.get(CheckModeratorsActivityProcessorService);
 		idService = app.get(IdService);
 		roleService = app.get(RoleService) as jest.Mocked<RoleService>;
+		announcementService = app.get(AnnouncementService) as jest.Mocked<AnnouncementService>;
+		emailService = app.get(EmailService) as jest.Mocked<EmailService>;
+		systemWebhookService = app.get(SystemWebhookService) as jest.Mocked<SystemWebhookService>;
 
 		app.enableShutdownHooks();
 	});
@@ -102,6 +143,15 @@ describe('CheckModeratorsActivityProcessorService', () => {
 			now: new Date(baseDate),
 			shouldClearNativeTimers: true,
 		});
+
+		systemWebhook1 = crateSystemWebhook({ on: ['inactiveModeratorsWarning'] });
+		systemWebhook2 = crateSystemWebhook({ on: ['inactiveModeratorsWarning', 'inactiveModeratorsInvitationOnlyChanged'] });
+		systemWebhook3 = crateSystemWebhook({ on: ['abuseReport'] });
+
+		emailService.sendEmail.mockReturnValue(Promise.resolve());
+		announcementService.create.mockReturnValue(Promise.resolve({} as never));
+		systemWebhookService.fetchActiveSystemWebhooks.mockResolvedValue([systemWebhook1, systemWebhook2, systemWebhook3]);
+		systemWebhookService.enqueueSystemWebhook.mockReturnValue(Promise.resolve({} as never));
 	});
 
 	afterEach(async () => {
@@ -109,6 +159,9 @@ describe('CheckModeratorsActivityProcessorService', () => {
 		await usersRepository.delete({});
 		await userProfilesRepository.delete({});
 		roleService.getModerators.mockReset();
+		announcementService.create.mockReset();
+		emailService.sendEmail.mockReset();
+		systemWebhookService.enqueueSystemWebhook.mockReset();
 	});
 
 	afterAll(async () => {
@@ -152,7 +205,7 @@ describe('CheckModeratorsActivityProcessorService', () => {
 			expect(result.inactiveModerators).toEqual([user1]);
 		});
 
-		test('[countdown] 猶予まで24時間ある場合、猶予1日として計算される', async () => {
+		test('[remainingTime] 猶予まで24時間ある場合、猶予1日として計算される', async () => {
 			const [user1, user2] = await Promise.all([
 				createUser({ lastActiveDate: subDays(baseDate, 8) }),
 				// 猶予はこのユーザ基準で計算される想定。
@@ -165,10 +218,11 @@ describe('CheckModeratorsActivityProcessorService', () => {
 			const result = await service.evaluateModeratorsInactiveDays();
 			expect(result.isModeratorsInactive).toBe(false);
 			expect(result.inactiveModerators).toEqual([user1]);
-			expect(result.inactivityLimitCountdown).toBe(1);
+			expect(result.remainingTime.asDays).toBe(1);
+			expect(result.remainingTime.asHours).toBe(24);
 		});
 
-		test('[countdown] 猶予まで25時間ある場合、猶予1日として計算される', async () => {
+		test('[remainingTime] 猶予まで25時間ある場合、猶予1日として計算される', async () => {
 			const [user1, user2] = await Promise.all([
 				createUser({ lastActiveDate: subDays(baseDate, 8) }),
 				// 猶予はこのユーザ基準で計算される想定。
@@ -181,10 +235,11 @@ describe('CheckModeratorsActivityProcessorService', () => {
 			const result = await service.evaluateModeratorsInactiveDays();
 			expect(result.isModeratorsInactive).toBe(false);
 			expect(result.inactiveModerators).toEqual([user1]);
-			expect(result.inactivityLimitCountdown).toBe(1);
+			expect(result.remainingTime.asDays).toBe(1);
+			expect(result.remainingTime.asHours).toBe(25);
 		});
 
-		test('[countdown] 猶予まで23時間ある場合、猶予0日として計算される', async () => {
+		test('[remainingTime] 猶予まで23時間ある場合、猶予0日として計算される', async () => {
 			const [user1, user2] = await Promise.all([
 				createUser({ lastActiveDate: subDays(baseDate, 8) }),
 				// 猶予はこのユーザ基準で計算される想定。
@@ -197,10 +252,11 @@ describe('CheckModeratorsActivityProcessorService', () => {
 			const result = await service.evaluateModeratorsInactiveDays();
 			expect(result.isModeratorsInactive).toBe(false);
 			expect(result.inactiveModerators).toEqual([user1]);
-			expect(result.inactivityLimitCountdown).toBe(0);
+			expect(result.remainingTime.asDays).toBe(0);
+			expect(result.remainingTime.asHours).toBe(23);
 		});
 
-		test('[countdown] 期限ちょうどの場合、猶予0日として計算される', async () => {
+		test('[remainingTime] 期限ちょうどの場合、猶予0日として計算される', async () => {
 			const [user1, user2] = await Promise.all([
 				createUser({ lastActiveDate: subDays(baseDate, 8) }),
 				// 猶予はこのユーザ基準で計算される想定。
@@ -213,10 +269,11 @@ describe('CheckModeratorsActivityProcessorService', () => {
 			const result = await service.evaluateModeratorsInactiveDays();
 			expect(result.isModeratorsInactive).toBe(false);
 			expect(result.inactiveModerators).toEqual([user1]);
-			expect(result.inactivityLimitCountdown).toBe(0);
+			expect(result.remainingTime.asDays).toBe(0);
+			expect(result.remainingTime.asHours).toBe(0);
 		});
 
-		test('[countdown] 期限より1時間超過している場合、猶予-1日として計算される', async () => {
+		test('[remainingTime] 期限より1時間超過している場合、猶予-1日として計算される', async () => {
 			const [user1, user2] = await Promise.all([
 				createUser({ lastActiveDate: subDays(baseDate, 8) }),
 				// 猶予はこのユーザ基準で計算される想定。
@@ -229,7 +286,94 @@ describe('CheckModeratorsActivityProcessorService', () => {
 			const result = await service.evaluateModeratorsInactiveDays();
 			expect(result.isModeratorsInactive).toBe(true);
 			expect(result.inactiveModerators).toEqual([user1, user2]);
-			expect(result.inactivityLimitCountdown).toBe(-1);
+			expect(result.remainingTime.asDays).toBe(-1);
+			expect(result.remainingTime.asHours).toBe(-1);
+		});
+
+		test('[remainingTime] 期限より25時間超過している場合、猶予-2日として計算される', async () => {
+			const [user1, user2] = await Promise.all([
+				createUser({ lastActiveDate: subDays(baseDate, 10) }),
+				// 猶予はこのユーザ基準で計算される想定。
+				// 期限より1時間超過->猶予-1日として計算されるはずである
+				createUser({ lastActiveDate: subDays(subHours(baseDate, 25), 7) }),
+			]);
+
+			mockModeratorRole([user1, user2]);
+
+			const result = await service.evaluateModeratorsInactiveDays();
+			expect(result.isModeratorsInactive).toBe(true);
+			expect(result.inactiveModerators).toEqual([user1, user2]);
+			expect(result.remainingTime.asDays).toBe(-2);
+			expect(result.remainingTime.asHours).toBe(-25);
+		});
+	});
+
+	describe('notifyInactiveModeratorsWarning', () => {
+		test('[notification + mail] 通知はモデレータ全員に発信され、メールはメールアドレスが存在+認証済みの場合のみ', async () => {
+			const [user1, user2, user3, user4, root] = await Promise.all([
+				createUser({}, { email: 'user1@example.com', emailVerified: true }),
+				createUser({}, { email: 'user2@example.com', emailVerified: false }),
+				createUser({}, { email: null, emailVerified: false }),
+				createUser({}, { email: 'user4@example.com', emailVerified: true }),
+				createUser({ isRoot: true }, { email: 'root@example.com', emailVerified: true }),
+			]);
+
+			mockModeratorRole([user1, user2, user3, root]);
+			await service.notifyInactiveModeratorsWarning({ time: 1, asDays: 0, asHours: 0 });
+
+			expect(emailService.sendEmail).toHaveBeenCalledTimes(2);
+			expect(emailService.sendEmail.mock.calls[0][0]).toBe('user1@example.com');
+			expect(emailService.sendEmail.mock.calls[1][0]).toBe('root@example.com');
+		});
+
+		test('[systemWebhook] "inactiveModeratorsWarning"が有効なSystemWebhookに対して送信される', async () => {
+			const [user1] = await Promise.all([
+				createUser({}, { email: 'user1@example.com', emailVerified: true }),
+			]);
+
+			mockModeratorRole([user1]);
+			await service.notifyInactiveModeratorsWarning({ time: 1, asDays: 0, asHours: 0 });
+
+			expect(systemWebhookService.enqueueSystemWebhook).toHaveBeenCalledTimes(2);
+			expect(systemWebhookService.enqueueSystemWebhook.mock.calls[0][0]).toEqual(systemWebhook1);
+			expect(systemWebhookService.enqueueSystemWebhook.mock.calls[1][0]).toEqual(systemWebhook2);
+		});
+	});
+
+	describe('notifyChangeToInvitationOnly', () => {
+		test('[notification + mail] 通知はモデレータ全員に発信され、メールはメールアドレスが存在+認証済みの場合のみ', async () => {
+			const [user1, user2, user3, user4, root] = await Promise.all([
+				createUser({}, { email: 'user1@example.com', emailVerified: true }),
+				createUser({}, { email: 'user2@example.com', emailVerified: false }),
+				createUser({}, { email: null, emailVerified: false }),
+				createUser({}, { email: 'user4@example.com', emailVerified: true }),
+				createUser({ isRoot: true }, { email: 'root@example.com', emailVerified: true }),
+			]);
+
+			mockModeratorRole([user1, user2, user3, root]);
+			await service.notifyChangeToInvitationOnly();
+
+			expect(announcementService.create).toHaveBeenCalledTimes(4);
+			expect(announcementService.create.mock.calls[0][0].userId).toBe(user1.id);
+			expect(announcementService.create.mock.calls[1][0].userId).toBe(user2.id);
+			expect(announcementService.create.mock.calls[2][0].userId).toBe(user3.id);
+			expect(announcementService.create.mock.calls[3][0].userId).toBe(root.id);
+
+			expect(emailService.sendEmail).toHaveBeenCalledTimes(2);
+			expect(emailService.sendEmail.mock.calls[0][0]).toBe('user1@example.com');
+			expect(emailService.sendEmail.mock.calls[1][0]).toBe('root@example.com');
+		});
+
+		test('[systemWebhook] "inactiveModeratorsInvitationOnlyChanged"が有効なSystemWebhookに対して送信される', async () => {
+			const [user1] = await Promise.all([
+				createUser({}, { email: 'user1@example.com', emailVerified: true }),
+			]);
+
+			mockModeratorRole([user1]);
+			await service.notifyChangeToInvitationOnly();
+
+			expect(systemWebhookService.enqueueSystemWebhook).toHaveBeenCalledTimes(1);
+			expect(systemWebhookService.enqueueSystemWebhook.mock.calls[0][0]).toEqual(systemWebhook2);
 		});
 	});
 });
diff --git a/packages/frontend/src/components/MkSystemWebhookEditor.vue b/packages/frontend/src/components/MkSystemWebhookEditor.vue
index a00cf0d9d3..485d003f93 100644
--- a/packages/frontend/src/components/MkSystemWebhookEditor.vue
+++ b/packages/frontend/src/components/MkSystemWebhookEditor.vue
@@ -55,6 +55,18 @@ SPDX-License-Identifier: AGPL-3.0-only
 								</MkSwitch>
 								<MkButton v-show="mode === 'edit'" transparent :class="$style.testButton" :disabled="!(isActive && events.userCreated)" @click="test('userCreated')"><i class="ti ti-send"></i></MkButton>
 							</div>
+							<div :class="$style.switchBox">
+								<MkSwitch v-model="events.inactiveModeratorsWarning" :disabled="disabledEvents.inactiveModeratorsWarning">
+									<template #label>{{ i18n.ts._webhookSettings._systemEvents.inactiveModeratorsWarning }}</template>
+								</MkSwitch>
+								<MkButton v-show="mode === 'edit'" transparent :class="$style.testButton" :disabled="!(isActive && events.inactiveModeratorsWarning)" @click="test('inactiveModeratorsWarning')"><i class="ti ti-send"></i></MkButton>
+							</div>
+							<div :class="$style.switchBox">
+								<MkSwitch v-model="events.inactiveModeratorsInvitationOnlyChanged" :disabled="disabledEvents.inactiveModeratorsInvitationOnlyChanged">
+									<template #label>{{ i18n.ts._webhookSettings._systemEvents.inactiveModeratorsInvitationOnlyChanged }}</template>
+								</MkSwitch>
+								<MkButton v-show="mode === 'edit'" transparent :class="$style.testButton" :disabled="!(isActive && events.inactiveModeratorsInvitationOnlyChanged)" @click="test('inactiveModeratorsInvitationOnlyChanged')"><i class="ti ti-send"></i></MkButton>
+							</div>
 						</div>
 
 						<div v-show="mode === 'edit'" :class="$style.description">
@@ -100,6 +112,8 @@ type EventType = {
 	abuseReport: boolean;
 	abuseReportResolved: boolean;
 	userCreated: boolean;
+	inactiveModeratorsWarning: boolean;
+	inactiveModeratorsInvitationOnlyChanged: boolean;
 }
 
 const emit = defineEmits<{
@@ -123,6 +137,8 @@ const events = ref<EventType>({
 	abuseReport: true,
 	abuseReportResolved: true,
 	userCreated: true,
+	inactiveModeratorsWarning: true,
+	inactiveModeratorsInvitationOnlyChanged: true,
 });
 const isActive = ref<boolean>(true);
 
@@ -130,6 +146,8 @@ const disabledEvents = ref<EventType>({
 	abuseReport: false,
 	abuseReportResolved: false,
 	userCreated: false,
+	inactiveModeratorsWarning: false,
+	inactiveModeratorsInvitationOnlyChanged: false,
 });
 
 const disableSubmitButton = computed(() => {
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 6494614026..698c08826a 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -5048,7 +5048,7 @@ export type components = {
       latestSentAt: string | null;
       latestStatus: number | null;
       name: string;
-      on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
+      on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
       url: string;
       secret: string;
     };
@@ -10249,7 +10249,7 @@ export type operations = {
         'application/json': {
           isActive: boolean;
           name: string;
-          on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
+          on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
           url: string;
           secret: string;
         };
@@ -10359,7 +10359,7 @@ export type operations = {
       content: {
         'application/json': {
           isActive?: boolean;
-          on?: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
+          on?: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
         };
       };
     };
@@ -10472,7 +10472,7 @@ export type operations = {
           id: string;
           isActive: boolean;
           name: string;
-          on: ('abuseReport' | 'abuseReportResolved' | 'userCreated')[];
+          on: ('abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged')[];
           url: string;
           secret: string;
         };
@@ -10531,7 +10531,7 @@ export type operations = {
           /** Format: misskey:id */
           webhookId: string;
           /** @enum {string} */
-          type: 'abuseReport' | 'abuseReportResolved' | 'userCreated';
+          type: 'abuseReport' | 'abuseReportResolved' | 'userCreated' | 'inactiveModeratorsWarning' | 'inactiveModeratorsInvitationOnlyChanged';
           override?: {
             url?: string;
             secret?: string;

From fb23b24f5cbaca3f7a1ad4ab4893802cbf7c3e53 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com>
Date: Sun, 13 Oct 2024 11:43:27 +0000
Subject: [PATCH 17/34] Bump version to 2024.10.1-beta.4

---
 package.json                     | 2 +-
 packages/misskey-js/package.json | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/package.json b/package.json
index 2c84c55303..4b477aba4b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "misskey",
-	"version": "2024.10.1-beta.3",
+	"version": "2024.10.1-beta.4",
 	"codename": "nasubi",
 	"repository": {
 		"type": "git",
diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json
index e5f4b7b9dd..a59385dc10 100644
--- a/packages/misskey-js/package.json
+++ b/packages/misskey-js/package.json
@@ -1,7 +1,7 @@
 {
 	"type": "module",
 	"name": "misskey-js",
-	"version": "2024.10.1-beta.3",
+	"version": "2024.10.1-beta.4",
 	"description": "Misskey SDK for JavaScript",
 	"license": "MIT",
 	"main": "./built/index.js",

From 088e05ea66f0bf9442f2d4d9772958dfe8e76d8b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?=
 <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Mon, 14 Oct 2024 02:54:01 +0900
Subject: [PATCH 18/34] =?UTF-8?q?fix(frontend):=20=E4=BD=BF=E7=94=A8?=
 =?UTF-8?q?=E3=81=95=E3=82=8C=E3=81=A6=E3=81=84=E3=82=8Bexpose=E3=82=92?=
 =?UTF-8?q?=E5=BE=A9=E6=B4=BB=E3=81=95=E3=81=9B=E3=82=8B=20(#14764)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../src/components/global/MkStickyContainer.vue     | 13 +++++++++----
 1 file changed, 9 insertions(+), 4 deletions(-)

diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue
index 2763ecadd6..1aebf487bb 100644
--- a/packages/frontend/src/components/global/MkStickyContainer.vue
+++ b/packages/frontend/src/components/global/MkStickyContainer.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 -->
 
 <template>
-<div>
+<div ref="rootEl">
 	<div ref="headerEl" :class="$style.header">
 		<slot name="header"></slot>
 	</div>
@@ -22,12 +22,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 </template>
 
 <script lang="ts" setup>
-import { onMounted, onUnmounted, provide, inject, Ref, ref, watch, shallowRef } from 'vue';
+import { onMounted, onUnmounted, provide, inject, Ref, ref, watch, useTemplateRef } from 'vue';
 
 import { CURRENT_STICKY_BOTTOM, CURRENT_STICKY_TOP } from '@@/js/const.js';
 
-const headerEl = shallowRef<HTMLElement>();
-const footerEl = shallowRef<HTMLElement>();
+const rootEl = useTemplateRef('rootEl');
+const headerEl = useTemplateRef('headerEl');
+const footerEl = useTemplateRef('footerEl');
 
 const headerHeight = ref<string | undefined>();
 const childStickyTop = ref(0);
@@ -76,6 +77,10 @@ onMounted(() => {
 onUnmounted(() => {
 	observer.disconnect();
 });
+
+defineExpose({
+	rootEl,
+});
 </script>
 
 <style lang='scss' module>

From d0bb0b51f5ec8a9125ee768d75a1e8a9f76c6849 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?=
 <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Mon, 14 Oct 2024 03:06:10 +0900
Subject: [PATCH 19/34] =?UTF-8?q?fix(frontend):=20=E3=82=BF=E3=82=A4?=
 =?UTF-8?q?=E3=83=A0=E3=83=A9=E3=82=A4=E3=83=B3=E3=81=A7=E3=80=81=E5=BA=83?=
 =?UTF-8?q?=E5=91=8A=E3=81=8C=E3=81=AA=E3=81=84=E9=9A=9B=E3=81=AB=E3=82=82?=
 =?UTF-8?q?=E5=BA=83=E5=91=8A=E3=81=AEwrapper=E3=81=8C=E5=87=BA=E3=81=A6?=
 =?UTF-8?q?=E3=81=97=E3=81=BE=E3=81=86=E3=81=AE=E3=82=92=E4=BF=AE=E6=AD=A3?=
 =?UTF-8?q?=20(#14763)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../frontend/src/components/MkDateSeparatedList.vue   | 11 +++++++++--
 1 file changed, 9 insertions(+), 2 deletions(-)

diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue
index 5976aa02f5..f04e5cf7c6 100644
--- a/packages/frontend/src/components/MkDateSeparatedList.vue
+++ b/packages/frontend/src/components/MkDateSeparatedList.vue
@@ -9,6 +9,7 @@ import MkAd from '@/components/global/MkAd.vue';
 import { isDebuggerEnabled, stackTraceInstances } from '@/debug.js';
 import { i18n } from '@/i18n.js';
 import * as os from '@/os.js';
+import { instance } from '@/instance.js';
 import { defaultStore } from '@/store.js';
 import { MisskeyEntity } from '@/types/date-separated-list.js';
 
@@ -99,10 +100,10 @@ export default defineComponent({
 
 				return [el, separator];
 			} else {
-				if (props.ad && item._shouldInsertAd_) {
+				if (props.ad && instance.ads.length > 0 && item._shouldInsertAd_) {
 					return [h('div', {
 						key: item.id + ':ad',
-						style: 'padding: 8px; background-size: auto auto; background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px );',
+						class: $style['ad-wrapper'],
 					}, [h(MkAd, {
 						prefer: ['horizontal', 'horizontal-big'],
 					})]), el];
@@ -255,5 +256,11 @@ export default defineComponent({
 .date-2-icon {
 	margin-left: 8px;
 }
+
+.ad-wrapper {
+	padding: 8px;
+	background-size: auto auto;
+	background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px);
+}
 </style>
 

From 064d6ca56f66ff3061fb27897df429e534288462 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Mon, 14 Oct 2024 09:11:03 +0900
Subject: [PATCH 20/34] =?UTF-8?q?fix(backend):=20RBT=E6=9C=89=E5=8A=B9?=
 =?UTF-8?q?=E6=99=82=E3=80=81=E3=83=AA=E3=83=8E=E3=83=BC=E3=83=88=E3=81=AE?=
 =?UTF-8?q?=E3=83=AA=E3=82=A2=E3=82=AF=E3=82=B7=E3=83=A7=E3=83=B3=E3=81=8C?=
 =?UTF-8?q?=E5=8F=8D=E6=98=A0=E3=81=95=E3=82=8C=E3=81=AA=E3=81=84=E5=95=8F?=
 =?UTF-8?q?=E9=A1=8C=E3=82=92=E4=BF=AE=E6=AD=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md                                  |  1 +
 .../src/core/entities/NoteEntityService.ts    | 27 +++++++++++++++++--
 2 files changed, 26 insertions(+), 2 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 130eb00b77..22b5506f28 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@
 ### Server
 - Feat: モデレータ権限を持つユーザが全員7日間活動しなかった場合は自動的に招待制へと移行するように ( #13437 )
 - Fix: `admin/emoji/update`エンドポイントのidのみ指定した時不正なエラーが発生するバグを修正
+- Fix: RBT有効時、リノートのリアクションが反映されない問題を修正
 
 ### Server
 - Fix: キューのエラーログを簡略化するように  
diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts
index c64e9151a7..e530772dd9 100644
--- a/packages/backend/src/core/entities/NoteEntityService.ts
+++ b/packages/backend/src/core/entities/NoteEntityService.ts
@@ -22,6 +22,29 @@ import type { ReactionService } from '../ReactionService.js';
 import type { UserEntityService } from './UserEntityService.js';
 import type { DriveFileEntityService } from './DriveFileEntityService.js';
 
+function isPureRenote(note: MiNote): note is MiNote & { renoteId: MiNote['id']; renote: MiNote } {
+	return (
+		note.renote != null &&
+		note.reply == null &&
+		note.text == null &&
+		note.cw == null &&
+		(note.fileIds == null || note.fileIds.length === 0) &&
+		!note.hasPoll
+	);
+}
+
+function getAppearNoteIds(notes: MiNote[]): Set<string> {
+	const appearNoteIds = new Set<string>();
+	for (const note of notes) {
+		if (isPureRenote(note)) {
+			appearNoteIds.add(note.renoteId);
+		} else {
+			appearNoteIds.add(note.id);
+		}
+	}
+	return appearNoteIds;
+}
+
 @Injectable()
 export class NoteEntityService implements OnModuleInit {
 	private userEntityService: UserEntityService;
@@ -421,7 +444,7 @@ export class NoteEntityService implements OnModuleInit {
 	) {
 		if (notes.length === 0) return [];
 
-		const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany(notes.map(x => x.id)) : null;
+		const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(notes)]) : null;
 
 		const meId = me ? me.id : null;
 		const myReactionsMap = new Map<MiNote['id'], string | null>();
@@ -432,7 +455,7 @@ export class NoteEntityService implements OnModuleInit {
 			const oldId = this.idService.gen(Date.now() - 2000);
 
 			for (const note of notes) {
-				if (note.renote && (note.text == null && note.fileIds.length === 0)) { // pure renote
+				if (isPureRenote(note)) {
 					const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.renote.reactions, bufferedReactions?.get(note.renote.id)?.deltas ?? {})).reduce((a, b) => a + b, 0);
 					if (reactionsCount === 0) {
 						myReactionsMap.set(note.renote.id, null);

From 2190092de6409c5dbb02a042d98918580171f4c2 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Mon, 14 Oct 2024 11:22:02 +0900
Subject: [PATCH 21/34] =?UTF-8?q?perf(frontend):=20=E3=83=8E=E3=83=BC?=
 =?UTF-8?q?=E3=83=88=E3=81=AE=E3=83=AC=E3=83=B3=E3=83=80=E3=83=AA=E3=83=B3?=
 =?UTF-8?q?=E3=82=B0=E3=82=92=E3=82=B9=E3=82=AD=E3=83=83=E3=83=97=E3=81=A7?=
 =?UTF-8?q?=E3=81=8D=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 packages/frontend/src/components/MkNote.vue   | 21 ++++++++-----------
 .../frontend/src/pages/settings/other.vue     |  8 +++++++
 packages/frontend/src/store.ts                |  4 ++++
 3 files changed, 21 insertions(+), 12 deletions(-)

diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index be93b3c529..828ad2e872 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 	v-show="!isDeleted"
 	ref="rootEl"
 	v-hotkey="keymap"
-	:class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover }]"
+	:class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover, [$style.skipRender]: defaultStore.state.skipNoteRender }]"
 	:tabindex="isDeleted ? '-1' : '0'"
 >
 	<MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
@@ -171,6 +171,9 @@ import { computed, inject, onMounted, ref, shallowRef, Ref, watch, provide } fro
 import * as mfm from 'mfm-js';
 import * as Misskey from 'misskey-js';
 import { isLink } from '@@/js/is-link.js';
+import { shouldCollapsed } from '@@/js/collapsed.js';
+import { host } from '@@/js/config.js';
+import type { MenuItem } from '@/types/menu.js';
 import MkNoteSub from '@/components/MkNoteSub.vue';
 import MkNoteHeader from '@/components/MkNoteHeader.vue';
 import MkNoteSimple from '@/components/MkNoteSimple.vue';
@@ -200,11 +203,8 @@ import { deepClone } from '@/scripts/clone.js';
 import { useTooltip } from '@/scripts/use-tooltip.js';
 import { claimAchievement } from '@/scripts/achievements.js';
 import { getNoteSummary } from '@/scripts/get-note-summary.js';
-import type { MenuItem } from '@/types/menu.js';
 import MkRippleEffect from '@/components/MkRippleEffect.vue';
 import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
-import { shouldCollapsed } from '@@/js/collapsed.js';
-import { host } from '@@/js/config.js';
 import { isEnabledUrlPreview } from '@/instance.js';
 import { type Keymap } from '@/scripts/hotkey.js';
 import { focusPrev, focusNext } from '@/scripts/focus.js';
@@ -619,14 +619,6 @@ function emitUpdReaction(emoji: string, delta: number) {
 	overflow: clip;
 	contain: content;
 
-	// これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、
-	// 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう
-	// ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、
-	// 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる
-	// 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?)
-	//content-visibility: auto;
-	//contain-intrinsic-size: 0 128px;
-
 	&:focus-visible {
 		outline: none;
 
@@ -687,6 +679,11 @@ function emitUpdReaction(emoji: string, delta: number) {
 	}
 }
 
+.skipRender {
+	content-visibility: auto;
+	contain-intrinsic-size: 0 150px;
+}
+
 .tip {
 	display: flex;
 	align-items: center;
diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue
index 410a3f53c7..4a52e59d02 100644
--- a/packages/frontend/src/pages/settings/other.vue
+++ b/packages/frontend/src/pages/settings/other.vue
@@ -54,6 +54,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<MkSwitch v-model="enableCondensedLine">
 						<template #label>Enable condensed line</template>
 					</MkSwitch>
+					<MkSwitch v-model="skipNoteRender">
+						<template #label>Enable note render skipping</template>
+					</MkSwitch>
 				</div>
 			</MkFolder>
 
@@ -105,9 +108,14 @@ const $i = signinRequired();
 
 const reportError = computed(defaultStore.makeGetterSetter('reportError'));
 const enableCondensedLine = computed(defaultStore.makeGetterSetter('enableCondensedLine'));
+const skipNoteRender = computed(defaultStore.makeGetterSetter('skipNoteRender'));
 const devMode = computed(defaultStore.makeGetterSetter('devMode'));
 const defaultWithReplies = computed(defaultStore.makeGetterSetter('defaultWithReplies'));
 
+watch(skipNoteRender, async () => {
+	await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true });
+});
+
 async function deleteAccount() {
 	{
 		const { canceled } = await os.confirm({
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index cb52938980..4f641e7513 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -468,6 +468,10 @@ export const defaultStore = markRaw(new Storage('base', {
 		where: 'device',
 		default: 'app' as 'app' | 'appWithShift' | 'native',
 	},
+	skipNoteRender: {
+		where: 'device',
+		default: false,
+	},
 
 	sound_masterVolume: {
 		where: 'device',

From 521d92014db757192b09d62f627f5f1b3ae7c5f5 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Mon, 14 Oct 2024 11:22:51 +0900
Subject: [PATCH 22/34] New Crowdin updates (#14753)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (Korean)

* New translations ja-jp.yml (Chinese Simplified)

* New translations ja-jp.yml (Catalan)

* New translations ja-jp.yml (Chinese Simplified)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (Korean)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (Chinese Simplified)

* New translations ja-jp.yml (Italian)

* New translations ja-jp.yml (Italian)
---
 locales/ca-ES.yml |  2 ++
 locales/it-IT.yml | 32 ++++++++++++++++++++++++++------
 locales/ko-KR.yml |  9 +++++++++
 locales/zh-CN.yml | 11 ++++++++++-
 locales/zh-TW.yml | 38 +++++++++++++++++++++++++++++---------
 5 files changed, 76 insertions(+), 16 deletions(-)

diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml
index 7b668e5ce9..b9f3fecc76 100644
--- a/locales/ca-ES.yml
+++ b/locales/ca-ES.yml
@@ -1286,6 +1286,7 @@ passkeyVerificationFailed: "La verificació a fallat"
 passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verificació de la passkey a estat correcta, però s'ha deshabilitat l'inici de sessió sense contrasenya."
 messageToFollower: "Missatge als meus seguidors"
 target: "Assumpte "
+testCaptchaWarning: "És una característica dissenyada per a la prova de CAPTCHA. <strong>No l'utilitzes en l'entorn real.</strong>"
 _abuseUserReport:
   forward: "Reenviar "
   forwardDescription: "Reenvia l'informe a una altra instància com un compte del sistema anònima."
@@ -1430,6 +1431,7 @@ _serverSettings:
   reactionsBufferingDescription: "Quan s'activa aquesta opció millora bastant el rendiment en recuperar les línies de temps reduint la càrrega de la base. Com a contrapunt, augmentarà  l'ús de memòria de Redís. Desactiva aquesta opció en cas de tenir un servidor amb poca memòria o si tens problemes d'inestabilitat."
   inquiryUrl: "URL de consulta "
   inquiryUrlDescription: "Escriu adreça URL per al formulari de consulta per al mantenidor del servidor o una pàgina web amb el contacte d'informació."
+  thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Si no es detecta activitat per part del moderador durant un període de temps, aquesta opció es desactiva automàticament per evitar el correu brossa."
 _accountMigration:
   moveFrom: "Migrar un altre compte a aquest"
   moveFromSub: "Crear un àlies per un altre compte"
diff --git a/locales/it-IT.yml b/locales/it-IT.yml
index d42fff326c..bcabf1bdb6 100644
--- a/locales/it-IT.yml
+++ b/locales/it-IT.yml
@@ -454,6 +454,7 @@ totpDescription: "Puoi autenticarti inserendo un codice OTP tramite la tua App d
 moderator: "Moderatore"
 moderation: "moderazione"
 moderationNote: "Promemoria di moderazione"
+moderationNoteDescription: "Puoi scrivere promemoria condivisi solo tra moderatori."
 addModerationNote: "Aggiungi promemoria di moderazione"
 moderationLogs: "Cronologia di moderazione"
 nUsersMentioned: "{n} profili ne parlano"
@@ -841,7 +842,7 @@ onlineStatus: "Stato di connessione"
 hideOnlineStatus: "Modalità invisibile"
 hideOnlineStatusDescription: "Attivando questa opzione potresti ridurre l'usabilità di alcune funzioni, come la ricerca."
 online: "Online"
-active: "Attività"
+active: "Attivo"
 offline: "Offline"
 notRecommended: "Sconsigliato"
 botProtection: "Protezione contro i bot"
@@ -1086,6 +1087,7 @@ retryAllQueuesConfirmTitle: "Vuoi ritentare adesso?"
 retryAllQueuesConfirmText: "Potrebbe sovraccaricare il server temporaneamente."
 enableChartsForRemoteUser: "Abilita i grafici per i profili remoti"
 enableChartsForFederatedInstances: "Abilita i grafici per le istanze federate"
+enableStatsForFederatedInstances: "Informazioni statistiche sui server federati"
 showClipButtonInNoteFooter: "Aggiungi il bottone Clip tra le azioni delle Note"
 reactionsDisplaySize: "Grandezza delle reazioni"
 limitWidthOfReaction: "Limita la larghezza delle reazioni e ridimensionale"
@@ -1285,6 +1287,19 @@ unknownWebAuthnKey: "Questa è una passkey sconosciuta."
 passkeyVerificationFailed: "La verifica della passkey non è riuscita."
 passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verifica della passkey è riuscita, ma l'accesso senza password è disabilitato."
 messageToFollower: "Messaggio ai follower"
+target: "Riferimento"
+testCaptchaWarning: "Questa funzione è destinata al test CAPTCHA. <strong>Da non utilizzare in ambiente di produzione.</strong>"
+prohibitedWordsForNameOfUser: "Parole proibite (nome utente)"
+prohibitedWordsForNameOfUserDescription: "Il sistema rifiuta di rinominare un utente, se il nome contiene qualsiasi parola nell'elenco. Sono esenti i profili con privilegi di moderazione."
+yourNameContainsProhibitedWords: "Il nome che hai scelto contiene una o più parole vietate"
+yourNameContainsProhibitedWordsDescription: "Se desideri comunque utilizzare questo nome, contatta l''amministrazione."
+_abuseUserReport:
+  forward: "Inoltra"
+  forwardDescription: "Inoltra il report al server remoto, per mezzo di account di sistema, anonimo."
+  resolve: "Risolvi"
+  accept: "Approva"
+  reject: "Rifiuta"
+  resolveTutorial: "Se moderi una segnalazione legittima, scegli \"Approva\" per risolvere positivamente.\nSe la segnalazione non è legittima, seleziona \"Rifiuta\" per risolvere negativamente."
 _delivery:
   status: "Stato della consegna"
   stop: "Sospensione"
@@ -1312,16 +1327,16 @@ _bubbleGame:
 _announcement:
   forExistingUsers: "Solo ai profili attuali"
   forExistingUsersDescription: "L'annuncio sarà visibile solo ai profili esistenti in questo momento. Se disabilitato, sarà visibile anche ai profili che verranno creati dopo la pubblicazione di questo annuncio."
-  needConfirmationToRead: "Richiede la conferma di lettura"
-  needConfirmationToReadDescription: "Sarà visualizzata una finestra di dialogo che richiede la conferma di lettura. Inoltre, non è soggetto a conferme di lettura massicce."
+  needConfirmationToRead: "Conferma di lettura obbligatoria"
+  needConfirmationToReadDescription: "I profili riceveranno una finestra di dialogo che richiede di accettare obbligatoriamente per procedere. Tale richiesta è esente da  \"conferma tutte\"."
   end: "Archivia l'annuncio"
   tooManyActiveAnnouncementDescription: "L'esperienza delle persone può peggiorare se ci sono troppi annunci attivi. Considera anche l'archiviazione degli annunci conclusi."
   readConfirmTitle: "Segnare come già letto?"
   readConfirmText: "Hai già letto \"{title}˝?"
   shouldNotBeUsedToPresentPermanentInfo: "Ti consigliamo di utilizzare gli annunci per pubblicare informazioni tempestive e limitate nel tempo, anziché informazioni importanti a lungo andare nel tempo, poiché potrebbero risultare difficili da ritrovare e peggiorare la fruibilità del servizio, specialmente alle nuove persone iscritte."
   dialogAnnouncementUxWarn: "Ti consigliamo di usarli con cautela, poiché è molto probabile che avere più di un annuncio in stile \"finestra di dialogo\" peggiori sensibilmente la fruibilità del servizio, specialmente alle nuove persone iscritte."
-  silence: "Silenziare gli annunci"
-  silenceDescription: "Se attivi questa opzione, non riceverai notifiche sugli annunci, evitando di contrassegnarle come già lette."
+  silence: "Annuncio silenzioso"
+  silenceDescription: "Attivando questa opzione, non invierai la notifica, evitando che debba essere contrassegnata come già letta."
 _initialAccountSetting:
   accountCreated: "Il tuo profilo è stato creato!"
   letsStartAccountSetup: "Per iniziare, impostiamo il tuo profilo."
@@ -1422,6 +1437,7 @@ _serverSettings:
   reactionsBufferingDescription: "Attivando questa opzione, puoi migliorare significativamente le prestazioni durante la creazione delle reazioni e ridurre il carico sul database. Tuttavia, aumenterà l'impiego di memoria Redis."
   inquiryUrl: "URL di contatto"
   inquiryUrlDescription: "Specificare l'URL al modulo di contatto, oppure le informazioni con i dati di contatto dell'amministrazione."
+  thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Per prevenire SPAM, questa impostazione verrà disattivata automaticamente, se non si rileva alcuna attività di moderazione durante un certo periodo di tempo."
 _accountMigration:
   moveFrom: "Migra un altro profilo dentro a questo"
   moveFromSub: "Crea un alias verso un altro profilo remoto"
@@ -2187,7 +2203,7 @@ _widgets:
   _userList:
     chooseList: "Seleziona una lista"
   clicker: "Cliccaggio"
-  birthdayFollowings: "Chi nacque oggi"
+  birthdayFollowings: "Compleanni del giorno"
 _cw:
   hide: "Nascondere"
   show: "Continua la lettura..."
@@ -2476,6 +2492,8 @@ _webhookSettings:
     abuseReport: "Quando arriva una segnalazione"
     abuseReportResolved: "Quando una segnalazione è risolta"
     userCreated: "Quando viene creato un profilo"
+    inactiveModeratorsWarning: "Quando un profilo moderatore rimane inattivo per un determinato periodo"
+    inactiveModeratorsInvitationOnlyChanged: "Quando la moderazione è rimasta inattiva per un determinato periodo e il sistema è cambiato in modalità \"solo inviti\""
   deleteConfirm: "Vuoi davvero eliminare il Webhook?"
   testRemarks: "Clicca il bottone a destra dell'interruttore, per provare l'invio di un webhook con dati fittizi."
 _abuseReport:
@@ -2521,6 +2539,8 @@ _moderationLogTypes:
   markSensitiveDriveFile: "File nel Drive segnato come esplicito"
   unmarkSensitiveDriveFile: "File nel Drive segnato come non esplicito"
   resolveAbuseReport: "Segnalazione risolta"
+  forwardAbuseReport: "Segnalazione inoltrata"
+  updateAbuseReportNote: "Ha aggiornato la segnalazione"
   createInvitation: "Genera codice di invito"
   createAd: "Banner creato"
   deleteAd: "Banner eliminato"
diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml
index 973140dca2..414202adab 100644
--- a/locales/ko-KR.yml
+++ b/locales/ko-KR.yml
@@ -1087,6 +1087,7 @@ retryAllQueuesConfirmTitle: "지금 다시 시도하시겠습니까?"
 retryAllQueuesConfirmText: "일시적으로 서버의 부하가 증가할 수 있습니다."
 enableChartsForRemoteUser: "리모트 유저의 차트를 생성"
 enableChartsForFederatedInstances: "리모트 서버의 차트를 생성"
+enableStatsForFederatedInstances: "리모트 서버 정보 받아오기"
 showClipButtonInNoteFooter: "노트 동작에 클립을 추가"
 reactionsDisplaySize: "리액션 표시 크기"
 limitWidthOfReaction: "리액션의 최대 폭을 제한하고 작게 표시하기"
@@ -1287,6 +1288,11 @@ passkeyVerificationFailed: "패스키 검증을 실패했습니다."
 passkeyVerificationSucceededButPasswordlessLoginDisabled: "패스키를 검증했으나, 비밀번호 없이 로그인하기가 꺼져 있습니다."
 messageToFollower: "팔로워에 보낼 메시지"
 target: "대상"
+testCaptchaWarning: "CAPTCHA를 테스트하기 위한 기능입니다. <strong>실제 환경에서는 사용하지 마세요.</strong>"
+prohibitedWordsForNameOfUser: "금지 단어 (사용자 이름)"
+prohibitedWordsForNameOfUserDescription: "이 목록에 포함되는 키워드가 사용자 이름에 있는 경우, 일반 사용자는 이름을 바꿀 수 없습니다. 모더레이터 권한을 가진 사용자는 제한 대상에서 제외됩니다."
+yourNameContainsProhibitedWords: "바꾸려는 이름에 금지된 키워드가 포함되어 있습니다."
+yourNameContainsProhibitedWordsDescription: "이름에 금지된 키워드가 있습니다. 이름을 사용해야 하는 경우, 서버 관리자에 문의하세요."
 _abuseUserReport:
   forward: "전달"
   forwardDescription: "익명 시스템 계정을 사용하여 리모트 서버에 신고 내용을 전달할 수 있습니다."
@@ -1431,6 +1437,7 @@ _serverSettings:
   reactionsBufferingDescription: "활성화 한 경우, 리액션 작성 퍼포먼스가 대폭 향상되어 DB의 부하를 줄일 수 있으나, Redis의 메모리 사용량이 많아집니다."
   inquiryUrl: "문의처 URL"
   inquiryUrlDescription: "서버 운영자에게 보내는 문의 양식의 URL이나 운영자의 연락처 등이 적힌 웹 페이지의 URL을 설정합니다."
+  thisSettingWillAutomaticallyOffWhenModeratorsInactive: "일정 기간동안 모더레이터의 활동이 감지되지 않는 경우, 스팸 방지를 위해 이 설정은 자동으로 꺼집니다."
 _accountMigration:
   moveFrom: "다른 계정에서 이 계정으로 이사"
   moveFromSub: "다른 계정에 대한 별칭을 생성"
@@ -2485,6 +2492,8 @@ _webhookSettings:
     abuseReport: "유저롭"
     abuseReportResolved: "받은 신고를 처리했을 때"
     userCreated: "유저가 생성되었을 때"
+    inactiveModeratorsWarning: "모더레이터가 일정 기간동안 활동하지 않은 경우"
+    inactiveModeratorsInvitationOnlyChanged: "모더레이터가 일정 기간 활동하지 않아 시스템에 의해 초대제로 바뀐 경우"
   deleteConfirm: "Webhook을 삭제할까요?"
   testRemarks: "스위치 오른쪽에 있는 버튼을 클릭하여 더미 데이터를 사용한 테스트용 웹 훅을 보낼 수 있습니다."
 _abuseReport:
diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml
index 8b681efb13..b81018cc1f 100644
--- a/locales/zh-CN.yml
+++ b/locales/zh-CN.yml
@@ -1087,6 +1087,7 @@ retryAllQueuesConfirmTitle: "要再尝试一次吗?"
 retryAllQueuesConfirmText: "可能会使服务器负荷在一定时间内增加"
 enableChartsForRemoteUser: "生成远程用户的图表"
 enableChartsForFederatedInstances: "生成远程服务器的图表"
+enableStatsForFederatedInstances: "获取远程服务器的信息"
 showClipButtonInNoteFooter: "在贴文下方显示便签按钮"
 reactionsDisplaySize: "回应显示大小"
 limitWidthOfReaction: "限制回应的最大宽度,并将其缩小显示"
@@ -1287,6 +1288,11 @@ passkeyVerificationFailed: "验证通行密钥失败。"
 passkeyVerificationSucceededButPasswordlessLoginDisabled: "通行密钥验证成功,但账户未开启无密码登录。"
 messageToFollower: "给关注者的消息"
 target: "对象"
+testCaptchaWarning: "此功能为测试 CAPTCHA 用。<strong>请勿在正式环境中使用。</strong>"
+prohibitedWordsForNameOfUser: "用户名中禁止的词"
+prohibitedWordsForNameOfUserDescription: "更改用户名时,如果用户名中包含此列表里的词汇,用户的改名请求将被拒绝。持有管理员权限的用户不受此限制。"
+yourNameContainsProhibitedWords: "目标用户名包含违禁词"
+yourNameContainsProhibitedWordsDescription: "用户名内含有违禁词。若想使用此用户名,请联系服务器管理员。"
 _abuseUserReport:
   forward: "转发"
   forwardDescription: "目标是匿名系统账户,将把举报转发给远程服务器。"
@@ -1431,6 +1437,7 @@ _serverSettings:
   reactionsBufferingDescription: "开启时可显著提高发送回应时的性能,及减轻数据库负荷。但 Redis 的内存用量会相应增加。"
   inquiryUrl: "联络地址"
   inquiryUrlDescription: "用来指定诸如向服务运营商咨询的论坛地址,或记载了运营商联系方式之类的网页地址。"
+  thisSettingWillAutomaticallyOffWhenModeratorsInactive: "若在一段时间内没有检测到管理活动,为防止垃圾信息,此设定将自动关闭。"
 _accountMigration:
   moveFrom: "从别的账号迁移到此账户"
   moveFromSub: "为另一个账户建立别名"
@@ -2262,7 +2269,7 @@ _profile:
   avatarDecorationMax: "最多可添加 {max} 个挂件"
   followedMessage: "被关注时显示的消息"
   followedMessageDescription: "可以设置被关注时向对方显示的短消息。"
-  followedMessageDescriptionForLockedAccount: "需要批准才能关注的情况下,消息是在被请求被批准后显示。"
+  followedMessageDescriptionForLockedAccount: "需要批准才能关注的情况下,消息是在请求被批准后显示。"
 _exportOrImport:
   allNotes: "所有帖子"
   favoritedNotes: "收藏的帖子"
@@ -2485,6 +2492,8 @@ _webhookSettings:
     abuseReport: "当收到举报时"
     abuseReportResolved: "当举报被处理时"
     userCreated: "当用户被创建时"
+    inactiveModeratorsWarning: "当管理员在一段时间内不活跃时"
+    inactiveModeratorsInvitationOnlyChanged: "当因为管理员在一段时间内不活跃,导致服务器变为邀请制时"
   deleteConfirm: "要删除 webhook 吗?"
   testRemarks: "点击开关右侧的按钮,可以发送使用假数据的测试 Webhook。"
 _abuseReport:
diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml
index 55b504e8fb..de18342bbf 100644
--- a/locales/zh-TW.yml
+++ b/locales/zh-TW.yml
@@ -454,6 +454,7 @@ totpDescription: "以驗證應用程式輸入一次性密碼"
 moderator: "審查員"
 moderation: "審查"
 moderationNote: "管理筆記"
+moderationNoteDescription: "您可以編寫僅在審查員之間共用的註解。"
 addModerationNote: "新增管理筆記"
 moderationLogs: "管理日誌"
 nUsersMentioned: "被 {n} 個人提及"
@@ -519,7 +520,7 @@ menuStyle: "選單風格"
 style: "風格"
 drawer: "側邊欄"
 popup: "彈出式視窗"
-showNoteActionsOnlyHover: "僅在游標停留時顯示貼文的操作選項"
+showNoteActionsOnlyHover: "僅在游標停留時顯示貼文的"
 showReactionsCount: "顯示貼文的反應數目"
 noHistory: "沒有歷史紀錄"
 signinHistory: "登入歷史"
@@ -1018,7 +1019,7 @@ show: "檢視"
 neverShow: "不再顯示"
 remindMeLater: "以後再說"
 didYouLikeMisskey: "您喜歡 Misskey 嗎?"
-pleaseDonate: "Misskey 是由 {host} 使用的免費軟體。請贊助我們,讓開發得以持續!"
+pleaseDonate: "Misskey是由{host}使用的免費軟體。請贊助我們,讓開發的工作能夠持續!"
 correspondingSourceIsAvailable: "對應的原始碼可以在 {anchor} 處找到。"
 roles: "角色"
 role: "角色"
@@ -1086,6 +1087,7 @@ retryAllQueuesConfirmTitle: "要現在重試嗎?"
 retryAllQueuesConfirmText: "伺服器的負荷可能會暫時增加。"
 enableChartsForRemoteUser: "生成遠端使用者的圖表"
 enableChartsForFederatedInstances: "生成遠端伺服器的圖表"
+enableStatsForFederatedInstances: "取得遠端伺服器資訊"
 showClipButtonInNoteFooter: "新增摘錄按鈕至貼文"
 reactionsDisplaySize: "反應的顯示尺寸"
 limitWidthOfReaction: "限制反應的最大寬度,並縮小顯示尺寸。"
@@ -1194,7 +1196,7 @@ showRenotes: "顯示其他人的轉發貼文"
 edited: "已編輯"
 notificationRecieveConfig: "接受通知的設定"
 mutualFollow: "互相追隨"
-followingOrFollower: "追隨中或追隨者"
+followingOrFollower: "追隨中或者追隨者"
 fileAttachedOnly: "只顯示包含附件的貼文"
 showRepliesToOthersInTimeline: "顯示給其他人的回覆"
 hideRepliesToOthersInTimeline: "在時間軸上隱藏給其他人的回覆"
@@ -1265,7 +1267,7 @@ useNativeUIForVideoAudioPlayer: "使用瀏覽器的 UI 播放影片與音訊"
 keepOriginalFilename: "保留原始檔名"
 keepOriginalFilenameDescription: "如果關閉此設置,上傳時檔案名稱會自動替換為隨機字串。"
 noDescription: "沒有說明文字"
-alwaysConfirmFollow: "點擊追隨時總是顯示確認訊息"
+alwaysConfirmFollow: "跟隨時總是確認"
 inquiry: "聯絡我們"
 tryAgain: "請再試一次。"
 confirmWhenRevealingSensitiveMedia: "要顯示敏感媒體時需確認"
@@ -1285,6 +1287,19 @@ unknownWebAuthnKey: "未註冊的金鑰。"
 passkeyVerificationFailed: "驗證金鑰失敗。"
 passkeyVerificationSucceededButPasswordlessLoginDisabled: "雖然驗證金鑰成功,但是無密碼登入的方式是停用的。"
 messageToFollower: "給追隨者的訊息"
+target: "目標 "
+testCaptchaWarning: "此功能用於 CAPTCHA 的測試。<strong>請勿在正式環境中使用。</strong>"
+prohibitedWordsForNameOfUser: "禁止使用的字詞(使用者名稱)"
+prohibitedWordsForNameOfUserDescription: "如果使用者名稱包含此清單中的任何字串,則拒絕重新命名使用者。 具有審查員權限的使用者不受此限制的影響。"
+yourNameContainsProhibitedWords: "您嘗試更改的名稱包含禁止的字串"
+yourNameContainsProhibitedWordsDescription: "名稱中包含禁止使用的字串。 如果您想使用此名稱,請聯絡您的伺服器管理員。"
+_abuseUserReport:
+  forward: "轉發"
+  forwardDescription: "以匿名系統帳戶將檢舉轉發至遠端伺服器。"
+  resolve: "解決"
+  accept: "接受"
+  reject: "拒絕"
+  resolveTutorial: "如果您已回覆正當的檢舉,請選擇「接受」以將案件標記為已解決。\n 如果檢舉的內容不正當,請選擇「拒絕」將案件標記為已解決。"
 _delivery:
   status: "傳送狀態"
   stop: "停止發送"
@@ -1422,6 +1437,7 @@ _serverSettings:
   reactionsBufferingDescription: "啟用時,可以顯著提高建立反應時的效能並減少資料庫的負載。 但是,Redis 記憶體使用量會增加。"
   inquiryUrl: "聯絡表單網址"
   inquiryUrlDescription: "指定伺服器運營者的聯絡表單網址,或包含運營者聯絡資訊網頁的網址。"
+  thisSettingWillAutomaticallyOffWhenModeratorsInactive: "為了防止 spam,如果一段期間內沒有偵測到審查員的活動,此設定將自動關閉。"
 _accountMigration:
   moveFrom: "從其他帳戶遷移到這個帳戶"
   moveFromSub: "為另一個帳戶建立別名"
@@ -1435,7 +1451,7 @@ _accountMigration:
   startMigration: "遷移"
   migrationConfirm: "確定要將這個帳戶遷移至 {account} 嗎?一旦遷移就無法撤銷,也就無法以原來的狀態使用這個帳戶。\n另外,請確認在要遷移到的帳戶已經建立了一個別名。"
   movedAndCannotBeUndone: "帳戶已遷移。\n遷移無法撤消。"
-  postMigrationNote: "在完成遷移的 24 小時後解除此帳戶的追隨。此帳戶的追隨中、追隨者數量變為 0。由於不會解除追隨者,你的追隨者仍然可以繼續檢視這個帳戶發布給追隨者的貼文。"
+  postMigrationNote: "取消追蹤此帳戶將在遷移操作後 24 小時執行。\n 此帳戶有 0 個關注者/關注者。 您的關注者仍然可以看到此帳戶的關注者帖子,因為您不會被取消關注。"
   movedTo: "要遷移到的帳戶:"
 _achievements:
   earnedAt: "獲得日期"
@@ -1555,7 +1571,7 @@ _achievements:
     _markedAsCat:
       title: "我是貓"
       description: "已將帳戶設定為貓"
-      flavor: "還沒有名字。"
+      flavor: "沒有名字。"
     _following1:
       title: "首次追隨"
       description: "首次追隨了"
@@ -1569,7 +1585,7 @@ _achievements:
       title: "一百位朋友"
       description: "追隨超過100人了"
     _following300:
-      title: "朋友過多"
+      title: "朋友太多"
       description: "追隨超過300人了"
     _followers1:
       title: "第一個追隨者"
@@ -1895,7 +1911,7 @@ _channel:
   following: "追隨中"
   usersCount: "有 {n} 人參與"
   notesCount: "有 {n} 篇貼文"
-  nameAndDescription: "名稱與說明"
+  nameAndDescription: "名稱"
   nameOnly: "僅名稱"
   allowRenoteToExternal: "允許在頻道外轉發和引用"
 _menuDisplay:
@@ -2476,6 +2492,8 @@ _webhookSettings:
     abuseReport: "當使用者檢舉時"
     abuseReportResolved: "當處理了使用者的檢舉時"
     userCreated: "使用者被新增時"
+    inactiveModeratorsWarning: "當審查員在一段時間內沒有活動時"
+    inactiveModeratorsInvitationOnlyChanged: "當審查員在一段時間內不活動時,系統會將模式變更為邀請制"
   deleteConfirm: "請問是否要刪除 Webhook?"
   testRemarks: "按下切換開關右側的按鈕,就會將假資料發送至 Webhook。"
 _abuseReport:
@@ -2490,7 +2508,7 @@ _abuseReport:
         mail: "寄送到擁有監察員權限的使用者電子郵件地址(僅在收到檢舉時)"
         webhook: "向指定的 SystemWebhook 發送通知(在收到檢舉和解決檢舉時發送)"
     keywords: "關鍵字"
-    notifiedUser: "被通知的使用者"
+    notifiedUser: "通知的使用者"
     notifiedWebhook: "使用的 Webhook"
     deleteConfirm: "確定要刪除通知對象嗎?"
 _moderationLogTypes:
@@ -2521,6 +2539,8 @@ _moderationLogTypes:
   markSensitiveDriveFile: "標記為敏感檔案"
   unmarkSensitiveDriveFile: "撤銷標記為敏感檔案"
   resolveAbuseReport: "解決檢舉"
+  forwardAbuseReport: "轉發檢舉"
+  updateAbuseReportNote: "更新檢舉的審查備註"
   createInvitation: "建立邀請碼"
   createAd: "建立廣告"
   deleteAd: "刪除廣告"

From 8b7290d6b0aca61d8c57f294a40fd5bd3b19c235 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?=
 <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Mon, 14 Oct 2024 11:23:26 +0900
Subject: [PATCH 23/34] =?UTF-8?q?enhance(backend):=20=E5=80=8B=E4=BA=BA?=
 =?UTF-8?q?=E5=AE=9B=E3=81=AE=E3=81=8A=E7=9F=A5=E3=82=89=E3=81=9B=E3=81=AF?=
 =?UTF-8?q?=E3=82=8F=E3=81=8B=E3=81=A3=E3=81=9F=E3=82=92=E6=8A=BC=E3=81=99?=
 =?UTF-8?q?=E3=81=A8=E3=82=A2=E3=83=BC=E3=82=AB=E3=82=A4=E3=83=96=E3=81=99?=
 =?UTF-8?q?=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=20(#14762)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* enhance(backend): 個人宛のお知らせはわかったを押すとアーカイブするように

* Update Changelog

* enhance(frontend): アーカイブ済みのものを読み込めるように

* Update Changelog

* fix changelog

* :art:
---
 CHANGELOG.md                                     |  2 ++
 packages/backend/src/core/AnnouncementService.ts |  7 +++++++
 packages/frontend/src/pages/admin-user.vue       | 10 ++++++++++
 3 files changed, 19 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 22b5506f28..9e42d0448e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,10 +9,12 @@
 
 ### Client
 - Enhance: l10nの更新
+- Enhance: アーカイブした個人宛のお知らせを表示・編集できるように
 - Fix: メールアドレス不要でCaptchaが有効な場合にアカウント登録完了後自動でのログインに失敗する問題を修正
 
 ### Server
 - Feat: モデレータ権限を持つユーザが全員7日間活動しなかった場合は自動的に招待制へと移行するように ( #13437 )
+- Enhance: 個人宛のお知らせは「わかった」を押すと自動的にアーカイブされるように
 - Fix: `admin/emoji/update`エンドポイントのidのみ指定した時不正なエラーが発生するバグを修正
 - Fix: RBT有効時、リノートのリアクションが反映されない問題を修正
 
diff --git a/packages/backend/src/core/AnnouncementService.ts b/packages/backend/src/core/AnnouncementService.ts
index 40a9db01c0..d4fcf19439 100644
--- a/packages/backend/src/core/AnnouncementService.ts
+++ b/packages/backend/src/core/AnnouncementService.ts
@@ -209,6 +209,13 @@ export class AnnouncementService {
 			return;
 		}
 
+		const announcement = await this.announcementsRepository.findOneBy({ id: announcementId });
+		if (announcement != null && announcement.userId === user.id) {
+			await this.announcementsRepository.update(announcementId, {
+				isActive: false,
+			});
+		}
+
 		if ((await this.getUnreadAnnouncements(user)).length === 0) {
 			this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements');
 		}
diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue
index d33b116059..948e7a3cce 100644
--- a/packages/frontend/src/pages/admin-user.vue
+++ b/packages/frontend/src/pages/admin-user.vue
@@ -153,6 +153,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<div v-else-if="tab === 'announcements'" class="_gaps">
 				<MkButton primary rounded @click="createAnnouncement"><i class="ti ti-plus"></i> {{ i18n.ts.new }}</MkButton>
 
+				<MkSelect v-model="announcementsStatus">
+					<template #label>{{ i18n.ts.filter }}</template>
+					<option value="active">{{ i18n.ts.active }}</option>
+					<option value="archived">{{ i18n.ts.archived }}</option>
+				</MkSelect>
+
 				<MkPagination :pagination="announcementsPagination">
 					<template #default="{ items }">
 						<div class="_gaps_s">
@@ -254,11 +260,15 @@ const filesPagination = {
 		userId: props.userId,
 	})),
 };
+
+const announcementsStatus = ref<'active' | 'archived'>('active');
+
 const announcementsPagination = {
 	endpoint: 'admin/announcements/list' as const,
 	limit: 10,
 	params: computed(() => ({
 		userId: props.userId,
+		status: announcementsStatus.value,
 	})),
 };
 const expandedRoles = ref([]);

From ddca6bdc0171918a0c5b5d8dc61320bd65e4af06 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com>
Date: Mon, 14 Oct 2024 02:34:17 +0000
Subject: [PATCH 24/34] Bump version to 2024.10.1-beta.5

---
 package.json                     | 2 +-
 packages/misskey-js/package.json | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/package.json b/package.json
index 4b477aba4b..37a11fb20b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "misskey",
-	"version": "2024.10.1-beta.4",
+	"version": "2024.10.1-beta.5",
 	"codename": "nasubi",
 	"repository": {
 		"type": "git",
diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json
index a59385dc10..590d2367db 100644
--- a/packages/misskey-js/package.json
+++ b/packages/misskey-js/package.json
@@ -1,7 +1,7 @@
 {
 	"type": "module",
 	"name": "misskey-js",
-	"version": "2024.10.1-beta.4",
+	"version": "2024.10.1-beta.5",
 	"description": "Misskey SDK for JavaScript",
 	"license": "MIT",
 	"main": "./built/index.js",

From 64bbce4cf4f6d17c7d3309968d95815f177d9544 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Mon, 14 Oct 2024 12:32:59 +0900
Subject: [PATCH 25/34] perf(frontend): improve notification rendering
 performance

---
 packages/frontend/src/components/MkNotification.vue | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue
index bef425097e..093bdb8b4c 100644
--- a/packages/frontend/src/components/MkNotification.vue
+++ b/packages/frontend/src/components/MkNotification.vue
@@ -220,6 +220,8 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
 	overflow-wrap: break-word;
 	display: flex;
 	contain: content;
+	content-visibility: auto;
+	contain-intrinsic-size: 0 100px;
 
 	--eventFollow: #36aed2;
 	--eventRenote: #36d298;

From c46d6d8edd05b3dd69cf894e29d740d7fe1300ed Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Mon, 14 Oct 2024 12:34:03 +0900
Subject: [PATCH 26/34] perf(frontend-embed): improve note rendering
 performance

---
 packages/frontend-embed/src/components/EmNote.vue | 14 ++++----------
 1 file changed, 4 insertions(+), 10 deletions(-)

diff --git a/packages/frontend-embed/src/components/EmNote.vue b/packages/frontend-embed/src/components/EmNote.vue
index d4b4827c90..f5b064c293 100644
--- a/packages/frontend-embed/src/components/EmNote.vue
+++ b/packages/frontend-embed/src/components/EmNote.vue
@@ -108,6 +108,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 import { computed, inject, ref, shallowRef } from 'vue';
 import * as mfm from 'mfm-js';
 import * as Misskey from 'misskey-js';
+import { shouldCollapsed } from '@@/js/collapsed.js';
+import { url } from '@@/js/config.js';
 import I18n from '@/components/I18n.vue';
 import EmNoteSub from '@/components/EmNoteSub.vue';
 import EmNoteHeader from '@/components/EmNoteHeader.vue';
@@ -123,8 +125,6 @@ import EmUserName from '@/components/EmUserName.vue';
 import EmTime from '@/components/EmTime.vue';
 import { userPage } from '@/utils.js';
 import { i18n } from '@/i18n.js';
-import { shouldCollapsed } from '@@/js/collapsed.js';
-import { url } from '@@/js/config.js';
 
 function getAppearNote(note: Misskey.entities.Note) {
 	return Misskey.note.isPureRenote(note) ? note.renote : note;
@@ -164,14 +164,8 @@ const isDeleted = ref(false);
 	font-size: 1.05em;
 	overflow: clip;
 	contain: content;
-
-	// これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、
-	// 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう
-	// ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、
-	// 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる
-	// 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?)
-	//content-visibility: auto;
-  //contain-intrinsic-size: 0 128px;
+	content-visibility: auto;
+  contain-intrinsic-size: 0 150px;
 
 	&:focus-visible {
 		outline: none;

From 3b361a9d0bbc2a6fce6076e379ed08febb447d59 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Mon, 14 Oct 2024 12:37:18 +0900
Subject: [PATCH 27/34] perf(frontend): make skipNoteRender on by default

---
 packages/frontend/src/store.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 4f641e7513..aab67e0b5c 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -470,7 +470,7 @@ export const defaultStore = markRaw(new Storage('base', {
 	},
 	skipNoteRender: {
 		where: 'device',
-		default: false,
+		default: true,
 	},
 
 	sound_masterVolume: {

From 140322b8e2bfce65d39edef0e4cd4f5e93ce1d14 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Mon, 14 Oct 2024 12:38:12 +0900
Subject: [PATCH 28/34] Update CHANGELOG.md

---
 CHANGELOG.md | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9e42d0448e..4631615bc7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,8 +8,9 @@
 - Feat: ユーザーの名前に禁止ワードを設定できるように
 
 ### Client
-- Enhance: l10nの更新
+- Enhance: タイムライン表示時のパフォーマンスを向上
 - Enhance: アーカイブした個人宛のお知らせを表示・編集できるように
+- Enhance: l10nの更新
 - Fix: メールアドレス不要でCaptchaが有効な場合にアカウント登録完了後自動でのログインに失敗する問題を修正
 
 ### Server

From 04e74aa28c8cbab840313c2e257896f97fc460fe Mon Sep 17 00:00:00 2001
From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com>
Date: Mon, 14 Oct 2024 04:19:47 +0000
Subject: [PATCH 29/34] Bump version to 2024.10.1-beta.6

---
 package.json                     | 2 +-
 packages/misskey-js/package.json | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/package.json b/package.json
index 37a11fb20b..59b75fece4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "misskey",
-	"version": "2024.10.1-beta.5",
+	"version": "2024.10.1-beta.6",
 	"codename": "nasubi",
 	"repository": {
 		"type": "git",
diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json
index 590d2367db..0f8433fbb1 100644
--- a/packages/misskey-js/package.json
+++ b/packages/misskey-js/package.json
@@ -1,7 +1,7 @@
 {
 	"type": "module",
 	"name": "misskey-js",
-	"version": "2024.10.1-beta.5",
+	"version": "2024.10.1-beta.6",
 	"description": "Misskey SDK for JavaScript",
 	"license": "MIT",
 	"main": "./built/index.js",

From b0a251d231b18007e0801dbf3517102c6b455320 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Mon, 14 Oct 2024 15:35:44 +0900
Subject: [PATCH 30/34] :art:

---
 packages/frontend/src/components/global/MkAd.vue | 4 ----
 1 file changed, 4 deletions(-)

diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue
index 792a087148..0d68d02e35 100644
--- a/packages/frontend/src/components/global/MkAd.vue
+++ b/packages/frontend/src/components/global/MkAd.vue
@@ -136,8 +136,6 @@ function reduceFrequency(): void {
 	}
 
 	&.form_horizontal {
-		padding: 8px;
-
 		> .link,
 		> .link > .img {
 			max-width: min(600px, 100%);
@@ -146,8 +144,6 @@ function reduceFrequency(): void {
 	}
 
 	&.form_horizontalBig {
-		padding: 8px;
-
 		> .link,
 		> .link > .img {
 			max-width: min(600px, 100%);

From 7fd8ef344b33b0a157bc197cbd64069695806936 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Mon, 14 Oct 2024 17:43:44 +0900
Subject: [PATCH 31/34] refactor

---
 .../backend/src/core/entities/NoteEntityService.ts   | 12 +-----------
 packages/backend/src/misc/is-renote.ts               | 11 +++++++++++
 2 files changed, 12 insertions(+), 11 deletions(-)

diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts
index e530772dd9..c24c80a5b5 100644
--- a/packages/backend/src/core/entities/NoteEntityService.ts
+++ b/packages/backend/src/core/entities/NoteEntityService.ts
@@ -16,23 +16,13 @@ import { bindThis } from '@/decorators.js';
 import { DebounceLoader } from '@/misc/loader.js';
 import { IdService } from '@/core/IdService.js';
 import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
+import { isPureRenote } from '@/misc/is-renote.js';
 import type { OnModuleInit } from '@nestjs/common';
 import type { CustomEmojiService } from '../CustomEmojiService.js';
 import type { ReactionService } from '../ReactionService.js';
 import type { UserEntityService } from './UserEntityService.js';
 import type { DriveFileEntityService } from './DriveFileEntityService.js';
 
-function isPureRenote(note: MiNote): note is MiNote & { renoteId: MiNote['id']; renote: MiNote } {
-	return (
-		note.renote != null &&
-		note.reply == null &&
-		note.text == null &&
-		note.cw == null &&
-		(note.fileIds == null || note.fileIds.length === 0) &&
-		!note.hasPoll
-	);
-}
-
 function getAppearNoteIds(notes: MiNote[]): Set<string> {
 	const appearNoteIds = new Set<string>();
 	for (const note of notes) {
diff --git a/packages/backend/src/misc/is-renote.ts b/packages/backend/src/misc/is-renote.ts
index 48f821806c..245057c64e 100644
--- a/packages/backend/src/misc/is-renote.ts
+++ b/packages/backend/src/misc/is-renote.ts
@@ -36,6 +36,17 @@ export function isQuote(note: Renote): note is Quote {
 		note.fileIds.length > 0;
 }
 
+export function isPureRenote(note: MiNote): note is MiNote & { renoteId: MiNote['id']; renote: MiNote } {
+	return (
+		note.renote != null &&
+		note.reply == null &&
+		note.text == null &&
+		note.cw == null &&
+		(note.fileIds == null || note.fileIds.length === 0) &&
+		!note.hasPoll
+	);
+}
+
 type PackedRenote =
 	Packed<'Note'> & {
 		renoteId: NonNullable<Packed<'Note'>['renoteId']>

From 77ebabb3dc76d6a422ea576ed60e5e4afe72d637 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Mon, 14 Oct 2024 17:51:47 +0900
Subject: [PATCH 32/34] Revert "refactor"

This reverts commit 7fd8ef344b33b0a157bc197cbd64069695806936.
---
 .../backend/src/core/entities/NoteEntityService.ts   | 12 +++++++++++-
 packages/backend/src/misc/is-renote.ts               | 11 -----------
 2 files changed, 11 insertions(+), 12 deletions(-)

diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts
index c24c80a5b5..e530772dd9 100644
--- a/packages/backend/src/core/entities/NoteEntityService.ts
+++ b/packages/backend/src/core/entities/NoteEntityService.ts
@@ -16,13 +16,23 @@ import { bindThis } from '@/decorators.js';
 import { DebounceLoader } from '@/misc/loader.js';
 import { IdService } from '@/core/IdService.js';
 import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
-import { isPureRenote } from '@/misc/is-renote.js';
 import type { OnModuleInit } from '@nestjs/common';
 import type { CustomEmojiService } from '../CustomEmojiService.js';
 import type { ReactionService } from '../ReactionService.js';
 import type { UserEntityService } from './UserEntityService.js';
 import type { DriveFileEntityService } from './DriveFileEntityService.js';
 
+function isPureRenote(note: MiNote): note is MiNote & { renoteId: MiNote['id']; renote: MiNote } {
+	return (
+		note.renote != null &&
+		note.reply == null &&
+		note.text == null &&
+		note.cw == null &&
+		(note.fileIds == null || note.fileIds.length === 0) &&
+		!note.hasPoll
+	);
+}
+
 function getAppearNoteIds(notes: MiNote[]): Set<string> {
 	const appearNoteIds = new Set<string>();
 	for (const note of notes) {
diff --git a/packages/backend/src/misc/is-renote.ts b/packages/backend/src/misc/is-renote.ts
index 245057c64e..48f821806c 100644
--- a/packages/backend/src/misc/is-renote.ts
+++ b/packages/backend/src/misc/is-renote.ts
@@ -36,17 +36,6 @@ export function isQuote(note: Renote): note is Quote {
 		note.fileIds.length > 0;
 }
 
-export function isPureRenote(note: MiNote): note is MiNote & { renoteId: MiNote['id']; renote: MiNote } {
-	return (
-		note.renote != null &&
-		note.reply == null &&
-		note.text == null &&
-		note.cw == null &&
-		(note.fileIds == null || note.fileIds.length === 0) &&
-		!note.hasPoll
-	);
-}
-
 type PackedRenote =
 	Packed<'Note'> & {
 		renoteId: NonNullable<Packed<'Note'>['renoteId']>

From f13c3909a09a73be9952723c431decbb0df67fef Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Mon, 14 Oct 2024 17:54:27 +0900
Subject: [PATCH 33/34] refactor(backend): remove unnecessary any

---
 packages/backend/src/core/entities/NoteEntityService.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts
index e530772dd9..c6e176d055 100644
--- a/packages/backend/src/core/entities/NoteEntityService.ts
+++ b/packages/backend/src/core/entities/NoteEntityService.ts
@@ -113,7 +113,7 @@ export class NoteEntityService implements OnModuleInit {
 				hide = false;
 			} else {
 				// 指定されているかどうか
-				const specified = packedNote.visibleUserIds!.some((id: any) => meId === id);
+				const specified = packedNote.visibleUserIds!.some(id => meId === id);
 
 				if (specified) {
 					hide = false;
@@ -250,7 +250,7 @@ export class NoteEntityService implements OnModuleInit {
 				return true;
 			} else {
 				// 指定されているかどうか
-				return note.visibleUserIds.some((id: any) => meId === id);
+				return note.visibleUserIds.some(id => meId === id);
 			}
 		}
 

From 5005cc8ae358cf61ae104e39282838d219538f3d Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Mon, 14 Oct 2024 21:00:20 +0900
Subject: [PATCH 34/34] add note

---
 packages/backend/src/core/entities/NoteEntityService.ts | 1 +
 1 file changed, 1 insertion(+)

diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts
index c6e176d055..3e1f094fce 100644
--- a/packages/backend/src/core/entities/NoteEntityService.ts
+++ b/packages/backend/src/core/entities/NoteEntityService.ts
@@ -22,6 +22,7 @@ import type { ReactionService } from '../ReactionService.js';
 import type { UserEntityService } from './UserEntityService.js';
 import type { DriveFileEntityService } from './DriveFileEntityService.js';
 
+// is-renote.tsとよしなにリンク
 function isPureRenote(note: MiNote): note is MiNote & { renoteId: MiNote['id']; renote: MiNote } {
 	return (
 		note.renote != null &&