This commit is contained in:
syuilo
2025-03-17 18:18:04 +09:00
parent 30be29a785
commit 06bd615334
9 changed files with 144 additions and 65 deletions

View File

@@ -54,14 +54,14 @@ export class ChatService {
public async createMessage(params: { public async createMessage(params: {
fromUser: { id: MiUser['id']; host: MiUser['host']; }; fromUser: { id: MiUser['id']; host: MiUser['host']; };
toUser?: MiUser | null; toUser?: MiUser | null;
//toGroup?: MiUserGroup | null; //toRoom?: MiUserRoom | null;
text?: string | null; text?: string | null;
file?: MiDriveFile | null; file?: MiDriveFile | null;
uri?: string | null; uri?: string | null;
}) { }) {
const { fromUser, toUser /*toGroup*/ } = params; const { fromUser, toUser /*toRoom*/ } = params;
if (toUser == null /*&& toGroup == null*/) { if (toUser == null /*&& toRoom == null*/) {
throw new Error('recipient is required'); throw new Error('recipient is required');
} }
@@ -76,7 +76,7 @@ export class ChatService {
id: this.idService.gen(), id: this.idService.gen(),
fromUserId: fromUser.id, fromUserId: fromUser.id,
toUserId: toUser ? toUser.id : null, toUserId: toUser ? toUser.id : null,
//toGroupId: recipientGroup ? recipientGroup.id : null, //toRoomId: recipientRoom ? recipientRoom.id : null,
text: params.text ? params.text.trim() : null, text: params.text ? params.text.trim() : null,
fileId: params.file ? params.file.id : null, fileId: params.file ? params.file.id : null,
reads: [], reads: [],
@@ -102,12 +102,12 @@ export class ChatService {
// 相手のストリーム // 相手のストリーム
this.globalEventService.publishChatStream(toUser.id, fromUser.id, 'message', packedMessage); this.globalEventService.publishChatStream(toUser.id, fromUser.id, 'message', packedMessage);
} }
}/* else if (toGroup) { }/* else if (toRoom) {
// グループのストリーム // グループのストリーム
this.globalEventService.publishGroupChatStream(toGroup.id, 'message', messageObj); this.globalEventService.publishRoomChatStream(toRoom.id, 'message', messageObj);
// メンバーのストリーム // メンバーのストリーム
const joinings = await this.userGroupJoiningsRepository.findBy({ userGroupId: toGroup.id }); const joinings = await this.userRoomJoiningsRepository.findBy({ userRoomId: toRoom.id });
for (const joining of joinings) { for (const joining of joinings) {
this.globalEventService.publishChatIndexStream(joining.userId, 'message', messageObj); this.globalEventService.publishChatIndexStream(joining.userId, 'message', messageObj);
this.globalEventService.publishMainStream(joining.userId, 'chatMessage', messageObj); this.globalEventService.publishMainStream(joining.userId, 'chatMessage', messageObj);
@@ -124,8 +124,8 @@ export class ChatService {
const packedMessageForTo = await this.chatMessageEntityService.pack(inserted, toUser); const packedMessageForTo = await this.chatMessageEntityService.pack(inserted, toUser);
this.globalEventService.publishMainStream(toUser.id, 'newChatMessage', packedMessageForTo); this.globalEventService.publishMainStream(toUser.id, 'newChatMessage', packedMessageForTo);
this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo); this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo);
}/* else if (toGroup) { }/* else if (toRoom) {
const joinings = await this.userGroupJoiningsRepository.findBy({ userGroupId: toGroup.id, userId: Not(fromUser.id) }); const joinings = await this.userRoomJoiningsRepository.findBy({ userRoomId: toRoom.id, userId: Not(fromUser.id) });
for (const joining of joinings) { for (const joining of joinings) {
if (freshMessage.reads.includes(joining.userId)) return; // 既読 if (freshMessage.reads.includes(joining.userId)) return; // 既読
this.globalEventService.publishMainStream(joining.userId, 'newChatMessage', messageObj); this.globalEventService.publishMainStream(joining.userId, 'newChatMessage', messageObj);
@@ -186,28 +186,28 @@ export class ChatService {
const activity = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${message.id}`), fromUser)); 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); this.queueService.deliver(fromUser, activity, toUser.inbox);
} }
}/* else if (message.groupId) { }/* else if (message.roomId) {
this.globalEventService.publishGroupChatStream(message.groupId, 'deleted', message.id); this.globalEventService.publishRoomChatStream(message.roomId, 'deleted', message.id);
}*/ }*/
} }
/* /*
@bindThis @bindThis
public async readGroupChatMessage( public async readRoomChatMessage(
userId: MiUser['id'], userId: MiUser['id'],
groupId: MiUserGroup['id'], roomId: MiUserRoom['id'],
messageIds: MiChatMessage['id'][], messageIds: MiChatMessage['id'][],
) { ) {
if (messageIds.length === 0) return; if (messageIds.length === 0) return;
// check joined // check joined
const joining = await this.userGroupJoiningsRepository.findOneBy({ const joining = await this.userRoomJoiningsRepository.findOneBy({
userId: userId, userId: userId,
userGroupId: groupId, userRoomId: roomId,
}); });
if (joining == null) { if (joining == null) {
throw new IdentifiableError('930a270c-714a-46b2-b776-ad27276dc569', 'Access denied (group).'); throw new IdentifiableError('930a270c-714a-46b2-b776-ad27276dc569', 'Access denied (room).');
} }
const messages = await this.chatMessagesRepository.findBy({ const messages = await this.chatMessagesRepository.findBy({
@@ -232,7 +232,7 @@ export class ChatService {
} }
// Publish event // Publish event
this.globalEventService.publishGroupChatStream(groupId, 'read', { this.globalEventService.publishRoomChatStream(roomId, 'read', {
ids: reads, ids: reads,
userId: userId, userId: userId,
}); });
@@ -245,14 +245,14 @@ export class ChatService {
} else { } else {
// そのグループにおいて未読がなければイベント発行 // そのグループにおいて未読がなければイベント発行
const unreadExist = await this.chatMessagesRepository.createQueryBuilder('message') const unreadExist = await this.chatMessagesRepository.createQueryBuilder('message')
.where('message.groupId = :groupId', { groupId: groupId }) .where('message.roomId = :roomId', { roomId: roomId })
.andWhere('message.userId != :userId', { userId: userId }) .andWhere('message.userId != :userId', { userId: userId })
.andWhere('NOT (:userId = ANY(message.reads))', { userId: userId }) .andWhere('NOT (:userId = ANY(message.reads))', { userId: userId })
.andWhere('message.createdAt > :joinedAt', { joinedAt: joining.createdAt }) // 自分が加入する前の会話については、未読扱いしない .andWhere('message.createdAt > :joinedAt', { joinedAt: joining.createdAt }) // 自分が加入する前の会話については、未読扱いしない
.getOne().then(x => x != null); .getOne().then(x => x != null);
if (!unreadExist) { if (!unreadExist) {
this.pushNotificationService.pushNotification(userId, 'readAllChatMessagesOfARoom', { groupId }); this.pushNotificationService.pushNotification(userId, 'readAllChatMessagesOfARoom', { roomId });
} }
} }
} }
@@ -300,7 +300,7 @@ export class ChatService {
.where('message.fromUserId = :meId', { meId: meId }) .where('message.fromUserId = :meId', { meId: meId })
.orWhere('message.toUserId = :meId', { meId: meId }); .orWhere('message.toUserId = :meId', { meId: meId });
})) }))
.andWhere('message.groupId IS NULL') .andWhere('message.roomId IS NULL')
.andWhere(`message.fromUserId NOT IN (${ mutingQuery.getQuery() })`) .andWhere(`message.fromUserId NOT IN (${ mutingQuery.getQuery() })`)
.andWhere(`message.toUserId NOT IN (${ mutingQuery.getQuery() })`); .andWhere(`message.toUserId NOT IN (${ mutingQuery.getQuery() })`);
@@ -324,27 +324,27 @@ export class ChatService {
} }
@bindThis @bindThis
public async groupHistory(meId: MiUser['id'], limit: number) { public async roomHistory(meId: MiUser['id'], limit: number) {
/* /*
const groups = await this.userGroupJoiningsRepository.findBy({ const rooms = await this.userRoomJoiningsRepository.findBy({
userId: meId, userId: meId,
}).then(xs => xs.map(x => x.userGroupId)); }).then(xs => xs.map(x => x.userRoomId));
if (groups.length === 0) { if (rooms.length === 0) {
return []; return [];
} }
const history: MiChatMessage[] = []; const history: MiChatMessage[] = [];
for (let i = 0; i < limit; i++) { for (let i = 0; i < limit; i++) {
const found = history.map(m => m.groupId!); const found = history.map(m => m.roomId!);
const query = this.chatMessagesRepository.createQueryBuilder('message') const query = this.chatMessagesRepository.createQueryBuilder('message')
.orderBy('message.id', 'DESC') .orderBy('message.id', 'DESC')
.where('message.groupId IN (:...groups)', { groups: groups }); .where('message.roomId IN (:...rooms)', { rooms: rooms });
if (found.length > 0) { if (found.length > 0) {
query.andWhere('message.groupId NOT IN (:...found)', { found: found }); query.andWhere('message.roomId NOT IN (:...found)', { found: found });
} }
const message = await query.getOne(); const message = await query.getOne();

View File

@@ -7,6 +7,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typ
import { id } from './util/id.js'; import { id } from './util/id.js';
import { MiUser } from './User.js'; import { MiUser } from './User.js';
import { MiDriveFile } from './DriveFile.js'; import { MiDriveFile } from './DriveFile.js';
import { MiChatRoom } from './ChatRoom.js';
@Entity('chat_message') @Entity('chat_message')
export class MiChatMessage { export class MiChatMessage {
@@ -37,19 +38,17 @@ export class MiChatMessage {
@JoinColumn() @JoinColumn()
public toUser: MiUser | null; public toUser: MiUser | null;
/*
@Index() @Index()
@Column({ @Column({
...id(), nullable: true, ...id(), nullable: true,
}) })
public toGroupId: MiUserGroup['id'] | null; public toRoomId: MiChatRoom['id'] | null;
@ManyToOne(type => MiUserGroup, { @ManyToOne(type => MiChatRoom, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@JoinColumn() @JoinColumn()
public toGroup: MiUserGroup | null; public toRoom: MiChatRoom | null;
*/
@Column('varchar', { @Column('varchar', {
length: 4096, nullable: true, length: 4096, nullable: true,

View File

@@ -0,0 +1,31 @@
/*
* 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';
@Entity('chat_room')
export class MiChatRoom {
@PrimaryColumn(id())
public id: string;
@Column('varchar', {
length: 256,
})
public name: string;
@Index()
@Column({
...id(),
})
public ownerId: MiUser['id'];
@ManyToOne(type => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public owner: MiUser | null;
}

View File

@@ -0,0 +1,39 @@
/*
* 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_membership')
export class MiChatRoomMembership {
@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

@@ -76,6 +76,8 @@ import { MiFlash } from '@/models/Flash.js';
import { MiFlashLike } from '@/models/FlashLike.js'; import { MiFlashLike } from '@/models/FlashLike.js';
import { MiUserListFavorite } from '@/models/UserListFavorite.js'; import { MiUserListFavorite } from '@/models/UserListFavorite.js';
import { MiChatMessage } from '@/models/ChatMessage.js'; import { MiChatMessage } from '@/models/ChatMessage.js';
import { MiChatRoom } from '@/models/ChatRoom.js';
import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js';
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiReversiGame } from '@/models/ReversiGame.js'; import { MiReversiGame } from '@/models/ReversiGame.js';
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
@@ -193,6 +195,8 @@ export {
MiFlashLike, MiFlashLike,
MiUserMemo, MiUserMemo,
MiChatMessage, MiChatMessage,
MiChatRoom,
MiChatRoomMembership,
MiBubbleGameRecord, MiBubbleGameRecord,
MiReversiGame, MiReversiGame,
}; };
@@ -266,5 +270,7 @@ export type FlashsRepository = Repository<MiFlash> & MiRepository<MiFlash>;
export type FlashLikesRepository = Repository<MiFlashLike> & MiRepository<MiFlashLike>; export type FlashLikesRepository = Repository<MiFlashLike> & MiRepository<MiFlashLike>;
export type UserMemoRepository = Repository<MiUserMemo> & MiRepository<MiUserMemo>; export type UserMemoRepository = Repository<MiUserMemo> & MiRepository<MiUserMemo>;
export type ChatMessagesRepository = Repository<MiChatMessage> & MiRepository<MiChatMessage>; export type ChatMessagesRepository = Repository<MiChatMessage> & MiRepository<MiChatMessage>;
export type ChatRoomsRepository = Repository<MiChatRoom> & MiRepository<MiChatRoom>;
export type ChatRoomMembershipsRepository = Repository<MiChatRoomMembership> & MiRepository<MiChatRoomMembership>;
export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord> & MiRepository<MiBubbleGameRecord>; export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord> & MiRepository<MiBubbleGameRecord>;
export type ReversiGamesRepository = Repository<MiReversiGame> & MiRepository<MiReversiGame>; export type ReversiGamesRepository = Repository<MiReversiGame> & MiRepository<MiReversiGame>;

View File

@@ -77,6 +77,8 @@ import { MiFlash } from '@/models/Flash.js';
import { MiFlashLike } from '@/models/FlashLike.js'; import { MiFlashLike } from '@/models/FlashLike.js';
import { MiUserMemo } from '@/models/UserMemo.js'; import { MiUserMemo } from '@/models/UserMemo.js';
import { MiChatMessage } from '@/models/ChatMessage.js'; import { MiChatMessage } from '@/models/ChatMessage.js';
import { MiChatRoom } from '@/models/ChatRoom.js';
import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js';
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiReversiGame } from '@/models/ReversiGame.js'; import { MiReversiGame } from '@/models/ReversiGame.js';
@@ -238,6 +240,8 @@ export const entities = [
MiFlashLike, MiFlashLike,
MiUserMemo, MiUserMemo,
MiChatMessage, MiChatMessage,
MiChatRoom,
MiChatRoomMembership,
MiBubbleGameRecord, MiBubbleGameRecord,
MiReversiGame, MiReversiGame,
...charts, ...charts,

View File

@@ -45,15 +45,15 @@ export const meta = {
id: '11795c64-40ea-4198-b06e-3c873ed9039d', id: '11795c64-40ea-4198-b06e-3c873ed9039d',
}, },
noSuchGroup: { noSuchRoom: {
message: 'No such group.', message: 'No such room.',
code: 'NO_SUCH_GROUP', code: 'NO_SUCH_ROOM',
id: 'c94e2a5d-06aa-4914-8fa6-6a42e73d6537', id: 'c94e2a5d-06aa-4914-8fa6-6a42e73d6537',
}, },
groupAccessDenied: { roomAccessDenied: {
message: 'You can not send messages to groups that you have not joined.', message: 'You can not send messages to rooms that you have not joined.',
code: 'GROUP_ACCESS_DENIED', code: 'ROOM_ACCESS_DENIED',
id: 'd96b3cca-5ad1-438b-ad8b-02f931308fbd', id: 'd96b3cca-5ad1-438b-ad8b-02f931308fbd',
}, },
@@ -130,22 +130,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
text: ps.text, text: ps.text,
file: file, file: file,
}); });
}/* else if (ps.groupId != null) { }/* else if (ps.roomId != null) {
// Fetch recipient (group) // Fetch recipient (room)
recipientGroup = await this.userGroupsRepository.findOneBy({ id: ps.groupId! }); recipientRoom = await this.userRoomsRepository.findOneBy({ id: ps.roomId! });
if (recipientGroup == null) { if (recipientRoom == null) {
throw new ApiError(meta.errors.noSuchGroup); throw new ApiError(meta.errors.noSuchRoom);
} }
// check joined // check joined
const joining = await this.userGroupJoiningsRepository.findOneBy({ const joining = await this.userRoomJoiningsRepository.findOneBy({
userId: me.id, userId: me.id,
userGroupId: recipientGroup.id, userRoomId: recipientRoom.id,
}); });
if (joining == null) { if (joining == null) {
throw new ApiError(meta.errors.groupAccessDenied); throw new ApiError(meta.errors.roomAccessDenied);
} }
}*/ }*/
}); });

View File

@@ -35,7 +35,7 @@ export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
group: { type: 'boolean', default: false }, room: { type: 'boolean', default: false },
}, },
} as const; } as const;
@@ -46,7 +46,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatService: ChatService, private chatService: ChatService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const history = ps.group ? await this.chatService.groupHistory(me.id, ps.limit) : await this.chatService.userHistory(me.id, ps.limit); const history = ps.room ? await this.chatService.roomHistory(me.id, ps.limit) : await this.chatService.userHistory(me.id, ps.limit);
return await this.chatMessageEntityService.packMany(history, me); return await this.chatMessageEntityService.packMany(history, me);
}); });

View File

@@ -35,15 +35,15 @@ export const meta = {
id: '11795c64-40ea-4198-b06e-3c873ed9039d', id: '11795c64-40ea-4198-b06e-3c873ed9039d',
}, },
noSuchGroup: { noSuchRoom: {
message: 'No such group.', message: 'No such room.',
code: 'NO_SUCH_GROUP', code: 'NO_SUCH_ROOM',
id: 'c4d9f88c-9270-4632-b032-6ed8cee36f7f', id: 'c4d9f88c-9270-4632-b032-6ed8cee36f7f',
}, },
groupAccessDenied: { roomAccessDenied: {
message: 'You can not read messages of groups that you have not joined.', message: 'You can not read messages of rooms that you have not joined.',
code: 'GROUP_ACCESS_DENIED', code: 'ROOM_ACCESS_DENIED',
id: 'a053a8dd-a491-4718-8f87-50775aad9284', id: 'a053a8dd-a491-4718-8f87-50775aad9284',
}, },
}, },
@@ -76,36 +76,36 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const messages = await this.chatService.userTimeline(me.id, other.id, ps.sinceId, ps.untilId, ps.limit); const messages = await this.chatService.userTimeline(me.id, other.id, ps.sinceId, ps.untilId, ps.limit);
return await this.chatMessageEntityService.packLiteMany(messages); return await this.chatMessageEntityService.packLiteMany(messages);
}/* else if (ps.groupId != null) { }/* else if (ps.roomId != null) {
// Fetch recipient (group) // Fetch recipient (room)
const recipientGroup = await this.userGroupRepository.findOneBy({ id: ps.groupId }); const recipientRoom = await this.userRoomRepository.findOneBy({ id: ps.roomId });
if (recipientGroup == null) { if (recipientRoom == null) {
throw new ApiError(meta.errors.noSuchGroup); throw new ApiError(meta.errors.noSuchRoom);
} }
// check joined // check joined
const joining = await this.userGroupJoiningsRepository.findOneBy({ const joining = await this.userRoomJoiningsRepository.findOneBy({
userId: me.id, userId: me.id,
userGroupId: recipientGroup.id, userRoomId: recipientRoom.id,
}); });
if (joining == null) { if (joining == null) {
throw new ApiError(meta.errors.groupAccessDenied); throw new ApiError(meta.errors.roomAccessDenied);
} }
const query = this.queryService.makePaginationQuery(this.messagingMessagesRepository.createQueryBuilder('message'), ps.sinceId, ps.untilId) const query = this.queryService.makePaginationQuery(this.messagingMessagesRepository.createQueryBuilder('message'), ps.sinceId, ps.untilId)
.andWhere('message.groupId = :groupId', { groupId: recipientGroup.id }); .andWhere('message.roomId = :roomId', { roomId: recipientRoom.id });
const messages = await query.take(ps.limit).getMany(); const messages = await query.take(ps.limit).getMany();
// Mark all as read // Mark all as read
if (ps.markAsRead) { if (ps.markAsRead) {
this.messagingService.readGroupMessagingMessage(me.id, recipientGroup.id, messages.map(x => x.id)); this.messagingService.readRoomMessagingMessage(me.id, recipientRoom.id, messages.map(x => x.id));
} }
return await Promise.all(messages.map(message => this.messagingMessageEntityService.pack(message, me, { return await Promise.all(messages.map(message => this.messagingMessageEntityService.pack(message, me, {
populateGroup: false, populateRoom: false,
}))); })));
}*/ }*/
}); });