mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
269 lines
9.6 KiB
TypeScript
269 lines
9.6 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {dispatchChannelEvent} from '@app/api/channel/services/ChannelGatewayDispatch';
|
|
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
|
import {MessageTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
|
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
|
import {CannotEditSystemMessageError} from '@fluxer/errors/src/domains/channel/CannotEditSystemMessageError';
|
|
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
|
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
|
import type {ChannelPinResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
|
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
|
import type {MessageID, UserID} from '../../../BrandedTypes';
|
|
import {createMessageID} from '../../../BrandedTypes';
|
|
import type {GuildAuditLogService} from '../../../guild/GuildAuditLogService';
|
|
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
|
|
import type {ISnowflakeService} from '../../../infrastructure/ISnowflakeService';
|
|
import type {RequestCache} from '../../../middleware/RequestCacheMiddleware';
|
|
import type {Channel} from '../../../models/Channel';
|
|
import type {Message} from '../../../models/Message';
|
|
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
|
|
import type {AuthenticatedChannel} from '../AuthenticatedChannel';
|
|
import {dispatchMessageCreateBroadcast, dispatchMessageUpdateBroadcast} from '../message/MessageGatewayDispatch';
|
|
import type {MessagePersistenceService} from '../message/MessagePersistenceService';
|
|
import {createMessageResponseDataService} from '../message/MessageResponseDataService';
|
|
import {MessageInteractionBase} from './MessageInteractionBase';
|
|
|
|
export class MessagePinService extends MessageInteractionBase {
|
|
constructor(
|
|
gatewayService: IGatewayService,
|
|
private channelRepository: IChannelRepositoryAggregate,
|
|
private snowflakeService: ISnowflakeService,
|
|
private messagePersistenceService: MessagePersistenceService,
|
|
private readonly guildAuditLogService: GuildAuditLogService,
|
|
) {
|
|
super(gatewayService);
|
|
}
|
|
|
|
private async assertMessageHistoryAccess({
|
|
authChannel,
|
|
messageId,
|
|
}: {
|
|
authChannel: AuthenticatedChannel;
|
|
messageId: MessageID;
|
|
}): Promise<void> {
|
|
if (!authChannel.guild) {
|
|
return;
|
|
}
|
|
if (await authChannel.hasPermission(Permissions.READ_MESSAGE_HISTORY)) {
|
|
return;
|
|
}
|
|
const cutoff = authChannel.guild.message_history_cutoff;
|
|
if (!cutoff || snowflakeToDate(messageId).getTime() < new Date(cutoff).getTime()) {
|
|
throw new UnknownMessageError();
|
|
}
|
|
}
|
|
|
|
async getChannelPins({
|
|
authChannel,
|
|
userId,
|
|
beforeTimestamp,
|
|
limit,
|
|
}: {
|
|
authChannel: AuthenticatedChannel;
|
|
userId: UserID;
|
|
requestCache: RequestCache;
|
|
beforeTimestamp?: Date;
|
|
limit?: number;
|
|
}): Promise<{
|
|
items: Array<ChannelPinResponse>;
|
|
has_more: boolean;
|
|
}> {
|
|
const {channel} = authChannel;
|
|
this.ensureTextChannel(channel);
|
|
const hasReadHistory = !authChannel.guild || (await authChannel.hasPermission(Permissions.READ_MESSAGE_HISTORY));
|
|
if (!hasReadHistory) {
|
|
const cutoff = authChannel.guild?.message_history_cutoff;
|
|
if (!cutoff) {
|
|
return {items: [], has_more: false};
|
|
}
|
|
}
|
|
const pageSize = Math.min(limit ?? 50, 50);
|
|
const effectiveBefore = beforeTimestamp ?? new Date();
|
|
const messages = await this.channelRepository.messageInteractions.listChannelPins(
|
|
channel.id,
|
|
effectiveBefore,
|
|
pageSize + 1,
|
|
);
|
|
const sorted = messages.sort((a, b) => (b.pinnedTimestamp?.getTime() ?? 0) - (a.pinnedTimestamp?.getTime() ?? 0));
|
|
let filtered = sorted;
|
|
if (!hasReadHistory) {
|
|
const cutoff = authChannel.guild!.message_history_cutoff!;
|
|
const cutoffTimestamp = new Date(cutoff).getTime();
|
|
filtered = sorted.filter((message) => snowflakeToDate(message.id).getTime() >= cutoffTimestamp);
|
|
}
|
|
const hasMore = filtered.length > pageSize;
|
|
const trimmed = hasMore ? filtered.slice(0, pageSize) : filtered;
|
|
const access = {
|
|
sourceGuildId: channel.guildId,
|
|
messageHistoryCutoff: hasReadHistory ? null : (authChannel.guild?.message_history_cutoff ?? null),
|
|
canReadMessageHistory: hasReadHistory,
|
|
};
|
|
const responseDataService = createMessageResponseDataService();
|
|
const messageResponses = await responseDataService.buildMessages({
|
|
userId,
|
|
messages: trimmed,
|
|
access,
|
|
});
|
|
const responseByMessageId = new Map(messageResponses.map((response) => [response.id, response]));
|
|
const items = trimmed.flatMap((message: Message) => {
|
|
const messageResponse = responseByMessageId.get(message.id.toString());
|
|
if (!messageResponse || !message.pinnedTimestamp) return [];
|
|
const {referenced_message: _referencedMessage, reactions: _reactions, ...pinnedMessage} = messageResponse;
|
|
return [
|
|
{
|
|
message: pinnedMessage,
|
|
pinned_at: message.pinnedTimestamp.toISOString(),
|
|
},
|
|
];
|
|
});
|
|
return {
|
|
items,
|
|
has_more: hasMore,
|
|
};
|
|
}
|
|
|
|
async pinMessage({
|
|
authChannel,
|
|
messageId,
|
|
userId,
|
|
}: {
|
|
authChannel: AuthenticatedChannel;
|
|
messageId: MessageID;
|
|
userId: UserID;
|
|
requestCache: RequestCache;
|
|
}): Promise<void> {
|
|
const {channel, guild, checkPermission} = authChannel;
|
|
if (guild) {
|
|
await checkPermission(Permissions.PIN_MESSAGES);
|
|
if (this.isOperationDisabled(guild, GuildOperations.SEND_MESSAGE)) {
|
|
throw new FeatureTemporarilyDisabledError();
|
|
}
|
|
}
|
|
this.ensureTextChannel(channel);
|
|
await this.assertMessageHistoryAccess({authChannel, messageId});
|
|
const message = await this.channelRepository.messages.getMessage(channel.id, messageId);
|
|
if (!message) throw new UnknownMessageError();
|
|
this.validateMessagePinnable(message);
|
|
if (message.pinnedTimestamp) return;
|
|
const now = new Date();
|
|
const updatedMessageData = {...message.toRow(), pinned_timestamp: now};
|
|
const updatedMessage = await this.channelRepository.messages.upsertMessage(updatedMessageData, message.toRow());
|
|
await this.channelRepository.messageInteractions.addChannelPin(channel.id, messageId, now);
|
|
const updatedChannelData = {...channel.toRow(), last_pin_timestamp: now};
|
|
const updatedChannel = await this.channelRepository.channelData.upsert(updatedChannelData);
|
|
await this.dispatchChannelPinsUpdate(updatedChannel);
|
|
await this.sendPinSystemMessage({channel, message, userId});
|
|
await dispatchMessageUpdateBroadcast({
|
|
gatewayService: this.gatewayService,
|
|
channel,
|
|
message: updatedMessage,
|
|
});
|
|
if (channel.guildId) {
|
|
await this.guildAuditLogService
|
|
.createBuilder(channel.guildId, userId)
|
|
.withAction(AuditLogActionType.MESSAGE_PIN, messageId.toString())
|
|
.withMetadata({
|
|
channel_id: channel.id.toString(),
|
|
message_id: messageId.toString(),
|
|
})
|
|
.withReason(null)
|
|
.commit();
|
|
}
|
|
}
|
|
|
|
async unpinMessage({
|
|
authChannel,
|
|
messageId,
|
|
userId,
|
|
}: {
|
|
authChannel: AuthenticatedChannel;
|
|
messageId: MessageID;
|
|
userId: UserID;
|
|
requestCache: RequestCache;
|
|
}): Promise<void> {
|
|
const {channel, guild, checkPermission} = authChannel;
|
|
if (guild) {
|
|
await checkPermission(Permissions.PIN_MESSAGES);
|
|
if (this.isOperationDisabled(guild, GuildOperations.SEND_MESSAGE)) {
|
|
throw new FeatureTemporarilyDisabledError();
|
|
}
|
|
}
|
|
this.ensureTextChannel(channel);
|
|
await this.assertMessageHistoryAccess({authChannel, messageId});
|
|
const message = await this.channelRepository.messages.getMessage(channel.id, messageId);
|
|
if (!message) throw new UnknownMessageError();
|
|
this.validateMessagePinnable(message);
|
|
if (!message.pinnedTimestamp) return;
|
|
const updatedMessageData = {...message.toRow(), pinned_timestamp: null};
|
|
const updatedMessage = await this.channelRepository.messages.upsertMessage(updatedMessageData, message.toRow());
|
|
await this.channelRepository.messageInteractions.removeChannelPin(channel.id, messageId);
|
|
await this.dispatchChannelPinsUpdate(channel);
|
|
await dispatchMessageUpdateBroadcast({
|
|
gatewayService: this.gatewayService,
|
|
channel,
|
|
message: updatedMessage,
|
|
});
|
|
if (channel.guildId) {
|
|
await this.guildAuditLogService
|
|
.createBuilder(channel.guildId, userId)
|
|
.withAction(AuditLogActionType.MESSAGE_UNPIN, messageId.toString())
|
|
.withMetadata({
|
|
channel_id: channel.id.toString(),
|
|
message_id: messageId.toString(),
|
|
})
|
|
.withReason(null)
|
|
.commit();
|
|
}
|
|
}
|
|
|
|
private validateMessagePinnable(message: Message): void {
|
|
const pinnableTypes: ReadonlySet<Message['type']> = new Set([MessageTypes.DEFAULT, MessageTypes.REPLY]);
|
|
if (!pinnableTypes.has(message.type)) {
|
|
throw new CannotEditSystemMessageError();
|
|
}
|
|
}
|
|
|
|
private async sendPinSystemMessage({
|
|
channel,
|
|
message,
|
|
userId,
|
|
}: {
|
|
channel: Channel;
|
|
message: Message;
|
|
userId: UserID;
|
|
}): Promise<void> {
|
|
const messageId = createMessageID(await this.snowflakeService.generateForChannel(channel.id));
|
|
const systemMessage = await this.messagePersistenceService.createSystemMessage({
|
|
messageId,
|
|
channelId: channel.id,
|
|
userId,
|
|
type: MessageTypes.CHANNEL_PINNED_MESSAGE,
|
|
guildId: channel.guildId,
|
|
messageReference: {
|
|
channel_id: channel.id,
|
|
message_id: message.id,
|
|
guild_id: null,
|
|
type: 0,
|
|
},
|
|
});
|
|
await dispatchMessageCreateBroadcast({
|
|
gatewayService: this.gatewayService,
|
|
channel,
|
|
message: systemMessage,
|
|
});
|
|
}
|
|
|
|
private async dispatchChannelPinsUpdate(channel: Channel): Promise<void> {
|
|
await dispatchChannelEvent({
|
|
gatewayService: this.gatewayService,
|
|
channel,
|
|
event: 'CHANNEL_PINS_UPDATE',
|
|
data: {
|
|
channel_id: channel.id.toString(),
|
|
last_pin_timestamp: channel.lastPinTimestamp?.toISOString() ?? null,
|
|
},
|
|
});
|
|
}
|
|
}
|