From cd7f7271ca5595cae95f6fb0280fac9dee77d751 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, 19 Apr 2024 15:22:23 +0900
Subject: [PATCH] =?UTF-8?q?enhance:=20=E6=96=B0=E3=81=97=E3=81=84=E3=82=B3?=
 =?UTF-8?q?=E3=83=B3=E3=83=87=E3=82=A3=E3=82=B7=E3=83=A7=E3=83=8A=E3=83=AB?=
 =?UTF-8?q?=E3=83=AD=E3=83=BC=E3=83=AB=E6=9D=A1=E4=BB=B6=E3=81=AE=E5=AE=9F?=
 =?UTF-8?q?=E8=A3=85=20(#13732)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* enhance: 新しいコンディショナルロールの実装

* fix: CHANGELOG.md
---
 CHANGELOG.md                                  |   6 +
 locales/index.d.ts                            |  20 +
 locales/ja-JP.yml                             |   5 +
 packages/backend/src/core/RoleService.ts      |  34 ++
 packages/backend/src/misc/json-schema.ts      |   2 +
 packages/backend/src/models/Role.ts           |  85 +++
 .../backend/src/models/json-schema/role.ts    |  17 +
 packages/backend/test/unit/RoleService.ts     | 512 +++++++++++++++---
 .../src/pages/admin/RolesEditorFormula.vue    |   5 +
 packages/misskey-js/etc/misskey-js.api.md     |   4 +
 packages/misskey-js/src/autogen/models.ts     |   1 +
 packages/misskey-js/src/autogen/types.ts      |   7 +-
 12 files changed, 624 insertions(+), 74 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36632e5024..c13fa664dd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,12 @@
 - Enhance: アンテナでBotによるノートを除外できるように  
   (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/545)
 - Enhance: クリップのノート数を表示するように
+- Enhance: コンディショナルロールの条件として以下を新たに追加 (#13667)
+  - 猫ユーザーか
+  - botユーザーか
+  - サスペンド済みユーザーか
+  - 鍵アカウントユーザーか
+  - 「アカウントを見つけやすくする」が有効なユーザーか
 - Fix: Play作成時に設定した公開範囲が機能していない問題を修正
 
 ### Client
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 8e31fc8d59..9bcd1979af 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -6592,6 +6592,26 @@ export interface Locale extends ILocale {
              * リモートユーザー
              */
             "isRemote": string;
+            /**
+             * 猫ユーザー
+             */
+            "isCat": string;
+            /**
+             * botユーザー
+             */
+            "isBot": string;
+            /**
+             * サスペンド済みユーザー
+             */
+            "isSuspended": string;
+            /**
+             * 鍵アカウントユーザー
+             */
+            "isLocked": string;
+            /**
+             * 「アカウントを見つけやすくする」が有効なユーザー
+             */
+            "isExplorable": string;
             /**
              * アカウント作成から~以内
              */
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index f598459792..5f7715b210 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1703,6 +1703,11 @@ _role:
     roleAssignedTo: "マニュアルロールにアサイン済み"
     isLocal: "ローカルユーザー"
     isRemote: "リモートユーザー"
+    isCat: "猫ユーザー"
+    isBot: "botユーザー"
+    isSuspended: "サスペンド済みユーザー"
+    isLocked: "鍵アカウントユーザー"
+    isExplorable: "「アカウントを見つけやすくする」が有効なユーザー"
     createdLessThan: "アカウント作成から~以内"
     createdMoreThan: "アカウント作成から~経過"
     followersLessThanOrEq: "フォロワー数が~以下"
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 09f3097114..70c537f9ab 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -205,45 +205,79 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
 	private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue): boolean {
 		try {
 			switch (value.type) {
+				// ~かつ~
 				case 'and': {
 					return value.values.every(v => this.evalCond(user, roles, v));
 				}
+				// ~または~
 				case 'or': {
 					return value.values.some(v => this.evalCond(user, roles, v));
 				}
+				// ~ではない
 				case 'not': {
 					return !this.evalCond(user, roles, value.value);
 				}
+				// マニュアルロールがアサインされている
 				case 'roleAssignedTo': {
 					return roles.some(r => r.id === value.roleId);
 				}
+				// ローカルユーザのみ
 				case 'isLocal': {
 					return this.userEntityService.isLocalUser(user);
 				}
+				// リモートユーザのみ
 				case 'isRemote': {
 					return this.userEntityService.isRemoteUser(user);
 				}
+				// サスペンド済みユーザである
+				case 'isSuspended': {
+					return user.isSuspended;
+				}
+				// 鍵アカウントユーザである
+				case 'isLocked': {
+					return user.isLocked;
+				}
+				// botユーザである
+				case 'isBot': {
+					return user.isBot;
+				}
+				// 猫である
+				case 'isCat': {
+					return user.isCat;
+				}
+				// 「ユーザを見つけやすくする」が有効なアカウント
+				case 'isExplorable': {
+					return user.isExplorable;
+				}
+				// ユーザが作成されてから指定期間経過した
 				case 'createdLessThan': {
 					return this.idService.parse(user.id).date.getTime() > (Date.now() - (value.sec * 1000));
 				}
+				// ユーザが作成されてから指定期間経っていない
 				case 'createdMoreThan': {
 					return this.idService.parse(user.id).date.getTime() < (Date.now() - (value.sec * 1000));
 				}
+				// フォロワー数が指定値以下
 				case 'followersLessThanOrEq': {
 					return user.followersCount <= value.value;
 				}
+				// フォロワー数が指定値以上
 				case 'followersMoreThanOrEq': {
 					return user.followersCount >= value.value;
 				}
+				// フォロー数が指定値以下
 				case 'followingLessThanOrEq': {
 					return user.followingCount <= value.value;
 				}
+				// フォロー数が指定値以上
 				case 'followingMoreThanOrEq': {
 					return user.followingCount >= value.value;
 				}
+				// ノート数が指定値以下
 				case 'notesLessThanOrEq': {
 					return user.notesCount <= value.value;
 				}
+				// ノート数が指定値以上
 				case 'notesMoreThanOrEq': {
 					return user.notesCount >= value.value;
 				}
diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts
index 46b0bb2fab..a620d7c94b 100644
--- a/packages/backend/src/misc/json-schema.ts
+++ b/packages/backend/src/misc/json-schema.ts
@@ -48,6 +48,7 @@ import {
 	packedRoleCondFormulaValueCreatedSchema,
 	packedRoleCondFormulaFollowersOrFollowingOrNotesSchema,
 	packedRoleCondFormulaValueSchema,
+	packedRoleCondFormulaValueUserSettingBooleanSchema,
 } from '@/models/json-schema/role.js';
 import { packedAdSchema } from '@/models/json-schema/ad.js';
 import { packedReversiGameLiteSchema, packedReversiGameDetailedSchema } from '@/models/json-schema/reversi-game.js';
@@ -97,6 +98,7 @@ export const refs = {
 	RoleCondFormulaLogics: packedRoleCondFormulaLogicsSchema,
 	RoleCondFormulaValueNot: packedRoleCondFormulaValueNot,
 	RoleCondFormulaValueIsLocalOrRemote: packedRoleCondFormulaValueIsLocalOrRemoteSchema,
+	RoleCondFormulaValueUserSettingBooleanSchema: packedRoleCondFormulaValueUserSettingBooleanSchema,
 	RoleCondFormulaValueAssignedRole: packedRoleCondFormulaValueAssignedRoleSchema,
 	RoleCondFormulaValueCreated: packedRoleCondFormulaValueCreatedSchema,
 	RoleCondFormulaFollowersOrFollowingOrNotes: packedRoleCondFormulaFollowersOrFollowingOrNotesSchema,
diff --git a/packages/backend/src/models/Role.ts b/packages/backend/src/models/Role.ts
index 058abe3118..a173971b2c 100644
--- a/packages/backend/src/models/Role.ts
+++ b/packages/backend/src/models/Role.ts
@@ -6,69 +6,149 @@
 import { Entity, Column, PrimaryColumn } from 'typeorm';
 import { id } from './util/id.js';
 
+/**
+ * ~かつ~
+ * 複数の条件を同時に満たす場合のみ成立とする
+ */
 type CondFormulaValueAnd = {
 	type: 'and';
 	values: RoleCondFormulaValue[];
 };
 
+/**
+ * ~または~
+ * 複数の条件のうち、いずれかを満たす場合のみ成立とする
+ */
 type CondFormulaValueOr = {
 	type: 'or';
 	values: RoleCondFormulaValue[];
 };
 
+/**
+ * ~ではない
+ * 条件を満たさない場合のみ成立とする
+ */
 type CondFormulaValueNot = {
 	type: 'not';
 	value: RoleCondFormulaValue;
 };
 
+/**
+ * ローカルユーザーのみ成立とする
+ */
 type CondFormulaValueIsLocal = {
 	type: 'isLocal';
 };
 
+/**
+ * リモートユーザーのみ成立とする
+ */
 type CondFormulaValueIsRemote = {
 	type: 'isRemote';
 };
 
+/**
+ * 既に指定のマニュアルロールにアサインされている場合のみ成立とする
+ */
 type CondFormulaValueRoleAssignedTo = {
 	type: 'roleAssignedTo';
 	roleId: string;
 };
 
+/**
+ * サスペンド済みアカウントの場合のみ成立とする
+ */
+type CondFormulaValueIsSuspended = {
+	type: 'isSuspended';
+};
+
+/**
+ * 鍵アカウントの場合のみ成立とする
+ */
+type CondFormulaValueIsLocked = {
+	type: 'isLocked';
+};
+
+/**
+ * botアカウントの場合のみ成立とする
+ */
+type CondFormulaValueIsBot = {
+	type: 'isBot';
+};
+
+/**
+ * 猫アカウントの場合のみ成立とする
+ */
+type CondFormulaValueIsCat = {
+	type: 'isCat';
+};
+
+/**
+ * 「ユーザを見つけやすくする」が有効なアカウントの場合のみ成立とする
+ */
+type CondFormulaValueIsExplorable = {
+	type: 'isExplorable';
+};
+
+/**
+ * ユーザが作成されてから指定期間経過した場合のみ成立とする
+ */
 type CondFormulaValueCreatedLessThan = {
 	type: 'createdLessThan';
 	sec: number;
 };
 
+/**
+ * ユーザが作成されてから指定期間経っていない場合のみ成立とする
+ */
 type CondFormulaValueCreatedMoreThan = {
 	type: 'createdMoreThan';
 	sec: number;
 };
 
+/**
+ * フォロワー数が指定値以下の場合のみ成立とする
+ */
 type CondFormulaValueFollowersLessThanOrEq = {
 	type: 'followersLessThanOrEq';
 	value: number;
 };
 
+/**
+ * フォロワー数が指定値以上の場合のみ成立とする
+ */
 type CondFormulaValueFollowersMoreThanOrEq = {
 	type: 'followersMoreThanOrEq';
 	value: number;
 };
 
+/**
+ * フォロー数が指定値以下の場合のみ成立とする
+ */
 type CondFormulaValueFollowingLessThanOrEq = {
 	type: 'followingLessThanOrEq';
 	value: number;
 };
 
+/**
+ * フォロー数が指定値以上の場合のみ成立とする
+ */
 type CondFormulaValueFollowingMoreThanOrEq = {
 	type: 'followingMoreThanOrEq';
 	value: number;
 };
 
+/**
+ * 投稿数が指定値以下の場合のみ成立とする
+ */
 type CondFormulaValueNotesLessThanOrEq = {
 	type: 'notesLessThanOrEq';
 	value: number;
 };
 
+/**
+ * 投稿数が指定値以上の場合のみ成立とする
+ */
 type CondFormulaValueNotesMoreThanOrEq = {
 	type: 'notesMoreThanOrEq';
 	value: number;
@@ -80,6 +160,11 @@ export type RoleCondFormulaValue = { id: string } & (
 	CondFormulaValueNot |
 	CondFormulaValueIsLocal |
 	CondFormulaValueIsRemote |
+	CondFormulaValueIsSuspended |
+	CondFormulaValueIsLocked |
+	CondFormulaValueIsBot |
+	CondFormulaValueIsCat |
+	CondFormulaValueIsExplorable |
 	CondFormulaValueRoleAssignedTo |
 	CondFormulaValueCreatedLessThan |
 	CondFormulaValueCreatedMoreThan |
diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts
index c770250503..d9987a70c3 100644
--- a/packages/backend/src/models/json-schema/role.ts
+++ b/packages/backend/src/models/json-schema/role.ts
@@ -57,6 +57,20 @@ export const packedRoleCondFormulaValueIsLocalOrRemoteSchema = {
 	},
 } as const;
 
+export const packedRoleCondFormulaValueUserSettingBooleanSchema = {
+	type: 'object',
+	properties: {
+		id: {
+			type: 'string', optional: false,
+		},
+		type: {
+			type: 'string',
+			nullable: false, optional: false,
+			enum: ['isSuspended', 'isLocked', 'isBot', 'isCat', 'isExplorable'],
+		},
+	},
+} as const;
+
 export const packedRoleCondFormulaValueAssignedRoleSchema = {
 	type: 'object',
 	properties: {
@@ -135,6 +149,9 @@ export const packedRoleCondFormulaValueSchema = {
 		{
 			ref: 'RoleCondFormulaValueIsLocalOrRemote',
 		},
+		{
+			ref: 'RoleCondFormulaValueUserSettingBooleanSchema',
+		},
 		{
 			ref: 'RoleCondFormulaValueAssignedRole',
 		},
diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts
index 19d03570e0..ec441735d7 100644
--- a/packages/backend/test/unit/RoleService.ts
+++ b/packages/backend/test/unit/RoleService.ts
@@ -3,6 +3,8 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+
 process.env.NODE_ENV = 'test';
 
 import { jest } from '@jest/globals';
@@ -20,6 +22,7 @@ import { IdService } from '@/core/IdService.js';
 import { GlobalEventService } from '@/core/GlobalEventService.js';
 import { secureRndstr } from '@/misc/secure-rndstr.js';
 import { NotificationService } from '@/core/NotificationService.js';
+import { RoleCondFormulaValue } from '@/models/Role.js';
 import { sleep } from '../utils.js';
 import type { TestingModule } from '@nestjs/testing';
 import type { MockFunctionMetadata } from 'jest-mock';
@@ -52,12 +55,26 @@ describe('RoleService', () => {
 			id: genAidx(Date.now()),
 			updatedAt: new Date(),
 			lastUsedAt: new Date(),
+			name: '',
 			description: '',
 			...data,
 		})
 			.then(x => rolesRepository.findOneByOrFail(x.identifiers[0]));
 	}
 
+	function createConditionalRole(condFormula: RoleCondFormulaValue, data: Partial<MiRole> = {}) {
+		return createRole({
+			name: `[conditional] ${condFormula.type}`,
+			target: 'conditional',
+			condFormula: condFormula,
+			...data,
+		});
+	}
+
+	function aidx() {
+		return genAidx(Date.now());
+	}
+
 	beforeEach(async () => {
 		clock = lolex.install({
 			now: new Date(),
@@ -73,6 +90,7 @@ describe('RoleService', () => {
 				CacheService,
 				IdService,
 				GlobalEventService,
+				UserEntityService,
 				{
 					provide: NotificationService,
 					useFactory: () => ({
@@ -209,79 +227,6 @@ describe('RoleService', () => {
 			expect(result.driveCapacityMb).toBe(100);
 		});
 
-		test('conditional role', async () => {
-			const user1 = await createUser({
-				id: genAidx(Date.now() - (1000 * 60 * 60 * 24 * 365)),
-			});
-			const user2 = await createUser({
-				id: genAidx(Date.now() - (1000 * 60 * 60 * 24 * 365)),
-				followersCount: 10,
-			});
-			await createRole({
-				name: 'a',
-				policies: {
-					canManageCustomEmojis: {
-						useDefault: false,
-						priority: 0,
-						value: true,
-					},
-				},
-				target: 'conditional',
-				condFormula: {
-					id: '232a4221-9816-49a6-a967-ae0fac52ec5e',
-					type: 'and',
-					values: [{
-						id: '2a37ef43-2d93-4c4d-87f6-f2fdb7d9b530',
-						type: 'followersMoreThanOrEq',
-						value: 10,
-					}, {
-						id: '1bd67839-b126-4f92-bad0-4e285dab453b',
-						type: 'createdMoreThan',
-						sec: 60 * 60 * 24 * 7,
-					}],
-				},
-			});
-
-			metaService.fetch.mockResolvedValue({
-				policies: {
-					canManageCustomEmojis: false,
-				},
-			} as any);
-
-			const user1Policies = await roleService.getUserPolicies(user1.id);
-			const user2Policies = await roleService.getUserPolicies(user2.id);
-			expect(user1Policies.canManageCustomEmojis).toBe(false);
-			expect(user2Policies.canManageCustomEmojis).toBe(true);
-		});
-
-		test('コンディショナルロール: マニュアルロールにアサイン済み', async () => {
-			const [user1, user2, role1] = await Promise.all([
-				createUser(),
-				createUser(),
-				createRole({
-					name: 'manual role',
-				}),
-			]);
-			const role2 = await createRole({
-				name: 'conditional role',
-				target: 'conditional',
-				condFormula: {
-					// idはバックエンドのロジックに必要ない?
-					id: 'bdc612bd-9d54-4675-ae83-0499c82ea670',
-					type: 'roleAssignedTo',
-					roleId: role1.id,
-				},
-			});
-			await roleService.assign(user2.id, role1.id);
-
-			const [u1role, u2role] = await Promise.all([
-				roleService.getUserRoles(user1.id),
-				roleService.getUserRoles(user2.id),
-			]);
-			expect(u1role.some(r => r.id === role2.id)).toBe(false);
-			expect(u2role.some(r => r.id === role2.id)).toBe(true);
-		});
-
 		test('expired role', async () => {
 			const user = await createUser();
 			const role = await createRole({
@@ -320,6 +265,427 @@ describe('RoleService', () => {
 		});
 	});
 
+	describe('conditional role', () => {
+		test('~かつ~', async () => {
+			const [user1, user2, user3, user4] = await Promise.all([
+				createUser({ isBot: true, isCat: false, isSuspended: false }),
+				createUser({ isBot: false, isCat: true, isSuspended: false }),
+				createUser({ isBot: true, isCat: true, isSuspended: false }),
+				createUser({ isBot: false, isCat: false, isSuspended: true }),
+			]);
+			const role1 = await createConditionalRole({
+				id: aidx(),
+				type: 'isBot',
+			});
+			const role2 = await createConditionalRole({
+				id: aidx(),
+				type: 'isCat',
+			});
+			const role3 = await createConditionalRole({
+				id: aidx(),
+				type: 'isSuspended',
+			});
+			const role4 = await createConditionalRole({
+				id: aidx(),
+				type: 'and',
+				values: [role1.condFormula, role2.condFormula],
+			});
+
+			const actual1 = await roleService.getUserRoles(user1.id);
+			const actual2 = await roleService.getUserRoles(user2.id);
+			const actual3 = await roleService.getUserRoles(user3.id);
+			const actual4 = await roleService.getUserRoles(user4.id);
+			expect(actual1.some(r => r.id === role4.id)).toBe(false);
+			expect(actual2.some(r => r.id === role4.id)).toBe(false);
+			expect(actual3.some(r => r.id === role4.id)).toBe(true);
+			expect(actual4.some(r => r.id === role4.id)).toBe(false);
+		});
+
+		test('~または~', async () => {
+			const [user1, user2, user3, user4] = await Promise.all([
+				createUser({ isBot: true, isCat: false, isSuspended: false }),
+				createUser({ isBot: false, isCat: true, isSuspended: false }),
+				createUser({ isBot: true, isCat: true, isSuspended: false }),
+				createUser({ isBot: false, isCat: false, isSuspended: true }),
+			]);
+			const role1 = await createConditionalRole({
+				id: aidx(),
+				type: 'isBot',
+			});
+			const role2 = await createConditionalRole({
+				id: aidx(),
+				type: 'isCat',
+			});
+			const role3 = await createConditionalRole({
+				id: aidx(),
+				type: 'isSuspended',
+			});
+			const role4 = await createConditionalRole({
+				id: aidx(),
+				type: 'or',
+				values: [role1.condFormula, role2.condFormula],
+			});
+
+			const actual1 = await roleService.getUserRoles(user1.id);
+			const actual2 = await roleService.getUserRoles(user2.id);
+			const actual3 = await roleService.getUserRoles(user3.id);
+			const actual4 = await roleService.getUserRoles(user4.id);
+			expect(actual1.some(r => r.id === role4.id)).toBe(true);
+			expect(actual2.some(r => r.id === role4.id)).toBe(true);
+			expect(actual3.some(r => r.id === role4.id)).toBe(true);
+			expect(actual4.some(r => r.id === role4.id)).toBe(false);
+		});
+
+		test('~ではない', async () => {
+			const [user1, user2, user3] = await Promise.all([
+				createUser({ isBot: true, isCat: false, isSuspended: false }),
+				createUser({ isBot: false, isCat: true, isSuspended: false }),
+				createUser({ isBot: true, isCat: true, isSuspended: false }),
+			]);
+			const role1 = await createConditionalRole({
+				id: aidx(),
+				type: 'isBot',
+			});
+			const role2 = await createConditionalRole({
+				id: aidx(),
+				type: 'isCat',
+			});
+			const role4 = await createConditionalRole({
+				id: aidx(),
+				type: 'not',
+				value: role1.condFormula,
+			});
+
+			const actual1 = await roleService.getUserRoles(user1.id);
+			const actual2 = await roleService.getUserRoles(user2.id);
+			const actual3 = await roleService.getUserRoles(user3.id);
+			expect(actual1.some(r => r.id === role4.id)).toBe(false);
+			expect(actual2.some(r => r.id === role4.id)).toBe(true);
+			expect(actual3.some(r => r.id === role4.id)).toBe(false);
+		});
+
+		test('マニュアルロールにアサイン済み', async () => {
+			const [user1, user2, role1] = await Promise.all([
+				createUser(),
+				createUser(),
+				createRole({
+					name: 'manual role',
+				}),
+			]);
+			const role2 = await createConditionalRole({
+				id: aidx(),
+				type: 'roleAssignedTo',
+				roleId: role1.id,
+			});
+			await roleService.assign(user2.id, role1.id);
+
+			const [u1role, u2role] = await Promise.all([
+				roleService.getUserRoles(user1.id),
+				roleService.getUserRoles(user2.id),
+			]);
+			expect(u1role.some(r => r.id === role2.id)).toBe(false);
+			expect(u2role.some(r => r.id === role2.id)).toBe(true);
+		});
+
+		test('ローカルユーザのみ', async () => {
+			const [user1, user2] = await Promise.all([
+				createUser({ host: null }),
+				createUser({ host: 'example.com' }),
+			]);
+			const role = await createConditionalRole({
+				id: aidx(),
+				type: 'isLocal',
+			});
+
+			const actual1 = await roleService.getUserRoles(user1.id);
+			const actual2 = await roleService.getUserRoles(user2.id);
+			expect(actual1.some(r => r.id === role.id)).toBe(true);
+			expect(actual2.some(r => r.id === role.id)).toBe(false);
+		});
+
+		test('リモートユーザのみ', async () => {
+			const [user1, user2] = await Promise.all([
+				createUser({ host: null }),
+				createUser({ host: 'example.com' }),
+			]);
+			const role = await createConditionalRole({
+				id: aidx(),
+				type: 'isRemote',
+			});
+
+			const actual1 = await roleService.getUserRoles(user1.id);
+			const actual2 = await roleService.getUserRoles(user2.id);
+			expect(actual1.some(r => r.id === role.id)).toBe(false);
+			expect(actual2.some(r => r.id === role.id)).toBe(true);
+		});
+
+		test('サスペンド済みユーザである', async () => {
+			const [user1, user2] = await Promise.all([
+				createUser({ isSuspended: false }),
+				createUser({ isSuspended: true }),
+			]);
+			const role = await createConditionalRole({
+				id: aidx(),
+				type: 'isSuspended',
+			});
+
+			const actual1 = await roleService.getUserRoles(user1.id);
+			const actual2 = await roleService.getUserRoles(user2.id);
+			expect(actual1.some(r => r.id === role.id)).toBe(false);
+			expect(actual2.some(r => r.id === role.id)).toBe(true);
+		});
+
+		test('鍵アカウントユーザである', async () => {
+			const [user1, user2] = await Promise.all([
+				createUser({ isLocked: false }),
+				createUser({ isLocked: true }),
+			]);
+			const role = await createConditionalRole({
+				id: aidx(),
+				type: 'isLocked',
+			});
+
+			const actual1 = await roleService.getUserRoles(user1.id);
+			const actual2 = await roleService.getUserRoles(user2.id);
+			expect(actual1.some(r => r.id === role.id)).toBe(false);
+			expect(actual2.some(r => r.id === role.id)).toBe(true);
+		});
+
+		test('botユーザである', async () => {
+			const [user1, user2] = await Promise.all([
+				createUser({ isBot: false }),
+				createUser({ isBot: true }),
+			]);
+			const role = await createConditionalRole({
+				id: aidx(),
+				type: 'isBot',
+			});
+
+			const actual1 = await roleService.getUserRoles(user1.id);
+			const actual2 = await roleService.getUserRoles(user2.id);
+			expect(actual1.some(r => r.id === role.id)).toBe(false);
+			expect(actual2.some(r => r.id === role.id)).toBe(true);
+		});
+
+		test('猫である', async () => {
+			const [user1, user2] = await Promise.all([
+				createUser({ isCat: false }),
+				createUser({ isCat: true }),
+			]);
+			const role = await createConditionalRole({
+				id: aidx(),
+				type: 'isCat',
+			});
+
+			const actual1 = await roleService.getUserRoles(user1.id);
+			const actual2 = await roleService.getUserRoles(user2.id);
+			expect(actual1.some(r => r.id === role.id)).toBe(false);
+			expect(actual2.some(r => r.id === role.id)).toBe(true);
+		});
+
+		test('「ユーザを見つけやすくする」が有効なアカウント', async () => {
+			const [user1, user2] = await Promise.all([
+				createUser({ isExplorable: false }),
+				createUser({ isExplorable: true }),
+			]);
+			const role = await createConditionalRole({
+				id: aidx(),
+				type: 'isExplorable',
+			});
+
+			const actual1 = await roleService.getUserRoles(user1.id);
+			const actual2 = await roleService.getUserRoles(user2.id);
+			expect(actual1.some(r => r.id === role.id)).toBe(false);
+			expect(actual2.some(r => r.id === role.id)).toBe(true);
+		});
+
+		test('ユーザが作成されてから指定期間経過した', async () => {
+			const base = new Date();
+			base.setMinutes(base.getMinutes() - 5);
+
+			const d1 = new Date(base);
+			const d2 = new Date(base);
+			const d3 = new Date(base);
+			d1.setSeconds(d1.getSeconds() - 1);
+			d3.setSeconds(d3.getSeconds() + 1);
+
+			const [user1, user2, user3] = await Promise.all([
+				// 4:59
+				createUser({ id: genAidx(d1.getTime()) }),
+				// 5:00
+				createUser({ id: genAidx(d2.getTime()) }),
+				// 5:01
+				createUser({ id: genAidx(d3.getTime()) }),
+			]);
+			const role = await createConditionalRole({
+				id: aidx(),
+				type: 'createdLessThan',
+				// 5 minutes
+				sec: 300,
+			});
+
+			const actual1 = await roleService.getUserRoles(user1.id);
+			const actual2 = await roleService.getUserRoles(user2.id);
+			const actual3 = await roleService.getUserRoles(user3.id);
+			expect(actual1.some(r => r.id === role.id)).toBe(false);
+			expect(actual2.some(r => r.id === role.id)).toBe(false);
+			expect(actual3.some(r => r.id === role.id)).toBe(true);
+		});
+
+		test('ユーザが作成されてから指定期間経っていない', async () => {
+			const base = new Date();
+			base.setMinutes(base.getMinutes() - 5);
+
+			const d1 = new Date(base);
+			const d2 = new Date(base);
+			const d3 = new Date(base);
+			d1.setSeconds(d1.getSeconds() - 1);
+			d3.setSeconds(d3.getSeconds() + 1);
+
+			const [user1, user2, user3] = await Promise.all([
+				// 4:59
+				createUser({ id: genAidx(d1.getTime()) }),
+				// 5:00
+				createUser({ id: genAidx(d2.getTime()) }),
+				// 5:01
+				createUser({ id: genAidx(d3.getTime()) }),
+			]);
+			const role = await createConditionalRole({
+				id: aidx(),
+				type: 'createdMoreThan',
+				// 5 minutes
+				sec: 300,
+			});
+
+			const actual1 = await roleService.getUserRoles(user1.id);
+			const actual2 = await roleService.getUserRoles(user2.id);
+			const actual3 = await roleService.getUserRoles(user3.id);
+			expect(actual1.some(r => r.id === role.id)).toBe(true);
+			expect(actual2.some(r => r.id === role.id)).toBe(false);
+			expect(actual3.some(r => r.id === role.id)).toBe(false);
+		});
+
+		test('フォロワー数が指定値以下', async () => {
+			const [user1, user2, user3] = await Promise.all([
+				createUser({ followersCount: 99 }),
+				createUser({ followersCount: 100 }),
+				createUser({ followersCount: 101 }),
+			]);
+			const role = await createConditionalRole({
+				id: aidx(),
+				type: 'followersLessThanOrEq',
+				value: 100,
+			});
+
+			const actual1 = await roleService.getUserRoles(user1.id);
+			const actual2 = await roleService.getUserRoles(user2.id);
+			const actual3 = await roleService.getUserRoles(user3.id);
+			expect(actual1.some(r => r.id === role.id)).toBe(true);
+			expect(actual2.some(r => r.id === role.id)).toBe(true);
+			expect(actual3.some(r => r.id === role.id)).toBe(false);
+		});
+
+		test('フォロワー数が指定値以下', async () => {
+			const [user1, user2, user3] = await Promise.all([
+				createUser({ followersCount: 99 }),
+				createUser({ followersCount: 100 }),
+				createUser({ followersCount: 101 }),
+			]);
+			const role = await createConditionalRole({
+				id: aidx(),
+				type: 'followersMoreThanOrEq',
+				value: 100,
+			});
+
+			const actual1 = await roleService.getUserRoles(user1.id);
+			const actual2 = await roleService.getUserRoles(user2.id);
+			const actual3 = await roleService.getUserRoles(user3.id);
+			expect(actual1.some(r => r.id === role.id)).toBe(false);
+			expect(actual2.some(r => r.id === role.id)).toBe(true);
+			expect(actual3.some(r => r.id === role.id)).toBe(true);
+		});
+
+		test('フォロー数が指定値以下', async () => {
+			const [user1, user2, user3] = await Promise.all([
+				createUser({ followingCount: 99 }),
+				createUser({ followingCount: 100 }),
+				createUser({ followingCount: 101 }),
+			]);
+			const role = await createConditionalRole({
+				id: aidx(),
+				type: 'followingLessThanOrEq',
+				value: 100,
+			});
+
+			const actual1 = await roleService.getUserRoles(user1.id);
+			const actual2 = await roleService.getUserRoles(user2.id);
+			const actual3 = await roleService.getUserRoles(user3.id);
+			expect(actual1.some(r => r.id === role.id)).toBe(true);
+			expect(actual2.some(r => r.id === role.id)).toBe(true);
+			expect(actual3.some(r => r.id === role.id)).toBe(false);
+		});
+
+		test('フォロー数が指定値以上', async () => {
+			const [user1, user2, user3] = await Promise.all([
+				createUser({ followingCount: 99 }),
+				createUser({ followingCount: 100 }),
+				createUser({ followingCount: 101 }),
+			]);
+			const role = await createConditionalRole({
+				id: aidx(),
+				type: 'followingMoreThanOrEq',
+				value: 100,
+			});
+
+			const actual1 = await roleService.getUserRoles(user1.id);
+			const actual2 = await roleService.getUserRoles(user2.id);
+			const actual3 = await roleService.getUserRoles(user3.id);
+			expect(actual1.some(r => r.id === role.id)).toBe(false);
+			expect(actual2.some(r => r.id === role.id)).toBe(true);
+			expect(actual3.some(r => r.id === role.id)).toBe(true);
+		});
+
+		test('ノート数が指定値以下', async () => {
+			const [user1, user2, user3] = await Promise.all([
+				createUser({ notesCount: 9 }),
+				createUser({ notesCount: 10 }),
+				createUser({ notesCount: 11 }),
+			]);
+			const role = await createConditionalRole({
+				id: aidx(),
+				type: 'notesLessThanOrEq',
+				value: 10,
+			});
+
+			const actual1 = await roleService.getUserRoles(user1.id);
+			const actual2 = await roleService.getUserRoles(user2.id);
+			const actual3 = await roleService.getUserRoles(user3.id);
+			expect(actual1.some(r => r.id === role.id)).toBe(true);
+			expect(actual2.some(r => r.id === role.id)).toBe(true);
+			expect(actual3.some(r => r.id === role.id)).toBe(false);
+		});
+
+		test('ノート数が指定値以上', async () => {
+			const [user1, user2, user3] = await Promise.all([
+				createUser({ notesCount: 9 }),
+				createUser({ notesCount: 10 }),
+				createUser({ notesCount: 11 }),
+			]);
+			const role = await createConditionalRole({
+				id: aidx(),
+				type: 'notesMoreThanOrEq',
+				value: 10,
+			});
+
+			const actual1 = await roleService.getUserRoles(user1.id);
+			const actual2 = await roleService.getUserRoles(user2.id);
+			const actual3 = await roleService.getUserRoles(user3.id);
+			expect(actual1.some(r => r.id === role.id)).toBe(false);
+			expect(actual2.some(r => r.id === role.id)).toBe(true);
+			expect(actual3.some(r => r.id === role.id)).toBe(true);
+		});
+	});
+
 	describe('assign', () => {
 		test('公開ロールの場合は通知される', async () => {
 			const user = await createUser();
diff --git a/packages/frontend/src/pages/admin/RolesEditorFormula.vue b/packages/frontend/src/pages/admin/RolesEditorFormula.vue
index 2f5b4c47d8..f001a4ac20 100644
--- a/packages/frontend/src/pages/admin/RolesEditorFormula.vue
+++ b/packages/frontend/src/pages/admin/RolesEditorFormula.vue
@@ -9,6 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<MkSelect v-model="type" :class="$style.typeSelect">
 			<option value="isLocal">{{ i18n.ts._role._condition.isLocal }}</option>
 			<option value="isRemote">{{ i18n.ts._role._condition.isRemote }}</option>
+			<option value="isSuspended">{{ i18n.ts._role._condition.isSuspended }}</option>
+			<option value="isLocked">{{ i18n.ts._role._condition.isLocked }}</option>
+			<option value="isBot">{{ i18n.ts._role._condition.isBot }}</option>
+			<option value="isCat">{{ i18n.ts._role._condition.isCat }}</option>
+			<option value="isExplorable">{{ i18n.ts._role._condition.isExplorable }}</option>
 			<option value="roleAssignedTo">{{ i18n.ts._role._condition.roleAssignedTo }}</option>
 			<option value="createdLessThan">{{ i18n.ts._role._condition.createdLessThan }}</option>
 			<option value="createdMoreThan">{{ i18n.ts._role._condition.createdMoreThan }}</option>
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index 360724d2a9..9720b04e39 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -1713,6 +1713,7 @@ declare namespace entities {
         RoleCondFormulaLogics,
         RoleCondFormulaValueNot,
         RoleCondFormulaValueIsLocalOrRemote,
+        RoleCondFormulaValueUserSettingBooleanSchema,
         RoleCondFormulaValueAssignedRole,
         RoleCondFormulaValueCreated,
         RoleCondFormulaFollowersOrFollowingOrNotes,
@@ -2745,6 +2746,9 @@ type RoleCondFormulaValueIsLocalOrRemote = components['schemas']['RoleCondFormul
 // @public (undocumented)
 type RoleCondFormulaValueNot = components['schemas']['RoleCondFormulaValueNot'];
 
+// @public (undocumented)
+type RoleCondFormulaValueUserSettingBooleanSchema = components['schemas']['RoleCondFormulaValueUserSettingBooleanSchema'];
+
 // @public (undocumented)
 type RoleLite = components['schemas']['RoleLite'];
 
diff --git a/packages/misskey-js/src/autogen/models.ts b/packages/misskey-js/src/autogen/models.ts
index 6f61458600..a6e5fbe689 100644
--- a/packages/misskey-js/src/autogen/models.ts
+++ b/packages/misskey-js/src/autogen/models.ts
@@ -38,6 +38,7 @@ export type Signin = components['schemas']['Signin'];
 export type RoleCondFormulaLogics = components['schemas']['RoleCondFormulaLogics'];
 export type RoleCondFormulaValueNot = components['schemas']['RoleCondFormulaValueNot'];
 export type RoleCondFormulaValueIsLocalOrRemote = components['schemas']['RoleCondFormulaValueIsLocalOrRemote'];
+export type RoleCondFormulaValueUserSettingBooleanSchema = components['schemas']['RoleCondFormulaValueUserSettingBooleanSchema'];
 export type RoleCondFormulaValueAssignedRole = components['schemas']['RoleCondFormulaValueAssignedRole'];
 export type RoleCondFormulaValueCreated = components['schemas']['RoleCondFormulaValueCreated'];
 export type RoleCondFormulaFollowersOrFollowingOrNotes = components['schemas']['RoleCondFormulaFollowersOrFollowingOrNotes'];
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index ae001cf874..131d20f09b 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -4586,6 +4586,11 @@ export type components = {
       /** @enum {string} */
       type: 'isLocal' | 'isRemote';
     };
+    RoleCondFormulaValueUserSettingBooleanSchema: {
+      id: string;
+      /** @enum {string} */
+      type: 'isSuspended' | 'isLocked' | 'isBot' | 'isCat' | 'isExplorable';
+    };
     RoleCondFormulaValueAssignedRole: {
       id: string;
       /** @enum {string} */
@@ -4608,7 +4613,7 @@ export type components = {
       type: 'followersLessThanOrEq' | 'followersMoreThanOrEq' | 'followingLessThanOrEq' | 'followingMoreThanOrEq' | 'notesLessThanOrEq' | 'notesMoreThanOrEq';
       value: number;
     };
-    RoleCondFormulaValue: components['schemas']['RoleCondFormulaLogics'] | components['schemas']['RoleCondFormulaValueNot'] | components['schemas']['RoleCondFormulaValueIsLocalOrRemote'] | components['schemas']['RoleCondFormulaValueAssignedRole'] | components['schemas']['RoleCondFormulaValueCreated'] | components['schemas']['RoleCondFormulaFollowersOrFollowingOrNotes'];
+    RoleCondFormulaValue: components['schemas']['RoleCondFormulaLogics'] | components['schemas']['RoleCondFormulaValueNot'] | components['schemas']['RoleCondFormulaValueIsLocalOrRemote'] | components['schemas']['RoleCondFormulaValueUserSettingBooleanSchema'] | components['schemas']['RoleCondFormulaValueAssignedRole'] | components['schemas']['RoleCondFormulaValueCreated'] | components['schemas']['RoleCondFormulaFollowersOrFollowingOrNotes'];
     RoleLite: {
       /**
        * Format: id