diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index d6af72ab57..492d700f7d 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2290,6 +2290,8 @@ _permissions: "read:clip-favorite": "クリップのいいねを見る" "read:federation": "連合に関する情報を取得する" "write:report-abuse": "違反を報告する" + "write:chat": "チャットを操作する" + "read:chat": "チャットを閲覧する" _auth: shareAccessTitle: "アプリへのアクセス許可" diff --git a/packages/backend/src/core/ChatService.ts b/packages/backend/src/core/ChatService.ts index 55f776831f..adea867a48 100644 --- a/packages/backend/src/core/ChatService.ts +++ b/packages/backend/src/core/ChatService.ts @@ -5,6 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; +import { Brackets } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { QueueService } from '@/core/QueueService.js'; @@ -17,6 +18,7 @@ import { PushNotificationService } from '@/core/PushNotificationService.js'; import { bindThis } from '@/decorators.js'; import type { ChatMessagesRepository, MiChatMessage, MiDriveFile, MiUser, UsersRepository } from '@/models/_.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { QueryService } from '@/core/QueryService.js'; @Injectable() export class ChatService { @@ -41,6 +43,7 @@ export class ChatService { private queueService: QueueService, private pushNotificationService: PushNotificationService, private userBlockingService: UserBlockingService, + private queryService: QueryService, ) { } @@ -251,4 +254,28 @@ export class ChatService { } } */ + + @bindThis + public async userTimeline(meId: MiUser['id'], otherId: MiUser['id'], sinceId: MiChatMessage['id'] | null, untilId: MiChatMessage['id'] | null, limit: number) { + const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), sinceId, untilId) + .andWhere(new Brackets(qb => { + qb + .where(new Brackets(qb => { + qb + .where('message.fromUserId = :meId') + .andWhere('message.toUserId = :otherId'); + })) + .orWhere(new Brackets(qb => { + qb + .where('message.fromUserId = :otherId') + .andWhere('message.toUserId = :meId'); + })); + })) + .setParameter('meId', meId) + .setParameter('otherId', otherId); + + const messages = await query.take(limit).getMany(); + + return messages; + } } diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 34e2e28568..25d8cbf8df 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -398,4 +398,5 @@ export * as 'users/search-by-username-and-host' from './endpoints/users/search-b 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/timeline' from './endpoints/chat/messages/timeline.js'; export * as 'v2/admin/emoji/list' from './endpoints/v2/admin/emoji/list.js'; diff --git a/packages/backend/src/server/api/endpoints/chat/messages/create.ts b/packages/backend/src/server/api/endpoints/chat/messages/create.ts index f317f9a5f5..1facf3f67a 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/create.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/create.ts @@ -10,13 +10,15 @@ 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 { DriveFilesRepository, MiUser } from '@/models/_.js'; +import type { DriveFilesRepository, MiUser } from '@/models/_.js'; export const meta = { tags: ['chat'], requireCredential: true, + prohibitMoved: true, + kind: 'write:chat', limit: { @@ -80,21 +82,8 @@ export const paramDef = { properties: { text: { type: 'string', nullable: true, maxLength: 2000 }, fileId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id', nullable: true }, }, - anyOf: [ - { - properties: { - userId: { type: 'string', format: 'misskey:id' }, - }, - required: ['userId'], - }, - { - properties: { - groupId: { type: 'string', format: 'misskey:id' }, - }, - required: ['groupId'], - }, - ], } as const; @Injectable() @@ -107,8 +96,22 @@ export default class extends Endpoint { // eslint- private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { - let toUser: MiUser | null; - //let toGroup: UserGroup | null; + 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); + } if (ps.userId != null) { // Myself @@ -116,11 +119,17 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.recipientIsYourself); } - // Fetch recipient (user) - toUser = await this.getterService.getUser(ps.userId).catch(err => { + const toUser = await this.getterService.getUser(ps.userId).catch(err => { if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); throw err; }); + + return await this.chatService.createMessage({ + fromUser: me, + toUser, + text: ps.text, + file: file, + }); }/* else if (ps.groupId != null) { // Fetch recipient (group) recipientGroup = await this.userGroupsRepository.findOneBy({ id: ps.groupId! }); @@ -139,31 +148,6 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.groupAccessDenied); } }*/ - - 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.createMessage({ - fromUser: me, - toUser, - toGroup, - text: ps.text, - file: file, - }); }); } } diff --git a/packages/backend/src/server/api/endpoints/chat/messages/timeline.ts b/packages/backend/src/server/api/endpoints/chat/messages/timeline.ts new file mode 100644 index 0000000000..834acf0757 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/timeline.ts @@ -0,0 +1,113 @@ +/* + * 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 { ChatMessageEntityService } from '@/core/entities/ChatMessageEntityService.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', + }, + + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: 'c4d9f88c-9270-4632-b032-6ed8cee36f7f', + }, + + groupAccessDenied: { + message: 'You can not read messages of groups that you have not joined.', + code: 'GROUP_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 { // eslint-disable-line import/no-default-export + constructor( + private chatMessageEntityService: ChatMessageEntityService, + 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); + + return await this.chatMessageEntityService.packLiteMany(messages); + }/* else if (ps.groupId != null) { + // Fetch recipient (group) + const recipientGroup = await this.userGroupRepository.findOneBy({ id: ps.groupId }); + + if (recipientGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // check joined + const joining = await this.userGroupJoiningsRepository.findOneBy({ + userId: me.id, + userGroupId: recipientGroup.id, + }); + + if (joining == null) { + throw new ApiError(meta.errors.groupAccessDenied); + } + + const query = this.queryService.makePaginationQuery(this.messagingMessagesRepository.createQueryBuilder('message'), ps.sinceId, ps.untilId) + .andWhere('message.groupId = :groupId', { groupId: recipientGroup.id }); + + const messages = await query.take(ps.limit).getMany(); + + // Mark all as read + if (ps.markAsRead) { + this.messagingService.readGroupMessagingMessage(me.id, recipientGroup.id, messages.map(x => x.id)); + } + + return await Promise.all(messages.map(message => this.messagingMessageEntityService.pack(message, me, { + populateGroup: false, + }))); + }*/ + }); + } +} diff --git a/packages/misskey-js/README.md b/packages/misskey-js/README.md index 4753e2434b..5ab2787c47 100644 --- a/packages/misskey-js/README.md +++ b/packages/misskey-js/README.md @@ -83,8 +83,8 @@ const mainChannel = stream.useChannel('main'); ``` ts const stream = new Misskey.Stream('https://misskey.test', { token: 'TOKEN' }); -const messagingChannel = stream.useChannel('messaging', { - otherparty: 'xxxxxxxxxx', +const chatChannel = stream.useChannel('chat', { + other: 'xxxxxxxxxx', }); ``` @@ -115,11 +115,11 @@ mainChannel.on('notification', notification => { ``` ts const stream = new Misskey.Stream('https://misskey.test', { token: 'TOKEN' }); -const messagingChannel = stream.useChannel('messaging', { - otherparty: 'xxxxxxxxxx', +const chatChannel = stream.useChannel('chat', { + other: 'xxxxxxxxxx', }); -messagingChannel.send('read', { +chatChannel.send('read', { id: 'xxxxxxxxxx' }); ``` diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts index c5911a70eb..9d0fe5e781 100644 --- a/packages/misskey-js/src/consts.ts +++ b/packages/misskey-js/src/consts.ts @@ -37,8 +37,8 @@ export const permissions = [ 'write:favorites', 'read:following', 'write:following', - 'read:messaging', - 'write:messaging', + 'read:messaging', // deprecated + 'write:messaging', // deprecated 'read:mutes', 'write:mutes', 'write:notes', @@ -110,6 +110,8 @@ export const permissions = [ 'read:clip-favorite', 'read:federation', 'write:report-abuse', + 'write:chat', + 'read:chat', ] as const; export const moderationLogTypes = [ diff --git a/packages/misskey-js/test/streaming.ts b/packages/misskey-js/test/streaming.ts index 06b55cd8af..7e784cd20c 100644 --- a/packages/misskey-js/test/streaming.ts +++ b/packages/misskey-js/test/streaming.ts @@ -42,26 +42,26 @@ describe('Streaming', () => { test('useChannel with parameters', async () => { const server = new WS('wss://misskey.test/streaming'); const stream = new Stream('https://misskey.test', { token: 'TOKEN' }); - const messagingChannelReceived: any[] = []; - const messaging = stream.useChannel('messaging', { otherparty: 'aaa' }); - messaging.on('message', payload => { - messagingChannelReceived.push(payload); + const chatChannelReceived: any[] = []; + const chat = stream.useChannel('chat', { other: 'aaa' }); + chat.on('message', payload => { + chatChannelReceived.push(payload); }); const ws = await server.connected; expect(new URLSearchParams(new URL(ws.url).search).get('i')).toEqual('TOKEN'); const msg = JSON.parse(await server.nextMessage as string); - const messagingChannelId = msg.body.id; + const chatChannelId = msg.body.id; expect(msg.type).toEqual('connect'); - expect(msg.body.channel).toEqual('messaging'); - expect(msg.body.params).toEqual({ otherparty: 'aaa' }); - expect(messagingChannelId != null).toEqual(true); + expect(msg.body.channel).toEqual('chat'); + expect(msg.body.params).toEqual({ other: 'aaa' }); + expect(chatChannelId != null).toEqual(true); server.send(JSON.stringify({ type: 'channel', body: { - id: messagingChannelId, + id: chatChannelId, type: 'message', body: { id: 'foo' @@ -69,7 +69,7 @@ describe('Streaming', () => { } })); - expect(messagingChannelReceived[0]).toEqual({ + expect(chatChannelReceived[0]).toEqual({ id: 'foo' }); @@ -81,20 +81,20 @@ describe('Streaming', () => { const server = new WS('wss://misskey.test/streaming'); const stream = new Stream('https://misskey.test', { token: 'TOKEN' }); - stream.useChannel('messaging', { otherparty: 'aaa' }); - stream.useChannel('messaging', { otherparty: 'bbb' }); + stream.useChannel('chat', { other: 'aaa' }); + stream.useChannel('chat', { other: 'bbb' }); const ws = await server.connected; expect(new URLSearchParams(new URL(ws.url).search).get('i')).toEqual('TOKEN'); const msg = JSON.parse(await server.nextMessage as string); - const messagingChannelId = msg.body.id; + const chatChannelId = msg.body.id; const msg2 = JSON.parse(await server.nextMessage as string); - const messagingChannelId2 = msg2.body.id; + const chatChannelId2 = msg2.body.id; - expect(messagingChannelId != null).toEqual(true); - expect(messagingChannelId2 != null).toEqual(true); - expect(messagingChannelId).not.toEqual(messagingChannelId2); + expect(chatChannelId != null).toEqual(true); + expect(chatChannelId2 != null).toEqual(true); + expect(chatChannelId).not.toEqual(chatChannelId2); stream.close(); server.close(); @@ -104,8 +104,8 @@ describe('Streaming', () => { const server = new WS('wss://misskey.test/streaming'); const stream = new Stream('https://misskey.test', { token: 'TOKEN' }); - const messaging = stream.useChannel('messaging', { otherparty: 'aaa' }); - messaging.send('read', { id: 'aaa' }); + const chat = stream.useChannel('chat', { other: 'aaa' }); + chat.send('read', { id: 'aaa' }); const ws = await server.connected; expect(new URLSearchParams(new URL(ws.url).search).get('i')).toEqual('TOKEN');