misskey/packages/backend/src/core/ChatService.ts

487 lines
16 KiB
TypeScript

/*
* 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, 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';
@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,
) {
}
@bindThis
public async createMessageToUser(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toUser: MiUser, params: {
text?: string | null;
file?: MiDriveFile | null;
uri?: string | null;
}) {
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.packMessageLite(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;
}) {
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) {
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.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'], sinceId: MiChatMessage['id'] | null, untilId: MiChatMessage['id'] | null, limit: number) {
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 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();
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();
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, name: string) {
const room = {
id: this.idService.gen(),
name: name,
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 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 });
// 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;
}
}