diff options
| author | syuilo <4439005+syuilo@users.noreply.github.com> | 2025-03-24 21:32:46 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-03-24 21:32:46 +0900 |
| commit | f1f24e39d2df3135493e2c2087230b428e2d02b7 (patch) | |
| tree | a5ae0e9d2cf810649b2f4e08ef4d00ce7ea91dc9 /packages/backend/src/core | |
| parent | fix(frontend): fix broken styles (diff) | |
| download | misskey-f1f24e39d2df3135493e2c2087230b428e2d02b7.tar.gz misskey-f1f24e39d2df3135493e2c2087230b428e2d02b7.tar.bz2 misskey-f1f24e39d2df3135493e2c2087230b428e2d02b7.zip | |
Feat: Chat (#15686)
* wip
* wip
* wip
* wip
* wip
* wip
* Update types.ts
* Create 1742203321812-chat.js
* wip
* wip
* Update room.vue
* Update home.vue
* Update home.vue
* Update ja-JP.yml
* Update index.d.ts
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Update CHANGELOG.md
* wip
* Update home.vue
* clean up
* Update misskey-js.api.md
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* lint fixes
* lint
* Update UserEntityService.ts
* search
* wip
* 🎨
* wip
* Update home.ownedRooms.vue
* wip
* Update CHANGELOG.md
* Update style.scss
* wip
* improve performance
* improve performance
* Update timeline.test.ts
Diffstat (limited to 'packages/backend/src/core')
| -rw-r--r-- | packages/backend/src/core/ChatService.ts | 776 | ||||
| -rw-r--r-- | packages/backend/src/core/CoreModule.ts | 18 | ||||
| -rw-r--r-- | packages/backend/src/core/GlobalEventService.ts | 38 | ||||
| -rw-r--r-- | packages/backend/src/core/NoteCreateService.ts | 27 | ||||
| -rw-r--r-- | packages/backend/src/core/NoteReadService.ts | 143 | ||||
| -rw-r--r-- | packages/backend/src/core/RoleService.ts | 3 | ||||
| -rw-r--r-- | packages/backend/src/core/UserFollowingService.ts | 28 | ||||
| -rw-r--r-- | packages/backend/src/core/WebhookTestService.ts | 2 | ||||
| -rw-r--r-- | packages/backend/src/core/entities/ChatEntityService.ts | 376 | ||||
| -rw-r--r-- | packages/backend/src/core/entities/UserEntityService.ts | 20 |
10 files changed, 1234 insertions, 197 deletions
diff --git a/packages/backend/src/core/ChatService.ts b/packages/backend/src/core/ChatService.ts new file mode 100644 index 0000000000..eece5d0da3 --- /dev/null +++ b/packages/backend/src/core/ChatService.ts @@ -0,0 +1,776 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import { Brackets } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import { QueueService } from '@/core/QueueService.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { PushNotificationService } from '@/core/PushNotificationService.js'; +import { bindThis } from '@/decorators.js'; +import type { ChatApprovalsRepository, ChatMessagesRepository, ChatRoomInvitationsRepository, ChatRoomMembershipsRepository, ChatRoomsRepository, MiChatMessage, MiChatRoom, MiChatRoomMembership, MiDriveFile, MiUser, MutingsRepository, UsersRepository } from '@/models/_.js'; +import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { QueryService } from '@/core/QueryService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js'; +import { Packed } from '@/misc/json-schema.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { emojiRegex } from '@/misc/emoji-regex.js'; + +const MAX_ROOM_MEMBERS = 30; +const MAX_REACTIONS_PER_MESSAGE = 100; +const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/; + +@Injectable() +export class ChatService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.chatMessagesRepository) + private chatMessagesRepository: ChatMessagesRepository, + + @Inject(DI.chatApprovalsRepository) + private chatApprovalsRepository: ChatApprovalsRepository, + + @Inject(DI.chatRoomsRepository) + private chatRoomsRepository: ChatRoomsRepository, + + @Inject(DI.chatRoomInvitationsRepository) + private chatRoomInvitationsRepository: ChatRoomInvitationsRepository, + + @Inject(DI.chatRoomMembershipsRepository) + private chatRoomMembershipsRepository: ChatRoomMembershipsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + private userEntityService: UserEntityService, + private chatEntityService: ChatEntityService, + private idService: IdService, + private globalEventService: GlobalEventService, + private apRendererService: ApRendererService, + private queueService: QueueService, + private pushNotificationService: PushNotificationService, + private userBlockingService: UserBlockingService, + private queryService: QueryService, + private roleService: RoleService, + private userFollowingService: UserFollowingService, + private customEmojiService: CustomEmojiService, + ) { + } + + @bindThis + public async createMessageToUser(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toUser: MiUser, params: { + text?: string | null; + file?: MiDriveFile | null; + uri?: string | null; + }): Promise<Packed<'ChatMessageLite'>> { + if (fromUser.id === toUser.id) { + throw new Error('yourself'); + } + + const approvals = await this.chatApprovalsRepository.createQueryBuilder('approval') + .where(new Brackets(qb => { // 自分が相手を許可しているか + qb.where('approval.userId = :fromUserId', { fromUserId: fromUser.id }) + .andWhere('approval.otherId = :toUserId', { toUserId: toUser.id }); + })) + .orWhere(new Brackets(qb => { // 相手が自分を許可しているか + qb.where('approval.userId = :toUserId', { toUserId: toUser.id }) + .andWhere('approval.otherId = :fromUserId', { fromUserId: fromUser.id }); + })) + .take(2) + .getMany(); + + const otherApprovedMe = approvals.some(approval => approval.userId === toUser.id); + const iApprovedOther = approvals.some(approval => approval.userId === fromUser.id); + + if (!otherApprovedMe) { + if (toUser.chatScope === 'none') { + throw new Error('recipient is cannot chat (none)'); + } else if (toUser.chatScope === 'followers') { + const isFollower = await this.userFollowingService.isFollowing(fromUser.id, toUser.id); + if (!isFollower) { + throw new Error('recipient is cannot chat (followers)'); + } + } else if (toUser.chatScope === 'following') { + const isFollowing = await this.userFollowingService.isFollowing(toUser.id, fromUser.id); + if (!isFollowing) { + throw new Error('recipient is cannot chat (following)'); + } + } else if (toUser.chatScope === 'mutual') { + const isMutual = await this.userFollowingService.isMutual(fromUser.id, toUser.id); + if (!isMutual) { + throw new Error('recipient is cannot chat (mutual)'); + } + } + } + + if (!(await this.roleService.getUserPolicies(toUser.id)).canChat) { + throw new Error('recipient is cannot chat (policy)'); + } + + const blocked = await this.userBlockingService.checkBlocked(toUser.id, fromUser.id); + if (blocked) { + throw new Error('blocked'); + } + + const message = { + id: this.idService.gen(), + fromUserId: fromUser.id, + toUserId: toUser.id, + text: params.text ? params.text.trim() : null, + fileId: params.file ? params.file.id : null, + reads: [], + uri: params.uri ?? null, + } satisfies Partial<MiChatMessage>; + + const inserted = await this.chatMessagesRepository.insertOne(message); + + // 相手を許可しておく + if (!iApprovedOther) { + this.chatApprovalsRepository.insertOne({ + id: this.idService.gen(), + userId: fromUser.id, + otherId: toUser.id, + }); + } + + const packedMessage = await this.chatEntityService.packMessageLiteFor1on1(inserted); + + if (this.userEntityService.isLocalUser(toUser)) { + const redisPipeline = this.redisClient.pipeline(); + redisPipeline.set(`newUserChatMessageExists:${toUser.id}:${fromUser.id}`, message.id); + redisPipeline.sadd(`newChatMessagesExists:${toUser.id}`, `user:${fromUser.id}`); + redisPipeline.exec(); + } + + if (this.userEntityService.isLocalUser(fromUser)) { + // 自分のストリーム + this.globalEventService.publishChatUserStream(fromUser.id, toUser.id, 'message', packedMessage); + } + + if (this.userEntityService.isLocalUser(toUser)) { + // 相手のストリーム + this.globalEventService.publishChatUserStream(toUser.id, fromUser.id, 'message', packedMessage); + } + + // 3秒経っても既読にならなかったらイベント発行 + if (this.userEntityService.isLocalUser(toUser)) { + setTimeout(async () => { + const marker = await this.redisClient.get(`newUserChatMessageExists:${toUser.id}:${fromUser.id}`); + + if (marker == null) return; // 既読 + + const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted, toUser); + this.globalEventService.publishMainStream(toUser.id, 'newChatMessage', packedMessageForTo); + //this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo); + }, 3000); + } + + return packedMessage; + } + + @bindThis + public async createMessageToRoom(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toRoom: MiChatRoom, params: { + text?: string | null; + file?: MiDriveFile | null; + uri?: string | null; + }): Promise<Packed<'ChatMessageLite'>> { + const memberships = await this.chatRoomMembershipsRepository.findBy({ roomId: toRoom.id }); + + if (toRoom.ownerId !== fromUser.id && !memberships.some(member => member.userId === fromUser.id)) { + throw new Error('you are not a member of the room'); + } + + const message = { + id: this.idService.gen(), + fromUserId: fromUser.id, + toRoomId: toRoom.id, + text: params.text ? params.text.trim() : null, + fileId: params.file ? params.file.id : null, + reads: [], + uri: params.uri ?? null, + } satisfies Partial<MiChatMessage>; + + const inserted = await this.chatMessagesRepository.insertOne(message); + + const packedMessage = await this.chatEntityService.packMessageLiteForRoom(inserted); + + this.globalEventService.publishChatRoomStream(toRoom.id, 'message', packedMessage); + + const redisPipeline = this.redisClient.pipeline(); + for (const membership of memberships) { + if (membership.isMuted) continue; + + redisPipeline.set(`newRoomChatMessageExists:${membership.userId}:${toRoom.id}`, message.id); + redisPipeline.sadd(`newChatMessagesExists:${membership.userId}`, `room:${toRoom.id}`); + } + redisPipeline.exec(); + + // 3秒経っても既読にならなかったらイベント発行 + setTimeout(async () => { + const redisPipeline = this.redisClient.pipeline(); + for (const membership of memberships) { + redisPipeline.get(`newRoomChatMessageExists:${membership.userId}:${toRoom.id}`); + } + const markers = await redisPipeline.exec(); + if (markers == null) throw new Error('redis error'); + + if (markers.every(marker => marker[1] == null)) return; + + const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted); + + for (let i = 0; i < memberships.length; i++) { + const marker = markers[i][1]; + if (marker == null) continue; + + this.globalEventService.publishMainStream(memberships[i].userId, 'newChatMessage', packedMessageForTo); + //this.pushNotificationService.pushNotification(memberships[i].userId, 'newChatMessage', packedMessageForTo); + } + }, 3000); + + return packedMessage; + } + + @bindThis + public async readUserChatMessage( + readerId: MiUser['id'], + senderId: MiUser['id'], + ): Promise<void> { + const redisPipeline = this.redisClient.pipeline(); + redisPipeline.del(`newUserChatMessageExists:${readerId}:${senderId}`); + redisPipeline.srem(`newChatMessagesExists:${readerId}`, `user:${senderId}`); + await redisPipeline.exec(); + } + + @bindThis + public async readRoomChatMessage( + readerId: MiUser['id'], + roomId: MiChatRoom['id'], + ): Promise<void> { + const redisPipeline = this.redisClient.pipeline(); + redisPipeline.del(`newRoomChatMessageExists:${readerId}:${roomId}`); + redisPipeline.srem(`newChatMessagesExists:${readerId}`, `room:${roomId}`); + await redisPipeline.exec(); + } + + @bindThis + public findMessageById(messageId: MiChatMessage['id']) { + return this.chatMessagesRepository.findOneBy({ id: messageId }); + } + + @bindThis + public findMyMessageById(userId: MiUser['id'], messageId: MiChatMessage['id']) { + return this.chatMessagesRepository.findOneBy({ id: messageId, fromUserId: userId }); + } + + @bindThis + public async deleteMessage(message: MiChatMessage) { + await this.chatMessagesRepository.delete(message.id); + + if (message.toUserId) { + const [fromUser, toUser] = await Promise.all([ + this.usersRepository.findOneByOrFail({ id: message.fromUserId }), + this.usersRepository.findOneByOrFail({ id: message.toUserId }), + ]); + + if (this.userEntityService.isLocalUser(fromUser)) this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId, 'deleted', message.id); + if (this.userEntityService.isLocalUser(toUser)) this.globalEventService.publishChatUserStream(message.toUserId, message.fromUserId, 'deleted', message.id); + + if (this.userEntityService.isLocalUser(fromUser) && this.userEntityService.isRemoteUser(toUser)) { + //const activity = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${message.id}`), fromUser)); + //this.queueService.deliver(fromUser, activity, toUser.inbox); + } + } else if (message.toRoomId) { + this.globalEventService.publishChatRoomStream(message.toRoomId, 'deleted', message.id); + } + } + + @bindThis + public async userTimeline(meId: MiUser['id'], otherId: MiUser['id'], limit: number, sinceId?: MiChatMessage['id'] | null, untilId?: MiChatMessage['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), sinceId, untilId) + .andWhere(new Brackets(qb => { + qb + .where(new Brackets(qb => { + qb + .where('message.fromUserId = :meId') + .andWhere('message.toUserId = :otherId'); + })) + .orWhere(new Brackets(qb => { + qb + .where('message.fromUserId = :otherId') + .andWhere('message.toUserId = :meId'); + })); + })) + .setParameter('meId', meId) + .setParameter('otherId', otherId); + + const messages = await query.take(limit).getMany(); + + return messages; + } + + @bindThis + public async roomTimeline(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatMessage['id'] | null, untilId?: MiChatMessage['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), sinceId, untilId) + .where('message.toRoomId = :roomId', { roomId }) + .leftJoinAndSelect('message.file', 'file') + .leftJoinAndSelect('message.fromUser', 'fromUser'); + + const messages = await query.take(limit).getMany(); + + return messages; + } + + @bindThis + public async userHistory(meId: MiUser['id'], limit: number): Promise<MiChatMessage[]> { + const history: MiChatMessage[] = []; + + const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: meId }); + + for (let i = 0; i < limit; i++) { + const found = history.map(m => (m.fromUserId === meId) ? m.toUserId! : m.fromUserId!); + + const query = this.chatMessagesRepository.createQueryBuilder('message') + .orderBy('message.id', 'DESC') + .where(new Brackets(qb => { + qb + .where('message.fromUserId = :meId', { meId: meId }) + .orWhere('message.toUserId = :meId', { meId: meId }); + })) + .andWhere('message.toRoomId IS NULL') + .andWhere(`message.fromUserId NOT IN (${ mutingQuery.getQuery() })`) + .andWhere(`message.toUserId NOT IN (${ mutingQuery.getQuery() })`); + + if (found.length > 0) { + query.andWhere('message.fromUserId NOT IN (:...found)', { found: found }); + query.andWhere('message.toUserId NOT IN (:...found)', { found: found }); + } + + query.setParameters(mutingQuery.getParameters()); + + const message = await query.getOne(); + + if (message) { + history.push(message); + } else { + break; + } + } + + return history; + } + + @bindThis + public async roomHistory(meId: MiUser['id'], limit: number): Promise<MiChatMessage[]> { + // TODO: 一回のクエリにまとめられるかも + const [memberRoomIds, ownedRoomIds] = await Promise.all([ + this.chatRoomMembershipsRepository.findBy({ + userId: meId, + }).then(xs => xs.map(x => x.roomId)), + this.chatRoomsRepository.findBy({ + ownerId: meId, + }).then(xs => xs.map(x => x.id)), + ]); + + const roomIds = memberRoomIds.concat(ownedRoomIds); + + if (memberRoomIds.length === 0 && ownedRoomIds.length === 0) { + return []; + } + + const history: MiChatMessage[] = []; + + for (let i = 0; i < limit; i++) { + const found = history.map(m => m.toRoomId!); + + const query = this.chatMessagesRepository.createQueryBuilder('message') + .orderBy('message.id', 'DESC') + .where('message.toRoomId IN (:...roomIds)', { roomIds }); + + if (found.length > 0) { + query.andWhere('message.toRoomId NOT IN (:...found)', { found: found }); + } + + const message = await query.getOne(); + + if (message) { + history.push(message); + } else { + break; + } + } + + return history; + } + + @bindThis + public async getUserReadStateMap(userId: MiUser['id'], otherIds: MiUser['id'][]) { + const readStateMap: Record<MiUser['id'], boolean> = {}; + + const redisPipeline = this.redisClient.pipeline(); + + for (const otherId of otherIds) { + redisPipeline.get(`newUserChatMessageExists:${userId}:${otherId}`); + } + + const markers = await redisPipeline.exec(); + if (markers == null) throw new Error('redis error'); + + for (let i = 0; i < otherIds.length; i++) { + const marker = markers[i][1]; + readStateMap[otherIds[i]] = marker == null; + } + + return readStateMap; + } + + @bindThis + public async getRoomReadStateMap(userId: MiUser['id'], roomIds: MiChatRoom['id'][]) { + const readStateMap: Record<MiChatRoom['id'], boolean> = {}; + + const redisPipeline = this.redisClient.pipeline(); + + for (const roomId of roomIds) { + redisPipeline.get(`newRoomChatMessageExists:${userId}:${roomId}`); + } + + const markers = await redisPipeline.exec(); + if (markers == null) throw new Error('redis error'); + + for (let i = 0; i < roomIds.length; i++) { + const marker = markers[i][1]; + readStateMap[roomIds[i]] = marker == null; + } + + return readStateMap; + } + + @bindThis + public async hasUnreadMessages(userId: MiUser['id']) { + const card = await this.redisClient.scard(`newChatMessagesExists:${userId}`); + return card > 0; + } + + @bindThis + public async createRoom(owner: MiUser, params: Partial<{ + name: string; + description: string; + }>) { + const room = { + id: this.idService.gen(), + name: params.name, + description: params.description, + ownerId: owner.id, + } satisfies Partial<MiChatRoom>; + + const created = await this.chatRoomsRepository.insertOne(room); + + return created; + } + + @bindThis + public async deleteRoom(room: MiChatRoom) { + await this.chatRoomsRepository.delete(room.id); + } + + @bindThis + public async findMyRoomById(ownerId: MiUser['id'], roomId: MiChatRoom['id']) { + return this.chatRoomsRepository.findOneBy({ id: roomId, ownerId: ownerId }); + } + + @bindThis + public async findRoomById(roomId: MiChatRoom['id']) { + return this.chatRoomsRepository.findOne({ where: { id: roomId }, relations: ['owner'] }); + } + + @bindThis + public async isRoomMember(room: MiChatRoom, userId: MiUser['id']) { + if (room.ownerId === userId) return true; + const membership = await this.chatRoomMembershipsRepository.findOneBy({ roomId: room.id, userId }); + return membership != null; + } + + @bindThis + public async createRoomInvitation(inviterId: MiUser['id'], roomId: MiChatRoom['id'], inviteeId: MiUser['id']) { + if (inviterId === inviteeId) { + throw new Error('yourself'); + } + + const room = await this.chatRoomsRepository.findOneByOrFail({ id: roomId, ownerId: inviterId }); + + const existingInvitation = await this.chatRoomInvitationsRepository.findOneBy({ roomId, userId: inviteeId }); + if (existingInvitation) { + throw new Error('already invited'); + } + + const membershipsCount = await this.chatRoomMembershipsRepository.countBy({ roomId }); + if (membershipsCount >= MAX_ROOM_MEMBERS) { + throw new Error('room is full'); + } + + // TODO: cehck block + + const invitation = { + id: this.idService.gen(), + roomId: room.id, + userId: inviteeId, + } satisfies Partial<MiChatRoomInvitation>; + + const created = await this.chatRoomInvitationsRepository.insertOne(invitation); + + return created; + } + + @bindThis + public async getOwnedRoomsWithPagination(ownerId: MiUser['id'], limit: number, sinceId?: MiChatRoom['id'] | null, untilId?: MiChatRoom['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatRoomsRepository.createQueryBuilder('room'), sinceId, untilId) + .where('room.ownerId = :ownerId', { ownerId }); + + const rooms = await query.take(limit).getMany(); + + return rooms; + } + + @bindThis + public async getReceivedRoomInvitationsWithPagination(userId: MiUser['id'], limit: number, sinceId?: MiChatRoomInvitation['id'] | null, untilId?: MiChatRoomInvitation['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatRoomInvitationsRepository.createQueryBuilder('invitation'), sinceId, untilId) + .where('invitation.userId = :userId', { userId }) + .andWhere('invitation.ignored = FALSE'); + + const invitations = await query.take(limit).getMany(); + + return invitations; + } + + @bindThis + public async joinToRoom(userId: MiUser['id'], roomId: MiChatRoom['id']) { + const invitation = await this.chatRoomInvitationsRepository.findOneByOrFail({ roomId, userId }); + + const membershipsCount = await this.chatRoomMembershipsRepository.countBy({ roomId }); + if (membershipsCount >= MAX_ROOM_MEMBERS) { + throw new Error('room is full'); + } + + const membership = { + id: this.idService.gen(), + roomId: roomId, + userId: userId, + } satisfies Partial<MiChatRoomMembership>; + + // TODO: transaction + await this.chatRoomMembershipsRepository.insertOne(membership); + await this.chatRoomInvitationsRepository.delete(invitation.id); + } + + @bindThis + public async ignoreRoomInvitation(userId: MiUser['id'], roomId: MiChatRoom['id']) { + const invitation = await this.chatRoomInvitationsRepository.findOneByOrFail({ roomId, userId }); + await this.chatRoomInvitationsRepository.update(invitation.id, { ignored: true }); + } + + @bindThis + public async leaveRoom(userId: MiUser['id'], roomId: MiChatRoom['id']) { + const membership = await this.chatRoomMembershipsRepository.findOneByOrFail({ roomId, userId }); + await this.chatRoomMembershipsRepository.delete(membership.id); + } + + @bindThis + public async muteRoom(userId: MiUser['id'], roomId: MiChatRoom['id'], mute: boolean) { + const membership = await this.chatRoomMembershipsRepository.findOneByOrFail({ roomId, userId }); + await this.chatRoomMembershipsRepository.update(membership.id, { isMuted: mute }); + } + + @bindThis + public async updateRoom(room: MiChatRoom, params: { + name?: string; + description?: string; + }): Promise<MiChatRoom> { + return this.chatRoomsRepository.createQueryBuilder().update() + .set(params) + .where('id = :id', { id: room.id }) + .returning('*') + .execute() + .then((response) => { + return response.raw[0]; + }); + } + + @bindThis + public async getRoomMembershipsWithPagination(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatRoomMembership['id'] | null, untilId?: MiChatRoomMembership['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatRoomMembershipsRepository.createQueryBuilder('membership'), sinceId, untilId) + .where('membership.roomId = :roomId', { roomId }); + + const memberships = await query.take(limit).getMany(); + + return memberships; + } + + @bindThis + public async searchMessages(meId: MiUser['id'], query: string, limit: number, params: { + userId?: MiUser['id'] | null; + roomId?: MiChatRoom['id'] | null; + }) { + const q = this.chatMessagesRepository.createQueryBuilder('message'); + + if (params.userId) { + q.andWhere(new Brackets(qb => { + qb + .where(new Brackets(qb => { + qb + .where('message.fromUserId = :meId') + .andWhere('message.toUserId = :otherId'); + })) + .orWhere(new Brackets(qb => { + qb + .where('message.fromUserId = :otherId') + .andWhere('message.toUserId = :meId'); + })); + })) + .setParameter('meId', meId) + .setParameter('otherId', params.userId); + } else if (params.roomId) { + q.where('message.toRoomId = :roomId', { roomId: params.roomId }); + } else { + const membershipsQuery = this.chatRoomMembershipsRepository.createQueryBuilder('membership') + .select('membership.roomId') + .where('membership.userId = :meId', { meId: meId }); + + const ownedRoomsQuery = this.chatRoomsRepository.createQueryBuilder('room') + .select('room.id') + .where('room.ownerId = :meId', { meId }); + + q.andWhere(new Brackets(qb => { + qb + .where('message.fromUserId = :meId') + .orWhere('message.toUserId = :meId') + .orWhere(`message.toRoomId IN (${membershipsQuery.getQuery()})`) + .orWhere(`message.toRoomId IN (${ownedRoomsQuery.getQuery()})`); + })); + + q.setParameters(membershipsQuery.getParameters()); + q.setParameters(ownedRoomsQuery.getParameters()); + } + + q.andWhere('LOWER(message.text) LIKE :q', { q: `%${ sqlLikeEscape(query.toLowerCase()) }%` }); + + q.leftJoinAndSelect('message.file', 'file'); + q.leftJoinAndSelect('message.fromUser', 'fromUser'); + q.leftJoinAndSelect('message.toUser', 'toUser'); + q.leftJoinAndSelect('message.toRoom', 'toRoom'); + q.leftJoinAndSelect('toRoom.owner', 'toRoomOwner'); + + const messages = await q.orderBy('message.id', 'DESC').take(limit).getMany(); + + return messages; + } + + @bindThis + public async react(messageId: MiChatMessage['id'], userId: MiUser['id'], reaction_: string) { + let reaction; + + // TODO: ReactionServiceのやつと共通化 + function normalize(x: string) { + const match = emojiRegex.exec(x); + if (match) { + // 合字を含む1つの絵文字 + const unicode = match[0]; + + // 異体字セレクタ除去 + return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, ''); + } else { + throw new Error('invalid emoji'); + } + } + + const custom = reaction_.match(isCustomEmojiRegexp); + + if (custom == null) { + reaction = normalize(reaction_); + } else { + const name = custom[1]; + const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name); + + if (emoji == null) { + throw new Error('no such emoji'); + } else { + reaction = `:${name}:`; + } + } + + const message = await this.chatMessagesRepository.findOneByOrFail({ id: messageId }); + + if (message.fromUserId === userId) { + throw new Error('cannot react to own message'); + } + + if (message.toRoomId === null && message.toUserId !== userId) { + throw new Error('cannot react to others message'); + } + + if (message.reactions.length >= MAX_REACTIONS_PER_MESSAGE) { + throw new Error('too many reactions'); + } + + const room = message.toRoomId ? await this.chatRoomsRepository.findOneByOrFail({ id: message.toRoomId }) : null; + + if (room) { + if (!await this.isRoomMember(room, userId)) { + throw new Error('cannot react to others message'); + } + } + + await this.chatMessagesRepository.createQueryBuilder().update() + .set({ + reactions: () => `array_append("reactions", '${userId}/${reaction}')`, + }) + .where('id = :id', { id: message.id }) + .execute(); + + if (room) { + this.globalEventService.publishChatRoomStream(room.id, 'react', { + messageId: message.id, + user: await this.userEntityService.pack(userId), + reaction, + }); + } else { + this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId!, 'react', { + messageId: message.id, + reaction, + }); + this.globalEventService.publishChatUserStream(message.toUserId!, message.fromUserId, 'react', { + messageId: message.id, + reaction, + }); + } + } + + @bindThis + public async getMyMemberships(userId: MiUser['id'], limit: number, sinceId?: MiChatRoomMembership['id'] | null, untilId?: MiChatRoomMembership['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatRoomMembershipsRepository.createQueryBuilder('membership'), sinceId, untilId) + .where('membership.userId = :userId', { userId }); + + const memberships = await query.take(limit).getMany(); + + return memberships; + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index dc85a23e5b..d8617e343c 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -44,7 +44,6 @@ import { ModerationLogService } from './ModerationLogService.js'; import { NoteCreateService } from './NoteCreateService.js'; import { NoteDeleteService } from './NoteDeleteService.js'; import { NotePiningService } from './NotePiningService.js'; -import { NoteReadService } from './NoteReadService.js'; import { NotificationService } from './NotificationService.js'; import { PollService } from './PollService.js'; import { PushNotificationService } from './PushNotificationService.js'; @@ -75,6 +74,7 @@ import { ClipService } from './ClipService.js'; import { FeaturedService } from './FeaturedService.js'; import { FanoutTimelineService } from './FanoutTimelineService.js'; import { ChannelFollowingService } from './ChannelFollowingService.js'; +import { ChatService } from './ChatService.js'; import { RegistryApiService } from './RegistryApiService.js'; import { ReversiService } from './ReversiService.js'; @@ -100,6 +100,7 @@ import { AppEntityService } from './entities/AppEntityService.js'; import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js'; import { BlockingEntityService } from './entities/BlockingEntityService.js'; import { ChannelEntityService } from './entities/ChannelEntityService.js'; +import { ChatEntityService } from './entities/ChatEntityService.js'; import { ClipEntityService } from './entities/ClipEntityService.js'; import { DriveFileEntityService } from './entities/DriveFileEntityService.js'; import { DriveFolderEntityService } from './entities/DriveFolderEntityService.js'; @@ -184,7 +185,6 @@ const $ModerationLogService: Provider = { provide: 'ModerationLogService', useEx const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService }; const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService }; const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService }; -const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService }; const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService }; const $PollService: Provider = { provide: 'PollService', useExisting: PollService }; const $SystemAccountService: Provider = { provide: 'SystemAccountService', useExisting: SystemAccountService }; @@ -221,6 +221,7 @@ const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: Fe const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService }; const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService }; const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService }; +const $ChatService: Provider = { provide: 'ChatService', useExisting: ChatService }; const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService }; const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService }; @@ -247,6 +248,7 @@ const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting: const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService }; const $BlockingEntityService: Provider = { provide: 'BlockingEntityService', useExisting: BlockingEntityService }; const $ChannelEntityService: Provider = { provide: 'ChannelEntityService', useExisting: ChannelEntityService }; +const $ChatEntityService: Provider = { provide: 'ChatEntityService', useExisting: ChatEntityService }; const $ClipEntityService: Provider = { provide: 'ClipEntityService', useExisting: ClipEntityService }; const $DriveFileEntityService: Provider = { provide: 'DriveFileEntityService', useExisting: DriveFileEntityService }; const $DriveFolderEntityService: Provider = { provide: 'DriveFolderEntityService', useExisting: DriveFolderEntityService }; @@ -333,7 +335,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting NoteCreateService, NoteDeleteService, NotePiningService, - NoteReadService, NotificationService, PollService, SystemAccountService, @@ -370,6 +371,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting FanoutTimelineService, FanoutTimelineEndpointService, ChannelFollowingService, + ChatService, RegistryApiService, ReversiService, @@ -396,6 +398,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AuthSessionEntityService, BlockingEntityService, ChannelEntityService, + ChatEntityService, ClipEntityService, DriveFileEntityService, DriveFolderEntityService, @@ -478,7 +481,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $NoteCreateService, $NoteDeleteService, $NotePiningService, - $NoteReadService, $NotificationService, $PollService, $SystemAccountService, @@ -515,6 +517,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $FanoutTimelineService, $FanoutTimelineEndpointService, $ChannelFollowingService, + $ChatService, $RegistryApiService, $ReversiService, @@ -541,6 +544,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AuthSessionEntityService, $BlockingEntityService, $ChannelEntityService, + $ChatEntityService, $ClipEntityService, $DriveFileEntityService, $DriveFolderEntityService, @@ -624,7 +628,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting NoteCreateService, NoteDeleteService, NotePiningService, - NoteReadService, NotificationService, PollService, SystemAccountService, @@ -661,6 +664,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting FanoutTimelineService, FanoutTimelineEndpointService, ChannelFollowingService, + ChatService, RegistryApiService, ReversiService, @@ -686,6 +690,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AuthSessionEntityService, BlockingEntityService, ChannelEntityService, + ChatEntityService, ClipEntityService, DriveFileEntityService, DriveFolderEntityService, @@ -768,7 +773,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $NoteCreateService, $NoteDeleteService, $NotePiningService, - $NoteReadService, $NotificationService, $PollService, $SystemAccountService, @@ -804,6 +808,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $FanoutTimelineService, $FanoutTimelineEndpointService, $ChannelFollowingService, + $ChatService, $RegistryApiService, $ReversiService, @@ -829,6 +834,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AuthSessionEntityService, $BlockingEntityService, $ChannelEntityService, + $ChatEntityService, $ClipEntityService, $DriveFileEntityService, $DriveFolderEntityService, diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 224fdabc4c..4da3b8bc78 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -20,7 +20,7 @@ import type { MiPage } from '@/models/Page.js'; import type { MiWebhook } from '@/models/Webhook.js'; import type { MiSystemWebhook } from '@/models/SystemWebhook.js'; import type { MiMeta } from '@/models/Meta.js'; -import { MiAvatarDecoration, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js'; +import { MiAvatarDecoration, MiChatMessage, MiChatRoom, MiReversiGame, 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'; @@ -72,12 +72,8 @@ export interface MainEventTypes { readAllNotifications: undefined; notificationFlushed: undefined; unreadNotification: Packed<'Notification'>; - unreadMention: MiNote['id']; - readAllUnreadMentions: undefined; - unreadSpecifiedNote: MiNote['id']; - readAllUnreadSpecifiedNotes: undefined; - readAllAntennas: undefined; unreadAntenna: MiAntenna; + newChatMessage: Packed<'ChatMessage'>; readAllAnnouncements: undefined; myTokenRegenerated: undefined; signin: { @@ -163,6 +159,16 @@ export interface AdminEventTypes { }; } +export interface ChatEventTypes { + message: Packed<'ChatMessageLite'>; + deleted: Packed<'ChatMessageLite'>['id']; + react: { + reaction: string; + user?: Packed<'UserLite'>; + messageId: MiChatMessage['id']; + }; +} + export interface ReversiEventTypes { matched: { game: Packed<'ReversiGameDetailed'>; @@ -202,7 +208,7 @@ export interface ReversiGameEventTypes { type Events<T extends object> = { [K in keyof T]: { type: K; body: T[K]; } }; type EventUnionFromDictionary< T extends object, - U = Events<T> + U = Events<T>, > = U[keyof U]; type SerializedAll<T> = { @@ -295,6 +301,14 @@ export type GlobalEvents = { name: 'notesStream'; payload: Serialized<Packed<'Note'>>; }; + chat: { + name: `chatUserStream:${MiUser['id']}-${MiUser['id']}`; + payload: EventTypesToEventPayload<ChatEventTypes>; + }; + chatRoom: { + name: `chatRoomStream:${MiChatRoom['id']}`; + payload: EventTypesToEventPayload<ChatEventTypes>; + }; reversi: { name: `reversiStream:${MiUser['id']}`; payload: EventTypesToEventPayload<ReversiEventTypes>; @@ -394,6 +408,16 @@ export class GlobalEventService { } @bindThis + public publishChatUserStream<K extends keyof ChatEventTypes>(fromUserId: MiUser['id'], toUserId: MiUser['id'], type: K, value?: ChatEventTypes[K]): void { + this.publish(`chatUserStream:${fromUserId}-${toUserId}`, type, typeof value === 'undefined' ? null : value); + } + + @bindThis + public publishChatRoomStream<K extends keyof ChatEventTypes>(toRoomId: MiChatRoom['id'], type: K, value?: ChatEventTypes[K]): void { + this.publish(`chatRoomStream:${toRoomId}`, type, typeof value === 'undefined' ? null : value); + } + + @bindThis public publishReversiStream<K extends keyof ReversiEventTypes>(userId: MiUser['id'], type: K, value?: ReversiEventTypes[K]): void { this.publish(`reversiStream:${userId}`, type, typeof value === 'undefined' ? null : value); } diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 8a79908e82..8f416f398c 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -42,7 +42,6 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { bindThis } from '@/decorators.js'; import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; @@ -199,7 +198,6 @@ export class NoteCreateService implements OnApplicationShutdown { private globalEventService: GlobalEventService, private queueService: QueueService, private fanoutTimelineService: FanoutTimelineService, - private noteReadService: NoteReadService, private notificationService: NotificationService, private relayService: RelayService, private federatedInstanceService: FederatedInstanceService, @@ -582,31 +580,6 @@ export class NoteCreateService implements OnApplicationShutdown { if (!silent) { if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); - // 未読通知を作成 - if (data.visibility === 'specified') { - if (data.visibleUsers == null) throw new Error('invalid param'); - - for (const u of data.visibleUsers) { - // ローカルユーザーのみ - if (!this.userEntityService.isLocalUser(u)) continue; - - this.noteReadService.insertNoteUnread(u.id, note, { - isSpecified: true, - isMentioned: false, - }); - } - } else { - for (const u of mentionedUsers) { - // ローカルユーザーのみ - if (!this.userEntityService.isLocalUser(u)) continue; - - this.noteReadService.insertNoteUnread(u.id, note, { - isSpecified: false, - isMentioned: true, - }); - } - } - // Pack the note const noteObj = await this.noteEntityService.pack(note, null, { skipHide: true, withReactionAndUserPairCache: true }); diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts deleted file mode 100644 index 181c9f7649..0000000000 --- a/packages/backend/src/core/NoteReadService.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { setTimeout } from 'node:timers/promises'; -import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import { In } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { MiUser } from '@/models/User.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { MiNote } from '@/models/Note.js'; -import { IdService } from '@/core/IdService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import type { NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository } from '@/models/_.js'; -import { bindThis } from '@/decorators.js'; -import { trackPromise } from '@/misc/promise-tracker.js'; - -@Injectable() -export class NoteReadService implements OnApplicationShutdown { - #shutdownController = new AbortController(); - - constructor( - @Inject(DI.noteUnreadsRepository) - private noteUnreadsRepository: NoteUnreadsRepository, - - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, - - @Inject(DI.noteThreadMutingsRepository) - private noteThreadMutingsRepository: NoteThreadMutingsRepository, - - private idService: IdService, - private globalEventService: GlobalEventService, - ) { - } - - @bindThis - public async insertNoteUnread(userId: MiUser['id'], note: MiNote, params: { - // NOTE: isSpecifiedがtrueならisMentionedは必ずfalse - isSpecified: boolean; - isMentioned: boolean; - }): Promise<void> { - //#region ミュートしているなら無視 - const mute = await this.mutingsRepository.findBy({ - muterId: userId, - }); - if (mute.map(m => m.muteeId).includes(note.userId)) return; - //#endregion - - // スレッドミュート - const isThreadMuted = await this.noteThreadMutingsRepository.exists({ - where: { - userId: userId, - threadId: note.threadId ?? note.id, - }, - }); - if (isThreadMuted) return; - - const unread = { - id: this.idService.gen(), - noteId: note.id, - userId: userId, - isSpecified: params.isSpecified, - isMentioned: params.isMentioned, - noteUserId: note.userId, - }; - - await this.noteUnreadsRepository.insert(unread); - - // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する - setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => { - const exist = await this.noteUnreadsRepository.exists({ where: { id: unread.id } }); - - if (!exist) return; - - if (params.isMentioned) { - this.globalEventService.publishMainStream(userId, 'unreadMention', note.id); - } - if (params.isSpecified) { - this.globalEventService.publishMainStream(userId, 'unreadSpecifiedNote', note.id); - } - }, () => { /* aborted, ignore it */ }); - } - - @bindThis - public async read( - userId: MiUser['id'], - notes: (MiNote | Packed<'Note'>)[], - ): Promise<void> { - if (notes.length === 0) return; - - const noteIds = new Set<MiNote['id']>(); - - for (const note of notes) { - if (note.mentions && note.mentions.includes(userId)) { - noteIds.add(note.id); - } else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) { - noteIds.add(note.id); - } - } - - if (noteIds.size === 0) return; - - // Remove the record - await this.noteUnreadsRepository.delete({ - userId: userId, - noteId: In(Array.from(noteIds)), - }); - - // TODO: ↓まとめてクエリしたい - - trackPromise(this.noteUnreadsRepository.countBy({ - userId: userId, - isMentioned: true, - }).then(mentionsCount => { - if (mentionsCount === 0) { - // 全て既読になったイベントを発行 - this.globalEventService.publishMainStream(userId, 'readAllUnreadMentions'); - } - })); - - trackPromise(this.noteUnreadsRepository.countBy({ - userId: userId, - isSpecified: true, - }).then(specifiedCount => { - if (specifiedCount === 0) { - // 全て既読になったイベントを発行 - this.globalEventService.publishMainStream(userId, 'readAllUnreadSpecifiedNotes'); - } - })); - } - - @bindThis - public dispose(): void { - this.#shutdownController.abort(); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); - } -} diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 01f3e0c116..86f8a5caa1 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -63,6 +63,7 @@ export type RolePolicies = { canImportFollowing: boolean; canImportMuting: boolean; canImportUserLists: boolean; + canChat: boolean; }; export const DEFAULT_POLICIES: RolePolicies = { @@ -97,6 +98,7 @@ export const DEFAULT_POLICIES: RolePolicies = { canImportFollowing: true, canImportMuting: true, canImportUserLists: true, + canChat: true, }; @Injectable() @@ -400,6 +402,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)), canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)), canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)), + canChat: calc('canChat', vs => vs.some(v => v === true)), }; } diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index b98ca97ec9..e7a6be99fb 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; -import { IsNull } from 'typeorm'; +import { Brackets, IsNull } from 'typeorm'; import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { QueueService } from '@/core/QueueService.js'; @@ -736,4 +736,30 @@ export class UserFollowingService implements OnModuleInit { .where('following.followerId = :followerId', { followerId: userId }) .getMany(); } + + @bindThis + public isFollowing(followerId: MiUser['id'], followeeId: MiUser['id']) { + return this.followingsRepository.exists({ + where: { + followerId, + followeeId, + }, + }); + } + + @bindThis + public async isMutual(aUserId: MiUser['id'], bUserId: MiUser['id']) { + const count = await this.followingsRepository.createQueryBuilder('following') + .where(new Brackets(qb => { + qb.where('following.followerId = :aUserId', { aUserId }) + .andWhere('following.followeeId = :bUserId', { bUserId }); + })) + .orWhere(new Brackets(qb => { + qb.where('following.followerId = :bUserId', { bUserId }) + .andWhere('following.followeeId = :aUserId', { aUserId }); + })) + .getCount(); + + return count === 2; + } } diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index e1a7801bb1..a6198f7686 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -53,6 +53,7 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser { requireSigninToViewContents: false, makeNotesFollowersOnlyBefore: null, makeNotesHiddenBefore: null, + chatScope: 'mutual', emojis: [], score: 0, host: null, @@ -461,6 +462,7 @@ export class WebhookTestService { publicReactions: true, followersVisibility: 'public', followingVisibility: 'public', + chatScope: 'mutual', twoFactorEnabled: false, usePasswordLessLogin: false, securityKeys: false, diff --git a/packages/backend/src/core/entities/ChatEntityService.ts b/packages/backend/src/core/entities/ChatEntityService.ts new file mode 100644 index 0000000000..099a9e3ad2 --- /dev/null +++ b/packages/backend/src/core/entities/ChatEntityService.ts @@ -0,0 +1,376 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { MiUser, ChatMessagesRepository, MiChatMessage, ChatRoomsRepository, MiChatRoom, MiChatRoomInvitation, ChatRoomInvitationsRepository, MiChatRoomMembership, ChatRoomMembershipsRepository } from '@/models/_.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/json-schema.js'; +import type { } from '@/models/Blocking.js'; +import { bindThis } from '@/decorators.js'; +import { IdService } from '@/core/IdService.js'; +import { UserEntityService } from './UserEntityService.js'; +import { DriveFileEntityService } from './DriveFileEntityService.js'; +import { In } from 'typeorm'; + +@Injectable() +export class ChatEntityService { + constructor( + @Inject(DI.chatMessagesRepository) + private chatMessagesRepository: ChatMessagesRepository, + + @Inject(DI.chatRoomsRepository) + private chatRoomsRepository: ChatRoomsRepository, + + @Inject(DI.chatRoomInvitationsRepository) + private chatRoomInvitationsRepository: ChatRoomInvitationsRepository, + + @Inject(DI.chatRoomMembershipsRepository) + private chatRoomMembershipsRepository: ChatRoomMembershipsRepository, + + private userEntityService: UserEntityService, + private driveFileEntityService: DriveFileEntityService, + private idService: IdService, + ) { + } + + @bindThis + public async packMessageDetailed( + src: MiChatMessage['id'] | MiChatMessage, + me?: { id: MiUser['id'] }, + options?: { + _hint_?: { + packedFiles?: Map<MiChatMessage['fileId'], Packed<'DriveFile'> | null>; + packedUsers?: Map<MiChatMessage['id'], Packed<'UserLite'>>; + packedRooms?: Map<MiChatMessage['toRoomId'], Packed<'ChatRoom'> | null>; + }; + }, + ): Promise<Packed<'ChatMessage'>> { + const packedUsers = options?._hint_?.packedUsers; + const packedFiles = options?._hint_?.packedFiles; + const packedRooms = options?._hint_?.packedRooms; + + const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src }); + + const reactions: { user: Packed<'UserLite'>; reaction: string; }[] = []; + + for (const record of message.reactions) { + const [userId, reaction] = record.split('/'); + reactions.push({ + user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId), + reaction, + }); + } + + return { + id: message.id, + createdAt: this.idService.parse(message.id).date.toISOString(), + text: message.text, + fromUserId: message.fromUserId, + fromUser: packedUsers?.get(message.fromUserId) ?? await this.userEntityService.pack(message.fromUser ?? message.fromUserId, me), + toUserId: message.toUserId, + toUser: message.toUserId ? (packedUsers?.get(message.toUserId) ?? await this.userEntityService.pack(message.toUser ?? message.toUserId, me)) : undefined, + toRoomId: message.toRoomId, + toRoom: message.toRoomId ? (packedRooms?.get(message.toRoomId) ?? await this.packRoom(message.toRoom ?? message.toRoomId, me)) : undefined, + fileId: message.fileId, + file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null, + reactions, + }; + } + + @bindThis + public async packMessagesDetailed( + messages: MiChatMessage[], + me: { id: MiUser['id'] }, + ) { + if (messages.length === 0) return []; + + const excludeMe = (x: MiUser | string) => { + if (typeof x === 'string') { + return x !== me.id; + } else { + return x.id !== me.id; + } + }; + + const users = [ + ...messages.map((m) => m.fromUser ?? m.fromUserId).filter(excludeMe), + ...messages.map((m) => m.toUser ?? m.toUserId).filter(x => x != null).filter(excludeMe), + ]; + + const reactedUserIds = messages.flatMap(x => x.reactions.map(r => r.split('/')[0])); + + for (const reactedUserId of reactedUserIds) { + if (!users.some(x => typeof x === 'string' ? x === reactedUserId : x.id === reactedUserId)) { + users.push(reactedUserId); + } + } + + const [packedUsers, packedFiles, packedRooms] = await Promise.all([ + this.userEntityService.packMany(users, me) + .then(users => new Map(users.map(u => [u.id, u]))), + this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null)) + .then(files => new Map(files.map(f => [f.id, f]))), + this.packRooms(messages.map(m => m.toRoom ?? m.toRoomId).filter(x => x != null), me) + .then(rooms => new Map(rooms.map(r => [r.id, r]))), + ]); + + return Promise.all(messages.map(message => this.packMessageDetailed(message, me, { _hint_: { packedUsers, packedFiles, packedRooms } }))); + } + + @bindThis + public async packMessageLiteFor1on1( + src: MiChatMessage['id'] | MiChatMessage, + options?: { + _hint_?: { + packedFiles: Map<MiChatMessage['fileId'], Packed<'DriveFile'> | null>; + }; + }, + ): Promise<Packed<'ChatMessageLite'>> { + const packedFiles = options?._hint_?.packedFiles; + + const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src }); + + const reactions: { reaction: string; }[] = []; + + for (const record of message.reactions) { + const [userId, reaction] = record.split('/'); + reactions.push({ + reaction, + }); + } + + return { + id: message.id, + createdAt: this.idService.parse(message.id).date.toISOString(), + text: message.text, + fromUserId: message.fromUserId, + toUserId: message.toUserId, + fileId: message.fileId, + file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null, + reactions, + }; + } + + @bindThis + public async packMessagesLiteFor1on1( + messages: MiChatMessage[], + ) { + if (messages.length === 0) return []; + + const [packedFiles] = await Promise.all([ + this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null)) + .then(files => new Map(files.map(f => [f.id, f]))), + ]); + + return Promise.all(messages.map(message => this.packMessageLiteFor1on1(message, { _hint_: { packedFiles } }))); + } + + @bindThis + public async packMessageLiteForRoom( + src: MiChatMessage['id'] | MiChatMessage, + options?: { + _hint_?: { + packedFiles: Map<MiChatMessage['fileId'], Packed<'DriveFile'> | null>; + packedUsers: Map<MiUser['id'], Packed<'UserLite'>>; + }; + }, + ): Promise<Packed<'ChatMessageLite'>> { + const packedFiles = options?._hint_?.packedFiles; + const packedUsers = options?._hint_?.packedUsers; + + const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src }); + + const reactions: { user: Packed<'UserLite'>; reaction: string; }[] = []; + + for (const record of message.reactions) { + const [userId, reaction] = record.split('/'); + reactions.push({ + user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId), + reaction, + }); + } + + return { + id: message.id, + createdAt: this.idService.parse(message.id).date.toISOString(), + text: message.text, + fromUserId: message.fromUserId, + fromUser: packedUsers?.get(message.fromUserId) ?? await this.userEntityService.pack(message.fromUser ?? message.fromUserId), + toRoomId: message.toRoomId, + fileId: message.fileId, + file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null, + reactions, + }; + } + + @bindThis + public async packMessagesLiteForRoom( + messages: MiChatMessage[], + ) { + if (messages.length === 0) return []; + + const users = messages.map(x => x.fromUser ?? x.fromUserId); + const reactedUserIds = messages.flatMap(x => x.reactions.map(r => r.split('/')[0])); + + for (const reactedUserId of reactedUserIds) { + if (!users.some(x => typeof x === 'string' ? x === reactedUserId : x.id === reactedUserId)) { + users.push(reactedUserId); + } + } + + const [packedUsers, packedFiles] = await Promise.all([ + this.userEntityService.packMany(users) + .then(users => new Map(users.map(u => [u.id, u]))), + this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null)) + .then(files => new Map(files.map(f => [f.id, f]))), + ]); + + return Promise.all(messages.map(message => this.packMessageLiteForRoom(message, { _hint_: { packedFiles, packedUsers } }))); + } + + @bindThis + public async packRoom( + src: MiChatRoom['id'] | MiChatRoom, + me?: { id: MiUser['id'] }, + options?: { + _hint_?: { + packedOwners: Map<MiChatRoom['id'], Packed<'UserLite'>>; + memberships?: Map<MiChatRoom['id'], MiChatRoomMembership | null | undefined>; + }; + }, + ): Promise<Packed<'ChatRoom'>> { + const room = typeof src === 'object' ? src : await this.chatRoomsRepository.findOneByOrFail({ id: src }); + + const membership = me && me.id !== room.ownerId ? (options?._hint_?.memberships?.get(room.id) ?? await this.chatRoomMembershipsRepository.findOneBy({ roomId: room.id, userId: me.id })) : null; + + return { + id: room.id, + createdAt: this.idService.parse(room.id).date.toISOString(), + name: room.name, + description: room.description, + ownerId: room.ownerId, + owner: options?._hint_?.packedOwners.get(room.ownerId) ?? await this.userEntityService.pack(room.owner ?? room.ownerId, me), + isMuted: membership != null ? membership.isMuted : false, + }; + } + + @bindThis + public async packRooms( + rooms: (MiChatRoom | MiChatRoom['id'])[], + me: { id: MiUser['id'] }, + ) { + if (rooms.length === 0) return []; + + const _rooms = rooms.filter((room): room is MiChatRoom => typeof room !== 'string'); + if (_rooms.length !== rooms.length) { + _rooms.push( + ...await this.chatRoomsRepository.find({ + where: { + id: In(rooms.filter((room): room is string => typeof room === 'string')), + }, + relations: ['owner'], + }), + ); + } + + const owners = _rooms.map(x => x.owner ?? x.ownerId); + + const [packedOwners, memberships] = await Promise.all([ + this.userEntityService.packMany(owners, me) + .then(users => new Map(users.map(u => [u.id, u]))), + this.chatRoomMembershipsRepository.find({ + where: { + roomId: In(_rooms.map(x => x.id)), + userId: me.id, + }, + }).then(memberships => new Map(_rooms.map(r => [r.id, memberships.find(m => m.roomId === r.id)]))), + ]); + + return Promise.all(_rooms.map(room => this.packRoom(room, me, { _hint_: { packedOwners, memberships } }))); + } + + @bindThis + public async packRoomInvitation( + src: MiChatRoomInvitation['id'] | MiChatRoomInvitation, + me: { id: MiUser['id'] }, + options?: { + _hint_?: { + packedRooms: Map<MiChatRoomInvitation['roomId'], Packed<'ChatRoom'>>; + packedUsers: Map<MiChatRoomInvitation['id'], Packed<'UserLite'>>; + }; + }, + ): Promise<Packed<'ChatRoomInvitation'>> { + const invitation = typeof src === 'object' ? src : await this.chatRoomInvitationsRepository.findOneByOrFail({ id: src }); + + return { + id: invitation.id, + createdAt: this.idService.parse(invitation.id).date.toISOString(), + roomId: invitation.roomId, + room: options?._hint_?.packedRooms.get(invitation.roomId) ?? await this.packRoom(invitation.room ?? invitation.roomId, me), + userId: invitation.userId, + user: options?._hint_?.packedUsers.get(invitation.userId) ?? await this.userEntityService.pack(invitation.user ?? invitation.userId, me), + }; + } + + @bindThis + public async packRoomInvitations( + invitations: MiChatRoomInvitation[], + me: { id: MiUser['id'] }, + ) { + if (invitations.length === 0) return []; + + return Promise.all(invitations.map(invitation => this.packRoomInvitation(invitation, me))); + } + + @bindThis + public async packRoomMembership( + src: MiChatRoomMembership['id'] | MiChatRoomMembership, + me: { id: MiUser['id'] }, + options?: { + populateUser?: boolean; + populateRoom?: boolean; + _hint_?: { + packedRooms: Map<MiChatRoomMembership['roomId'], Packed<'ChatRoom'>>; + packedUsers: Map<MiChatRoomMembership['id'], Packed<'UserLite'>>; + }; + }, + ): Promise<Packed<'ChatRoomMembership'>> { + const membership = typeof src === 'object' ? src : await this.chatRoomMembershipsRepository.findOneByOrFail({ id: src }); + + return { + id: membership.id, + createdAt: this.idService.parse(membership.id).date.toISOString(), + userId: membership.userId, + user: options?.populateUser ? (options._hint_?.packedUsers.get(membership.userId) ?? await this.userEntityService.pack(membership.user ?? membership.userId, me)) : undefined, + roomId: membership.roomId, + room: options?.populateRoom ? (options._hint_?.packedRooms.get(membership.roomId) ?? await this.packRoom(membership.room ?? membership.roomId, me)) : undefined, + }; + } + + @bindThis + public async packRoomMemberships( + memberships: MiChatRoomMembership[], + me: { id: MiUser['id'] }, + options: { + populateUser?: boolean; + populateRoom?: boolean; + } = {}, + ) { + if (memberships.length === 0) return []; + + const users = memberships.map(x => x.user ?? x.userId); + const rooms = memberships.map(x => x.room ?? x.roomId); + + const [packedUsers, packedRooms] = await Promise.all([ + this.userEntityService.packMany(users, me) + .then(users => new Map(users.map(u => [u.id, u]))), + this.packRooms(rooms, me) + .then(rooms => new Map(rooms.map(r => [r.id, r]))), + ]); + + return Promise.all(memberships.map(membership => this.packRoomMembership(membership, me, { ...options, _hint_: { packedUsers, packedRooms } }))); + } +} diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 69f698d9cb..92a684dc1a 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -32,7 +32,6 @@ import type { MiUserNotePining, MiUserProfile, MutingsRepository, - NoteUnreadsRepository, RenoteMutingsRepository, UserMemoRepository, UserNotePiningsRepository, @@ -48,9 +47,9 @@ import { IdService } from '@/core/IdService.js'; import type { AnnouncementService } from '@/core/AnnouncementService.js'; import type { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; +import { ChatService } from '@/core/ChatService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { NoteEntityService } from './NoteEntityService.js'; -import type { DriveFileEntityService } from './DriveFileEntityService.js'; import type { PageEntityService } from './PageEntityService.js'; const Ajv = _Ajv.default; @@ -94,6 +93,7 @@ export class UserEntityService implements OnModuleInit { private federatedInstanceService: FederatedInstanceService; private idService: IdService; private avatarDecorationService: AvatarDecorationService; + private chatService: ChatService; constructor( private moduleRef: ModuleRef, @@ -128,9 +128,6 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.renoteMutingsRepository) private renoteMutingsRepository: RenoteMutingsRepository, - @Inject(DI.noteUnreadsRepository) - private noteUnreadsRepository: NoteUnreadsRepository, - @Inject(DI.userNotePiningsRepository) private userNotePiningsRepository: UserNotePiningsRepository, @@ -152,6 +149,7 @@ export class UserEntityService implements OnModuleInit { this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); this.idService = this.moduleRef.get('IdService'); this.avatarDecorationService = this.moduleRef.get('AvatarDecorationService'); + this.chatService = this.moduleRef.get('ChatService'); } //#region Validators @@ -558,6 +556,7 @@ export class UserEntityService implements OnModuleInit { publicReactions: this.isLocalUser(user) ? profile!.publicReactions : false, // https://github.com/misskey-dev/misskey/issues/12964 followersVisibility: profile!.followersVisibility, followingVisibility: profile!.followingVisibility, + chatScope: user.chatScope, roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ id: role.id, name: role.name, @@ -598,14 +597,9 @@ export class UserEntityService implements OnModuleInit { isDeleted: user.isDeleted, twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none', hideOnlineStatus: user.hideOnlineStatus, - hasUnreadSpecifiedNotes: this.noteUnreadsRepository.count({ - where: { userId: user.id, isSpecified: true }, - take: 1, - }).then(count => count > 0), - hasUnreadMentions: this.noteUnreadsRepository.count({ - where: { userId: user.id, isMentioned: true }, - take: 1, - }).then(count => count > 0), + hasUnreadSpecifiedNotes: false, // 後方互換性のため + hasUnreadMentions: false, // 後方互換性のため + hasUnreadChatMessages: this.chatService.hasUnreadMessages(user.id), hasUnreadAnnouncement: unreadAnnouncements!.length > 0, unreadAnnouncements, hasUnreadAntenna: this.getHasUnreadAntenna(user.id), |