This commit is contained in:
syuilo 2025-03-23 14:44:49 +09:00
parent ba026cfcd5
commit 9e23531adc
34 changed files with 726 additions and 318 deletions

View File

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class Chat41742707840715 {
name = 'Chat41742707840715'
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "chat_room_invitation" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "roomId" character varying(32) NOT NULL, CONSTRAINT "PK_9d489521a312dd28225672de2dc" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_8552bb38e7ed038c5bdd398a38" ON "chat_room_invitation" ("userId") `);
await queryRunner.query(`CREATE INDEX "IDX_5f265075b215fc390a57523b12" ON "chat_room_invitation" ("roomId") `);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_044f2a7962b8ee5bbfaa02e8a3" ON "chat_room_invitation" ("userId", "roomId") `);
await queryRunner.query(`ALTER TABLE "chat_room_invitation" ADD CONSTRAINT "FK_8552bb38e7ed038c5bdd398a384" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "chat_room_invitation" ADD CONSTRAINT "FK_5f265075b215fc390a57523b12a" FOREIGN KEY ("roomId") REFERENCES "chat_room"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "chat_room_invitation" DROP CONSTRAINT "FK_5f265075b215fc390a57523b12a"`);
await queryRunner.query(`ALTER TABLE "chat_room_invitation" DROP CONSTRAINT "FK_8552bb38e7ed038c5bdd398a384"`);
await queryRunner.query(`DROP INDEX "public"."IDX_044f2a7962b8ee5bbfaa02e8a3"`);
await queryRunner.query(`DROP INDEX "public"."IDX_5f265075b215fc390a57523b12"`);
await queryRunner.query(`DROP INDEX "public"."IDX_8552bb38e7ed038c5bdd398a38"`);
await queryRunner.query(`DROP TABLE "chat_room_invitation"`);
}
}

View File

@ -12,15 +12,16 @@ 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 { ChatMessageEntityService } from '@/core/entities/ChatMessageEntityService.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, ChatRoomMembershipsRepository, ChatRoomsRepository, MiChatMessage, MiChatRoom, MiDriveFile, MiUser, MutingsRepository, UsersRepository } from '@/models/_.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 {
@ -43,6 +44,9 @@ export class ChatService {
@Inject(DI.chatRoomsRepository)
private chatRoomsRepository: ChatRoomsRepository,
@Inject(DI.chatRoomInvitationsRepository)
private chatRoomInvitationsRepository: ChatRoomInvitationsRepository,
@Inject(DI.chatRoomMembershipsRepository)
private chatRoomMembershipsRepository: ChatRoomMembershipsRepository,
@ -50,7 +54,7 @@ export class ChatService {
private mutingsRepository: MutingsRepository,
private userEntityService: UserEntityService,
private chatMessageEntityService: ChatMessageEntityService,
private chatEntityService: ChatEntityService,
private idService: IdService,
private globalEventService: GlobalEventService,
private apRendererService: ApRendererService,
@ -64,7 +68,7 @@ export class ChatService {
}
@bindThis
public async createMessage(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toUser: MiUser, params: {
public async createMessageToUser(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toUser: MiUser, params: {
text?: string | null;
file?: MiDriveFile | null;
uri?: string | null;
@ -139,61 +143,38 @@ export class ChatService {
});
}
const packedMessage = await this.chatMessageEntityService.packLite(inserted);
const packedMessage = await this.chatEntityService.packMessageLite(inserted);
if (this.userEntityService.isLocalUser(toUser)) {
const redisPipeline = this.redisClient.pipeline();
redisPipeline.set(`newChatMessageExists:${toUser.id}:${fromUser.id}`, message.id);
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.publishChatStream(fromUser.id, toUser.id, 'message', packedMessage);
this.globalEventService.publishChatUserStream(fromUser.id, toUser.id, 'message', packedMessage);
}
if (this.userEntityService.isLocalUser(toUser)) {
// 相手のストリーム
this.globalEventService.publishChatStream(toUser.id, fromUser.id, 'message', packedMessage);
this.globalEventService.publishChatUserStream(toUser.id, fromUser.id, 'message', packedMessage);
}
// 3秒経っても既読にならなかったらイベント発行
if (this.userEntityService.isLocalUser(toUser)) {
setTimeout(async () => {
const marker = await this.redisClient.get(`newChatMessageExists:${toUser.id}:${fromUser.id}`);
const marker = await this.redisClient.get(`newUserChatMessageExists:${toUser.id}:${fromUser.id}`);
if (marker == null) return; // 既読
const packedMessageForTo = await this.chatMessageEntityService.pack(inserted, toUser);
const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted, toUser);
this.globalEventService.publishMainStream(toUser.id, 'newChatMessage', packedMessageForTo);
this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo);
}, 3000);
}
/* TODO: AP
if (toUser && this.userEntityService.isLocalUser(fromUser) && this.userEntityService.isRemoteUser(toUser)) {
const note = {
id: message.id,
createdAt: message.createdAt,
fileIds: message.fileId ? [message.fileId] : [],
text: message.text,
userId: message.userId,
visibility: 'specified',
mentions: [toUser].map(u => u.id),
mentionedRemoteUsers: JSON.stringify([toUser].map(u => ({
uri: u.uri,
username: u.username,
host: u.host,
}))),
} as MiNote;
const activity = this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false, true), note));
this.queueService.deliver(fromUser, activity, toUser.inbox);
}
*/
return packedMessage;
}
@ -203,6 +184,12 @@ export class ChatService {
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,
@ -215,30 +202,36 @@ export class ChatService {
const inserted = await this.chatMessagesRepository.insertOne(message);
const packedMessage = await this.chatMessageEntityService.packLite(inserted);
const packedMessage = await this.chatEntityService.packMessageLiteForRoom(inserted);
/*
// グループのストリーム
this.globalEventService.publishRoomChatStream(toRoom.id, 'message', messageObj);
this.globalEventService.publishChatRoomStream(toRoom.id, 'message', packedMessage);
// メンバーのストリーム
const joinings = await this.userRoomJoiningsRepository.findBy({ userRoomId: toRoom.id });
for (const joining of joinings) {
this.globalEventService.publishChatIndexStream(joining.userId, 'message', messageObj);
this.globalEventService.publishMainStream(joining.userId, 'chatMessage', messageObj);
}
*/
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 joinings = await this.userRoomJoiningsRepository.findBy({ userRoomId: toRoom.id, userId: Not(fromUser.id) });
for (const joining of joinings) {
if (freshMessage.reads.includes(joining.userId)) return; // 既読
this.globalEventService.publishMainStream(joining.userId, 'newChatMessage', messageObj);
this.pushNotificationService.pushNotification(joining.userId, 'newChatMessage', messageObj);
}
*/
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;
@ -250,11 +243,22 @@ export class ChatService {
senderId: MiUser['id'],
): Promise<void> {
const redisPipeline = this.redisClient.pipeline();
redisPipeline.del(`newChatMessageExists:${readerId}:${senderId}`);
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 });
@ -275,84 +279,17 @@ export class ChatService {
this.usersRepository.findOneByOrFail({ id: message.toUserId }),
]);
if (this.userEntityService.isLocalUser(fromUser)) this.globalEventService.publishChatStream(message.fromUserId, message.toUserId, 'deleted', message.id);
if (this.userEntityService.isLocalUser(toUser)) this.globalEventService.publishChatStream(message.toUserId, message.fromUserId, 'deleted', message.id);
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.publishRoomChatStream(message.toRoomId, 'deleted', message.id);
}*/
}
/*
@bindThis
public async readRoomChatMessage(
userId: MiUser['id'],
roomId: MiUserRoom['id'],
messageIds: MiChatMessage['id'][],
) {
if (messageIds.length === 0) return;
// check joined
const joining = await this.userRoomJoiningsRepository.findOneBy({
userId: userId,
userRoomId: roomId,
});
if (joining == null) {
throw new IdentifiableError('930a270c-714a-46b2-b776-ad27276dc569', 'Access denied (room).');
}
const messages = await this.chatMessagesRepository.findBy({
id: In(messageIds),
});
const reads: ChatMessage['id'][] = [];
for (const message of messages) {
if (message.userId === userId) continue;
if (message.reads.includes(userId)) continue;
// Update document
await this.chatMessagesRepository.createQueryBuilder().update()
.set({
reads: (() => `array_append("reads", '${joining.userId}')`) as any,
})
.where('id = :id', { id: message.id })
.execute();
reads.push(message.id);
}
// Publish event
this.globalEventService.publishRoomChatStream(roomId, 'read', {
ids: reads,
userId: userId,
});
this.globalEventService.publishChatIndexStream(userId, 'read', reads);
if (!await this.userEntityService.getHasUnreadChatMessage(userId)) {
// 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行
this.globalEventService.publishMainStream(userId, 'readAllChatMessages');
this.pushNotificationService.pushNotification(userId, 'readAllChatMessages', undefined);
} else {
// そのグループにおいて未読がなければイベント発行
const unreadExist = await this.chatMessagesRepository.createQueryBuilder('message')
.where('message.toRoomId = :roomId', { roomId: roomId })
.andWhere('message.userId != :userId', { userId: userId })
.andWhere('NOT (:userId = ANY(message.reads))', { userId: userId })
.andWhere('message.createdAt > :joinedAt', { joinedAt: joining.createdAt }) // 自分が加入する前の会話については、未読扱いしない
.getOne().then(x => x != null);
if (!unreadExist) {
this.pushNotificationService.pushNotification(userId, 'readAllChatMessagesOfARoom', { roomId });
}
} 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) {
@ -421,24 +358,30 @@ export class ChatService {
@bindThis
public async roomHistory(meId: MiUser['id'], limit: number): Promise<MiChatMessage[]> {
return [];
/*
const rooms = await this.userRoomJoiningsRepository.findBy({
userId: meId,
}).then(xs => xs.map(x => x.userRoomId));
// 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)),
]);
if (rooms.length === 0) {
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.roomId!);
const found = history.map(m => m.toRoomId!);
const query = this.chatMessagesRepository.createQueryBuilder('message')
.orderBy('message.id', 'DESC')
.where('message.toRoomId IN (:...rooms)', { rooms: rooms });
.where('message.toRoomId IN (:...roomIds)', { roomIds });
if (found.length > 0) {
query.andWhere('message.toRoomId NOT IN (:...found)', { found: found });
@ -454,7 +397,6 @@ export class ChatService {
}
return history;
*/
}
@bindThis
@ -464,7 +406,7 @@ export class ChatService {
const redisPipeline = this.redisClient.pipeline();
for (const otherId of otherIds) {
redisPipeline.get(`newChatMessageExists:${userId}:${otherId}`);
redisPipeline.get(`newUserChatMessageExists:${userId}:${otherId}`);
}
const markers = await redisPipeline.exec();
@ -477,9 +419,68 @@ export class ChatService {
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;
}
}

View File

@ -100,7 +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 { ChatMessageEntityService } from './entities/ChatMessageEntityService.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';
@ -248,7 +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 $ChatMessageEntityService: Provider = { provide: 'ChatMessageEntityService', useExisting: ChatMessageEntityService };
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 };
@ -398,7 +398,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AuthSessionEntityService,
BlockingEntityService,
ChannelEntityService,
ChatMessageEntityService,
ChatEntityService,
ClipEntityService,
DriveFileEntityService,
DriveFolderEntityService,
@ -544,7 +544,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AuthSessionEntityService,
$BlockingEntityService,
$ChannelEntityService,
$ChatMessageEntityService,
$ChatEntityService,
$ClipEntityService,
$DriveFileEntityService,
$DriveFolderEntityService,
@ -690,7 +690,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AuthSessionEntityService,
BlockingEntityService,
ChannelEntityService,
ChatMessageEntityService,
ChatEntityService,
ClipEntityService,
DriveFileEntityService,
DriveFolderEntityService,
@ -834,7 +834,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AuthSessionEntityService,
$BlockingEntityService,
$ChannelEntityService,
$ChatMessageEntityService,
$ChatEntityService,
$ClipEntityService,
$DriveFileEntityService,
$DriveFolderEntityService,

View File

@ -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, 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';
@ -297,7 +297,11 @@ export type GlobalEvents = {
payload: Serialized<Packed<'Note'>>;
};
chat: {
name: `chatStream:${MiUser['id']}-${MiUser['id']}`;
name: `chatUserStream:${MiUser['id']}-${MiUser['id']}`;
payload: EventTypesToEventPayload<ChatEventTypes>;
};
chatRoom: {
name: `chatRoomStream:${MiChatRoom['id']}`;
payload: EventTypesToEventPayload<ChatEventTypes>;
};
reversi: {
@ -399,8 +403,13 @@ export class GlobalEventService {
}
@bindThis
public publishChatStream<K extends keyof ChatEventTypes>(fromUserId: MiUser['id'], toUserId: MiUser['id'], type: K, value?: ChatEventTypes[K]): void {
this.publish(`chatStream:${fromUserId}-${toUserId}`, type, typeof value === 'undefined' ? null : value);
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

View File

@ -0,0 +1,209 @@
/*
* 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 } 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';
@Injectable()
export class ChatEntityService {
constructor(
@Inject(DI.chatMessagesRepository)
private chatMessagesRepository: ChatMessagesRepository,
@Inject(DI.chatRoomsRepository)
private chatRoomsRepository: ChatRoomsRepository,
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 });
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.file ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file)) : null,
};
}
@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 [packedUsers, packedFiles] = 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)),
]);
return Promise.all(messages.map(message => this.packMessageDetailed(message, me, { _hint_: { packedUsers, packedFiles } })));
}
@bindThis
public async packMessageLite(
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 });
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.file ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file)) : null,
};
}
@bindThis
public async packMessagesLite(
messages: MiChatMessage[],
) {
if (messages.length === 0) return [];
const [packedFiles] = await Promise.all([
this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null)),
]);
return Promise.all(messages.map(message => this.packMessageLite(message, { _hint_: { packedFiles } })));
}
@bindThis
public async packMessageLiteForRoom(
src: MiChatMessage['id'] | MiChatMessage,
options?: {
_hint_?: {
packedFiles: Map<MiChatMessage['fileId'], Packed<'DriveFile'> | null>;
packedUsers: Map<MiChatMessage['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 });
return {
id: message.id,
createdAt: this.idService.parse(message.id).date.toISOString(),
text: message.text,
fromUserId: message.fromUserId,
toUserId: message.toUserId,
toUser: packedUsers?.get(message.toUserId) ?? await this.userEntityService.pack(message.toUser ?? message.toUserId),
toRoomId: message.toRoomId,
fileId: message.fileId,
file: message.file ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file)) : null,
};
}
@bindThis
public async packMessagesLiteForRoom(
messages: MiChatMessage[],
) {
if (messages.length === 0) return [];
const [packedUsers, packedFiles] = await Promise.all([
this.userEntityService.packMany(messages.map(x => x.fromUser))
.then(users => new Map(users.map(u => [u.id, u]))),
this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null)),
]);
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'>>;
};
},
): Promise<Packed<'ChatRoom'>> {
const room = typeof src === 'object' ? src : await this.chatRoomsRepository.findOneByOrFail({ id: src });
return {
id: room.id,
createdAt: this.idService.parse(room.id).date.toISOString(),
name: room.name,
ownerId: room.ownerId,
owner: options?._hint_?.packedOwners.get(room.ownerId) ?? await this.userEntityService.pack(room.owner ?? room.ownerId, me),
};
}
@bindThis
public async packRooms(
rooms: MiChatRoom[],
me: { id: MiUser['id'] },
) {
if (rooms.length === 0) return [];
const owners = rooms.map(x => x.owner ?? x.ownerId);
const packedOwners = await this.userEntityService.packMany(owners, me)
.then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(rooms.map(room => this.packRoom(room, me, { _hint_: { packedOwners } })));
}
}

View File

@ -1,127 +0,0 @@
/*
* 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 } 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';
@Injectable()
export class ChatMessageEntityService {
constructor(
@Inject(DI.chatMessagesRepository)
private chatMessagesRepository: ChatMessagesRepository,
private userEntityService: UserEntityService,
private driveFileEntityService: DriveFileEntityService,
private idService: IdService,
) {
}
@bindThis
public async pack(
src: MiChatMessage['id'] | MiChatMessage,
me: { id: MiUser['id'] },
options?: {
_hint_?: {
packedFiles: Map<MiChatMessage['fileId'], Packed<'DriveFile'> | null>;
packedUsers: Map<MiChatMessage['id'], Packed<'UserLite'>>
};
},
): Promise<Packed<'ChatMessage'>> {
const packedUsers = options?._hint_?.packedUsers;
const packedFiles = options?._hint_?.packedFiles;
const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src });
return {
id: message.id,
createdAt: this.idService.parse(message.id).date.toISOString(),
text: message.text,
fromUserId: message.fromUserId,
fromUser: message.fromUserId !== me.id ? (packedUsers?.get(message.fromUserId) ?? await this.userEntityService.pack(message.fromUser ?? message.fromUserId, me)) : undefined,
toUserId: message.toUserId,
toUser: (message.toUserId && message.toUserId !== me.id) ? (packedUsers?.get(message.toUserId) ?? await this.userEntityService.pack(message.toUser ?? message.toUserId, me)) : undefined,
//toRoomId: message.toRoomId,
//toRoom: message.toRoomId && opts.populateRoom ? await this.userRoomEntityService.pack(message.toRoom ?? message.toRoomId) : undefined,
fileId: message.fileId,
file: message.file ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file)) : null,
};
}
@bindThis
public async packMany(
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 [packedUsers, packedFiles] = 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)),
]);
return Promise.all(messages.map(message => this.pack(message, me, { _hint_: { packedUsers, packedFiles } })));
}
@bindThis
public async packLite(
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 });
return {
id: message.id,
createdAt: this.idService.parse(message.id).date.toISOString(),
text: message.text,
fromUserId: message.fromUserId,
toUserId: message.toUserId,
//toRoomId: message.toRoomId,
fileId: message.fileId,
file: message.file ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file)) : null,
};
}
@bindThis
public async packLiteMany(
messages: MiChatMessage[],
) {
if (messages.length === 0) return [];
const [packedFiles] = await Promise.all([
this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null)),
]);
return Promise.all(messages.map(message => this.packLite(message, { _hint_: { packedFiles } })));
}
}

View File

@ -86,6 +86,7 @@ export const DI = {
chatApprovalsRepository: Symbol('chatApprovalsRepository'),
chatRoomsRepository: Symbol('chatRoomsRepository'),
chatRoomMembershipsRepository: Symbol('chatRoomMembershipsRepository'),
chatRoomInvitationsRepository: Symbol('chatRoomInvitationsRepository'),
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
reversiGamesRepository: Symbol('reversiGamesRepository'),
//#endregion

View File

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { id } from './util/id.js';
import { MiUser } from './User.js';
import { MiChatRoom } from './ChatRoom.js';
@Entity('chat_room_invitation')
@Index(['userId', 'roomId'], { unique: true })
export class MiChatRoomInvitation {
@PrimaryColumn(id())
public id: string;
@Index()
@Column({
...id(),
})
public userId: MiUser['id'];
@ManyToOne(type => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user: MiUser | null;
@Index()
@Column({
...id(),
})
public roomId: MiChatRoom['id'];
@ManyToOne(type => MiChatRoom, {
onDelete: 'CASCADE',
})
@JoinColumn()
public room: MiChatRoom | null;
}

View File

@ -80,6 +80,7 @@ import {
MiChatMessage,
MiChatRoom,
MiChatRoomMembership,
MiChatRoomInvitation,
MiChatApproval,
} from './_.js';
import type { Provider } from '@nestjs/common';
@ -505,6 +506,12 @@ const $chatRoomMembershipsRepository: Provider = {
inject: [DI.db],
};
const $chatRoomInvitationsRepository: Provider = {
provide: DI.chatRoomInvitationsRepository,
useFactory: (db: DataSource) => db.getRepository(MiChatRoomInvitation).extend(miRepository as MiRepository<MiChatRoomInvitation>),
inject: [DI.db],
};
const $chatApprovalsRepository: Provider = {
provide: DI.chatApprovalsRepository,
useFactory: (db: DataSource) => db.getRepository(MiChatApproval).extend(miRepository as MiRepository<MiChatApproval>),
@ -596,6 +603,7 @@ const $reversiGamesRepository: Provider = {
$chatMessagesRepository,
$chatRoomsRepository,
$chatRoomMembershipsRepository,
$chatRoomInvitationsRepository,
$chatApprovalsRepository,
$bubbleGameRecordsRepository,
$reversiGamesRepository,
@ -671,6 +679,7 @@ const $reversiGamesRepository: Provider = {
$chatMessagesRepository,
$chatRoomsRepository,
$chatRoomMembershipsRepository,
$chatRoomInvitationsRepository,
$chatApprovalsRepository,
$bubbleGameRecordsRepository,
$reversiGamesRepository,

View File

@ -77,6 +77,7 @@ import { MiUserListFavorite } from '@/models/UserListFavorite.js';
import { MiChatMessage } from '@/models/ChatMessage.js';
import { MiChatRoom } from '@/models/ChatRoom.js';
import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js';
import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js';
import { MiChatApproval } from '@/models/ChatApproval.js';
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiReversiGame } from '@/models/ReversiGame.js';
@ -196,6 +197,7 @@ export {
MiChatMessage,
MiChatRoom,
MiChatRoomMembership,
MiChatRoomInvitation,
MiChatApproval,
MiBubbleGameRecord,
MiReversiGame,
@ -271,6 +273,7 @@ export type UserMemoRepository = Repository<MiUserMemo> & MiRepository<MiUserMem
export type ChatMessagesRepository = Repository<MiChatMessage> & MiRepository<MiChatMessage>;
export type ChatRoomsRepository = Repository<MiChatRoom> & MiRepository<MiChatRoom>;
export type ChatRoomMembershipsRepository = Repository<MiChatRoomMembership> & MiRepository<MiChatRoomMembership>;
export type ChatRoomInvitationsRepository = Repository<MiChatRoomInvitation> & MiRepository<MiChatRoomInvitation>;
export type ChatApprovalsRepository = Repository<MiChatApproval> & MiRepository<MiChatApproval>;
export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord> & MiRepository<MiBubbleGameRecord>;
export type ReversiGamesRepository = Repository<MiReversiGame> & MiRepository<MiReversiGame>;

View File

@ -81,6 +81,7 @@ import { MiUserMemo } from '@/models/UserMemo.js';
import { MiChatMessage } from '@/models/ChatMessage.js';
import { MiChatRoom } from '@/models/ChatRoom.js';
import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js';
import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js';
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiReversiGame } from '@/models/ReversiGame.js';
import { MiChatApproval } from '@/models/ChatApproval.js';
@ -240,6 +241,7 @@ export const entities = [
MiChatMessage,
MiChatRoom,
MiChatRoomMembership,
MiChatRoomInvitation,
MiChatApproval,
MiBubbleGameRecord,
MiReversiGame,

View File

@ -44,7 +44,8 @@ import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js';
import { ServerStatsChannelService } from './api/stream/channels/server-stats.js';
import { UserListChannelService } from './api/stream/channels/user-list.js';
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
import { ChatChannelService } from './api/stream/channels/chat.js';
import { ChatUserChannelService } from './api/stream/channels/chat-user.js';
import { ChatRoomChannelService } from './api/stream/channels/chat-room.js';
import { ReversiChannelService } from './api/stream/channels/reversi.js';
import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js';
@ -85,7 +86,8 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
GlobalTimelineChannelService,
HashtagChannelService,
RoleTimelineChannelService,
ChatChannelService,
ChatUserChannelService,
ChatRoomChannelService,
ReversiChannelService,
ReversiGameChannelService,
HomeTimelineChannelService,

View File

@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ChatService } from '@/core/ChatService.js';
import { ChatMessageEntityService } from '@/core/entities/ChatMessageEntityService.js';
import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
@ -42,15 +42,21 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private chatMessageEntityService: ChatMessageEntityService,
private chatEntityService: ChatEntityService,
private chatService: ChatService,
) {
super(meta, paramDef, async (ps, me) => {
const history = ps.room ? await this.chatService.roomHistory(me.id, ps.limit) : await this.chatService.userHistory(me.id, ps.limit);
const packedMessages = await this.chatMessageEntityService.packMany(history, me);
const packedMessages = await this.chatEntityService.packMessagesDetailed(history, me);
if (ps.room) {
const roomIds = history.map(m => m.toRoomId!);
const readStateMap = await this.chatService.getRoomReadStateMap(me.id, roomIds);
for (const message of packedMessages) {
message.isRead = readStateMap[message.toRoomId!] ?? false;
}
} else {
const otherIds = history.map(m => m.fromUserId === me.id ? m.toUserId! : m.fromUserId!);
const readStateMap = await this.chatService.getUserReadStateMap(me.id, otherIds);

View File

@ -125,7 +125,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw err;
});
return await this.chatService.createMessage(me, toUser, {
return await this.chatService.createMessageToUser(me, toUser, {
text: ps.text,
file: file,
});

View File

@ -8,7 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ChatService } from '@/core/ChatService.js';
import { ChatMessageEntityService } from '@/core/entities/ChatMessageEntityService.js';
import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {

View File

@ -8,7 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ChatService } from '@/core/ChatService.js';
import { ChatMessageEntityService } from '@/core/entities/ChatMessageEntityService.js';
import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
import { ApiError } from '@/server/api/error.js';
import { RoleService } from '@/core/RoleService.js';
@ -47,7 +47,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor(
private chatService: ChatService,
private roleService: RoleService,
private chatMessageEntityService: ChatMessageEntityService,
private chatEntityService: ChatEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const message = await this.chatService.findMessageById(ps.messageId);
@ -57,7 +57,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (message.fromUserId !== me.id && message.toUserId !== me.id && !(await this.roleService.isModerator(me))) {
throw new ApiError(meta.errors.noSuchMessage);
}
return this.chatMessageEntityService.pack(message, me);
return this.chatEntityService.packMessageDetailed(message, me);
});
}
}

View File

@ -8,7 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ChatService } from '@/core/ChatService.js';
import { ChatMessageEntityService } from '@/core/entities/ChatMessageEntityService.js';
import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
@ -62,7 +62,7 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private chatMessageEntityService: ChatMessageEntityService,
private chatEntityService: ChatEntityService,
private chatService: ChatService,
private getterService: GetterService,
) {
@ -77,7 +77,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.chatService.readUserChatMessage(me.id, other.id);
return await this.chatMessageEntityService.packLiteMany(messages);
return await this.chatEntityService.packMessagesLite(messages);
}/* else if (ps.roomId != null) {
// Fetch recipient (room)
const recipientRoom = await this.userRoomRepository.findOneBy({ id: ps.roomId });

View File

@ -0,0 +1,58 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
import { ChatService } from '@/core/ChatService.js';
import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
export const meta = {
tags: ['chat'],
requireCredential: true,
requiredRolePolicy: 'canChat',
prohibitMoved: true,
kind: 'write:chat',
limit: {
duration: ms('1day'),
max: 10,
},
res: {
type: 'object',
optional: false, nullable: false,
ref: 'ChatRoom',
},
errors: {
},
} as const;
export const paramDef = {
type: 'object',
properties: {
name: { type: 'string', maxLength: 256 },
},
required: ['name'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private chatService: ChatService,
private chatEntityService: ChatEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const room = await this.chatService.createRoom(me, ps.name);
return await this.chatEntityService.packRoom(room);
});
}
}

View File

@ -19,7 +19,8 @@ import { AntennaChannelService } from './channels/antenna.js';
import { DriveChannelService } from './channels/drive.js';
import { HashtagChannelService } from './channels/hashtag.js';
import { RoleTimelineChannelService } from './channels/role-timeline.js';
import { ChatChannelService } from './channels/chat.js';
import { ChatUserChannelService } from './channels/chat-user.js';
import { ChatRoomChannelService } from './channels/chat-room.js';
import { ReversiChannelService } from './channels/reversi.js';
import { ReversiGameChannelService } from './channels/reversi-game.js';
import { type MiChannelService } from './channel.js';
@ -41,7 +42,8 @@ export class ChannelsService {
private serverStatsChannelService: ServerStatsChannelService,
private queueStatsChannelService: QueueStatsChannelService,
private adminChannelService: AdminChannelService,
private chatChannelService: ChatChannelService,
private chatUserChannelService: ChatUserChannelService,
private chatRoomChannelService: ChatRoomChannelService,
private reversiChannelService: ReversiChannelService,
private reversiGameChannelService: ReversiGameChannelService,
) {
@ -64,7 +66,8 @@ export class ChannelsService {
case 'serverStats': return this.serverStatsChannelService;
case 'queueStats': return this.queueStatsChannelService;
case 'admin': return this.adminChannelService;
case 'chat': return this.chatChannelService;
case 'chatUser': return this.chatUserChannelService;
case 'chatRoom': return this.chatRoomChannelService;
case 'reversi': return this.reversiChannelService;
case 'reversiGame': return this.reversiGameChannelService;

View File

@ -0,0 +1,78 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { JsonObject } from '@/misc/json-value.js';
import { ChatService } from '@/core/ChatService.js';
import Channel, { type MiChannelService } from '../channel.js';
class ChatRoomChannel extends Channel {
public readonly chName = 'chatRoom';
public static shouldShare = false;
public static requireCredential = true as const;
public static kind = 'read:chat';
private roomId: string;
constructor(
private chatService: ChatService,
id: string,
connection: Channel['connection'],
) {
super(id, connection);
}
@bindThis
public async init(params: JsonObject) {
if (typeof params.roomId !== 'string') return;
this.roomId = params.roomId;
this.subscriber.on(`chatRoomStream:${this.roomId}`, this.onEvent);
}
@bindThis
private async onEvent(data: GlobalEvents['chat']['payload']) {
this.send(data.type, data.body);
}
@bindThis
public onMessage(type: string, body: any) {
switch (type) {
case 'read':
if (this.roomId) {
this.chatService.readRoomChatMessage(this.user!.id, this.roomId);
}
break;
}
}
@bindThis
public dispose() {
this.subscriber.off(`chatRoomStream:${this.roomId}`, this.onEvent);
}
}
@Injectable()
export class ChatRoomChannelService implements MiChannelService<true> {
public readonly shouldShare = ChatRoomChannel.shouldShare;
public readonly requireCredential = ChatRoomChannel.requireCredential;
public readonly kind = ChatRoomChannel.kind;
constructor(
private chatService: ChatService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): ChatRoomChannel {
return new ChatRoomChannel(
this.chatService,
id,
connection,
);
}
}

View File

@ -10,8 +10,8 @@ import type { JsonObject } from '@/misc/json-value.js';
import { ChatService } from '@/core/ChatService.js';
import Channel, { type MiChannelService } from '../channel.js';
class ChatChannel extends Channel {
public readonly chName = 'chat';
class ChatUserChannel extends Channel {
public readonly chName = 'chatUser';
public static shouldShare = false;
public static requireCredential = true as const;
public static kind = 'read:chat';
@ -31,7 +31,7 @@ class ChatChannel extends Channel {
if (typeof params.otherId !== 'string') return;
this.otherId = params.otherId;
this.subscriber.on(`chatStream:${this.user!.id}-${this.otherId}`, this.onEvent);
this.subscriber.on(`chatUserStream:${this.user!.id}-${this.otherId}`, this.onEvent);
}
@bindThis
@ -52,16 +52,15 @@ class ChatChannel extends Channel {
@bindThis
public dispose() {
// Unsubscribe events
this.subscriber.off(`chatStream:${this.user!.id}-${this.otherId}`, this.onEvent);
this.subscriber.off(`chatUserStream:${this.user!.id}-${this.otherId}`, this.onEvent);
}
}
@Injectable()
export class ChatChannelService implements MiChannelService<true> {
public readonly shouldShare = ChatChannel.shouldShare;
public readonly requireCredential = ChatChannel.requireCredential;
public readonly kind = ChatChannel.kind;
export class ChatUserChannelService implements MiChannelService<true> {
public readonly shouldShare = ChatUserChannel.shouldShare;
public readonly requireCredential = ChatUserChannel.requireCredential;
public readonly kind = ChatUserChannel.kind;
constructor(
private chatService: ChatService,
@ -69,8 +68,8 @@ export class ChatChannelService implements MiChannelService<true> {
}
@bindThis
public create(id: string, connection: Channel['connection']): ChatChannel {
return new ChatChannel(
public create(id: string, connection: Channel['connection']): ChatUserChannel {
return new ChatUserChannel(
this.chatService,
id,
connection,

View File

@ -88,11 +88,11 @@ const connection = ref<Misskey.ChannelConnection<Misskey.Channels['chat']> | nul
const showIndicator = ref(false);
async function initialize() {
const LIMIT = 20;
initializing.value = true;
if (props.userId) {
const LIMIT = 20;
const [u, m] = await Promise.all([
misskeyApi('users/show', { userId: props.userId }),
misskeyApi('chat/messages/timeline', { userId: props.userId, limit: LIMIT }),
@ -105,28 +105,30 @@ async function initialize() {
canFetchMore.value = true;
}
connection.value = useStream().useChannel('chat', {
connection.value = useStream().useChannel('chatUser', {
otherId: user.value.id,
});
}/* else {
user = null;
room = await misskeyApi('users/rooms/show', { roomId: props.roomId });
connection.value.on('message', onMessage);
connection.value.on('deleted', onDeleted);
} else {
const [r, m] = await Promise.all([
misskeyApi('chat/rooms/show', { roomId: props.roomId }),
misskeyApi('chat/messages/timeline', { roomId: props.roomId, limit: LIMIT }),
]);
pagination = {
endpoint: 'chat/messages',
limit: 20,
params: {
roomId: room?.id,
},
reversed: true,
};
connection = useStream().useChannel('chat', {
room: room?.id,
room.value = r;
messages.value = m;
if (messages.value.length === LIMIT) {
canFetchMore.value = true;
}
connection.value = useStream().useChannel('chatRoom', {
otherId: user.value.id,
});
}*/
connection.value.on('message', onMessage);
connection.value.on('deleted', onDeleted);
connection.value.on('message', onMessage);
connection.value.on('deleted', onDeleted);
}
window.document.addEventListener('visibilitychange', onVisibilitychange);

View File

@ -971,6 +971,12 @@ type ChatMessagesDeleteRequest = operations['chat___messages___delete']['request
// @public (undocumented)
type ChatMessagesDeleteResponse = operations['chat___messages___delete']['responses']['200']['content']['application/json'];
// @public (undocumented)
type ChatMessagesShowRequest = operations['chat___messages___show']['requestBody']['content']['application/json'];
// @public (undocumented)
type ChatMessagesShowResponse = operations['chat___messages___show']['responses']['200']['content']['application/json'];
// @public (undocumented)
type ChatMessagesTimelineRequest = operations['chat___messages___timeline']['requestBody']['content']['application/json'];
@ -1483,6 +1489,8 @@ declare namespace entities {
ChatMessagesCreateResponse,
ChatMessagesDeleteRequest,
ChatMessagesDeleteResponse,
ChatMessagesShowRequest,
ChatMessagesShowResponse,
ChatMessagesTimelineRequest,
ChatMessagesTimelineResponse,
ClipsAddNoteRequest,

View File

@ -1567,6 +1567,17 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:chat*
*/
request<E extends 'chat/messages/show', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*

View File

@ -213,6 +213,8 @@ import type {
ChatMessagesCreateResponse,
ChatMessagesDeleteRequest,
ChatMessagesDeleteResponse,
ChatMessagesShowRequest,
ChatMessagesShowResponse,
ChatMessagesTimelineRequest,
ChatMessagesTimelineResponse,
ClipsAddNoteRequest,
@ -737,6 +739,7 @@ export type Endpoints = {
'chat/history': { req: ChatHistoryRequest; res: ChatHistoryResponse };
'chat/messages/create': { req: ChatMessagesCreateRequest; res: ChatMessagesCreateResponse };
'chat/messages/delete': { req: ChatMessagesDeleteRequest; res: ChatMessagesDeleteResponse };
'chat/messages/show': { req: ChatMessagesShowRequest; res: ChatMessagesShowResponse };
'chat/messages/timeline': { req: ChatMessagesTimelineRequest; res: ChatMessagesTimelineResponse };
'clips/add-note': { req: ClipsAddNoteRequest; res: EmptyResponse };
'clips/create': { req: ClipsCreateRequest; res: ClipsCreateResponse };

View File

@ -216,6 +216,8 @@ export type ChatMessagesCreateRequest = operations['chat___messages___create']['
export type ChatMessagesCreateResponse = operations['chat___messages___create']['responses']['200']['content']['application/json'];
export type ChatMessagesDeleteRequest = operations['chat___messages___delete']['requestBody']['content']['application/json'];
export type ChatMessagesDeleteResponse = operations['chat___messages___delete']['responses']['200']['content']['application/json'];
export type ChatMessagesShowRequest = operations['chat___messages___show']['requestBody']['content']['application/json'];
export type ChatMessagesShowResponse = operations['chat___messages___show']['responses']['200']['content']['application/json'];
export type ChatMessagesTimelineRequest = operations['chat___messages___timeline']['requestBody']['content']['application/json'];
export type ChatMessagesTimelineResponse = operations['chat___messages___timeline']['responses']['200']['content']['application/json'];
export type ClipsAddNoteRequest = operations['clips___add-note']['requestBody']['content']['application/json'];

View File

@ -1385,6 +1385,15 @@ export type paths = {
*/
post: operations['chat___messages___delete'];
};
'/chat/messages/show': {
/**
* chat/messages/show
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:chat*
*/
post: operations['chat___messages___show'];
};
'/chat/messages/timeline': {
/**
* chat/messages/timeline
@ -13907,6 +13916,60 @@ export type operations = {
};
};
};
/**
* chat/messages/show
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:chat*
*/
chat___messages___show: {
requestBody: {
content: {
'application/json': {
/** Format: misskey:id */
messageId: string;
};
};
};
responses: {
/** @description OK (with results) */
200: {
content: {
'application/json': components['schemas']['ChatMessage'];
};
};
/** @description Client error */
400: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/**
* chat/messages/timeline
* @description No description provided.