add new role conditions for local/remote followers/followees

This commit is contained in:
Hazelnoot 2025-05-09 11:53:29 -04:00
parent a31089d054
commit 40a73bfcbe
6 changed files with 239 additions and 7 deletions

View file

@ -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:
@ -187,6 +207,52 @@ export class CacheService implements OnApplicationShutdown {
}) ?? null;
}
@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);

View file

@ -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;

View file

@ -147,6 +147,70 @@ type CondFormulaValueFollowingMoreThanOrEq = {
value: number;
};
/**
* 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;
};
/**
* 稿
*/
@ -182,6 +246,14 @@ export type RoleCondFormulaValue = { id: string } & (
CondFormulaValueFollowersMoreThanOrEq |
CondFormulaValueFollowingLessThanOrEq |
CondFormulaValueFollowingMoreThanOrEq |
CondFormulaValueLocalFollowersLessThanOrEq |
CondFormulaValueLocalFollowersMoreThanOrEq |
CondFormulaValueLocalFollowingLessThanOrEq |
CondFormulaValueLocalFollowingMoreThanOrEq |
CondFormulaValueRemoteFollowersLessThanOrEq |
CondFormulaValueRemoteFollowersMoreThanOrEq |
CondFormulaValueRemoteFollowingLessThanOrEq |
CondFormulaValueRemoteFollowingMoreThanOrEq |
CondFormulaValueNotesLessThanOrEq |
CondFormulaValueNotesMoreThanOrEq
);