merge: upstream
This commit is contained in:
commit
38e35e1472
185 changed files with 4442 additions and 2501 deletions
|
|
@ -9,7 +9,7 @@ import { IsNull, In, MoreThan, Not } from 'typeorm';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
||||
import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListJoiningsRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListMembershipsRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { RelationshipJobData, ThinUser } from '@/queue/types.js';
|
||||
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
|
|
@ -42,8 +42,8 @@ export class AccountMoveService {
|
|||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
@Inject(DI.userListJoiningsRepository)
|
||||
private userListJoiningsRepository: UserListJoiningsRepository,
|
||||
@Inject(DI.userListMembershipsRepository)
|
||||
private userListMembershipsRepository: UserListMembershipsRepository,
|
||||
|
||||
@Inject(DI.instancesRepository)
|
||||
private instancesRepository: InstancesRepository,
|
||||
|
|
@ -215,40 +215,40 @@ export class AccountMoveService {
|
|||
@bindThis
|
||||
public async updateLists(src: ThinUser, dst: MiUser): Promise<void> {
|
||||
// Return if there is no list to be updated.
|
||||
const oldJoinings = await this.userListJoiningsRepository.find({
|
||||
const oldMemberships = await this.userListMembershipsRepository.find({
|
||||
where: {
|
||||
userId: src.id,
|
||||
},
|
||||
});
|
||||
if (oldJoinings.length === 0) return;
|
||||
if (oldMemberships.length === 0) return;
|
||||
|
||||
const existingUserListIds = await this.userListJoiningsRepository.find({
|
||||
const existingUserListIds = await this.userListMembershipsRepository.find({
|
||||
where: {
|
||||
userId: dst.id,
|
||||
},
|
||||
}).then(joinings => joinings.map(joining => joining.userListId));
|
||||
}).then(memberships => memberships.map(membership => membership.userListId));
|
||||
|
||||
const newJoinings: Map<string, { createdAt: Date; userId: string; userListId: string; }> = new Map();
|
||||
const newMemberships: Map<string, { createdAt: Date; userId: string; userListId: string; }> = new Map();
|
||||
|
||||
// 重複しないようにIDを生成
|
||||
const genId = (): string => {
|
||||
let id: string;
|
||||
do {
|
||||
id = this.idService.genId();
|
||||
} while (newJoinings.has(id));
|
||||
} while (newMemberships.has(id));
|
||||
return id;
|
||||
};
|
||||
for (const joining of oldJoinings) {
|
||||
if (existingUserListIds.includes(joining.userListId)) continue; // skip if dst exists in this user's list
|
||||
newJoinings.set(genId(), {
|
||||
for (const membership of oldMemberships) {
|
||||
if (existingUserListIds.includes(membership.userListId)) continue; // skip if dst exists in this user's list
|
||||
newMemberships.set(genId(), {
|
||||
createdAt: new Date(),
|
||||
userId: dst.id,
|
||||
userListId: joining.userListId,
|
||||
userListId: membership.userListId,
|
||||
});
|
||||
}
|
||||
|
||||
const arrayToInsert = Array.from(newJoinings.entries()).map(entry => ({ ...entry[1], id: entry[0] }));
|
||||
await this.userListJoiningsRepository.insert(arrayToInsert);
|
||||
const arrayToInsert = Array.from(newMemberships.entries()).map(entry => ({ ...entry[1], id: entry[0] }));
|
||||
await this.userListMembershipsRepository.insert(arrayToInsert);
|
||||
|
||||
// Have the proxy account follow the new account in the same way as UserListService.push
|
||||
if (this.userEntityService.isRemoteUser(dst)) {
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
|
|||
import * as Acct from '@/misc/acct.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { AntennasRepository, UserListJoiningsRepository } from '@/models/_.js';
|
||||
import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { StreamMessages } from '@/server/api/stream/types.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -24,8 +24,8 @@ export class AntennaService implements OnApplicationShutdown {
|
|||
private antennas: MiAntenna[];
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
@Inject(DI.redisForTimelines)
|
||||
private redisForTimelines: Redis.Redis,
|
||||
|
||||
@Inject(DI.redisForSub)
|
||||
private redisForSub: Redis.Redis,
|
||||
|
|
@ -33,8 +33,8 @@ export class AntennaService implements OnApplicationShutdown {
|
|||
@Inject(DI.antennasRepository)
|
||||
private antennasRepository: AntennasRepository,
|
||||
|
||||
@Inject(DI.userListJoiningsRepository)
|
||||
private userListJoiningsRepository: UserListJoiningsRepository,
|
||||
@Inject(DI.userListMembershipsRepository)
|
||||
private userListMembershipsRepository: UserListMembershipsRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
|
|
@ -50,7 +50,7 @@ export class AntennaService implements OnApplicationShutdown {
|
|||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message as StreamMessages['internal']['payload'];
|
||||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||
switch (type) {
|
||||
case 'antennaCreated':
|
||||
this.antennas.push({
|
||||
|
|
@ -81,7 +81,7 @@ export class AntennaService implements OnApplicationShutdown {
|
|||
const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const)));
|
||||
const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna);
|
||||
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
const redisPipeline = this.redisForTimelines.pipeline();
|
||||
|
||||
for (const antenna of matchedAntennas) {
|
||||
redisPipeline.xadd(
|
||||
|
|
@ -108,7 +108,7 @@ export class AntennaService implements OnApplicationShutdown {
|
|||
if (antenna.src === 'home') {
|
||||
// TODO
|
||||
} else if (antenna.src === 'list') {
|
||||
const listUsers = (await this.userListJoiningsRepository.findBy({
|
||||
const listUsers = (await this.userListMembershipsRepository.findBy({
|
||||
userListId: antenna.userListId!,
|
||||
})).map(x => x.userId);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js';
|
||||
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
|
||||
import type { MiLocalUser, MiUser } from '@/models/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { StreamMessages } from '@/server/api/stream/types.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -25,7 +25,7 @@ export class CacheService implements OnApplicationShutdown {
|
|||
public userBlockingCache: RedisKVCache<Set<string>>;
|
||||
public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
|
||||
public renoteMutingsCache: RedisKVCache<Set<string>>;
|
||||
public userFollowingsCache: RedisKVCache<Set<string>>;
|
||||
public userFollowingsCache: RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>;
|
||||
public userFollowingChannelsCache: RedisKVCache<Set<string>>;
|
||||
|
||||
constructor(
|
||||
|
|
@ -136,12 +136,18 @@ export class CacheService implements OnApplicationShutdown {
|
|||
fromRedisConverter: (value) => new Set(JSON.parse(value)),
|
||||
});
|
||||
|
||||
this.userFollowingsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userFollowings', {
|
||||
this.userFollowingsCache = new RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>(this.redisClient, 'userFollowings', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
memoryCacheLifetime: 1000 * 60, // 1m
|
||||
fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId'] }).then(xs => new Set(xs.map(x => x.followeeId))),
|
||||
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
|
||||
fromRedisConverter: (value) => new Set(JSON.parse(value)),
|
||||
fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId', 'withReplies'] }).then(xs => {
|
||||
const obj: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
|
||||
for (const x of xs) {
|
||||
obj[x.followeeId] = { withReplies: x.withReplies };
|
||||
}
|
||||
return obj;
|
||||
}),
|
||||
toRedisConverter: (value) => JSON.stringify(value),
|
||||
fromRedisConverter: (value) => JSON.parse(value),
|
||||
});
|
||||
|
||||
this.userFollowingChannelsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userFollowingChannels', {
|
||||
|
|
@ -160,7 +166,7 @@ export class CacheService implements OnApplicationShutdown {
|
|||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message as StreamMessages['internal']['payload'];
|
||||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||
switch (type) {
|
||||
case 'userChangeSuspendedState':
|
||||
case 'remoteUserUpdated': {
|
||||
|
|
@ -188,6 +194,7 @@ export class CacheService implements OnApplicationShutdown {
|
|||
if (follower) follower.followingCount++;
|
||||
const followee = this.userByIdCache.get(body.followeeId);
|
||||
if (followee) followee.followersCount++;
|
||||
this.userFollowingsCache.delete(body.followerId);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import { SignupService } from './SignupService.js';
|
|||
import { WebAuthnService } from './WebAuthnService.js';
|
||||
import { UserBlockingService } from './UserBlockingService.js';
|
||||
import { CacheService } from './CacheService.js';
|
||||
import { UserService } from './UserService.js';
|
||||
import { UserFollowingService } from './UserFollowingService.js';
|
||||
import { UserKeypairService } from './UserKeypairService.js';
|
||||
import { UserListService } from './UserListService.js';
|
||||
|
|
@ -175,6 +176,7 @@ const $SignupService: Provider = { provide: 'SignupService', useExisting: Signup
|
|||
const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService };
|
||||
const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService };
|
||||
const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService };
|
||||
const $UserService: Provider = { provide: 'UserService', useExisting: UserService };
|
||||
const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService };
|
||||
const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService };
|
||||
const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService };
|
||||
|
|
@ -306,6 +308,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
WebAuthnService,
|
||||
UserBlockingService,
|
||||
CacheService,
|
||||
UserService,
|
||||
UserFollowingService,
|
||||
UserKeypairService,
|
||||
UserListService,
|
||||
|
|
@ -430,6 +433,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$WebAuthnService,
|
||||
$UserBlockingService,
|
||||
$CacheService,
|
||||
$UserService,
|
||||
$UserFollowingService,
|
||||
$UserKeypairService,
|
||||
$UserListService,
|
||||
|
|
@ -555,6 +559,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
WebAuthnService,
|
||||
UserBlockingService,
|
||||
CacheService,
|
||||
UserService,
|
||||
UserFollowingService,
|
||||
UserKeypairService,
|
||||
UserListService,
|
||||
|
|
@ -678,6 +683,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$WebAuthnService,
|
||||
$UserBlockingService,
|
||||
$CacheService,
|
||||
$UserService,
|
||||
$UserFollowingService,
|
||||
$UserKeypairService,
|
||||
$UserListService,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { bindThis } from '@/decorators.js';
|
|||
import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { query } from '@/misc/prelude/url.js';
|
||||
import type { Serialized } from '@/server/api/stream/types.js';
|
||||
import type { Serialized } from '@/types.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
|
||||
|
|
|
|||
|
|
@ -5,27 +5,254 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import type { MiChannel } from '@/models/Channel.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiUserProfile } from '@/models/UserProfile.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import type { MiUserList } from '@/models/UserList.js';
|
||||
import type { MiAntenna } from '@/models/Antenna.js';
|
||||
import type {
|
||||
StreamChannels,
|
||||
AdminStreamTypes,
|
||||
AntennaStreamTypes,
|
||||
BroadcastTypes,
|
||||
DriveStreamTypes,
|
||||
InternalStreamTypes,
|
||||
MainStreamTypes,
|
||||
NoteStreamTypes,
|
||||
UserListStreamTypes,
|
||||
RoleTimelineStreamTypes,
|
||||
} from '@/server/api/stream/types.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import type { MiDriveFolder } from '@/models/DriveFolder.js';
|
||||
import type { MiUserList } from '@/models/UserList.js';
|
||||
import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
|
||||
import type { MiSignin } from '@/models/Signin.js';
|
||||
import type { MiPage } from '@/models/Page.js';
|
||||
import type { MiWebhook } from '@/models/Webhook.js';
|
||||
import type { MiMeta } from '@/models/Meta.js';
|
||||
import { MiRole, MiRoleAssignment } from '@/models/_.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MiRole } from '@/models/_.js';
|
||||
import { Serialized } from '@/types.js';
|
||||
import type Emitter from 'strict-event-emitter-types';
|
||||
import type { EventEmitter } from 'events';
|
||||
|
||||
//#region Stream type-body definitions
|
||||
export interface BroadcastTypes {
|
||||
emojiAdded: {
|
||||
emoji: Packed<'EmojiDetailed'>;
|
||||
};
|
||||
emojiUpdated: {
|
||||
emojis: Packed<'EmojiDetailed'>[];
|
||||
};
|
||||
emojiDeleted: {
|
||||
emojis: {
|
||||
id?: string;
|
||||
name: string;
|
||||
[other: string]: any;
|
||||
}[];
|
||||
};
|
||||
announcementCreated: {
|
||||
announcement: Packed<'Announcement'>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MainEventTypes {
|
||||
notification: Packed<'Notification'>;
|
||||
mention: Packed<'Note'>;
|
||||
reply: Packed<'Note'>;
|
||||
renote: Packed<'Note'>;
|
||||
follow: Packed<'UserDetailedNotMe'>;
|
||||
followed: Packed<'User'>;
|
||||
unfollow: Packed<'User'>;
|
||||
meUpdated: Packed<'User'>;
|
||||
pageEvent: {
|
||||
pageId: MiPage['id'];
|
||||
event: string;
|
||||
var: any;
|
||||
userId: MiUser['id'];
|
||||
user: Packed<'User'>;
|
||||
};
|
||||
urlUploadFinished: {
|
||||
marker?: string | null;
|
||||
file: Packed<'DriveFile'>;
|
||||
};
|
||||
readAllNotifications: undefined;
|
||||
unreadNotification: Packed<'Notification'>;
|
||||
unreadMention: MiNote['id'];
|
||||
readAllUnreadMentions: undefined;
|
||||
unreadSpecifiedNote: MiNote['id'];
|
||||
readAllUnreadSpecifiedNotes: undefined;
|
||||
readAllAntennas: undefined;
|
||||
unreadAntenna: MiAntenna;
|
||||
readAllAnnouncements: undefined;
|
||||
myTokenRegenerated: undefined;
|
||||
signin: MiSignin;
|
||||
registryUpdated: {
|
||||
scope?: string[];
|
||||
key: string;
|
||||
value: any | null;
|
||||
};
|
||||
driveFileCreated: Packed<'DriveFile'>;
|
||||
readAntenna: MiAntenna;
|
||||
receiveFollowRequest: Packed<'User'>;
|
||||
announcementCreated: {
|
||||
announcement: Packed<'Announcement'>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DriveEventTypes {
|
||||
fileCreated: Packed<'DriveFile'>;
|
||||
fileDeleted: MiDriveFile['id'];
|
||||
fileUpdated: Packed<'DriveFile'>;
|
||||
folderCreated: Packed<'DriveFolder'>;
|
||||
folderDeleted: MiDriveFolder['id'];
|
||||
folderUpdated: Packed<'DriveFolder'>;
|
||||
}
|
||||
|
||||
export interface NoteEventTypes {
|
||||
pollVoted: {
|
||||
choice: number;
|
||||
userId: MiUser['id'];
|
||||
};
|
||||
deleted: {
|
||||
deletedAt: Date;
|
||||
};
|
||||
updated: {
|
||||
cw: string | null;
|
||||
text: string;
|
||||
};
|
||||
reacted: {
|
||||
reaction: string;
|
||||
emoji?: {
|
||||
name: string;
|
||||
url: string;
|
||||
} | null;
|
||||
userId: MiUser['id'];
|
||||
};
|
||||
unreacted: {
|
||||
reaction: string;
|
||||
userId: MiUser['id'];
|
||||
};
|
||||
}
|
||||
type NoteStreamEventTypes = {
|
||||
[key in keyof NoteEventTypes]: {
|
||||
id: MiNote['id'];
|
||||
body: NoteEventTypes[key];
|
||||
};
|
||||
};
|
||||
|
||||
export interface UserListEventTypes {
|
||||
userAdded: Packed<'User'>;
|
||||
userRemoved: Packed<'User'>;
|
||||
}
|
||||
|
||||
export interface AntennaEventTypes {
|
||||
note: MiNote;
|
||||
}
|
||||
|
||||
export interface RoleTimelineEventTypes {
|
||||
note: Packed<'Note'>;
|
||||
}
|
||||
|
||||
export interface AdminEventTypes {
|
||||
newAbuseUserReport: {
|
||||
id: MiAbuseUserReport['id'];
|
||||
targetUserId: MiUser['id'],
|
||||
reporterId: MiUser['id'],
|
||||
comment: string;
|
||||
};
|
||||
}
|
||||
//#endregion
|
||||
|
||||
// 辞書(interface or type)から{ type, body }ユニオンを定義
|
||||
// https://stackoverflow.com/questions/49311989/can-i-infer-the-type-of-a-value-using-extends-keyof-type
|
||||
// VS Codeの展開を防止するためにEvents型を定義
|
||||
type Events<T extends object> = { [K in keyof T]: { type: K; body: T[K]; } };
|
||||
type EventUnionFromDictionary<
|
||||
T extends object,
|
||||
U = Events<T>
|
||||
> = U[keyof U];
|
||||
|
||||
type SerializedAll<T> = {
|
||||
[K in keyof T]: Serialized<T[K]>;
|
||||
};
|
||||
|
||||
export interface InternalEventTypes {
|
||||
userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; };
|
||||
userTokenRegenerated: { id: MiUser['id']; oldToken: string; newToken: string; };
|
||||
remoteUserUpdated: { id: MiUser['id']; };
|
||||
follow: { followerId: MiUser['id']; followeeId: MiUser['id']; };
|
||||
unfollow: { followerId: MiUser['id']; followeeId: MiUser['id']; };
|
||||
blockingCreated: { blockerId: MiUser['id']; blockeeId: MiUser['id']; };
|
||||
blockingDeleted: { blockerId: MiUser['id']; blockeeId: MiUser['id']; };
|
||||
policiesUpdated: MiRole['policies'];
|
||||
roleCreated: MiRole;
|
||||
roleDeleted: MiRole;
|
||||
roleUpdated: MiRole;
|
||||
userRoleAssigned: MiRoleAssignment;
|
||||
userRoleUnassigned: MiRoleAssignment;
|
||||
webhookCreated: MiWebhook;
|
||||
webhookDeleted: MiWebhook;
|
||||
webhookUpdated: MiWebhook;
|
||||
antennaCreated: MiAntenna;
|
||||
antennaDeleted: MiAntenna;
|
||||
antennaUpdated: MiAntenna;
|
||||
metaUpdated: MiMeta;
|
||||
followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
|
||||
unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
|
||||
updateUserProfile: MiUserProfile;
|
||||
mute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
|
||||
unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
|
||||
userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; };
|
||||
userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; };
|
||||
}
|
||||
|
||||
// name/messages(spec) pairs dictionary
|
||||
export type GlobalEvents = {
|
||||
internal: {
|
||||
name: 'internal';
|
||||
payload: EventUnionFromDictionary<SerializedAll<InternalEventTypes>>;
|
||||
};
|
||||
broadcast: {
|
||||
name: 'broadcast';
|
||||
payload: EventUnionFromDictionary<SerializedAll<BroadcastTypes>>;
|
||||
};
|
||||
main: {
|
||||
name: `mainStream:${MiUser['id']}`;
|
||||
payload: EventUnionFromDictionary<SerializedAll<MainEventTypes>>;
|
||||
};
|
||||
drive: {
|
||||
name: `driveStream:${MiUser['id']}`;
|
||||
payload: EventUnionFromDictionary<SerializedAll<DriveEventTypes>>;
|
||||
};
|
||||
note: {
|
||||
name: `noteStream:${MiNote['id']}`;
|
||||
payload: EventUnionFromDictionary<SerializedAll<NoteStreamEventTypes>>;
|
||||
};
|
||||
userList: {
|
||||
name: `userListStream:${MiUserList['id']}`;
|
||||
payload: EventUnionFromDictionary<SerializedAll<UserListEventTypes>>;
|
||||
};
|
||||
roleTimeline: {
|
||||
name: `roleTimelineStream:${MiRole['id']}`;
|
||||
payload: EventUnionFromDictionary<SerializedAll<RoleTimelineEventTypes>>;
|
||||
};
|
||||
antenna: {
|
||||
name: `antennaStream:${MiAntenna['id']}`;
|
||||
payload: EventUnionFromDictionary<SerializedAll<AntennaEventTypes>>;
|
||||
};
|
||||
admin: {
|
||||
name: `adminStream:${MiUser['id']}`;
|
||||
payload: EventUnionFromDictionary<SerializedAll<AdminEventTypes>>;
|
||||
};
|
||||
notes: {
|
||||
name: 'notesStream';
|
||||
payload: Serialized<Packed<'Note'>>;
|
||||
};
|
||||
};
|
||||
|
||||
// API event definitions
|
||||
// ストリームごとのEmitterの辞書を用意
|
||||
type EventEmitterDictionary = { [x in keyof GlobalEvents]: Emitter.default<EventEmitter, { [y in GlobalEvents[x]['name']]: (e: GlobalEvents[x]['payload']) => void }> };
|
||||
// 共用体型を交差型にする型 https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection
|
||||
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
|
||||
// Emitter辞書から共用体型を作り、UnionToIntersectionで交差型にする
|
||||
export type StreamEventEmitter = UnionToIntersection<EventEmitterDictionary[keyof GlobalEvents]>;
|
||||
// { [y in name]: (e: spec) => void }をまとめてその交差型をEmitterにかけるとts(2590)にひっかかる
|
||||
|
||||
// provide stream channels union
|
||||
export type StreamChannels = GlobalEvents[keyof GlobalEvents]['name'];
|
||||
|
||||
@Injectable()
|
||||
export class GlobalEventService {
|
||||
|
|
@ -51,7 +278,7 @@ export class GlobalEventService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public publishInternalEvent<K extends keyof InternalStreamTypes>(type: K, value?: InternalStreamTypes[K]): void {
|
||||
public publishInternalEvent<K extends keyof InternalEventTypes>(type: K, value?: InternalEventTypes[K]): void {
|
||||
this.publish('internal', type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
|
|
@ -61,17 +288,17 @@ export class GlobalEventService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public publishMainStream<K extends keyof MainStreamTypes>(userId: MiUser['id'], type: K, value?: MainStreamTypes[K]): void {
|
||||
public publishMainStream<K extends keyof MainEventTypes>(userId: MiUser['id'], type: K, value?: MainEventTypes[K]): void {
|
||||
this.publish(`mainStream:${userId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public publishDriveStream<K extends keyof DriveStreamTypes>(userId: MiUser['id'], type: K, value?: DriveStreamTypes[K]): void {
|
||||
public publishDriveStream<K extends keyof DriveEventTypes>(userId: MiUser['id'], type: K, value?: DriveEventTypes[K]): void {
|
||||
this.publish(`driveStream:${userId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public publishNoteStream<K extends keyof NoteStreamTypes>(noteId: MiNote['id'], type: K, value?: NoteStreamTypes[K]): void {
|
||||
public publishNoteStream<K extends keyof NoteEventTypes>(noteId: MiNote['id'], type: K, value?: NoteEventTypes[K]): void {
|
||||
this.publish(`noteStream:${noteId}`, type, {
|
||||
id: noteId,
|
||||
body: value,
|
||||
|
|
@ -79,17 +306,17 @@ export class GlobalEventService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public publishUserListStream<K extends keyof UserListStreamTypes>(listId: MiUserList['id'], type: K, value?: UserListStreamTypes[K]): void {
|
||||
public publishUserListStream<K extends keyof UserListEventTypes>(listId: MiUserList['id'], type: K, value?: UserListEventTypes[K]): void {
|
||||
this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public publishAntennaStream<K extends keyof AntennaStreamTypes>(antennaId: MiAntenna['id'], type: K, value?: AntennaStreamTypes[K]): void {
|
||||
public publishAntennaStream<K extends keyof AntennaEventTypes>(antennaId: MiAntenna['id'], type: K, value?: AntennaEventTypes[K]): void {
|
||||
this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public publishRoleTimelineStream<K extends keyof RoleTimelineStreamTypes>(roleId: MiRole['id'], type: K, value?: RoleTimelineStreamTypes[K]): void {
|
||||
public publishRoleTimelineStream<K extends keyof RoleTimelineEventTypes>(roleId: MiRole['id'], type: K, value?: RoleTimelineEventTypes[K]): void {
|
||||
this.publish(`roleTimelineStream:${roleId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +326,7 @@ export class GlobalEventService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public publishAdminStream<K extends keyof AdminStreamTypes>(userId: MiUser['id'], type: K, value?: AdminStreamTypes[K]): void {
|
||||
public publishAdminStream<K extends keyof AdminEventTypes>(userId: MiUser['id'], type: K, value?: AdminEventTypes[K]): void {
|
||||
this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { DI } from '@/di-symbols.js';
|
|||
import { MiMeta } from '@/models/Meta.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { StreamMessages } from '@/server/api/stream/types.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -46,7 +46,7 @@ export class MetaService implements OnApplicationShutdown {
|
|||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message as StreamMessages['internal']['payload'];
|
||||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||
switch (type) {
|
||||
case 'metaUpdated': {
|
||||
this.cache = body;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { setImmediate } from 'node:timers/promises';
|
||||
import * as mfm from 'mfm-js';
|
||||
import { In, DataSource } from 'typeorm';
|
||||
import { In, DataSource, IsNull, LessThan } from 'typeorm';
|
||||
import * as Redis from 'ioredis';
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import RE2 from 're2';
|
||||
|
|
@ -14,7 +14,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
|
|||
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
||||
import type { IMentionedRemoteUsers } from '@/models/Note.js';
|
||||
import { MiNote } from '@/models/Note.js';
|
||||
import type { ChannelsRepository, FollowingsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import type { MiApp } from '@/models/App.js';
|
||||
import { concat } from '@/misc/prelude/array.js';
|
||||
|
|
@ -54,8 +54,6 @@ import { RoleService } from '@/core/RoleService.js';
|
|||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { SearchService } from '@/core/SearchService.js';
|
||||
|
||||
const mutedWordsCache = new MemorySingleCache<{ userId: MiUserProfile['userId']; mutedWords: MiUserProfile['mutedWords']; }[]>(1000 * 60 * 5);
|
||||
|
||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||
|
||||
class NotificationManager {
|
||||
|
|
@ -110,9 +108,8 @@ class NotificationManager {
|
|||
// 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する
|
||||
if (!mentioneesMutedUserIds.includes(this.notifier.id)) {
|
||||
this.notificationService.createNotification(x.target, x.reason, {
|
||||
notifierId: this.notifier.id,
|
||||
noteId: this.note.id,
|
||||
});
|
||||
}, this.notifier.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -158,8 +155,8 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
@Inject(DI.redisForTimelines)
|
||||
private redisForTimelines: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
|
@ -176,8 +173,8 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.mutedNotesRepository)
|
||||
private mutedNotesRepository: MutedNotesRepository,
|
||||
@Inject(DI.userListMembershipsRepository)
|
||||
private userListMembershipsRepository: UserListMembershipsRepository,
|
||||
|
||||
@Inject(DI.channelsRepository)
|
||||
private channelsRepository: ChannelsRepository,
|
||||
|
|
@ -188,6 +185,9 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
private idService: IdService,
|
||||
|
|
@ -335,7 +335,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
|
||||
|
||||
if (data.channel) {
|
||||
this.redisClient.xadd(
|
||||
this.redisForTimelines.xadd(
|
||||
`channelTimeline:${data.channel.id}`,
|
||||
'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(),
|
||||
'*',
|
||||
|
|
@ -481,26 +481,13 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
// Increment notes count (user)
|
||||
this.incNotesCountOfUser(user);
|
||||
|
||||
// Word mute
|
||||
mutedWordsCache.fetch(() => this.userProfilesRepository.find({
|
||||
where: {
|
||||
enableWordMute: true,
|
||||
},
|
||||
select: ['userId', 'mutedWords'],
|
||||
})).then(us => {
|
||||
for (const u of us) {
|
||||
checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
|
||||
if (shouldMute) {
|
||||
this.mutedNotesRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
userId: u.userId,
|
||||
noteId: note.id,
|
||||
reason: 'word',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
if (data.visibility === 'public' || data.visibility === 'home') {
|
||||
this.pushToTl(note, user);
|
||||
} else if (data.visibility === 'followers') {
|
||||
this.pushToTl(note, user);
|
||||
} else if (data.visibility === 'specified') {
|
||||
// TODO
|
||||
}
|
||||
|
||||
this.antennaService.addNoteToAntennas(note, user);
|
||||
|
||||
|
|
@ -509,15 +496,16 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
if (data.reply == null) {
|
||||
// TODO: キャッシュ
|
||||
this.followingsRepository.findBy({
|
||||
followeeId: user.id,
|
||||
notify: 'normal',
|
||||
}).then(followings => {
|
||||
for (const following of followings) {
|
||||
// TODO: ワードミュート考慮
|
||||
this.notificationService.createNotification(following.followerId, 'note', {
|
||||
notifierId: user.id,
|
||||
noteId: note.id,
|
||||
});
|
||||
}, user.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -813,6 +801,205 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
return mentionedUsers;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) {
|
||||
const redisPipeline = this.redisForTimelines.pipeline();
|
||||
|
||||
if (note.channelId) {
|
||||
const channelFollowings = await this.channelFollowingsRepository.find({
|
||||
where: {
|
||||
followeeId: note.channelId,
|
||||
},
|
||||
select: ['followerId'],
|
||||
});
|
||||
|
||||
for (const channelFollowing of channelFollowings) {
|
||||
redisPipeline.xadd(
|
||||
`homeTimeline:${channelFollowing.followerId}`,
|
||||
'MAXLEN', '~', '200',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
`homeTimelineWithFiles:${channelFollowing.followerId}`,
|
||||
'MAXLEN', '~', '100',
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO: キャッシュ?
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: {
|
||||
followeeId: user.id,
|
||||
followerHost: IsNull(),
|
||||
isFollowerHibernated: false,
|
||||
},
|
||||
select: ['followerId', 'withReplies'],
|
||||
});
|
||||
|
||||
const userListMemberships = await this.userListMembershipsRepository.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: ['userListId', 'withReplies'],
|
||||
});
|
||||
|
||||
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
|
||||
for (const following of followings) {
|
||||
// 自分自身以外への返信
|
||||
if (note.replyId && note.replyUserId !== note.userId) {
|
||||
if (!following.withReplies) continue;
|
||||
}
|
||||
|
||||
redisPipeline.xadd(
|
||||
`homeTimeline:${following.followerId}`,
|
||||
'MAXLEN', '~', '200',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
`homeTimelineWithFiles:${following.followerId}`,
|
||||
'MAXLEN', '~', '100',
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO
|
||||
//if (note.visibility === 'followers') {
|
||||
// // TODO: 重そうだから何とかしたい Set 使う?
|
||||
// userLists = userLists.filter(x => followings.some(f => f.followerId === x.userListUserId));
|
||||
//}
|
||||
|
||||
for (const userListMembership of userListMemberships) {
|
||||
// 自分自身以外への返信
|
||||
if (note.replyId && note.replyUserId !== note.userId) {
|
||||
if (!userListMembership.withReplies) continue;
|
||||
}
|
||||
|
||||
redisPipeline.xadd(
|
||||
`userListTimeline:${userListMembership.userListId}`,
|
||||
'MAXLEN', '~', '200',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
`userListTimelineWithFiles:${userListMembership.userListId}`,
|
||||
'MAXLEN', '~', '100',
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
}
|
||||
|
||||
{ // 自分自身のHTL
|
||||
redisPipeline.xadd(
|
||||
`homeTimeline:${user.id}`,
|
||||
'MAXLEN', '~', '200',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
`homeTimelineWithFiles:${user.id}`,
|
||||
'MAXLEN', '~', '100',
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (note.visibility === 'public' || note.visibility === 'home') {
|
||||
// 自分自身以外への返信
|
||||
if (note.replyId && note.replyUserId !== note.userId) {
|
||||
redisPipeline.xadd(
|
||||
`userTimelineWithReplies:${user.id}`,
|
||||
'MAXLEN', '~', '1000',
|
||||
'*',
|
||||
'note', note.id);
|
||||
} else {
|
||||
redisPipeline.xadd(
|
||||
`userTimeline:${user.id}`,
|
||||
'MAXLEN', '~', '1000',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
`userTimelineWithFiles:${user.id}`,
|
||||
'MAXLEN', '~', '500',
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
|
||||
if (note.visibility === 'public' && note.userHost == null) {
|
||||
redisPipeline.xadd(
|
||||
'localTimeline',
|
||||
'MAXLEN', '~', '1000',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
'localTimelineWithFiles',
|
||||
'MAXLEN', '~', '500',
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Math.random() < 0.1) {
|
||||
process.nextTick(() => {
|
||||
this.checkHibernation(followings);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
redisPipeline.exec();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async checkHibernation(followings: MiFollowing[]) {
|
||||
if (followings.length === 0) return;
|
||||
|
||||
const shuffle = (array: MiFollowing[]) => {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
return array;
|
||||
};
|
||||
|
||||
// ランダムに最大1000件サンプリング
|
||||
const samples = shuffle(followings).slice(0, Math.min(followings.length, 1000));
|
||||
|
||||
const hibernatedUsers = await this.usersRepository.find({
|
||||
where: {
|
||||
id: In(samples.map(x => x.followerId)),
|
||||
lastActiveDate: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 50))),
|
||||
},
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
if (hibernatedUsers.length > 0) {
|
||||
this.usersRepository.update({
|
||||
id: In(hibernatedUsers.map(x => x.id)),
|
||||
}, {
|
||||
isHibernated: true,
|
||||
});
|
||||
|
||||
this.followingsRepository.update({
|
||||
followerId: In(hibernatedUsers.map(x => x.id)),
|
||||
}, {
|
||||
isFollowerHibernated: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.#shutdownController.abort();
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { setImmediate } from 'node:timers/promises';
|
||||
import * as mfm from 'mfm-js';
|
||||
import { In } from 'typeorm';
|
||||
import { In, LessThan } from 'typeorm';
|
||||
import * as Redis from 'ioredis';
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import RE2 from 're2';
|
||||
|
|
@ -14,7 +14,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
|
|||
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
||||
import type { IMentionedRemoteUsers } from '@/models/Note.js';
|
||||
import { MiNote } from '@/models/Note.js';
|
||||
import type { NoteEditRepository, ChannelsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { NoteEditRepository, ChannelsRepository, InstancesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository, UserListMembershipsRepository, ChannelFollowingsRepository, MiFollowing } from '@/models/_.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import type { MiApp } from '@/models/App.js';
|
||||
import { concat } from '@/misc/prelude/array.js';
|
||||
|
|
@ -49,8 +49,6 @@ import { RoleService } from '@/core/RoleService.js';
|
|||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { SearchService } from '@/core/SearchService.js';
|
||||
|
||||
const mutedWordsCache = new MemorySingleCache<{ userId: MiUserProfile['userId']; mutedWords: MiUserProfile['mutedWords']; }[]>(1000 * 60 * 5);
|
||||
|
||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||
|
||||
class NotificationManager {
|
||||
|
|
@ -105,9 +103,8 @@ class NotificationManager {
|
|||
// 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する
|
||||
if (!mentioneesMutedUserIds.includes(this.notifier.id)) {
|
||||
this.notificationService.createNotification(x.target, x.reason, {
|
||||
notifierId: this.notifier.id,
|
||||
noteId: this.note.id,
|
||||
});
|
||||
}, this.notifier.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -152,8 +149,8 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
@Inject(DI.redisForTimelines)
|
||||
private redisForTimelines: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
|
@ -170,12 +167,15 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.mutedNotesRepository)
|
||||
private mutedNotesRepository: MutedNotesRepository,
|
||||
|
||||
@Inject(DI.channelsRepository)
|
||||
private channelsRepository: ChannelsRepository,
|
||||
|
||||
@Inject(DI.userListMembershipsRepository)
|
||||
private userListMembershipsRepository: UserListMembershipsRepository,
|
||||
|
||||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
|
||||
@Inject(DI.noteThreadMutingsRepository)
|
||||
private noteThreadMutingsRepository: NoteThreadMutingsRepository,
|
||||
|
||||
|
|
@ -423,7 +423,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
await this.notesRepository.update(oldnote.id, note);
|
||||
|
||||
if (data.channel) {
|
||||
this.redisClient.xadd(
|
||||
this.redisForTimelines.xadd(
|
||||
`channelTimeline:${data.channel.id}`,
|
||||
'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(),
|
||||
'*',
|
||||
|
|
@ -461,27 +461,6 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
this.hashtagService.updateHashtags(user, tags);
|
||||
}
|
||||
|
||||
// Word mute
|
||||
mutedWordsCache.fetch(() => this.userProfilesRepository.find({
|
||||
where: {
|
||||
enableWordMute: true,
|
||||
},
|
||||
select: ['userId', 'mutedWords'],
|
||||
})).then(us => {
|
||||
for (const u of us) {
|
||||
checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
|
||||
if (shouldMute) {
|
||||
this.mutedNotesRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
userId: u.userId,
|
||||
noteId: note.id,
|
||||
reason: 'word',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (data.poll && data.poll.expiresAt) {
|
||||
const delay = data.poll.expiresAt.getTime() - Date.now();
|
||||
this.queueService.endedPollNotificationQueue.add(note.id, {
|
||||
|
|
@ -492,6 +471,14 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
});
|
||||
}
|
||||
|
||||
if (data.visibility === 'public' || data.visibility === 'home') {
|
||||
this.pushToTl(note, user);
|
||||
} else if (data.visibility === 'followers') {
|
||||
this.pushToTl(note, user);
|
||||
} else if (data.visibility === 'specified') {
|
||||
// TODO
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user);
|
||||
|
||||
|
|
@ -524,7 +511,8 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
const noteObj = await this.noteEntityService.pack(note);
|
||||
|
||||
this.globalEventService.publishNoteStream(note.id, 'updated', {
|
||||
updatedAt: note.updatedAt!,
|
||||
cw: note.cw,
|
||||
text: note.text!,
|
||||
});
|
||||
|
||||
this.roleService.addNoteToRoleTimeline(noteObj);
|
||||
|
|
@ -651,6 +639,163 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
this.index(note);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) {
|
||||
const redisPipeline = this.redisForTimelines.pipeline();
|
||||
|
||||
if (note.channelId) {
|
||||
const channelFollowings = await this.channelFollowingsRepository.find({
|
||||
where: {
|
||||
followeeId: note.channelId,
|
||||
},
|
||||
select: ['followerId'],
|
||||
});
|
||||
|
||||
for (const channelFollowing of channelFollowings) {
|
||||
redisPipeline.xadd(
|
||||
`homeTimeline:${channelFollowing.followerId}`,
|
||||
'MAXLEN', '~', '200',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
`homeTimelineWithFiles:${channelFollowing.followerId}`,
|
||||
'MAXLEN', '~', '100',
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO: キャッシュ?
|
||||
|
||||
const userListMemberships = await this.userListMembershipsRepository.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: ['userListId', 'withReplies'],
|
||||
});
|
||||
|
||||
// TODO
|
||||
//if (note.visibility === 'followers') {
|
||||
// // TODO: 重そうだから何とかしたい Set 使う?
|
||||
// userLists = userLists.filter(x => followings.some(f => f.followerId === x.userListUserId));
|
||||
//}
|
||||
|
||||
for (const userListMembership of userListMemberships) {
|
||||
// 自分自身以外への返信
|
||||
if (note.replyId && note.replyUserId !== note.userId) {
|
||||
if (!userListMembership.withReplies) continue;
|
||||
}
|
||||
|
||||
redisPipeline.xadd(
|
||||
`userListTimeline:${userListMembership.userListId}`,
|
||||
'MAXLEN', '~', '200',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
`userListTimelineWithFiles:${userListMembership.userListId}`,
|
||||
'MAXLEN', '~', '100',
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
}
|
||||
|
||||
{ // 自分自身のHTL
|
||||
redisPipeline.xadd(
|
||||
`homeTimeline:${user.id}`,
|
||||
'MAXLEN', '~', '200',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
`homeTimelineWithFiles:${user.id}`,
|
||||
'MAXLEN', '~', '100',
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (note.visibility === 'public' || note.visibility === 'home') {
|
||||
// 自分自身以外への返信
|
||||
if (note.replyId && note.replyUserId !== note.userId) {
|
||||
redisPipeline.xadd(
|
||||
`userTimelineWithReplies:${user.id}`,
|
||||
'MAXLEN', '~', '1000',
|
||||
'*',
|
||||
'note', note.id);
|
||||
} else {
|
||||
redisPipeline.xadd(
|
||||
`userTimeline:${user.id}`,
|
||||
'MAXLEN', '~', '1000',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
`userTimelineWithFiles:${user.id}`,
|
||||
'MAXLEN', '~', '500',
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
|
||||
if (note.visibility === 'public' && note.userHost == null) {
|
||||
redisPipeline.xadd(
|
||||
'localTimeline',
|
||||
'MAXLEN', '~', '1000',
|
||||
'*',
|
||||
'note', note.id);
|
||||
|
||||
if (note.fileIds.length > 0) {
|
||||
redisPipeline.xadd(
|
||||
'localTimelineWithFiles',
|
||||
'MAXLEN', '~', '500',
|
||||
'*',
|
||||
'note', note.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
redisPipeline.exec();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async checkHibernation(followings: MiFollowing[]) {
|
||||
if (followings.length === 0) return;
|
||||
|
||||
const shuffle = (array: MiFollowing[]) => {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
return array;
|
||||
};
|
||||
|
||||
// ランダムに最大1000件サンプリング
|
||||
const samples = shuffle(followings).slice(0, Math.min(followings.length, 1000));
|
||||
|
||||
const hibernatedUsers = await this.usersRepository.find({
|
||||
where: {
|
||||
id: In(samples.map(x => x.followerId)),
|
||||
lastActiveDate: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 50))),
|
||||
},
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
if (hibernatedUsers.length > 0) {
|
||||
this.usersRepository.update({
|
||||
id: In(hibernatedUsers.map(x => x.id)),
|
||||
}, {
|
||||
isHibernated: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private isSensitive(note: Option, sensitiveWord: string[]): boolean {
|
||||
if (sensitiveWord.length > 0) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { NotificationEntityService } from '@/core/entities/NotificationEntitySer
|
|||
import { IdService } from '@/core/IdService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { UserListService } from '@/core/UserListService.js';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService implements OnApplicationShutdown {
|
||||
|
|
@ -38,6 +39,7 @@ export class NotificationService implements OnApplicationShutdown {
|
|||
private globalEventService: GlobalEventService,
|
||||
private pushNotificationService: PushNotificationService,
|
||||
private cacheService: CacheService,
|
||||
private userListService: UserListService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -74,27 +76,59 @@ export class NotificationService implements OnApplicationShutdown {
|
|||
public async createNotification(
|
||||
notifieeId: MiUser['id'],
|
||||
type: MiNotification['type'],
|
||||
data: Partial<MiNotification>,
|
||||
data: Omit<Partial<MiNotification>, 'notifierId'>,
|
||||
notifierId?: MiUser['id'] | null,
|
||||
): Promise<MiNotification | null> {
|
||||
const profile = await this.cacheService.userProfileCache.fetch(notifieeId);
|
||||
const isMuted = profile.mutingNotificationTypes.includes(type);
|
||||
if (isMuted) return null;
|
||||
|
||||
if (data.notifierId) {
|
||||
if (notifieeId === data.notifierId) {
|
||||
// 古いMisskeyバージョンのキャッシュが残っている可能性がある
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
const recieveConfig = (profile.notificationRecieveConfig ?? {})[type];
|
||||
if (recieveConfig?.type === 'never') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (notifierId) {
|
||||
if (notifieeId === notifierId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId);
|
||||
if (mutings.has(data.notifierId)) {
|
||||
if (mutings.has(notifierId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (recieveConfig?.type === 'following') {
|
||||
const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId));
|
||||
if (!isFollowing) {
|
||||
return null;
|
||||
}
|
||||
} else if (recieveConfig?.type === 'follower') {
|
||||
const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId));
|
||||
if (!isFollower) {
|
||||
return null;
|
||||
}
|
||||
} else if (recieveConfig?.type === 'mutualFollow') {
|
||||
const [isFollowing, isFollower] = await Promise.all([
|
||||
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)),
|
||||
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)),
|
||||
]);
|
||||
if (!isFollowing && !isFollower) {
|
||||
return null;
|
||||
}
|
||||
} else if (recieveConfig?.type === 'list') {
|
||||
const isMember = await this.userListService.membersCache.fetch(recieveConfig.userListId).then(members => members.has(notifierId));
|
||||
if (!isMember) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notification = {
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
type: type,
|
||||
notifierId: notifierId,
|
||||
...data,
|
||||
} as MiNotification;
|
||||
|
||||
|
|
@ -117,8 +151,8 @@ export class NotificationService implements OnApplicationShutdown {
|
|||
this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed);
|
||||
this.pushNotificationService.pushNotification(notifieeId, 'notification', packed);
|
||||
|
||||
if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! }));
|
||||
if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! }));
|
||||
if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: notifierId! }));
|
||||
if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: notifierId! }));
|
||||
}, () => { /* aborted, ignore it */ });
|
||||
|
||||
return notification;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import { Brackets, ObjectLiteral } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, MutedNotesRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js';
|
||||
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
|
|
@ -23,9 +23,6 @@ export class QueryService {
|
|||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
|
||||
@Inject(DI.mutedNotesRepository)
|
||||
private mutedNotesRepository: MutedNotesRepository,
|
||||
|
||||
@Inject(DI.blockingsRepository)
|
||||
private blockingsRepository: BlockingsRepository,
|
||||
|
||||
|
|
@ -108,39 +105,6 @@ export class QueryService {
|
|||
q.setParameters(blockedQuery.getParameters());
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateChannelQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void {
|
||||
if (me == null) {
|
||||
q.andWhere('note.channelId IS NULL');
|
||||
} else {
|
||||
q.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
const channelFollowingQuery = this.channelFollowingsRepository.createQueryBuilder('channelFollowing')
|
||||
.select('channelFollowing.followeeId')
|
||||
.where('channelFollowing.followerId = :followerId', { followerId: me.id });
|
||||
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
// チャンネルのノートではない
|
||||
.where('note.channelId IS NULL')
|
||||
// または自分がフォローしているチャンネルのノート
|
||||
.orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`);
|
||||
}));
|
||||
|
||||
q.setParameters(channelFollowingQuery.getParameters());
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateMutedNoteQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
||||
const mutedQuery = this.mutedNotesRepository.createQueryBuilder('muted')
|
||||
.select('muted.noteId')
|
||||
.where('muted.userId = :userId', { userId: me.id });
|
||||
|
||||
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
|
||||
|
||||
q.setParameters(mutedQuery.getParameters());
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
||||
const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')
|
||||
|
|
@ -212,32 +176,6 @@ export class QueryService {
|
|||
q.setParameters(mutingQuery.getParameters());
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateRepliesQuery(q: SelectQueryBuilder<any>, withReplies: boolean, me?: Pick<MiUser, 'id'> | null): void {
|
||||
if (me == null) {
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
.where('note.replyId IS NULL') // 返信ではない
|
||||
.orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信
|
||||
.where('note.replyId IS NOT NULL')
|
||||
.andWhere('note.replyUserId = note.userId');
|
||||
}));
|
||||
}));
|
||||
} else if (!withReplies) {
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
.where('note.replyId IS NULL') // 返信ではない
|
||||
.orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信
|
||||
.orWhere(new Brackets(qb => { qb // 返信だけど自分の行った返信
|
||||
.where('note.replyId IS NOT NULL')
|
||||
.andWhere('note.userId = :meId', { meId: me.id });
|
||||
}))
|
||||
.orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信
|
||||
.where('note.replyId IS NOT NULL')
|
||||
.andWhere('note.replyUserId = note.userId');
|
||||
}));
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void {
|
||||
// This code must always be synchronized with the checks in Notes.isVisibleForMe.
|
||||
|
|
|
|||
|
|
@ -219,10 +219,9 @@ export class ReactionService {
|
|||
// リアクションされたユーザーがローカルユーザーなら通知を作成
|
||||
if (note.userHost === null) {
|
||||
this.notificationService.createNotification(note.userId, 'reaction', {
|
||||
notifierId: user.id,
|
||||
noteId: note.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
}, user.id);
|
||||
}
|
||||
|
||||
//#region 配信
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { MetaService } from '@/core/MetaService.js';
|
|||
import { CacheService } from '@/core/CacheService.js';
|
||||
import type { RoleCondFormulaValue } from '@/models/Role.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { StreamMessages } from '@/server/api/stream/types.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
|
|
@ -32,6 +32,7 @@ export type RolePolicies = {
|
|||
inviteExpirationTime: number;
|
||||
canManageCustomEmojis: boolean;
|
||||
canSearchNotes: boolean;
|
||||
canUseTranslator: boolean;
|
||||
canHideAds: boolean;
|
||||
driveCapacityMb: number;
|
||||
alwaysMarkNsfw: boolean;
|
||||
|
|
@ -56,6 +57,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
|||
inviteExpirationTime: 0,
|
||||
canManageCustomEmojis: false,
|
||||
canSearchNotes: false,
|
||||
canUseTranslator: true,
|
||||
canHideAds: false,
|
||||
driveCapacityMb: 100,
|
||||
alwaysMarkNsfw: false,
|
||||
|
|
@ -114,7 +116,7 @@ export class RoleService implements OnApplicationShutdown {
|
|||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message as StreamMessages['internal']['payload'];
|
||||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||
switch (type) {
|
||||
case 'roleCreated': {
|
||||
const cached = this.rolesCache.get();
|
||||
|
|
@ -300,6 +302,7 @@ export class RoleService implements OnApplicationShutdown {
|
|||
inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)),
|
||||
canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)),
|
||||
canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)),
|
||||
canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)),
|
||||
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),
|
||||
driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)),
|
||||
alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)),
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import type { MiBlocking } from '@/models/Blocking.js';
|
|||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/_.js';
|
||||
import type { FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListMembershipsRepository } from '@/models/_.js';
|
||||
import Logger from '@/logger.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
|
|
@ -38,8 +38,8 @@ export class UserBlockingService implements OnModuleInit {
|
|||
@Inject(DI.userListsRepository)
|
||||
private userListsRepository: UserListsRepository,
|
||||
|
||||
@Inject(DI.userListJoiningsRepository)
|
||||
private userListJoiningsRepository: UserListJoiningsRepository,
|
||||
@Inject(DI.userListMembershipsRepository)
|
||||
private userListMembershipsRepository: UserListMembershipsRepository,
|
||||
|
||||
private cacheService: CacheService,
|
||||
private userEntityService: UserEntityService,
|
||||
|
|
@ -149,7 +149,7 @@ export class UserBlockingService implements OnModuleInit {
|
|||
});
|
||||
|
||||
for (const userList of userLists) {
|
||||
await this.userListJoiningsRepository.delete({
|
||||
await this.userListMembershipsRepository.delete({
|
||||
userListId: userList.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -123,7 +123,11 @@ export class UserFollowingService implements OnModuleInit {
|
|||
// フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or
|
||||
// フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである
|
||||
// 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく
|
||||
if (followee.isLocked || (followeeProfile.carefulBot && follower.isBot) || (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee))) {
|
||||
if (
|
||||
followee.isLocked ||
|
||||
(followeeProfile.carefulBot && follower.isBot) ||
|
||||
(this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee) && process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING !== 'true')
|
||||
) {
|
||||
let autoAccept = false;
|
||||
|
||||
// 鍵アカウントであっても、既にフォローされていた場合はスルー
|
||||
|
|
@ -230,8 +234,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
|
||||
// 通知を作成
|
||||
this.notificationService.createNotification(follower.id, 'followRequestAccepted', {
|
||||
notifierId: followee.id,
|
||||
});
|
||||
}, followee.id);
|
||||
}
|
||||
|
||||
if (alreadyFollowed) return;
|
||||
|
|
@ -304,8 +307,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
|
||||
// 通知を作成
|
||||
this.notificationService.createNotification(followee.id, 'follow', {
|
||||
notifierId: follower.id,
|
||||
});
|
||||
}, follower.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -488,9 +490,8 @@ export class UserFollowingService implements OnModuleInit {
|
|||
|
||||
// 通知を作成
|
||||
this.notificationService.createNotification(followee.id, 'receiveFollowRequest', {
|
||||
notifierId: follower.id,
|
||||
followRequestId: followRequest.id,
|
||||
});
|
||||
}, follower.id);
|
||||
}
|
||||
|
||||
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { UserListJoiningsRepository } from '@/models/_.js';
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import type { UserListMembershipsRepository } from '@/models/_.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiUserList } from '@/models/UserList.js';
|
||||
import type { MiUserListJoining } from '@/models/UserListJoining.js';
|
||||
import type { MiUserListMembership } from '@/models/UserListMembership.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
|
@ -16,14 +17,24 @@ import { ProxyAccountService } from '@/core/ProxyAccountService.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { RedisKVCache } from '@/misc/cache.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserListService {
|
||||
export class UserListService implements OnApplicationShutdown {
|
||||
public static TooManyUsersError = class extends Error {};
|
||||
|
||||
public membersCache: RedisKVCache<Set<string>>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.userListJoiningsRepository)
|
||||
private userListJoiningsRepository: UserListJoiningsRepository,
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.redisForSub)
|
||||
private redisForSub: Redis.Redis,
|
||||
|
||||
@Inject(DI.userListMembershipsRepository)
|
||||
private userListMembershipsRepository: UserListMembershipsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
|
|
@ -32,24 +43,63 @@ export class UserListService {
|
|||
private proxyAccountService: ProxyAccountService,
|
||||
private queueService: QueueService,
|
||||
) {
|
||||
this.membersCache = new RedisKVCache<Set<string>>(this.redisClient, 'userListMembers', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
memoryCacheLifetime: 1000 * 60, // 1m
|
||||
fetcher: (key) => this.userListMembershipsRepository.find({ where: { userListId: key }, select: ['userId'] }).then(xs => new Set(xs.map(x => x.userId))),
|
||||
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
|
||||
fromRedisConverter: (value) => new Set(JSON.parse(value)),
|
||||
});
|
||||
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async push(target: MiUser, list: MiUserList, me: MiUser) {
|
||||
const currentCount = await this.userListJoiningsRepository.countBy({
|
||||
private async onMessage(_: string, data: string): Promise<void> {
|
||||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||
switch (type) {
|
||||
case 'userListMemberAdded': {
|
||||
const { userListId, memberId } = body;
|
||||
const members = await this.membersCache.get(userListId);
|
||||
if (members) {
|
||||
members.add(memberId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'userListMemberRemoved': {
|
||||
const { userListId, memberId } = body;
|
||||
const members = await this.membersCache.get(userListId);
|
||||
if (members) {
|
||||
members.delete(memberId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async addMember(target: MiUser, list: MiUserList, me: MiUser) {
|
||||
const currentCount = await this.userListMembershipsRepository.countBy({
|
||||
userListId: list.id,
|
||||
});
|
||||
if (currentCount > (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) {
|
||||
throw new UserListService.TooManyUsersError();
|
||||
}
|
||||
|
||||
await this.userListJoiningsRepository.insert({
|
||||
await this.userListMembershipsRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
userId: target.id,
|
||||
userListId: list.id,
|
||||
} as MiUserListJoining);
|
||||
} as MiUserListMembership);
|
||||
|
||||
this.globalEventService.publishInternalEvent('userListMemberAdded', { userListId: list.id, memberId: target.id });
|
||||
this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target));
|
||||
|
||||
// このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする
|
||||
|
|
@ -60,4 +110,44 @@ export class UserListService {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async removeMember(target: MiUser, list: MiUserList) {
|
||||
await this.userListMembershipsRepository.delete({
|
||||
userId: target.id,
|
||||
userListId: list.id,
|
||||
});
|
||||
|
||||
this.globalEventService.publishInternalEvent('userListMemberRemoved', { userListId: list.id, memberId: target.id });
|
||||
this.globalEventService.publishUserListStream(list.id, 'userRemoved', await this.userEntityService.pack(target));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async updateMembership(target: MiUser, list: MiUserList, options: { withReplies?: boolean }) {
|
||||
const membership = await this.userListMembershipsRepository.findOneBy({
|
||||
userId: target.id,
|
||||
userListId: list.id,
|
||||
});
|
||||
|
||||
if (membership == null) {
|
||||
throw new Error('User is not a member of the list');
|
||||
}
|
||||
|
||||
await this.userListMembershipsRepository.update({
|
||||
id: membership.id,
|
||||
}, {
|
||||
withReplies: options.withReplies,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.redisForSub.off('message', this.onMessage);
|
||||
this.membersCache.dispose();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
53
packages/backend/src/core/UserService.ts
Normal file
53
packages/backend/src/core/UserService.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { FollowingsRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async updateLastActiveDate(user: MiUser): Promise<void> {
|
||||
if (user.isHibernated) {
|
||||
const result = await this.usersRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
lastActiveDate: new Date(),
|
||||
})
|
||||
.where('id = :id', { id: user.id })
|
||||
.returning('*')
|
||||
.execute()
|
||||
.then((response) => {
|
||||
return response.raw[0];
|
||||
});
|
||||
const wokeUp = result.isHibernated;
|
||||
if (wokeUp) {
|
||||
this.usersRepository.update(user.id, {
|
||||
isHibernated: false,
|
||||
});
|
||||
this.followingsRepository.update({
|
||||
followerId: user.id,
|
||||
}, {
|
||||
isFollowerHibernated: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.usersRepository.update(user.id, {
|
||||
lastActiveDate: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import type { WebhooksRepository } from '@/models/_.js';
|
|||
import type { MiWebhook } from '@/models/Webhook.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { StreamMessages } from '@/server/api/stream/types.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -45,7 +45,7 @@ export class WebhookService implements OnApplicationShutdown {
|
|||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message as StreamMessages['internal']['payload'];
|
||||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||
switch (type) {
|
||||
case 'webhookCreated':
|
||||
if (body.active) {
|
||||
|
|
|
|||
|
|
@ -102,13 +102,13 @@ export class NoteEntityService implements OnModuleInit {
|
|||
} else if (meId === packedNote.userId) {
|
||||
hide = false;
|
||||
} else if (packedNote.reply && (meId === packedNote.reply.userId)) {
|
||||
// 自分の投稿に対するリプライ
|
||||
// 自分の投稿に対するリプライ
|
||||
hide = false;
|
||||
} else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) {
|
||||
// 自分へのメンション
|
||||
// 自分へのメンション
|
||||
hide = false;
|
||||
} else {
|
||||
// フォロワーかどうか
|
||||
// フォロワーかどうか
|
||||
const isFollowing = await this.followingsRepository.exist({
|
||||
where: {
|
||||
followeeId: packedNote.userId,
|
||||
|
|
@ -312,6 +312,7 @@ export class NoteEntityService implements OnModuleInit {
|
|||
const packed: Packed<'Note'> = await awaitAll({
|
||||
id: note.id,
|
||||
createdAt: note.createdAt.toISOString(),
|
||||
updatedAt: note.updatedAt ? note.updatedAt.toISOString() : undefined,
|
||||
userId: note.userId,
|
||||
user: this.userEntityService.pack(note.user ?? note.userId, me, {
|
||||
detail: false,
|
||||
|
|
@ -342,7 +343,6 @@ export class NoteEntityService implements OnModuleInit {
|
|||
mentions: note.mentions && note.mentions.length > 0 ? note.mentions : undefined,
|
||||
uri: note.uri ?? undefined,
|
||||
url: note.url ?? undefined,
|
||||
updatedAt: note.updatedAt != null ? note.updatedAt.toISOString() : undefined,
|
||||
...(meId ? {
|
||||
myReaction: this.populateMyReaction(note, meId, options?._hint_),
|
||||
} : {}),
|
||||
|
|
|
|||
|
|
@ -458,7 +458,8 @@ export class UserEntityService implements OnModuleInit {
|
|||
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
|
||||
mutedWords: profile!.mutedWords,
|
||||
mutedInstances: profile!.mutedInstances,
|
||||
mutingNotificationTypes: profile!.mutingNotificationTypes,
|
||||
mutingNotificationTypes: [], // 後方互換性のため
|
||||
notificationRecieveConfig: profile!.notificationRecieveConfig,
|
||||
emailNotificationTypes: profile!.emailNotificationTypes,
|
||||
achievements: profile!.achievements,
|
||||
loggedInDays: profile!.loggedInDates.length,
|
||||
|
|
@ -492,6 +493,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
isMuted: relation.isMuted,
|
||||
isRenoteMuted: relation.isRenoteMuted,
|
||||
notify: relation.following?.notify ?? 'none',
|
||||
withReplies: relation.following?.withReplies ?? false,
|
||||
} : {}),
|
||||
} as Promiseable<Packed<'User'>> as Promiseable<IsMeAndIsUserDetailed<ExpectsMe, D>>;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UserListJoiningsRepository, UserListsRepository } from '@/models/_.js';
|
||||
import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { } from '@/models/Blocking.js';
|
||||
import type { MiUserList } from '@/models/UserList.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { UserEntityService } from './UserEntityService.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserListEntityService {
|
||||
|
|
@ -17,8 +18,10 @@ export class UserListEntityService {
|
|||
@Inject(DI.userListsRepository)
|
||||
private userListsRepository: UserListsRepository,
|
||||
|
||||
@Inject(DI.userListJoiningsRepository)
|
||||
private userListJoiningsRepository: UserListJoiningsRepository,
|
||||
@Inject(DI.userListMembershipsRepository)
|
||||
private userListMembershipsRepository: UserListMembershipsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -28,7 +31,7 @@ export class UserListEntityService {
|
|||
): Promise<Packed<'UserList'>> {
|
||||
const userList = typeof src === 'object' ? src : await this.userListsRepository.findOneByOrFail({ id: src });
|
||||
|
||||
const users = await this.userListJoiningsRepository.findBy({
|
||||
const users = await this.userListMembershipsRepository.findBy({
|
||||
userListId: userList.id,
|
||||
});
|
||||
|
||||
|
|
@ -40,5 +43,18 @@ export class UserListEntityService {
|
|||
isPublic: userList.isPublic,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packMembershipsMany(
|
||||
memberships: MiUserListMembership[],
|
||||
) {
|
||||
return Promise.all(memberships.map(async x => ({
|
||||
id: x.id,
|
||||
createdAt: x.createdAt.toISOString(),
|
||||
userId: x.userId,
|
||||
user: await this.userEntityService.pack(x.userId),
|
||||
withReplies: x.withReplies,
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue