summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authorHazelnoot <acomputerdog@gmail.com>2025-05-12 09:37:17 +0000
committerHazelnoot <acomputerdog@gmail.com>2025-05-12 09:37:17 +0000
commit0f68914610dedc710d293099b1c7475d9fe3a8c3 (patch)
tree936c2c051ba778bf03174a673e4dc3e6ee285894 /packages
parentmerge: fix migration setting note sound to 1 if not changed from default (!1015) (diff)
parentreset default value for new followers role conditions (diff)
downloadsharkey-0f68914610dedc710d293099b1c7475d9fe3a8c3.tar.gz
sharkey-0f68914610dedc710d293099b1c7475d9fe3a8c3.tar.bz2
sharkey-0f68914610dedc710d293099b1c7475d9fe3a8c3.zip
merge: Add new role conditions for local/remote followers/followees (!1002)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1002 Approved-by: Marie <github@yuugi.dev> Approved-by: dakkar <dakkar@thenautilus.net>
Diffstat (limited to 'packages')
-rw-r--r--packages/backend/src/core/CacheService.ts66
-rw-r--r--packages/backend/src/core/RoleService.ts39
-rw-r--r--packages/backend/src/models/Role.ts72
-rw-r--r--packages/frontend/src/pages/admin/RolesEditorFormula.vue52
4 files changed, 222 insertions, 7 deletions
diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts
index e9900373b4..822bb9d42c 100644
--- a/packages/backend/src/core/CacheService.ts
+++ b/packages/backend/src/core/CacheService.ts
@@ -15,6 +15,13 @@ import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
+export interface FollowStats {
+ localFollowing: number;
+ localFollowers: number;
+ remoteFollowing: number;
+ remoteFollowers: number;
+}
+
@Injectable()
export class CacheService implements OnApplicationShutdown {
public userByIdCache: MemoryKVCache<MiUser>;
@@ -27,6 +34,7 @@ export class CacheService implements OnApplicationShutdown {
public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
public renoteMutingsCache: RedisKVCache<Set<string>>;
public userFollowingsCache: RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>;
+ private readonly userFollowStatsCache = new MemoryKVCache<FollowStats>(1000 * 60 * 10); // 10 minutes
constructor(
@Inject(DI.redis)
@@ -167,6 +175,18 @@ export class CacheService implements OnApplicationShutdown {
const followee = this.userByIdCache.get(body.followeeId);
if (followee) followee.followersCount++;
this.userFollowingsCache.delete(body.followerId);
+ this.userFollowStatsCache.delete(body.followerId);
+ this.userFollowStatsCache.delete(body.followeeId);
+ break;
+ }
+ case 'unfollow': {
+ const follower = this.userByIdCache.get(body.followerId);
+ if (follower) follower.followingCount--;
+ const followee = this.userByIdCache.get(body.followeeId);
+ if (followee) followee.followersCount--;
+ this.userFollowingsCache.delete(body.followerId);
+ this.userFollowStatsCache.delete(body.followerId);
+ this.userFollowStatsCache.delete(body.followeeId);
break;
}
default:
@@ -188,6 +208,52 @@ export class CacheService implements OnApplicationShutdown {
}
@bindThis
+ public async getFollowStats(userId: MiUser['id']): Promise<FollowStats> {
+ return await this.userFollowStatsCache.fetch(userId, async () => {
+ const stats = {
+ localFollowing: 0,
+ localFollowers: 0,
+ remoteFollowing: 0,
+ remoteFollowers: 0,
+ };
+
+ const followings = await this.followingsRepository.findBy([
+ { followerId: userId },
+ { followeeId: userId },
+ ]);
+
+ for (const following of followings) {
+ if (following.followerId === userId) {
+ // increment following; user is a follower of someone else
+ if (following.followeeHost == null) {
+ stats.localFollowing++;
+ } else {
+ stats.remoteFollowing++;
+ }
+ } else if (following.followeeId === userId) {
+ // increment followers; user is followed by someone else
+ if (following.followerHost == null) {
+ stats.localFollowers++;
+ } else {
+ stats.remoteFollowers++;
+ }
+ } else {
+ // Should never happen
+ }
+ }
+
+ // Infer remote-remote followers heuristically, since we don't track that info directly.
+ const user = await this.findUserById(userId);
+ if (user.host !== null) {
+ stats.remoteFollowing = Math.max(0, user.followingCount - stats.localFollowing);
+ stats.remoteFollowers = Math.max(0, user.followersCount - stats.localFollowers);
+ }
+
+ return stats;
+ });
+ }
+
+ @bindThis
public dispose(): void {
this.redisForSub.off('message', this.onMessage);
this.userByIdCache.dispose();
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index d948325503..3fdac4c580 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -20,6 +20,7 @@ import type { MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js';
+import type { FollowStats } from '@/core/CacheService.js';
import type { RoleCondFormulaValue } from '@/models/Role.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
@@ -221,20 +222,20 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
}
@bindThis
- private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue): boolean {
+ private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue, followStats: FollowStats): boolean {
try {
switch (value.type) {
// ~かつ~
case 'and': {
- return value.values.every(v => this.evalCond(user, roles, v));
+ return value.values.every(v => this.evalCond(user, roles, v, followStats));
}
// ~または~
case 'or': {
- return value.values.some(v => this.evalCond(user, roles, v));
+ return value.values.some(v => this.evalCond(user, roles, v, followStats));
}
// ~ではない
case 'not': {
- return !this.evalCond(user, roles, value.value);
+ return !this.evalCond(user, roles, value.value, followStats);
}
// マニュアルロールがアサインされている
case 'roleAssignedTo': {
@@ -305,6 +306,30 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
case 'followingMoreThanOrEq': {
return user.followingCount >= value.value;
}
+ case 'localFollowersLessThanOrEq': {
+ return followStats.localFollowers <= value.value;
+ }
+ case 'localFollowersMoreThanOrEq': {
+ return followStats.localFollowers >= value.value;
+ }
+ case 'localFollowingLessThanOrEq': {
+ return followStats.localFollowing <= value.value;
+ }
+ case 'localFollowingMoreThanOrEq': {
+ return followStats.localFollowing >= value.value;
+ }
+ case 'remoteFollowersLessThanOrEq': {
+ return followStats.remoteFollowers <= value.value;
+ }
+ case 'remoteFollowersMoreThanOrEq': {
+ return followStats.remoteFollowers >= value.value;
+ }
+ case 'remoteFollowingLessThanOrEq': {
+ return followStats.remoteFollowing <= value.value;
+ }
+ case 'remoteFollowingMoreThanOrEq': {
+ return followStats.remoteFollowing >= value.value;
+ }
// ノート数が指定値以下
case 'notesLessThanOrEq': {
return user.notesCount <= value.value;
@@ -340,10 +365,11 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
@bindThis
public async getUserRoles(userId: MiUser['id']) {
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
+ const followStats = await this.cacheService.getFollowStats(userId);
const assigns = await this.getUserAssigns(userId);
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
- const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula));
+ const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula, followStats));
return [...assignedRoles, ...matchedCondRoles];
}
@@ -357,12 +383,13 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
// 期限切れのロールを除外
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
+ const followStats = await this.cacheService.getFollowStats(userId);
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
const assignedBadgeRoles = assignedRoles.filter(r => r.asBadge);
const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional'));
if (badgeCondRoles.length > 0) {
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
- const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, assignedRoles, r.condFormula));
+ const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, assignedRoles, r.condFormula, followStats));
return [...assignedBadgeRoles, ...matchedBadgeCondRoles];
} else {
return assignedBadgeRoles;
diff --git a/packages/backend/src/models/Role.ts b/packages/backend/src/models/Role.ts
index d7ae8ed38c..2caf3e0bd3 100644
--- a/packages/backend/src/models/Role.ts
+++ b/packages/backend/src/models/Role.ts
@@ -148,6 +148,70 @@ type CondFormulaValueFollowingMoreThanOrEq = {
};
/**
+ * Is followed by at most N local users
+ */
+type CondFormulaValueLocalFollowersLessThanOrEq = {
+ type: 'localFollowersLessThanOrEq';
+ value: number;
+};
+
+/**
+ * Is followed by at least N local users
+ */
+type CondFormulaValueLocalFollowersMoreThanOrEq = {
+ type: 'localFollowersMoreThanOrEq';
+ value: number;
+};
+
+/**
+ * Is following at most N local users
+ */
+type CondFormulaValueLocalFollowingLessThanOrEq = {
+ type: 'localFollowingLessThanOrEq';
+ value: number;
+};
+
+/**
+ * Is following at least N local users
+ */
+type CondFormulaValueLocalFollowingMoreThanOrEq = {
+ type: 'localFollowingMoreThanOrEq';
+ value: number;
+};
+
+/**
+ * Is followed by at most N remote users
+ */
+type CondFormulaValueRemoteFollowersLessThanOrEq = {
+ type: 'remoteFollowersLessThanOrEq';
+ value: number;
+};
+
+/**
+ * Is followed by at least N remote users
+ */
+type CondFormulaValueRemoteFollowersMoreThanOrEq = {
+ type: 'remoteFollowersMoreThanOrEq';
+ value: number;
+};
+
+/**
+ * Is following at most N remote users
+ */
+type CondFormulaValueRemoteFollowingLessThanOrEq = {
+ type: 'remoteFollowingLessThanOrEq';
+ value: number;
+};
+
+/**
+ * Is following at least N remote users
+ */
+type CondFormulaValueRemoteFollowingMoreThanOrEq = {
+ type: 'remoteFollowingMoreThanOrEq';
+ value: number;
+};
+
+/**
* 投稿数が指定値以下の場合のみ成立とする
*/
type CondFormulaValueNotesLessThanOrEq = {
@@ -182,6 +246,14 @@ export type RoleCondFormulaValue = { id: string } & (
CondFormulaValueFollowersMoreThanOrEq |
CondFormulaValueFollowingLessThanOrEq |
CondFormulaValueFollowingMoreThanOrEq |
+ CondFormulaValueLocalFollowersLessThanOrEq |
+ CondFormulaValueLocalFollowersMoreThanOrEq |
+ CondFormulaValueLocalFollowingLessThanOrEq |
+ CondFormulaValueLocalFollowingMoreThanOrEq |
+ CondFormulaValueRemoteFollowersLessThanOrEq |
+ CondFormulaValueRemoteFollowersMoreThanOrEq |
+ CondFormulaValueRemoteFollowingLessThanOrEq |
+ CondFormulaValueRemoteFollowingMoreThanOrEq |
CondFormulaValueNotesLessThanOrEq |
CondFormulaValueNotesMoreThanOrEq
);
diff --git a/packages/frontend/src/pages/admin/RolesEditorFormula.vue b/packages/frontend/src/pages/admin/RolesEditorFormula.vue
index 53a4836caa..0f3afd5b22 100644
--- a/packages/frontend/src/pages/admin/RolesEditorFormula.vue
+++ b/packages/frontend/src/pages/admin/RolesEditorFormula.vue
@@ -22,6 +22,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<option value="followersMoreThanOrEq">{{ i18n.ts._role._condition.followersMoreThanOrEq }}</option>
<option value="followingLessThanOrEq">{{ i18n.ts._role._condition.followingLessThanOrEq }}</option>
<option value="followingMoreThanOrEq">{{ i18n.ts._role._condition.followingMoreThanOrEq }}</option>
+ <option value="localFollowersLessThanOrEq">{{ i18n.ts._role._condition.localFollowersLessThanOrEq }}</option>
+ <option value="localFollowersMoreThanOrEq">{{ i18n.ts._role._condition.localFollowersMoreThanOrEq }}</option>
+ <option value="localFollowingLessThanOrEq">{{ i18n.ts._role._condition.localFollowingLessThanOrEq }}</option>
+ <option value="localFollowingMoreThanOrEq">{{ i18n.ts._role._condition.localFollowingMoreThanOrEq }}</option>
+ <option value="remoteFollowersLessThanOrEq">{{ i18n.ts._role._condition.remoteFollowersLessThanOrEq }}</option>
+ <option value="remoteFollowersMoreThanOrEq">{{ i18n.ts._role._condition.remoteFollowersMoreThanOrEq }}</option>
+ <option value="remoteFollowingLessThanOrEq">{{ i18n.ts._role._condition.remoteFollowingLessThanOrEq }}</option>
+ <option value="remoteFollowingMoreThanOrEq">{{ i18n.ts._role._condition.remoteFollowingMoreThanOrEq }}</option>
<option value="notesLessThanOrEq">{{ i18n.ts._role._condition.notesLessThanOrEq }}</option>
<option value="notesMoreThanOrEq">{{ i18n.ts._role._condition.notesMoreThanOrEq }}</option>
<option value="and">{{ i18n.ts._role._condition.and }}</option>
@@ -56,7 +64,26 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #suffix>sec</template>
</MkInput>
- <MkInput v-else-if="['followersLessThanOrEq', 'followersMoreThanOrEq', 'followingLessThanOrEq', 'followingMoreThanOrEq', 'notesLessThanOrEq', 'notesMoreThanOrEq'].includes(type)" v-model="v.value" type="number">
+ <MkInput
+ v-else-if="[
+ 'followersLessThanOrEq',
+ 'followersMoreThanOrEq',
+ 'followingLessThanOrEq',
+ 'followingMoreThanOrEq',
+ 'localFollowersLessThanOrEq',
+ 'localFollowersMoreThanOrEq',
+ 'localFollowingLessThanOrEq',
+ 'localFollowingMoreThanOrEq',
+ 'remoteFollowersLessThanOrEq',
+ 'remoteFollowersMoreThanOrEq',
+ 'remoteFollowingLessThanOrEq',
+ 'remoteFollowingMoreThanOrEq',
+ 'notesLessThanOrEq',
+ 'notesMoreThanOrEq'
+ ].includes(type)"
+ v-model="v.value"
+ type="number"
+ >
</MkInput>
<MkSelect v-else-if="type === 'roleAssignedTo'" v-model="v.roleId">
@@ -70,6 +97,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-if="type === 'isFromInstance'" v-model="v.subdomains">
<template #label>{{ i18n.ts._role._condition.isFromInstanceSubdomains }}</template>
</MkSwitch>
+
+ <div v-if="['remoteFollowersLessThanOrEq', 'remoteFollowersMoreThanOrEq', 'remoteFollowingLessThanOrEq', 'remoteFollowingMoreThanOrEq'].includes(type)" :class="$style.warningBanner">
+ <i class="ti ti-alert-triangle"></i>
+ {{ i18n.ts._role.remoteDataWarning }}
+ </div>
</div>
</template>
@@ -123,6 +155,14 @@ const type = computed({
if (t === 'followersMoreThanOrEq') v.value.value = 10;
if (t === 'followingLessThanOrEq') v.value.value = 10;
if (t === 'followingMoreThanOrEq') v.value.value = 10;
+ if (t === 'localFollowersLessThanOrEq') v.value.value = 10;
+ if (t === 'localFollowersMoreThanOrEq') v.value.value = 10;
+ if (t === 'localFollowingLessThanOrEq') v.value.value = 10;
+ if (t === 'localFollowingMoreThanOrEq') v.value.value = 10;
+ if (t === 'remoteFollowersLessThanOrEq') v.value.value = 10;
+ if (t === 'remoteFollowersMoreThanOrEq') v.value.value = 10;
+ if (t === 'remoteFollowingLessThanOrEq') v.value.value = 10;
+ if (t === 'remoteFollowingMoreThanOrEq') v.value.value = 10;
if (t === 'notesLessThanOrEq') v.value.value = 10;
if (t === 'notesMoreThanOrEq') v.value.value = 10;
if (t === 'isFromInstance') {
@@ -178,4 +218,14 @@ function removeSelf() {
border-color: var(--MI_THEME-accent);
}
}
+
+.warningBanner {
+ color: var(--MI_THEME-warn);
+ width: 100%;
+ padding: 0 6px;
+
+ > i {
+ margin-right: 4px;
+ }
+}
</style>