diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 6696d7395d..ab433dd39c 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1892,6 +1892,7 @@ _role: canImportFollowing: "フォローのインポートを許可" canImportMuting: "ミュートのインポートを許可" canImportUserLists: "リストのインポートを許可" + canChat: "チャットを許可" _condition: roleAssignedTo: "マニュアルロールにアサイン済み" isLocal: "ローカルユーザー" diff --git a/packages/backend/src/core/ChatService.ts b/packages/backend/src/core/ChatService.ts index 8289f5252a..f5b9cf7e8d 100644 --- a/packages/backend/src/core/ChatService.ts +++ b/packages/backend/src/core/ChatService.ts @@ -16,9 +16,11 @@ import { ChatMessageEntityService } from '@/core/entities/ChatMessageEntityServi import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { PushNotificationService } from '@/core/PushNotificationService.js'; import { bindThis } from '@/decorators.js'; -import type { ChatMessagesRepository, MiChatMessage, MiDriveFile, MiUser, MutingsRepository, UsersRepository } from '@/models/_.js'; +import type { ChatMessagesRepository, 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'; @Injectable() export class ChatService { @@ -47,36 +49,46 @@ export class ChatService { private pushNotificationService: PushNotificationService, private userBlockingService: UserBlockingService, private queryService: QueryService, + private roleService: RoleService, + private userFollowingService: UserFollowingService, ) { } @bindThis - public async createMessage(params: { - fromUser: { id: MiUser['id']; host: MiUser['host']; }; - toUser?: MiUser | null; - //toRoom?: MiUserRoom | null; + public async createMessage(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toUser: MiUser, params: { text?: string | null; file?: MiDriveFile | null; uri?: string | null; }) { - const { fromUser, toUser /*toRoom*/ } = params; - - if (toUser == null /*&& toRoom == null*/) { - throw new Error('recipient is required'); + if (fromUser.id === toUser.id) { + throw new Error('yourself'); } - if (toUser) { - const blocked = await this.userBlockingService.checkBlocked(toUser.id, fromUser.id); - if (blocked) { - throw new Error('blocked'); + if (toUser.chatScope === 'none') { + throw new Error('recipient is cannot chat'); + } else if (toUser.chatScope === 'followers') { + + } else if (toUser.chatScope === 'following') { + } else if (toUser.chatScope === 'mutual') { + const isMutual = await this.userFollowingService.isMutual(fromUser.id, toUser.id); + if (!isMutual) { + throw new Error('recipient is cannot chat'); } } + if ((await this.roleService.getUserPolicies(toUser.id)).canChat) { + throw new Error('recipient is cannot chat'); + } + + 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, - //toRoomId: recipientRoom ? recipientRoom.id : null, + toUserId: toUser.id, text: params.text ? params.text.trim() : null, fileId: params.file ? params.file.id : null, reads: [], @@ -87,36 +99,26 @@ export class ChatService { const packedMessage = await this.chatMessageEntityService.packLite(inserted); - if (toUser) { + if (this.userEntityService.isLocalUser(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(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 (toRoom) { - // グループのストリーム - this.globalEventService.publishRoomChatStream(toRoom.id, 'message', messageObj); - - // メンバーのストリーム - 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); - } - }*/ + if (this.userEntityService.isLocalUser(toUser)) { + // 相手のストリーム + this.globalEventService.publishChatStream(toUser.id, fromUser.id, 'message', packedMessage); + } // 3秒経っても既読にならなかったらイベント発行 - setTimeout(async () => { - if (toUser && this.userEntityService.isLocalUser(toUser)) { + if (this.userEntityService.isLocalUser(toUser)) { + setTimeout(async () => { const marker = await this.redisClient.get(`newChatMessageExists:${toUser.id}:${fromUser.id}`); if (marker == null) return; // 既読 @@ -124,15 +126,8 @@ export class ChatService { const packedMessageForTo = await this.chatMessageEntityService.pack(inserted, toUser); this.globalEventService.publishMainStream(toUser.id, 'newChatMessage', packedMessageForTo); this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo); - }/* else if (toRoom) { - 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); - } - }*/ - }, 3000); + }, 3000); + } /* TODO: AP if (toUser && this.userEntityService.isLocalUser(fromUser) && this.userEntityService.isRemoteUser(toUser)) { @@ -160,6 +155,53 @@ export class ChatService { return packedMessage; } + @bindThis + public async createMessageToRoom(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toRoom: MiChatRoom, params: { + text?: string | null; + file?: MiDriveFile | null; + uri?: string | null; + }) { + const message = { + id: this.idService.gen(), + fromUserId: fromUser.id, + toRoomId: toRoom.id, + 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); + + /* + // グループのストリーム + this.globalEventService.publishRoomChatStream(toRoom.id, 'message', messageObj); + + // メンバーのストリーム + 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); + } + */ + + // 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); + } + */ + }, 3000); + + return packedMessage; + } + @bindThis public async readUserChatMessage( readerId: MiUser['id'], diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 01f3e0c116..86f8a5caa1 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -63,6 +63,7 @@ export type RolePolicies = { canImportFollowing: boolean; canImportMuting: boolean; canImportUserLists: boolean; + canChat: boolean; }; export const DEFAULT_POLICIES: RolePolicies = { @@ -97,6 +98,7 @@ export const DEFAULT_POLICIES: RolePolicies = { canImportFollowing: true, canImportMuting: true, canImportUserLists: true, + canChat: true, }; @Injectable() @@ -400,6 +402,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)), canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)), canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)), + canChat: calc('canChat', vs => vs.some(v => v === true)), }; } diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index b98ca97ec9..aac340f079 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; -import { IsNull } from 'typeorm'; +import { Brackets, IsNull } from 'typeorm'; import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { QueueService } from '@/core/QueueService.js'; @@ -736,4 +736,20 @@ export class UserFollowingService implements OnModuleInit { .where('following.followerId = :followerId', { followerId: userId }) .getMany(); } + + @bindThis + public async isMutual(aUserId: MiUser['id'], bUserId: MiUser['id']) { + const count = await this.followingsRepository.createQueryBuilder('following') + .where(new Brackets(qb => { + qb.where('following.followerId = :aUserId', { aUserId }) + .andWhere('following.followeeId = :bUserId', { bUserId }); + })) + .orWhere(new Brackets(qb => { + qb.where('following.followerId = :bUserId', { bUserId }) + .andWhere('following.followeeId = :aUserId', { aUserId }); + })) + .getCount(); + + return count === 2; + } } diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 630240efde..bc652cea62 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -225,6 +225,17 @@ export class MiUser { }) public emojis: string[]; + // チャットを許可する相手 + // everyone: 誰からでも + // followers: フォロワーのみ + // following: フォローしているユーザーのみ + // mutual: 相互フォローのみ + // none: 誰からも受け付けない + @Column('varchar', { + length: 128, default: 'mutual', + }) + public chatScope: 'everyone' | 'followers' | 'following' | 'mutual' | 'none'; + @Index() @Column('varchar', { length: 128, nullable: true, diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 3537de94c8..1685a806c9 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -292,6 +292,10 @@ export const packedRolePoliciesSchema = { type: 'boolean', optional: false, nullable: false, }, + canChat: { + type: 'boolean', + optional: false, nullable: false, + }, }, } as const; 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 7e91eb8121..6ad100c116 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/create.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/create.ts @@ -16,6 +16,7 @@ export const meta = { tags: ['chat'], requireCredential: true, + requiredRolePolicy: 'canChat', prohibitMoved: true, diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts index 9e20479e26..cb9b1df711 100644 --- a/packages/frontend-shared/js/const.ts +++ b/packages/frontend-shared/js/const.ts @@ -107,6 +107,7 @@ export const ROLE_POLICIES = [ 'canImportFollowing', 'canImportMuting', 'canImportUserLists', + 'canChat', ] as const; // なんか動かない diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 4e9f4edb70..d1e823215a 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -160,6 +160,26 @@ SPDX-License-Identifier: AGPL-3.0-only + + + +
+ + + + + + + + + +
+
+