diff --git a/packages/backend/src/core/ChatService.ts b/packages/backend/src/core/ChatService.ts new file mode 100644 index 0000000000..55f776831f --- /dev/null +++ b/packages/backend/src/core/ChatService.ts @@ -0,0 +1,254 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import { QueueService } from '@/core/QueueService.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ChatMessageEntityService } from '@/core/entities/ChatMessageEntityService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +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'; + +@Injectable() +export class ChatService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.chatMessagesRepository) + private chatMessagesRepository: ChatMessagesRepository, + + private userEntityService: UserEntityService, + private chatMessageEntityService: ChatMessageEntityService, + private idService: IdService, + private globalEventService: GlobalEventService, + private apRendererService: ApRendererService, + private queueService: QueueService, + private pushNotificationService: PushNotificationService, + private userBlockingService: UserBlockingService, + ) { + } + + @bindThis + public async createMessage(params: { + fromUser: { id: MiUser['id']; host: MiUser['host']; }; + toUser?: MiUser | null; + //toGroup?: MiUserGroup | null; + text?: string | null; + file?: MiDriveFile | null; + uri?: string | null; + }) { + const { fromUser, toUser /*toGroup*/ } = params; + + if (toUser == null /*&& toGroup == null*/) { + throw new Error('recipient is required'); + } + + if (toUser) { + const blocked = await this.userBlockingService.checkBlocked(toUser.id, fromUser.id); + if (blocked) { + throw new Error('blocked'); + } + } + + const message = { + id: this.idService.gen(), + fromUserId: fromUser.id, + toUserId: toUser ? toUser.id : null, + //toGroupId: recipientGroup ? recipientGroup.id : null, + text: params.text ? params.text.trim() : null, + fileId: params.file ? params.file.id : null, + reads: [], + uri: params.uri ?? null, + } satisfies Partial; + + const inserted = await this.chatMessagesRepository.insertOne(message); + + const packedMessage = await this.chatMessageEntityService.packLite(inserted); + + if (toUser) { + const redisPipeline = this.redisClient.pipeline(); + redisPipeline.set(`newChatMessageExists:${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); + } + + if (this.userEntityService.isLocalUser(toUser)) { + // 相手のストリーム + this.globalEventService.publishChatStream(toUser.id, fromUser.id, 'message', packedMessage); + } + }/* else if (toGroup) { + // グループのストリーム + this.globalEventService.publishGroupChatStream(toGroup.id, 'message', messageObj); + + // メンバーのストリーム + const joinings = await this.userGroupJoiningsRepository.findBy({ userGroupId: toGroup.id }); + for (const joining of joinings) { + this.globalEventService.publishChatIndexStream(joining.userId, 'message', messageObj); + this.globalEventService.publishMainStream(joining.userId, 'chatMessage', messageObj); + } + }*/ + + // 3秒経っても既読にならなかったらイベント発行 + setTimeout(async () => { + if (toUser && this.userEntityService.isLocalUser(toUser)) { + const marker = await this.redisClient.get(`newChatMessageExists:${toUser.id}:${fromUser.id}`); + + if (marker == null) return; // 既読 + + const packedMessageForTo = await this.chatMessageEntityService.pack(inserted, toUser); + this.globalEventService.publishMainStream(toUser.id, 'newChatMessage', packedMessageForTo); + this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo); + }/* else if (toGroup) { + const joinings = await this.userGroupJoiningsRepository.findBy({ userGroupId: toGroup.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); + } + }*/ + }, 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; + } + + @bindThis + public async readUserChatMessage( + readerId: MiUser['id'], + senderId: MiUser['id'], + ) { + const redisPipeline = this.redisClient.pipeline(); + redisPipeline.del(`newChatMessageExists:${readerId}:${senderId}`); + redisPipeline.srem(`newChatMessagesExists:${readerId}`, `user:${senderId}`); + redisPipeline.exec(); + } + + @bindThis + public async deleteMessage(message: MiChatMessage) { + await this.chatMessagesRepository.delete(message.id); + + if (message.toUserId) { + const fromUser = await this.usersRepository.findOneByOrFail({ id: message.fromUserId }); + const toUser = await 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.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.groupId) { + this.globalEventService.publishGroupChatStream(message.groupId, 'deleted', message.id); + }*/ + } + + /* + @bindThis + public async readGroupChatMessage( + userId: MiUser['id'], + groupId: MiUserGroup['id'], + messageIds: MiChatMessage['id'][], + ) { + if (messageIds.length === 0) return; + + // check joined + const joining = await this.userGroupJoiningsRepository.findOneBy({ + userId: userId, + userGroupId: groupId, + }); + + if (joining == null) { + throw new IdentifiableError('930a270c-714a-46b2-b776-ad27276dc569', 'Access denied (group).'); + } + + 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.publishGroupChatStream(groupId, '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.groupId = :groupId', { groupId: groupId }) + .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', { groupId }); + } + } + } + */ +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index dc85a23e5b..19863ac12d 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -75,6 +75,7 @@ import { ClipService } from './ClipService.js'; import { FeaturedService } from './FeaturedService.js'; import { FanoutTimelineService } from './FanoutTimelineService.js'; import { ChannelFollowingService } from './ChannelFollowingService.js'; +import { ChatService } from './ChatService.js'; import { RegistryApiService } from './RegistryApiService.js'; import { ReversiService } from './ReversiService.js'; @@ -100,6 +101,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 { ClipEntityService } from './entities/ClipEntityService.js'; import { DriveFileEntityService } from './entities/DriveFileEntityService.js'; import { DriveFolderEntityService } from './entities/DriveFolderEntityService.js'; @@ -221,6 +223,7 @@ const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: Fe const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService }; const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService }; const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService }; +const $ChatService: Provider = { provide: 'ChatService', useExisting: ChatService }; const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService }; const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService }; @@ -247,6 +250,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 $ClipEntityService: Provider = { provide: 'ClipEntityService', useExisting: ClipEntityService }; const $DriveFileEntityService: Provider = { provide: 'DriveFileEntityService', useExisting: DriveFileEntityService }; const $DriveFolderEntityService: Provider = { provide: 'DriveFolderEntityService', useExisting: DriveFolderEntityService }; @@ -370,6 +374,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting FanoutTimelineService, FanoutTimelineEndpointService, ChannelFollowingService, + ChatService, RegistryApiService, ReversiService, @@ -396,6 +401,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AuthSessionEntityService, BlockingEntityService, ChannelEntityService, + ChatMessageEntityService, ClipEntityService, DriveFileEntityService, DriveFolderEntityService, @@ -515,6 +521,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $FanoutTimelineService, $FanoutTimelineEndpointService, $ChannelFollowingService, + $ChatService, $RegistryApiService, $ReversiService, @@ -541,6 +548,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AuthSessionEntityService, $BlockingEntityService, $ChannelEntityService, + $ChatMessageEntityService, $ClipEntityService, $DriveFileEntityService, $DriveFolderEntityService, @@ -661,6 +669,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting FanoutTimelineService, FanoutTimelineEndpointService, ChannelFollowingService, + ChatService, RegistryApiService, ReversiService, @@ -686,6 +695,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AuthSessionEntityService, BlockingEntityService, ChannelEntityService, + ChatMessageEntityService, ClipEntityService, DriveFileEntityService, DriveFolderEntityService, @@ -804,6 +814,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $FanoutTimelineService, $FanoutTimelineEndpointService, $ChannelFollowingService, + $ChatService, $RegistryApiService, $ReversiService, @@ -829,6 +840,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AuthSessionEntityService, $BlockingEntityService, $ChannelEntityService, + $ChatMessageEntityService, $ClipEntityService, $DriveFileEntityService, $DriveFolderEntityService, diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 224fdabc4c..c1b4efb01e 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -163,6 +163,11 @@ export interface AdminEventTypes { }; } +export interface ChatEventTypes { + message: Packed<'ChatMessage'>; + deleted: Packed<'ChatMessage'>['id']; +} + export interface ReversiEventTypes { matched: { game: Packed<'ReversiGameDetailed'>; @@ -202,7 +207,7 @@ export interface ReversiGameEventTypes { type Events = { [K in keyof T]: { type: K; body: T[K]; } }; type EventUnionFromDictionary< T extends object, - U = Events + U = Events, > = U[keyof U]; type SerializedAll = { @@ -295,6 +300,10 @@ export type GlobalEvents = { name: 'notesStream'; payload: Serialized>; }; + chat: { + name: `chatStream:${MiUser['id']}-${MiUser['id']}`; + payload: EventTypesToEventPayload; + }; reversi: { name: `reversiStream:${MiUser['id']}`; payload: EventTypesToEventPayload; @@ -393,6 +402,11 @@ export class GlobalEventService { this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value); } + @bindThis + public publishChatStream(fromUserId: MiUser['id'], toUserId: MiUser['id'], type: K, value?: ChatEventTypes[K]): void { + this.publish(`chatStream:${fromUserId}-${toUserId}`, type, typeof value === 'undefined' ? null : value); + } + @bindThis public publishReversiStream(userId: MiUser['id'], type: K, value?: ReversiEventTypes[K]): void { this.publish(`reversiStream:${userId}`, type, typeof value === 'undefined' ? null : value); diff --git a/packages/backend/src/core/entities/ChatMessageEntityService.ts b/packages/backend/src/core/entities/ChatMessageEntityService.ts new file mode 100644 index 0000000000..1e3d1bcc94 --- /dev/null +++ b/packages/backend/src/core/entities/ChatMessageEntityService.ts @@ -0,0 +1,127 @@ +/* + * 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 | null>; + packedUsers: Map> + }; + }, + ): Promise> { + 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, + //toGroupId: message.toGroupId, + //toGroup: message.toGroupId && opts.populateGroup ? await this.userGroupEntityService.pack(message.toGroup ?? message.toGroupId) : 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 | null>; + }; + }, + ): Promise> { + 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, + //toGroupId: message.toGroupId, + 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 } }))); + } +} + diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index a306aac1a1..6f441ebb7b 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -83,6 +83,7 @@ export const DI = { flashsRepository: Symbol('flashsRepository'), flashLikesRepository: Symbol('flashLikesRepository'), userMemosRepository: Symbol('userMemosRepository'), + chatMessagesRepository: Symbol('chatMessagesRepository'), bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'), reversiGamesRepository: Symbol('reversiGamesRepository'), //#endregion diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index ac74d68c95..3264d46db1 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -63,6 +63,7 @@ import { } from '@/models/json-schema/meta.js'; import { packedSystemWebhookSchema } from '@/models/json-schema/system-webhook.js'; import { packedAbuseReportNotificationRecipientSchema } from '@/models/json-schema/abuse-report-notification-recipient.js'; +import { packedChatMessageSchema, packedChatMessageLiteSchema } from '@/models/json-schema/chat-message.js'; export const refs = { UserLite: packedUserLiteSchema, @@ -120,6 +121,8 @@ export const refs = { MetaDetailed: packedMetaDetailedSchema, SystemWebhook: packedSystemWebhookSchema, AbuseReportNotificationRecipient: packedAbuseReportNotificationRecipientSchema, + ChatMessage: packedChatMessageSchema, + ChatMessageLite: packedChatMessageLiteSchema, }; export type Packed = SchemaType; @@ -170,11 +173,11 @@ export interface Schema extends OfSchema { type RequiredPropertyNames = { [K in keyof s]: - // K is not optional - s[K]['optional'] extends false ? K : - // K has default value - s[K]['default'] extends null | string | number | boolean | Record ? K : - never + // K is not optional + s[K]['optional'] extends false ? K : + // K has default value + s[K]['default'] extends null | string | number | boolean | Record ? K : + never }[keyof s]; export type Obj = Record; @@ -213,11 +216,11 @@ type ObjectSchemaTypeDef

= p['anyOf'] extends ReadonlyArray ? p['anyOf'][number]['required'] extends ReadonlyArray ? UnionObjType> & ObjType> : never - : ObjType> - : - p['anyOf'] extends ReadonlyArray ? never : // see CONTRIBUTING.md - p['allOf'] extends ReadonlyArray ? UnionToIntersection> : - any; + : ObjType> + : + p['anyOf'] extends ReadonlyArray ? never : // see CONTRIBUTING.md + p['allOf'] extends ReadonlyArray ? UnionToIntersection> : + any; type ObjectSchemaType

= NullOrUndefined>; @@ -227,30 +230,30 @@ export type SchemaTypeDef

= p['type'] extends 'number' ? number : p['type'] extends 'string' ? ( p['enum'] extends readonly (string | null)[] ? - p['enum'][number] : - p['format'] extends 'date-time' ? string : // Dateにする?? - string + p['enum'][number] : + p['format'] extends 'date-time' ? string : // Dateにする?? + string ) : - p['type'] extends 'boolean' ? boolean : - p['type'] extends 'object' ? ObjectSchemaTypeDef

: - p['type'] extends 'array' ? ( - p['items'] extends OfSchema ? ( - p['items']['anyOf'] extends ReadonlyArray ? UnionSchemaType>[] : - p['items']['oneOf'] extends ReadonlyArray ? ArrayUnion>> : - p['items']['allOf'] extends ReadonlyArray ? UnionToIntersection>>[] : - never + p['type'] extends 'boolean' ? boolean : + p['type'] extends 'object' ? ObjectSchemaTypeDef

: + p['type'] extends 'array' ? ( + p['items'] extends OfSchema ? ( + p['items']['anyOf'] extends ReadonlyArray ? UnionSchemaType>[] : + p['items']['oneOf'] extends ReadonlyArray ? ArrayUnion>> : + p['items']['allOf'] extends ReadonlyArray ? UnionToIntersection>>[] : + never + ) : + p['prefixItems'] extends ReadonlyArray ? ( + p['items'] extends NonNullable ? [...ArrayToTuple, ...SchemaType[]] : + p['items'] extends false ? ArrayToTuple : + p['unevaluatedItems'] extends false ? ArrayToTuple : + [...ArrayToTuple, ...unknown[]] + ) : + p['items'] extends NonNullable ? SchemaType[] : + any[] ) : - p['prefixItems'] extends ReadonlyArray ? ( - p['items'] extends NonNullable ? [...ArrayToTuple, ...SchemaType[]] : - p['items'] extends false ? ArrayToTuple : - p['unevaluatedItems'] extends false ? ArrayToTuple : - [...ArrayToTuple, ...unknown[]] - ) : - p['items'] extends NonNullable ? SchemaType[] : - any[] - ) : - p['anyOf'] extends ReadonlyArray ? UnionSchemaType & PartialIntersection> : - p['oneOf'] extends ReadonlyArray ? UnionSchemaType : - any; + p['anyOf'] extends ReadonlyArray ? UnionSchemaType & PartialIntersection> : + p['oneOf'] extends ReadonlyArray ? UnionSchemaType : + any; export type SchemaType

= NullOrUndefined>; diff --git a/packages/backend/src/models/ChatMessage.ts b/packages/backend/src/models/ChatMessage.ts new file mode 100644 index 0000000000..964d8135b5 --- /dev/null +++ b/packages/backend/src/models/ChatMessage.ts @@ -0,0 +1,86 @@ +/* + * 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 { MiDriveFile } from './DriveFile.js'; + +@Entity('chat_message') +export class MiChatMessage { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + }) + public fromUserId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public fromUser: MiUser | null; + + @Index() + @Column({ + ...id(), nullable: true, + }) + public toUserId: MiUser['id'] | null; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public toUser: MiUser | null; + + /* + @Index() + @Column({ + ...id(), nullable: true, + }) + public toGroupId: MiUserGroup['id'] | null; + + @ManyToOne(type => MiUserGroup, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public toGroup: MiUserGroup | null; + */ + + @Column('varchar', { + length: 4096, nullable: true, + }) + public text: string | null; + + @Column('varchar', { + length: 512, nullable: true, + }) + public uri: string | null; + + @Column({ + ...id(), + array: true, default: '{}', + }) + public reads: MiUser['id'][]; + + @Column({ + ...id(), + nullable: true, + }) + public fileId: MiDriveFile['id'] | null; + + @ManyToOne(type => MiDriveFile, { + onDelete: 'SET NULL', + }) + @JoinColumn() + public file: MiDriveFile | null; + + @Column('varchar', { + length: 1024, array: true, default: '{}', + }) + public reactions: string[]; +} diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index fa15760c00..b98f8cb8b7 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -3,13 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { FindOneOptions, InsertQueryBuilder, ObjectLiteral, Repository, SelectQueryBuilder, TypeORMError } from 'typeorm'; -import { DriverUtils } from 'typeorm/driver/DriverUtils.js'; +import { FindOneOptions, InsertQueryBuilder, ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm'; import { RelationCountLoader } from 'typeorm/query-builder/relation-count/RelationCountLoader.js'; import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLoader.js'; import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js'; -import { ObjectUtils } from 'typeorm/util/ObjectUtils.js'; -import { OrmUtils } from 'typeorm/util/OrmUtils.js'; import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js'; import { MiAccessToken } from '@/models/AccessToken.js'; @@ -78,6 +75,7 @@ import { MiRoleAssignment } from '@/models/RoleAssignment.js'; import { MiFlash } from '@/models/Flash.js'; import { MiFlashLike } from '@/models/FlashLike.js'; import { MiUserListFavorite } from '@/models/UserListFavorite.js'; +import { MiChatMessage } from '@/models/ChatMessage.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; @@ -194,6 +192,7 @@ export { MiFlash, MiFlashLike, MiUserMemo, + MiChatMessage, MiBubbleGameRecord, MiReversiGame, }; @@ -266,5 +265,6 @@ export type RoleAssignmentsRepository = Repository & MiReposit export type FlashsRepository = Repository & MiRepository; export type FlashLikesRepository = Repository & MiRepository; export type UserMemoRepository = Repository & MiRepository; +export type ChatMessagesRepository = Repository & MiRepository; export type BubbleGameRecordsRepository = Repository & MiRepository; export type ReversiGamesRepository = Repository & MiRepository; diff --git a/packages/backend/src/models/json-schema/chat-message.ts b/packages/backend/src/models/json-schema/chat-message.ts new file mode 100644 index 0000000000..58e9af1bd3 --- /dev/null +++ b/packages/backend/src/models/json-schema/chat-message.ts @@ -0,0 +1,86 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedChatMessageSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + fromUserId: { + type: 'string', + optional: false, nullable: false, + }, + fromUser: { + type: 'object', + optional: true, nullable: false, + ref: 'UserLite', + }, + toUserId: { + type: 'string', + optional: true, nullable: true, + }, + toUser: { + type: 'object', + optional: true, nullable: true, + ref: 'UserLite', + }, + text: { + type: 'string', + optional: true, nullable: true, + }, + fileId: { + type: 'string', + optional: true, nullable: true, + }, + file: { + type: 'object', + optional: true, nullable: true, + ref: 'DriveFile', + }, + }, +} as const; + +export const packedChatMessageLiteSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + fromUserId: { + type: 'string', + optional: false, nullable: false, + }, + toUserId: { + type: 'string', + optional: true, nullable: true, + }, + text: { + type: 'string', + optional: true, nullable: true, + }, + fileId: { + type: 'string', + optional: true, nullable: true, + }, + file: { + type: 'object', + optional: true, nullable: true, + ref: 'DriveFile', + }, + }, +} as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 043332d4b5..96d4ba6a0a 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -76,6 +76,7 @@ import { MiRoleAssignment } from '@/models/RoleAssignment.js'; import { MiFlash } from '@/models/Flash.js'; import { MiFlashLike } from '@/models/FlashLike.js'; import { MiUserMemo } from '@/models/UserMemo.js'; +import { MiChatMessage } from '@/models/ChatMessage.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; @@ -236,6 +237,7 @@ export const entities = [ MiFlash, MiFlashLike, MiUserMemo, + MiChatMessage, MiBubbleGameRecord, MiReversiGame, ...charts, diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 560d3f6587..34e2e28568 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -397,4 +397,5 @@ 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 '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 new file mode 100644 index 0000000000..f317f9a5f5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/create.ts @@ -0,0 +1,169 @@ +/* + * 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 { DriveFilesRepository, MiUser } from '@/models/_.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + limit: { + duration: ms('1hour'), + max: 500, + }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessageLite', + }, + + errors: { + recipientIsYourself: { + message: 'You can not send a message to yourself.', + code: 'RECIPIENT_IS_YOURSELF', + id: '17e2ba79-e22a-4cbc-bf91-d327643f4a7e', + }, + + 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: 'c94e2a5d-06aa-4914-8fa6-6a42e73d6537', + }, + + groupAccessDenied: { + message: 'You can not send messages to groups that you have not joined.', + code: 'GROUP_ACCESS_DENIED', + id: 'd96b3cca-5ad1-438b-ad8b-02f931308fbd', + }, + + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: '4372b8e2-185d-4146-8749-2f68864a3e5f', + }, + + contentRequired: { + message: 'Content required. You need to set text or fileId.', + code: 'CONTENT_REQUIRED', + id: '25587321-b0e6-449c-9239-f8925092942c', + }, + + youHaveBeenBlocked: { + message: 'You cannot send a message because you have been blocked by this user.', + code: 'YOU_HAVE_BEEN_BLOCKED', + id: 'c15a5199-7422-4968-941a-2a462c478f7d', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + text: { type: 'string', nullable: true, maxLength: 2000 }, + fileId: { type: 'string', format: 'misskey:id' }, + }, + anyOf: [ + { + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], + }, + { + properties: { + groupId: { type: 'string', format: 'misskey:id' }, + }, + required: ['groupId'], + }, + ], +} as const; + +@Injectable() +export default class extends Endpoint { // 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) => { + let toUser: MiUser | null; + //let toGroup: UserGroup | null; + + if (ps.userId != null) { + // Myself + if (ps.userId === me.id) { + throw new ApiError(meta.errors.recipientIsYourself); + } + + // Fetch recipient (user) + 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; + }); + }/* else if (ps.groupId != null) { + // Fetch recipient (group) + recipientGroup = await this.userGroupsRepository.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); + } + }*/ + + 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, + }); + }); + } +}