This commit is contained in:
syuilo
2025-03-23 18:34:25 +09:00
parent b98d3cbc29
commit 9b9ff81201
25 changed files with 1096 additions and 465 deletions

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class Chat51742721896936 {
name = 'Chat51742721896936'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "chat_room_invitation" ADD "ignored" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "chat_room_invitation" DROP COLUMN "ignored"`);
}
}

View File

@@ -22,6 +22,7 @@ import { QueryService } from '@/core/QueryService.js';
import { RoleService } from '@/core/RoleService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js';
import { Packed } from '@/misc/json-schema.js';
const MAX_ROOM_MEMBERS = 30;
@@ -74,7 +75,7 @@ export class ChatService {
text?: string | null;
file?: MiDriveFile | null;
uri?: string | null;
}) {
}): Promise<Packed<'ChatMessageLite'>> {
if (fromUser.id === toUser.id) {
throw new Error('yourself');
}
@@ -185,7 +186,7 @@ export class ChatService {
text?: string | null;
file?: MiDriveFile | null;
uri?: string | null;
}) {
}): Promise<Packed<'ChatMessageLite'>> {
const memberships = await this.chatRoomMembershipsRepository.findBy({ roomId: toRoom.id });
if (toRoom.ownerId !== fromUser.id && !memberships.some(member => member.userId === fromUser.id)) {
@@ -294,7 +295,7 @@ export class ChatService {
}
@bindThis
public async userTimeline(meId: MiUser['id'], otherId: MiUser['id'], sinceId: MiChatMessage['id'] | null, untilId: MiChatMessage['id'] | null, limit: number) {
public async userTimeline(meId: MiUser['id'], otherId: MiUser['id'], limit: number, sinceId?: MiChatMessage['id'] | null, untilId?: MiChatMessage['id'] | null) {
const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), sinceId, untilId)
.andWhere(new Brackets(qb => {
qb
@@ -317,6 +318,16 @@ export class ChatService {
return messages;
}
@bindThis
public async roomTimeline(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatMessage['id'] | null, untilId?: MiChatMessage['id'] | null) {
const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), sinceId, untilId)
.where('message.toRoomId = :roomId', { roomId });
const messages = await query.take(limit).getMany();
return messages;
}
@bindThis
public async userHistory(meId: MiUser['id'], limit: number): Promise<MiChatMessage[]> {
const history: MiChatMessage[] = [];
@@ -525,7 +536,8 @@ export class ChatService {
@bindThis
public async getReceivedRoomInvitationsWithPagination(userId: MiUser['id'], limit: number, sinceId?: MiChatRoomInvitation['id'] | null, untilId?: MiChatRoomInvitation['id'] | null) {
const query = this.queryService.makePaginationQuery(this.chatRoomInvitationsRepository.createQueryBuilder('invitation'), sinceId, untilId)
.where('invitation.userId = :userId', { userId });
.where('invitation.userId = :userId', { userId })
.andWhere('invitation.ignored = FALSE');
const invitations = await query.take(limit).getMany();
@@ -553,9 +565,9 @@ export class ChatService {
}
@bindThis
public async rejectRoomInvitation(userId: MiUser['id'], roomId: MiChatRoom['id']) {
public async ignoreRoomInvitation(userId: MiUser['id'], roomId: MiChatRoom['id']) {
const invitation = await this.chatRoomInvitationsRepository.findOneByOrFail({ roomId, userId });
await this.chatRoomInvitationsRepository.delete(invitation.id);
await this.chatRoomInvitationsRepository.update(invitation.id, { ignored: true });
}
@bindThis

View File

@@ -154,8 +154,7 @@ export class ChatEntityService {
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),
fromUser: packedUsers?.get(message.fromUserId) ?? await this.userEntityService.pack(message.fromUser ?? message.fromUserId),
toRoomId: message.toRoomId,
fileId: message.fileId,
file: message.file ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file)) : null,
@@ -169,7 +168,7 @@ export class ChatEntityService {
if (messages.length === 0) return [];
const [packedUsers, packedFiles] = await Promise.all([
this.userEntityService.packMany(messages.map(x => x.fromUser))
this.userEntityService.packMany(messages.map(x => x.fromUser ?? x.fromUserId))
.then(users => new Map(users.map(u => [u.id, u]))),
this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null)),
]);

View File

@@ -37,4 +37,9 @@ export class MiChatRoomInvitation {
})
@JoinColumn()
public room: MiChatRoom | null;
@Column('boolean', {
default: false,
})
public ignored: boolean;
}

View File

@@ -396,10 +396,12 @@ export * as 'users/search' from './endpoints/users/search.js';
export * as 'users/search-by-username-and-host' from './endpoints/users/search-by-username-and-host.js';
export * as 'users/show' from './endpoints/users/show.js';
export * as 'users/update-memo' from './endpoints/users/update-memo.js';
export * as 'chat/messages/create' from './endpoints/chat/messages/create.js';
export * as 'chat/messages/create-to-user' from './endpoints/chat/messages/create-to-user.js';
export * as 'chat/messages/create-to-room' from './endpoints/chat/messages/create-to-room.js';
export * as 'chat/messages/delete' from './endpoints/chat/messages/delete.js';
export * as 'chat/messages/show' from './endpoints/chat/messages/show.js';
export * as 'chat/messages/timeline' from './endpoints/chat/messages/timeline.js';
export * as 'chat/messages/user-timeline' from './endpoints/chat/messages/user-timeline.js';
export * as 'chat/messages/room-timeline' from './endpoints/chat/messages/room-timeline.js';
export * as 'chat/rooms/create' from './endpoints/chat/rooms/create.js';
export * as 'chat/rooms/delete' from './endpoints/chat/rooms/delete.js';
export * as 'chat/rooms/join' from './endpoints/chat/rooms/join.js';
@@ -409,7 +411,7 @@ export * as 'chat/rooms/owned' from './endpoints/chat/rooms/owned.js';
export * as 'chat/rooms/update' from './endpoints/chat/rooms/update.js';
export * as 'chat/rooms/members' from './endpoints/chat/rooms/members.js';
export * as 'chat/rooms/invitations/create' from './endpoints/chat/rooms/invitations/create.js';
export * as 'chat/rooms/invitations/reject' from './endpoints/chat/rooms/invitations/reject.js';
export * as 'chat/rooms/invitations/ignore' from './endpoints/chat/rooms/invitations/ignore.js';
export * as 'chat/rooms/invitations/inbox' from './endpoints/chat/rooms/invitations/inbox.js';
export * as 'chat/history' from './endpoints/chat/history.js';
export * as 'v2/admin/emoji/list' from './endpoints/v2/admin/emoji/list.js';

View File

@@ -0,0 +1,105 @@
/*
* 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 { GetterService } from '@/server/api/GetterService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
import { ChatService } from '@/core/ChatService.js';
import type { DriveFilesRepository, MiUser } from '@/models/_.js';
export const meta = {
tags: ['chat'],
requireCredential: true,
requiredRolePolicy: 'canChat',
prohibitMoved: true,
kind: 'write:chat',
limit: {
duration: ms('1hour'),
max: 500,
},
res: {
type: 'object',
optional: false, nullable: false,
ref: 'ChatMessageLite',
},
errors: {
noSuchRoom: {
message: 'No such room.',
code: 'NO_SUCH_ROOM',
id: '8098520d-2da5-4e8f-8ee1-df78b55a4ec6',
},
noSuchFile: {
message: 'No such file.',
code: 'NO_SUCH_FILE',
id: 'b6accbd3-1d7b-4d9f-bdb7-eb185bac06db',
},
contentRequired: {
message: 'Content required. You need to set text or fileId.',
code: 'CONTENT_REQUIRED',
id: '340517b7-6d04-42c0-bac1-37ee804e3594',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
text: { type: 'string', nullable: true, maxLength: 2000 },
fileId: { type: 'string', format: 'misskey:id' },
toRoomId: { type: 'string', format: 'misskey:id' },
},
required: ['toRoomId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
private getterService: GetterService,
private chatService: ChatService,
) {
super(meta, paramDef, async (ps, me) => {
const room = await this.chatService.findRoomById(ps.toRoomId);
if (room == null) {
throw new ApiError(meta.errors.noSuchRoom);
}
let file = null;
if (ps.fileId != null) {
file = await this.driveFilesRepository.findOneBy({
id: ps.fileId,
userId: me.id,
});
if (file == null) {
throw new ApiError(meta.errors.noSuchFile);
}
}
// テキストが無いかつ添付ファイルも無かったらエラー
if (ps.text == null && file == null) {
throw new ApiError(meta.errors.contentRequired);
}
return await this.chatService.createMessageToRoom(me, room, {
text: ps.text,
file: file,
});
});
}
}

View File

@@ -46,18 +46,6 @@ export const meta = {
id: '11795c64-40ea-4198-b06e-3c873ed9039d',
},
noSuchRoom: {
message: 'No such room.',
code: 'NO_SUCH_ROOM',
id: 'c94e2a5d-06aa-4914-8fa6-6a42e73d6537',
},
roomAccessDenied: {
message: 'You can not send messages to rooms that you have not joined.',
code: 'ROOM_ACCESS_DENIED',
id: 'd96b3cca-5ad1-438b-ad8b-02f931308fbd',
},
noSuchFile: {
message: 'No such file.',
code: 'NO_SUCH_FILE',
@@ -83,8 +71,9 @@ export const paramDef = {
properties: {
text: { type: 'string', nullable: true, maxLength: 2000 },
fileId: { type: 'string', format: 'misskey:id' },
toUserId: { type: 'string', format: 'misskey:id', nullable: true },
toUserId: { type: 'string', format: 'misskey:id' },
},
required: ['toUserId'],
} as const;
@Injectable()
@@ -114,39 +103,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.contentRequired);
}
if (ps.toUserId != null) {
// Myself
if (ps.toUserId === me.id) {
throw new ApiError(meta.errors.recipientIsYourself);
}
// Myself
if (ps.toUserId === me.id) {
throw new ApiError(meta.errors.recipientIsYourself);
}
const toUser = await this.getterService.getUser(ps.toUserId).catch(err => {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw err;
});
const toUser = await this.getterService.getUser(ps.toUserId).catch(err => {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw err;
});
return await this.chatService.createMessageToUser(me, toUser, {
text: ps.text,
file: file,
});
}/* else if (ps.toRoomId != null) {
// Fetch recipient (room)
recipientRoom = await this.userRoomsRepository.findOneBy({ id: ps.toRoomId! });
if (recipientRoom == null) {
throw new ApiError(meta.errors.noSuchRoom);
}
// check joined
const joining = await this.userRoomJoiningsRepository.findOneBy({
userId: me.id,
userRoomId: recipientRoom.id,
});
if (joining == null) {
throw new ApiError(meta.errors.roomAccessDenied);
}
}*/
return await this.chatService.createMessageToUser(me, toUser, {
text: ps.text,
file: file,
});
});
}
}

View File

@@ -0,0 +1,73 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
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 { ChatEntityService } from '@/core/entities/ChatEntityService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
tags: ['chat'],
requireCredential: true,
kind: 'read:chat',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'ChatMessageLite',
},
},
errors: {
noSuchRoom: {
message: 'No such room.',
code: 'NO_SUCH_ROOM',
id: 'c4d9f88c-9270-4632-b032-6ed8cee36f7f',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
roomId: { type: 'string', format: 'misskey:id' },
},
required: ['roomId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private chatEntityService: ChatEntityService,
private chatService: ChatService,
) {
super(meta, paramDef, async (ps, me) => {
const room = await this.chatService.findRoomById(ps.roomId);
if (room == null) {
throw new ApiError(meta.errors.noSuchRoom);
}
if (!(await this.chatService.isRoomMember(room.id, me.id)) && room.ownerId !== me.id) {
throw new ApiError(meta.errors.noSuchRoom);
}
const messages = await this.chatService.roomTimeline(room.id, ps.limit, ps.sinceId, ps.untilId);
this.chatService.readRoomChatMessage(me.id, room.id);
return await this.chatEntityService.packMessagesLiteForRoom(messages);
});
}
}

View File

@@ -1,115 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
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 { ChatEntityService } from '@/core/entities/ChatEntityService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
tags: ['chat'],
requireCredential: true,
kind: 'read:chat',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'ChatMessageLite',
},
},
errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: '11795c64-40ea-4198-b06e-3c873ed9039d',
},
noSuchRoom: {
message: 'No such room.',
code: 'NO_SUCH_ROOM',
id: 'c4d9f88c-9270-4632-b032-6ed8cee36f7f',
},
roomAccessDenied: {
message: 'You can not read messages of rooms that you have not joined.',
code: 'ROOM_ACCESS_DENIED',
id: 'a053a8dd-a491-4718-8f87-50775aad9284',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
userId: { type: 'string', format: 'misskey:id', nullable: true },
},
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private chatEntityService: ChatEntityService,
private chatService: ChatService,
private getterService: GetterService,
) {
super(meta, paramDef, async (ps, me) => {
if (ps.userId != null) {
const other = await this.getterService.getUser(ps.userId).catch(err => {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw err;
});
const messages = await this.chatService.userTimeline(me.id, other.id, ps.sinceId, ps.untilId, ps.limit);
this.chatService.readUserChatMessage(me.id, other.id);
return await this.chatEntityService.packMessagesLite(messages);
}/* else if (ps.roomId != null) {
// Fetch recipient (room)
const recipientRoom = await this.userRoomRepository.findOneBy({ id: ps.roomId });
if (recipientRoom == null) {
throw new ApiError(meta.errors.noSuchRoom);
}
// check joined
const joining = await this.userRoomJoiningsRepository.findOneBy({
userId: me.id,
userRoomId: recipientRoom.id,
});
if (joining == null) {
throw new ApiError(meta.errors.roomAccessDenied);
}
const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), ps.sinceId, ps.untilId)
.andWhere('message.roomId = :roomId', { roomId: recipientRoom.id });
const messages = await query.take(ps.limit).getMany();
// Mark all as read
if (ps.markAsRead) {
this.chatService.readRoomMessagingMessage(me.id, recipientRoom.id, messages.map(x => x.id));
}
return await Promise.all(messages.map(message => this.chatMessageEntityService.pack(message, me, {
populateRoom: false,
})));
}*/
});
}
}

View File

@@ -0,0 +1,71 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
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 { ChatEntityService } from '@/core/entities/ChatEntityService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
tags: ['chat'],
requireCredential: true,
kind: 'read:chat',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'ChatMessageLite',
},
},
errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: '11795c64-40ea-4198-b06e-3c873ed9039d',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
userId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private chatEntityService: ChatEntityService,
private chatService: ChatService,
private getterService: GetterService,
) {
super(meta, paramDef, async (ps, me) => {
const other = await this.getterService.getUser(ps.userId).catch(err => {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw err;
});
const messages = await this.chatService.userTimeline(me.id, other.id, ps.limit, ps.sinceId, ps.untilId);
this.chatService.readUserChatMessage(me.id, other.id);
return await this.chatEntityService.packMessagesLite(messages);
});
}
}

View File

@@ -61,7 +61,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (room == null) {
throw new ApiError(meta.errors.noSuchRoom);
}
const invitation = await this.chatService.createRoomInvitation(ps.userId, room.id, ps.userId);
const invitation = await this.chatService.createRoomInvitation(me.id, room.id, ps.userId);
return await this.chatEntityService.packRoomInvitation(invitation, me);
});
}

View File

@@ -42,7 +42,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatService: ChatService,
) {
super(meta, paramDef, async (ps, me) => {
await this.chatService.rejectRoomInvitation(me.id, ps.roomId);
await this.chatService.ignoreRoomInvitation(me.id, ps.roomId);
});
}
}