This commit is contained in:
syuilo
2025-03-22 09:20:50 +09:00
parent 202c1b2910
commit 37ee6ec9a8
11 changed files with 156 additions and 48 deletions

View File

@@ -1892,6 +1892,7 @@ _role:
canImportFollowing: "フォローのインポートを許可" canImportFollowing: "フォローのインポートを許可"
canImportMuting: "ミュートのインポートを許可" canImportMuting: "ミュートのインポートを許可"
canImportUserLists: "リストのインポートを許可" canImportUserLists: "リストのインポートを許可"
canChat: "チャットを許可"
_condition: _condition:
roleAssignedTo: "マニュアルロールにアサイン済み" roleAssignedTo: "マニュアルロールにアサイン済み"
isLocal: "ローカルユーザー" isLocal: "ローカルユーザー"

View File

@@ -16,9 +16,11 @@ import { ChatMessageEntityService } from '@/core/entities/ChatMessageEntityServi
import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { PushNotificationService } from '@/core/PushNotificationService.js'; import { PushNotificationService } from '@/core/PushNotificationService.js';
import { bindThis } from '@/decorators.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 { UserBlockingService } from '@/core/UserBlockingService.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { RoleService } from '@/core/RoleService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
@Injectable() @Injectable()
export class ChatService { export class ChatService {
@@ -47,36 +49,46 @@ export class ChatService {
private pushNotificationService: PushNotificationService, private pushNotificationService: PushNotificationService,
private userBlockingService: UserBlockingService, private userBlockingService: UserBlockingService,
private queryService: QueryService, private queryService: QueryService,
private roleService: RoleService,
private userFollowingService: UserFollowingService,
) { ) {
} }
@bindThis @bindThis
public async createMessage(params: { public async createMessage(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toUser: MiUser, params: {
fromUser: { id: MiUser['id']; host: MiUser['host']; };
toUser?: MiUser | 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 /*toRoom*/ } = params; if (fromUser.id === toUser.id) {
throw new Error('yourself');
if (toUser == null /*&& toRoom == null*/) {
throw new Error('recipient is required');
} }
if (toUser) { if (toUser.chatScope === 'none') {
const blocked = await this.userBlockingService.checkBlocked(toUser.id, fromUser.id); throw new Error('recipient is cannot chat');
if (blocked) { } else if (toUser.chatScope === 'followers') {
throw new Error('blocked');
} 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 = { const message = {
id: this.idService.gen(), id: this.idService.gen(),
fromUserId: fromUser.id, fromUserId: fromUser.id,
toUserId: toUser ? toUser.id : null, toUserId: toUser.id,
//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: [],
@@ -87,36 +99,26 @@ export class ChatService {
const packedMessage = await this.chatMessageEntityService.packLite(inserted); const packedMessage = await this.chatMessageEntityService.packLite(inserted);
if (toUser) { if (this.userEntityService.isLocalUser(toUser)) {
const redisPipeline = this.redisClient.pipeline(); const redisPipeline = this.redisClient.pipeline();
redisPipeline.set(`newChatMessageExists:${toUser.id}:${fromUser.id}`, message.id); redisPipeline.set(`newChatMessageExists:${toUser.id}:${fromUser.id}`, message.id);
redisPipeline.sadd(`newChatMessagesExists:${toUser.id}`, `user:${fromUser.id}`); redisPipeline.sadd(`newChatMessagesExists:${toUser.id}`, `user:${fromUser.id}`);
redisPipeline.exec(); redisPipeline.exec();
}
if (this.userEntityService.isLocalUser(fromUser)) { if (this.userEntityService.isLocalUser(fromUser)) {
// 自分のストリーム // 自分のストリーム
this.globalEventService.publishChatStream(fromUser.id, toUser.id, 'message', packedMessage); this.globalEventService.publishChatStream(fromUser.id, toUser.id, 'message', packedMessage);
} }
if (this.userEntityService.isLocalUser(toUser)) { if (this.userEntityService.isLocalUser(toUser)) {
// 相手のストリーム // 相手のストリーム
this.globalEventService.publishChatStream(toUser.id, fromUser.id, 'message', packedMessage); 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);
}
}*/
// 3秒経っても既読にならなかったらイベント発行 // 3秒経っても既読にならなかったらイベント発行
setTimeout(async () => { if (this.userEntityService.isLocalUser(toUser)) {
if (toUser && this.userEntityService.isLocalUser(toUser)) { setTimeout(async () => {
const marker = await this.redisClient.get(`newChatMessageExists:${toUser.id}:${fromUser.id}`); const marker = await this.redisClient.get(`newChatMessageExists:${toUser.id}:${fromUser.id}`);
if (marker == null) return; // 既読 if (marker == null) return; // 既読
@@ -124,15 +126,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 (toRoom) { }, 3000);
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);
/* TODO: AP /* TODO: AP
if (toUser && this.userEntityService.isLocalUser(fromUser) && this.userEntityService.isRemoteUser(toUser)) { if (toUser && this.userEntityService.isLocalUser(fromUser) && this.userEntityService.isRemoteUser(toUser)) {
@@ -160,6 +155,53 @@ export class ChatService {
return packedMessage; 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<MiChatMessage>;
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 @bindThis
public async readUserChatMessage( public async readUserChatMessage(
readerId: MiUser['id'], readerId: MiUser['id'],

View File

@@ -63,6 +63,7 @@ export type RolePolicies = {
canImportFollowing: boolean; canImportFollowing: boolean;
canImportMuting: boolean; canImportMuting: boolean;
canImportUserLists: boolean; canImportUserLists: boolean;
canChat: boolean;
}; };
export const DEFAULT_POLICIES: RolePolicies = { export const DEFAULT_POLICIES: RolePolicies = {
@@ -97,6 +98,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
canImportFollowing: true, canImportFollowing: true,
canImportMuting: true, canImportMuting: true,
canImportUserLists: true, canImportUserLists: true,
canChat: true,
}; };
@Injectable() @Injectable()
@@ -400,6 +402,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)), canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)),
canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)), canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)),
canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)), canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)),
canChat: calc('canChat', vs => vs.some(v => v === true)),
}; };
} }

View File

@@ -5,7 +5,7 @@
import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core'; 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 type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { QueueService } from '@/core/QueueService.js'; import { QueueService } from '@/core/QueueService.js';
@@ -736,4 +736,20 @@ export class UserFollowingService implements OnModuleInit {
.where('following.followerId = :followerId', { followerId: userId }) .where('following.followerId = :followerId', { followerId: userId })
.getMany(); .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;
}
} }

View File

@@ -225,6 +225,17 @@ export class MiUser {
}) })
public emojis: string[]; public emojis: string[];
// チャットを許可する相手
// everyone: 誰からでも
// followers: フォロワーのみ
// following: フォローしているユーザーのみ
// mutual: 相互フォローのみ
// none: 誰からも受け付けない
@Column('varchar', {
length: 128, default: 'mutual',
})
public chatScope: 'everyone' | 'followers' | 'following' | 'mutual' | 'none';
@Index() @Index()
@Column('varchar', { @Column('varchar', {
length: 128, nullable: true, length: 128, nullable: true,

View File

@@ -292,6 +292,10 @@ export const packedRolePoliciesSchema = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
canChat: {
type: 'boolean',
optional: false, nullable: false,
},
}, },
} as const; } as const;

View File

@@ -16,6 +16,7 @@ export const meta = {
tags: ['chat'], tags: ['chat'],
requireCredential: true, requireCredential: true,
requiredRolePolicy: 'canChat',
prohibitMoved: true, prohibitMoved: true,

View File

@@ -107,6 +107,7 @@ export const ROLE_POLICIES = [
'canImportFollowing', 'canImportFollowing',
'canImportMuting', 'canImportMuting',
'canImportUserLists', 'canImportUserLists',
'canChat',
] as const; ] as const;
// なんか動かない // なんか動かない

View File

@@ -160,6 +160,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</MkFolder> </MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canChat, 'canChat'])">
<template #label>{{ i18n.ts._role._options.canChat }}</template>
<template #suffix>
<span v-if="role.policies.canChat.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canChat.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canChat)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canChat.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canChat.value" :disabled="role.policies.canChat.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canChat.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])"> <MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
<template #label>{{ i18n.ts._role._options.mentionMax }}</template> <template #label>{{ i18n.ts._role._options.mentionMax }}</template>
<template #suffix> <template #suffix>

View File

@@ -51,6 +51,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch> </MkSwitch>
</MkFolder> </MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canChat, 'canChat'])">
<template #label>{{ i18n.ts._role._options.canChat }}</template>
<template #suffix>{{ policies.canChat ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canChat">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])"> <MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
<template #label>{{ i18n.ts._role._options.mentionMax }}</template> <template #label>{{ i18n.ts._role._options.mentionMax }}</template>
<template #suffix>{{ policies.mentionLimit }}</template> <template #suffix>{{ policies.mentionLimit }}</template>

View File

@@ -4974,6 +4974,7 @@ export type components = {
canImportFollowing: boolean; canImportFollowing: boolean;
canImportMuting: boolean; canImportMuting: boolean;
canImportUserLists: boolean; canImportUserLists: boolean;
canChat: boolean;
}; };
ReversiGameLite: { ReversiGameLite: {
/** Format: id */ /** Format: id */
@@ -13800,7 +13801,7 @@ export type operations = {
/** Format: misskey:id */ /** Format: misskey:id */
fileId?: string; fileId?: string;
/** Format: misskey:id */ /** Format: misskey:id */
userId?: string | null; toUserId?: string | null;
}; };
}; };
}; };