Merge branch 'misskey-develop' into merge/2025-03-24

# Conflicts:
#	package.json
#	packages/backend/src/core/entities/NotificationEntityService.ts
#	packages/backend/src/types.ts
#	packages/frontend/src/pages/admin/modlog.ModLog.vue
#	packages/misskey-js/src/consts.ts
#	packages/misskey-js/src/entities.ts
This commit is contained in:
Hazelnoot 2025-03-25 16:17:34 -04:00
commit 40975719ec
82 changed files with 1563 additions and 298 deletions

View file

@ -26,11 +26,27 @@ import { Packed } from '@/misc/json-schema.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { emojiRegex } from '@/misc/emoji-regex.js';
import { NotificationService } from '@/core/NotificationService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
const MAX_ROOM_MEMBERS = 30;
const MAX_REACTIONS_PER_MESSAGE = 100;
const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/;
// TODO: ReactionServiceのやつと共通化
function normalizeEmojiString(x: string) {
const match = emojiRegex.exec(x);
if (match) {
// 合字を含む1つの絵文字
const unicode = match[0];
// 異体字セレクタ除去
return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, '');
} else {
throw new Error('invalid emoji');
}
}
@Injectable()
export class ChatService {
constructor(
@ -68,11 +84,13 @@ export class ChatService {
private apRendererService: ApRendererService,
private queueService: QueueService,
private pushNotificationService: PushNotificationService,
private notificationService: NotificationService,
private userBlockingService: UserBlockingService,
private queryService: QueryService,
private roleService: RoleService,
private userFollowingService: UserFollowingService,
private customEmojiService: CustomEmojiService,
private moderationLogService: ModerationLogService,
) {
}
@ -199,6 +217,8 @@ export class ChatService {
throw new Error('you are not a member of the room');
}
const membershipsOtherThanMe = memberships.filter(member => member.userId !== fromUser.id);
const message = {
id: this.idService.gen(),
fromUserId: fromUser.id,
@ -216,7 +236,7 @@ export class ChatService {
this.globalEventService.publishChatRoomStream(toRoom.id, 'message', packedMessage);
const redisPipeline = this.redisClient.pipeline();
for (const membership of memberships) {
for (const membership of membershipsOtherThanMe) {
if (membership.isMuted) continue;
redisPipeline.set(`newRoomChatMessageExists:${membership.userId}:${toRoom.id}`, message.id);
@ -227,7 +247,7 @@ export class ChatService {
// 3秒経っても既読にならなかったらイベント発行
setTimeout(async () => {
const redisPipeline = this.redisClient.pipeline();
for (const membership of memberships) {
for (const membership of membershipsOtherThanMe) {
redisPipeline.get(`newRoomChatMessageExists:${membership.userId}:${toRoom.id}`);
}
const markers = await redisPipeline.exec();
@ -237,12 +257,12 @@ export class ChatService {
const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted);
for (let i = 0; i < memberships.length; i++) {
for (let i = 0; i < membershipsOtherThanMe.length; i++) {
const marker = markers[i][1];
if (marker == null) continue;
this.globalEventService.publishMainStream(memberships[i].userId, 'newChatMessage', packedMessageForTo);
//this.pushNotificationService.pushNotification(memberships[i].userId, 'newChatMessage', packedMessageForTo);
this.globalEventService.publishMainStream(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo);
//this.pushNotificationService.pushNotification(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo);
}
}, 3000);
@ -281,6 +301,20 @@ export class ChatService {
return this.chatMessagesRepository.findOneBy({ id: messageId, fromUserId: userId });
}
@bindThis
public async hasPermissionToViewRoomTimeline(meId: MiUser['id'], room: MiChatRoom) {
if (await this.isRoomMember(room, meId)) {
return true;
} else {
const iAmModerator = await this.roleService.isModerator({ id: meId });
if (iAmModerator) {
return true;
}
return false;
}
}
@bindThis
public async deleteMessage(message: MiChatMessage) {
await this.chatMessagesRepository.delete(message.id);
@ -330,7 +364,7 @@ export class ChatService {
@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 })
.andWhere('message.toRoomId = :roomId', { roomId })
.leftJoinAndSelect('message.file', 'file')
.leftJoinAndSelect('message.fromUser', 'fromUser');
@ -489,8 +523,33 @@ export class ChatService {
}
@bindThis
public async deleteRoom(room: MiChatRoom) {
public async hasPermissionToDeleteRoom(meId: MiUser['id'], room: MiChatRoom) {
if (room.ownerId === meId) {
return true;
}
const iAmModerator = await this.roleService.isModerator({ id: meId });
if (iAmModerator) {
return true;
}
return false;
}
@bindThis
public async deleteRoom(room: MiChatRoom, deleter?: MiUser) {
await this.chatRoomsRepository.delete(room.id);
if (deleter) {
const deleterIsModerator = await this.roleService.isModerator(deleter);
if (deleterIsModerator) {
this.moderationLogService.log(deleter, 'deleteChatRoom', {
roomId: room.id,
room: room,
});
}
}
}
@bindThis
@ -542,13 +601,27 @@ export class ChatService {
const created = await this.chatRoomInvitationsRepository.insertOne(invitation);
this.notificationService.createNotification(inviteeId, 'chatRoomInvitationReceived', {
invitationId: invitation.id,
}, inviterId);
return created;
}
@bindThis
public async getSentRoomInvitationsWithPagination(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatRoomInvitation['id'] | null, untilId?: MiChatRoomInvitation['id'] | null) {
const query = this.queryService.makePaginationQuery(this.chatRoomInvitationsRepository.createQueryBuilder('invitation'), sinceId, untilId)
.andWhere('invitation.roomId = :roomId', { roomId });
const invitations = await query.take(limit).getMany();
return invitations;
}
@bindThis
public async getOwnedRoomsWithPagination(ownerId: MiUser['id'], limit: number, sinceId?: MiChatRoom['id'] | null, untilId?: MiChatRoom['id'] | null) {
const query = this.queryService.makePaginationQuery(this.chatRoomsRepository.createQueryBuilder('room'), sinceId, untilId)
.where('room.ownerId = :ownerId', { ownerId });
.andWhere('room.ownerId = :ownerId', { ownerId });
const rooms = await query.take(limit).getMany();
@ -558,7 +631,7 @@ 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 })
.andWhere('invitation.userId = :userId', { userId })
.andWhere('invitation.ignored = FALSE');
const invitations = await query.take(limit).getMany();
@ -622,7 +695,7 @@ export class ChatService {
@bindThis
public async getRoomMembershipsWithPagination(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatRoomMembership['id'] | null, untilId?: MiChatRoomMembership['id'] | null) {
const query = this.queryService.makePaginationQuery(this.chatRoomMembershipsRepository.createQueryBuilder('membership'), sinceId, untilId)
.where('membership.roomId = :roomId', { roomId });
.andWhere('membership.roomId = :roomId', { roomId });
const memberships = await query.take(limit).getMany();
@ -692,24 +765,10 @@ export class ChatService {
public async react(messageId: MiChatMessage['id'], userId: MiUser['id'], reaction_: string) {
let reaction;
// TODO: ReactionServiceのやつと共通化
function normalize(x: string) {
const match = emojiRegex.exec(x);
if (match) {
// 合字を含む1つの絵文字
const unicode = match[0];
// 異体字セレクタ除去
return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, '');
} else {
throw new Error('invalid emoji');
}
}
const custom = reaction_.match(isCustomEmojiRegexp);
if (custom == null) {
reaction = normalize(reaction_);
reaction = normalizeEmojiString(reaction_);
} else {
const name = custom[1];
const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name);
@ -768,10 +827,56 @@ export class ChatService {
}
}
@bindThis
public async unreact(messageId: MiChatMessage['id'], userId: MiUser['id'], reaction_: string) {
let reaction;
const custom = reaction_.match(isCustomEmojiRegexp);
if (custom == null) {
reaction = normalizeEmojiString(reaction_);
} else { // 削除されたカスタム絵文字のリアクションを削除したいかもしれないので絵文字の存在チェックはする必要なし
const name = custom[1];
reaction = `:${name}:`;
}
// NOTE: 自分のリアクションを(あれば)削除するだけなので諸々の権限チェックは必要なし
const message = await this.chatMessagesRepository.findOneByOrFail({ id: messageId });
const room = message.toRoomId ? await this.chatRoomsRepository.findOneByOrFail({ id: message.toRoomId }) : null;
await this.chatMessagesRepository.createQueryBuilder().update()
.set({
reactions: () => `array_remove("reactions", '${userId}/${reaction}')`,
})
.where('id = :id', { id: message.id })
.execute();
// TODO: 実際に削除が行われたときのみイベントを発行する
if (room) {
this.globalEventService.publishChatRoomStream(room.id, 'unreact', {
messageId: message.id,
user: await this.userEntityService.pack(userId),
reaction,
});
} else {
this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId!, 'unreact', {
messageId: message.id,
reaction,
});
this.globalEventService.publishChatUserStream(message.toUserId!, message.fromUserId, 'unreact', {
messageId: message.id,
reaction,
});
}
}
@bindThis
public async getMyMemberships(userId: MiUser['id'], limit: number, sinceId?: MiChatRoomMembership['id'] | null, untilId?: MiChatRoomMembership['id'] | null) {
const query = this.queryService.makePaginationQuery(this.chatRoomMembershipsRepository.createQueryBuilder('membership'), sinceId, untilId)
.where('membership.userId = :userId', { userId });
.andWhere('membership.userId = :userId', { userId });
const memberships = await query.take(limit).getMany();

View file

@ -172,6 +172,11 @@ export interface ChatEventTypes {
user?: Packed<'UserLite'>;
messageId: MiChatMessage['id'];
};
unreact: {
reaction: string;
user?: Packed<'UserLite'>;
messageId: MiChatMessage['id'];
};
}
export interface ReversiEventTypes {

View file

@ -16,6 +16,7 @@ import { bindThis } from '@/decorators.js';
import { FilterUnionByProperty, groupedNotificationTypes } from '@/types.js';
import { CacheService } from '@/core/CacheService.js';
import { RoleEntityService } from './RoleEntityService.js';
import { ChatEntityService } from './ChatEntityService.js';
import type { OnModuleInit } from '@nestjs/common';
import type { UserEntityService } from './UserEntityService.js';
import type { NoteEntityService } from './NoteEntityService.js';
@ -27,6 +28,7 @@ export class NotificationEntityService implements OnModuleInit {
private userEntityService: UserEntityService;
private noteEntityService: NoteEntityService;
private roleEntityService: RoleEntityService;
private chatEntityService: ChatEntityService;
constructor(
private moduleRef: ModuleRef,
@ -41,9 +43,6 @@ export class NotificationEntityService implements OnModuleInit {
private followRequestsRepository: FollowRequestsRepository,
private cacheService: CacheService,
//private userEntityService: UserEntityService,
//private noteEntityService: NoteEntityService,
) {
}
@ -51,6 +50,7 @@ export class NotificationEntityService implements OnModuleInit {
this.userEntityService = this.moduleRef.get('UserEntityService');
this.noteEntityService = this.moduleRef.get('NoteEntityService');
this.roleEntityService = this.moduleRef.get('RoleEntityService');
this.chatEntityService = this.moduleRef.get('ChatEntityService');
}
/**
@ -59,7 +59,6 @@ export class NotificationEntityService implements OnModuleInit {
async #packInternal <T extends MiNotification | MiGroupedNotification> (
src: T,
meId: MiUser['id'],
options: {
checkValidNotifier?: boolean;
},
@ -92,7 +91,7 @@ export class NotificationEntityService implements OnModuleInit {
// if the user has been deleted, don't show this notification
if (needsUser && !userIfNeed) return null;
// #region Grouped notifications
//#region Grouped notifications
if (notification.type === 'reaction:grouped') {
const reactions = (await Promise.all(notification.reactions.map(async reaction => {
const user = hint?.packedUsers != null
@ -137,7 +136,7 @@ export class NotificationEntityService implements OnModuleInit {
users,
});
}
// #endregion
//#endregion
const needsRole = notification.type === 'roleAssigned';
const role = needsRole
@ -151,6 +150,13 @@ export class NotificationEntityService implements OnModuleInit {
return null;
}
const needsChatRoomInvitation = notification.type === 'chatRoomInvitationReceived';
const chatRoomInvitation = needsChatRoomInvitation ? await this.chatEntityService.packRoomInvitation(notification.invitationId, { id: meId }).catch(() => null) : undefined;
// if the invitation has been deleted, don't show this notification
if (needsChatRoomInvitation && !chatRoomInvitation) {
return null;
}
return await awaitAll({
id: notification.id,
createdAt: new Date(notification.createdAt).toISOString(),
@ -164,6 +170,9 @@ export class NotificationEntityService implements OnModuleInit {
...(notification.type === 'roleAssigned' ? {
role: role,
} : {}),
...(notification.type === 'chatRoomInvitationReceived' ? {
invitation: chatRoomInvitation,
} : {}),
...(notification.type === 'followRequestAccepted' ? {
message: notification.message,
} : {}),

View file

@ -75,6 +75,12 @@ export type MiNotification = {
id: string;
createdAt: string;
roleId: MiRole['id'];
} | {
type: 'chatRoomInvitationReceived';
id: string;
createdAt: string;
notifierId: MiUser['id'];
invitationId: string;
} | {
type: 'achievementEarned';
id: string;

View file

@ -287,6 +287,21 @@ export const packedNotificationSchema = {
optional: false, nullable: false,
},
},
}, {
type: 'object',
properties: {
...baseSchema.properties,
type: {
type: 'string',
optional: false, nullable: false,
enum: ['chatRoomInvitationReceived'],
},
invitation: {
type: 'object',
ref: 'ChatRoomInvitation',
optional: false, nullable: false,
},
},
}, {
type: 'object',
properties: {

View file

@ -681,6 +681,7 @@ export const packedMeDetailedOnlySchema = {
receiveFollowRequest: { optional: true, ...notificationRecieveConfig },
followRequestAccepted: { optional: true, ...notificationRecieveConfig },
roleAssigned: { optional: true, ...notificationRecieveConfig },
chatRoomInvitationReceived: { optional: true, ...notificationRecieveConfig },
achievementEarned: { optional: true, ...notificationRecieveConfig },
app: { optional: true, ...notificationRecieveConfig },
test: { optional: true, ...notificationRecieveConfig },

View file

@ -423,6 +423,7 @@ export * as 'chat/messages/create-to-room' from './endpoints/chat/messages/creat
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/react' from './endpoints/chat/messages/react.js';
export * as 'chat/messages/unreact' from './endpoints/chat/messages/unreact.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/messages/search' from './endpoints/chat/messages/search.js';
@ -439,5 +440,6 @@ 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/ignore' from './endpoints/chat/rooms/invitations/ignore.js';
export * as 'chat/rooms/invitations/inbox' from './endpoints/chat/rooms/invitations/inbox.js';
export * as 'chat/rooms/invitations/outbox' from './endpoints/chat/rooms/invitations/outbox.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

@ -111,6 +111,7 @@ export const meta = {
receiveFollowRequest: { optional: true, ...notificationRecieveConfig },
followRequestAccepted: { optional: true, ...notificationRecieveConfig },
roleAssigned: { optional: true, ...notificationRecieveConfig },
chatRoomInvitationReceived: { optional: true, ...notificationRecieveConfig },
achievementEarned: { optional: true, ...notificationRecieveConfig },
app: { optional: true, ...notificationRecieveConfig },
test: { optional: true, ...notificationRecieveConfig },

View file

@ -59,7 +59,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchRoom);
}
if (!(await this.chatService.isRoomMember(room, me.id))) {
if (!await this.chatService.hasPermissionToViewRoomTimeline(me.id, room)) {
throw new ApiError(meta.errors.noSuchRoom);
}

View file

@ -0,0 +1,49 @@
/*
* 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 { ApiError } from '@/server/api/error.js';
export const meta = {
tags: ['chat'],
requireCredential: true,
kind: 'write:chat',
res: {
},
errors: {
noSuchMessage: {
message: 'No such message.',
code: 'NO_SUCH_MESSAGE',
id: 'c39ea42f-e3ca-428a-ad57-390e0a711595',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
messageId: { type: 'string', format: 'misskey:id' },
reaction: { type: 'string' },
},
required: ['messageId', 'reaction'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private chatService: ChatService,
) {
super(meta, paramDef, async (ps, me) => {
await this.chatService.unreact(ps.messageId, me.id, ps.reaction);
});
}
}

View file

@ -42,11 +42,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private chatService: ChatService,
) {
super(meta, paramDef, async (ps, me) => {
const room = await this.chatService.findMyRoomById(me.id, ps.roomId);
const room = await this.chatService.findRoomById(ps.roomId);
if (room == null) {
throw new ApiError(meta.errors.noSuchRoom);
}
await this.chatService.deleteRoom(room);
if (!await this.chatService.hasPermissionToDeleteRoom(me.id, room)) {
throw new ApiError(meta.errors.noSuchRoom);
}
await this.chatService.deleteRoom(room, me);
});
}
}

View file

@ -0,0 +1,67 @@
/*
* 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 { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
import { ChatService } from '@/core/ChatService.js';
import { ChatEntityService } from '@/core/entities/ChatEntityService.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: 'ChatRoomInvitation',
},
},
errors: {
noSuchRoom: {
message: 'No such room.',
code: 'NO_SUCH_ROOM',
id: 'a3c6b309-9717-4316-ae94-a69b53437237',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
roomId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { 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 chatService: ChatService,
private chatEntityService: ChatEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const room = await this.chatService.findMyRoomById(me.id, ps.roomId);
if (room == null) {
throw new ApiError(meta.errors.noSuchRoom);
}
const invitations = await this.chatService.getSentRoomInvitationsWithPagination(ps.roomId, ps.limit, ps.sinceId, ps.untilId);
return this.chatEntityService.packRoomInvitations(invitations, me);
});
}
}

View file

@ -237,6 +237,7 @@ export const paramDef = {
receiveFollowRequest: notificationRecieveConfig,
followRequestAccepted: notificationRecieveConfig,
roleAssigned: notificationRecieveConfig,
chatRoomInvitationReceived: notificationRecieveConfig,
achievementEarned: notificationRecieveConfig,
app: notificationRecieveConfig,
test: notificationRecieveConfig,

View file

@ -15,6 +15,7 @@
* receiveFollowRequest -
* followRequestAccepted -
* roleAssigned -
* chatRoomInvitationReceived -
* achievementEarned -
* exportCompleted -
* login -
@ -35,6 +36,7 @@ export const notificationTypes = [
'receiveFollowRequest',
'followRequestAccepted',
'roleAssigned',
'chatRoomInvitationReceived',
'achievementEarned',
'exportCompleted',
'login',
@ -134,6 +136,7 @@ export const moderationLogTypes = [
'deletePage',
'deleteFlash',
'deleteGalleryPost',
'deleteChatRoom',
'acceptQuotesUser',
'rejectQuotesUser',
'acceptQuotesInstance',
@ -453,6 +456,10 @@ export type ModerationLogPayloads = {
id: string;
host: string;
};
deleteChatRoom: {
roomId: string;
room: any;
};
updateProxyAccountDescription: {
before: string | null;
after: string | null;