mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
initial commit
This commit is contained in:
9029 files changed
+1490197
No files matched your search
@@ -0,0 +1,477 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import * as DraftActionCreators from '~/actions/DraftActionCreators';
|
||||
import * as MessageActionCreators from '~/actions/MessageActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as SlowmodeActionCreators from '~/actions/SlowmodeActionCreators';
|
||||
import {APIErrorCodes} from '~/Constants';
|
||||
import {FeatureTemporarilyDisabledModal} from '~/components/alerts/FeatureTemporarilyDisabledModal';
|
||||
import {FileSizeTooLargeModal} from '~/components/alerts/FileSizeTooLargeModal';
|
||||
import {MessageEditFailedModal} from '~/components/alerts/MessageEditFailedModal';
|
||||
import {MessageEditTooQuickModal} from '~/components/alerts/MessageEditTooQuickModal';
|
||||
import {MessageSendFailedModal} from '~/components/alerts/MessageSendFailedModal';
|
||||
import {MessageSendTooQuickModal} from '~/components/alerts/MessageSendTooQuickModal';
|
||||
import {NSFWContentRejectedModal} from '~/components/alerts/NSFWContentRejectedModal';
|
||||
import {SlowmodeRateLimitedModal} from '~/components/alerts/SlowmodeRateLimitedModal';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import i18n from '~/i18n';
|
||||
import {CloudUpload} from '~/lib/CloudUpload';
|
||||
import http, {type HttpError, type HttpResponse} from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import {Queue, type QueueEntry} from '~/lib/Queue';
|
||||
import type {AllowedMentions, Message, MessageStickerItem} from '~/records/MessageRecord';
|
||||
import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore';
|
||||
import {createSystemMessage} from '~/utils/CommandUtils';
|
||||
import {prepareAttachmentsForNonce} from '~/utils/MessageAttachmentUtils';
|
||||
import {
|
||||
type ApiAttachmentMetadata,
|
||||
buildMessageCreateRequest,
|
||||
type MessageCreateRequest,
|
||||
type MessageEditRequest,
|
||||
type MessageReference,
|
||||
} from '~/utils/MessageRequestUtils';
|
||||
|
||||
const logger = new Logger('MessageQueue');
|
||||
|
||||
const DEFAULT_MAX_SIZE = 5;
|
||||
const DEV_MESSAGE_DELAY = 3000;
|
||||
|
||||
interface BaseMessagePayload {
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
interface SendMessagePayload extends BaseMessagePayload {
|
||||
type: 'send';
|
||||
nonce: string;
|
||||
content: string;
|
||||
hasAttachments?: boolean;
|
||||
allowedMentions?: AllowedMentions;
|
||||
messageReference?: MessageReference;
|
||||
flags?: number;
|
||||
favoriteMemeId?: string;
|
||||
stickers?: Array<MessageStickerItem>;
|
||||
tts?: boolean;
|
||||
}
|
||||
|
||||
interface EditMessagePayload extends BaseMessagePayload {
|
||||
type: 'edit';
|
||||
messageId: string;
|
||||
content?: string;
|
||||
flags?: number;
|
||||
}
|
||||
|
||||
export type MessageQueuePayload = SendMessagePayload | EditMessagePayload;
|
||||
|
||||
export interface RetryError {
|
||||
retryAfter?: number;
|
||||
}
|
||||
|
||||
export interface ApiErrorBody {
|
||||
code?: number | string;
|
||||
retry_after?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const getApiErrorBody = (error: HttpError): ApiErrorBody | undefined => {
|
||||
return typeof error?.body === 'object' && error.body !== null ? (error.body as ApiErrorBody) : undefined;
|
||||
};
|
||||
|
||||
function isSendPayload(payload: MessageQueuePayload): payload is SendMessagePayload {
|
||||
return payload.type === 'send';
|
||||
}
|
||||
|
||||
function isEditPayload(payload: MessageQueuePayload): payload is EditMessagePayload {
|
||||
return payload.type === 'edit';
|
||||
}
|
||||
|
||||
function isRateLimitError(error: HttpError): boolean {
|
||||
return error?.status === 429;
|
||||
}
|
||||
|
||||
function isSlowmodeError(error: HttpError): boolean {
|
||||
return error?.status === 400 && getApiErrorBody(error)?.code === APIErrorCodes.SLOWMODE_RATE_LIMITED;
|
||||
}
|
||||
|
||||
function isFeatureDisabledError(error: HttpError): boolean {
|
||||
return error?.status === 403 && getApiErrorBody(error)?.code === APIErrorCodes.FEATURE_TEMPORARILY_DISABLED;
|
||||
}
|
||||
|
||||
function isExplicitContentError(error: HttpError): boolean {
|
||||
return getApiErrorBody(error)?.code === APIErrorCodes.EXPLICIT_CONTENT_CANNOT_BE_SENT;
|
||||
}
|
||||
|
||||
function isFileTooLargeError(error: HttpError): boolean {
|
||||
return getApiErrorBody(error)?.code === APIErrorCodes.FILE_SIZE_TOO_LARGE;
|
||||
}
|
||||
|
||||
function isDMRestrictedError(error: HttpError): boolean {
|
||||
return getApiErrorBody(error)?.code === APIErrorCodes.CANNOT_SEND_MESSAGES_TO_USER;
|
||||
}
|
||||
|
||||
function isUnclaimedAccountError(error: HttpError): boolean {
|
||||
return getApiErrorBody(error)?.code === APIErrorCodes.UNCLAIMED_ACCOUNT_RESTRICTED;
|
||||
}
|
||||
|
||||
class MessageQueue extends Queue<MessageQueuePayload, HttpResponse<Message> | undefined> {
|
||||
private readonly maxSize: number;
|
||||
private readonly abortControllers = new Map<string, AbortController>();
|
||||
|
||||
constructor(maxSize = DEFAULT_MAX_SIZE) {
|
||||
super({logger, defaultRetryAfter: 100});
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
isFull(): boolean {
|
||||
return this.queueLength >= this.maxSize;
|
||||
}
|
||||
|
||||
drain(
|
||||
message: MessageQueuePayload,
|
||||
completed: (err: RetryError | null, result?: HttpResponse<Message>) => void,
|
||||
): void {
|
||||
if (isSendPayload(message)) {
|
||||
this.handleSend(message, completed);
|
||||
} else if (isEditPayload(message)) {
|
||||
this.handleEdit(message, completed);
|
||||
} else {
|
||||
logger.error('Unknown message type, completing with null');
|
||||
completed(null);
|
||||
}
|
||||
}
|
||||
|
||||
cancelRequest(nonce: string): void {
|
||||
logger.info('Cancel message send:', nonce);
|
||||
const controller = this.abortControllers.get(nonce);
|
||||
controller?.abort();
|
||||
this.abortControllers.delete(nonce);
|
||||
}
|
||||
|
||||
cancelPendingSendRequests(channelId: string): Array<SendMessagePayload> {
|
||||
const cancelled: Array<SendMessagePayload> = [];
|
||||
const remaining: Array<QueueEntry<MessageQueuePayload, HttpResponse<Message> | undefined>> = [];
|
||||
|
||||
while (this.queue.length > 0) {
|
||||
const entry = this.queue.shift()!;
|
||||
|
||||
if (isSendPayload(entry.message) && entry.message.channelId === channelId) {
|
||||
cancelled.push(entry.message);
|
||||
this.cancelRequest(entry.message.nonce);
|
||||
} else {
|
||||
remaining.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
this.queue.push(...remaining);
|
||||
logger.info('Cancel pending send requests', cancelled.length);
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
private async handleSend(
|
||||
payload: SendMessagePayload,
|
||||
completed: (err: RetryError | null, result?: HttpResponse<Message>) => void,
|
||||
): Promise<void> {
|
||||
const {channelId, nonce, hasAttachments} = payload;
|
||||
|
||||
try {
|
||||
await this.applyDevDelay();
|
||||
|
||||
if (DeveloperOptionsStore.forceFailMessageSends) {
|
||||
throw new Error('Forced message send failure');
|
||||
}
|
||||
|
||||
let attachments: Array<ApiAttachmentMetadata> | undefined;
|
||||
let files: Array<File> | undefined;
|
||||
|
||||
if (hasAttachments) {
|
||||
const result = await prepareAttachmentsForNonce(nonce, payload.favoriteMemeId);
|
||||
attachments = result.attachments;
|
||||
files = result.files;
|
||||
}
|
||||
|
||||
const requestBody = buildMessageCreateRequest({
|
||||
content: payload.content,
|
||||
nonce,
|
||||
attachments,
|
||||
allowedMentions: payload.allowedMentions,
|
||||
messageReference: payload.messageReference,
|
||||
flags: payload.flags,
|
||||
favoriteMemeId: payload.favoriteMemeId,
|
||||
stickers: payload.stickers,
|
||||
tts: payload.tts,
|
||||
});
|
||||
|
||||
logger.debug(`Sending message to channel ${channelId}`);
|
||||
|
||||
const response = await this.sendMessageRequest(channelId, nonce, requestBody, files);
|
||||
|
||||
logger.debug(`Successfully sent message to channel ${channelId}`);
|
||||
|
||||
if (hasAttachments) {
|
||||
CloudUpload.removeMessageUpload(nonce);
|
||||
}
|
||||
|
||||
completed(null, response);
|
||||
} catch (error) {
|
||||
const httpError = error as HttpError;
|
||||
logger.error(`Failed to send message to channel ${channelId}:`, error);
|
||||
|
||||
if (isRateLimitError(httpError)) {
|
||||
this.handleSendRateLimit(httpError, completed);
|
||||
} else {
|
||||
this.handleSendError(channelId, nonce, httpError, i18n, payload.hasAttachments);
|
||||
completed(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async applyDevDelay(): Promise<void> {
|
||||
if (!DeveloperOptionsStore.slowMessageSend) return;
|
||||
|
||||
logger.debug(`Slow message send enabled, delaying by ${DEV_MESSAGE_DELAY}ms`);
|
||||
await new Promise((resolve) => setTimeout(resolve, DEV_MESSAGE_DELAY));
|
||||
}
|
||||
|
||||
private async sendMessageRequest(
|
||||
channelId: string,
|
||||
nonce: string,
|
||||
requestBody: MessageCreateRequest,
|
||||
files?: Array<File>,
|
||||
): Promise<HttpResponse<Message>> {
|
||||
const abortController = new AbortController();
|
||||
this.abortControllers.set(nonce, abortController);
|
||||
|
||||
try {
|
||||
if (files?.length) {
|
||||
logger.debug('Sending message with multipart form data');
|
||||
return await this.sendMultipartMessage(channelId, requestBody, files, abortController.signal, nonce);
|
||||
}
|
||||
|
||||
return await http.post<Message>({
|
||||
url: Endpoints.CHANNEL_MESSAGES(channelId),
|
||||
body: requestBody,
|
||||
signal: abortController.signal,
|
||||
rejectWithError: true,
|
||||
});
|
||||
} finally {
|
||||
this.abortControllers.delete(nonce);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendMultipartMessage(
|
||||
channelId: string,
|
||||
requestBody: MessageCreateRequest,
|
||||
files: Array<File>,
|
||||
signal: AbortSignal,
|
||||
nonce?: string,
|
||||
): Promise<HttpResponse<Message>> {
|
||||
const formData = new FormData();
|
||||
formData.append('payload_json', JSON.stringify(requestBody));
|
||||
|
||||
files.forEach((file, index) => {
|
||||
formData.append(`files[${index}]`, file);
|
||||
});
|
||||
|
||||
return http.post<Message>({
|
||||
url: Endpoints.CHANNEL_MESSAGES(channelId),
|
||||
body: formData,
|
||||
signal,
|
||||
rejectWithError: true,
|
||||
onRequestProgress: nonce
|
||||
? (event) => {
|
||||
if (event.lengthComputable && event.total > 0) {
|
||||
const progress = (event.loaded / event.total) * 100;
|
||||
CloudUpload.updateSendingProgress(nonce, progress);
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private handleSendRateLimit(
|
||||
error: HttpError,
|
||||
completed: (err: RetryError | null, result?: HttpResponse<Message>) => void,
|
||||
): void {
|
||||
const retryAfterSeconds = getApiErrorBody(error)?.retry_after ?? 0;
|
||||
const retryAfterMs = retryAfterSeconds > 0 ? retryAfterSeconds * 1000 : undefined;
|
||||
|
||||
completed({retryAfter: retryAfterMs});
|
||||
|
||||
this.handleRateLimitError(retryAfterSeconds);
|
||||
}
|
||||
|
||||
private handleSendError(
|
||||
channelId: string,
|
||||
nonce: string,
|
||||
error: HttpError,
|
||||
i18n: I18n,
|
||||
hasAttachments?: boolean,
|
||||
): void {
|
||||
MessageActionCreators.sendError(channelId, nonce);
|
||||
|
||||
if (hasAttachments) {
|
||||
this.restoreFailedMessage(channelId, nonce);
|
||||
}
|
||||
|
||||
if (isDMRestrictedError(error)) {
|
||||
const systemMessage = createSystemMessage(
|
||||
channelId,
|
||||
i18n._(
|
||||
msg`Your message could not be delivered. This is usually because you don't share a community with the recipient or the recipient is only accepting direct messages from friends.`,
|
||||
),
|
||||
);
|
||||
MessageActionCreators.createOptimistic(channelId, systemMessage.toJSON());
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUnclaimedAccountError(error)) {
|
||||
const systemMessage = createSystemMessage(
|
||||
channelId,
|
||||
i18n._(msg`Your message could not be delivered. You need to claim your account to send direct messages.`),
|
||||
);
|
||||
MessageActionCreators.createOptimistic(channelId, systemMessage.toJSON());
|
||||
return;
|
||||
}
|
||||
|
||||
this.showErrorModal(error, channelId);
|
||||
}
|
||||
|
||||
private restoreFailedMessage(channelId: string, nonce: string): void {
|
||||
const messageUpload = CloudUpload.getMessageUpload(nonce);
|
||||
|
||||
CloudUpload.restoreAttachmentsToTextarea(nonce);
|
||||
|
||||
const contentToRestore = messageUpload?.content ?? '';
|
||||
DraftActionCreators.createDraft(channelId, contentToRestore);
|
||||
|
||||
if (messageUpload?.messageReference) {
|
||||
MessageActionCreators.startReply(
|
||||
channelId,
|
||||
messageUpload.messageReference.message_id,
|
||||
messageUpload.allowedMentions?.replied_user ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
MessageActionCreators.deleteOptimistic(channelId, nonce);
|
||||
}
|
||||
|
||||
private showErrorModal(error: HttpError, channelId?: string): void {
|
||||
if (isSlowmodeError(error)) {
|
||||
const retryAfter = Math.ceil(getApiErrorBody(error)?.retry_after ?? 0);
|
||||
const timestamp = Date.now() - retryAfter * 1000;
|
||||
if (channelId) {
|
||||
SlowmodeActionCreators.updateSlowmodeTimestamp(channelId, timestamp);
|
||||
}
|
||||
ModalActionCreators.push(modal(() => <SlowmodeRateLimitedModal retryAfter={retryAfter} />));
|
||||
} else if (isFeatureDisabledError(error)) {
|
||||
ModalActionCreators.push(modal(() => <FeatureTemporarilyDisabledModal />));
|
||||
} else if (isExplicitContentError(error)) {
|
||||
ModalActionCreators.push(modal(() => <NSFWContentRejectedModal />));
|
||||
} else if (isFileTooLargeError(error)) {
|
||||
ModalActionCreators.push(modal(() => <FileSizeTooLargeModal />));
|
||||
} else {
|
||||
ModalActionCreators.push(modal(() => <MessageSendFailedModal />));
|
||||
}
|
||||
}
|
||||
|
||||
private handleRateLimitError(retryAfter: number, onRetry?: () => void): void {
|
||||
ModalActionCreators.push(modal(() => <MessageSendTooQuickModal retryAfter={retryAfter} onRetry={onRetry} />));
|
||||
}
|
||||
|
||||
private async handleEdit(
|
||||
payload: EditMessagePayload,
|
||||
completed: (err: RetryError | null, result?: HttpResponse<Message>) => void,
|
||||
): Promise<void> {
|
||||
const {channelId, messageId, content, flags} = payload;
|
||||
|
||||
const abortController = new AbortController();
|
||||
this.abortControllers.set(messageId, abortController);
|
||||
|
||||
try {
|
||||
logger.debug(`Editing message ${messageId} in channel ${channelId}`);
|
||||
|
||||
const body = this.buildEditRequestBody(content, flags);
|
||||
|
||||
const response = await http.patch<Message>({
|
||||
url: Endpoints.CHANNEL_MESSAGE(channelId, messageId),
|
||||
body,
|
||||
signal: abortController.signal,
|
||||
rejectWithError: true,
|
||||
});
|
||||
|
||||
logger.debug(`Successfully edited message ${messageId} in channel ${channelId}`);
|
||||
completed(null, response);
|
||||
} catch (error) {
|
||||
const httpError = error as HttpError;
|
||||
logger.error(`Failed to edit message ${messageId} in channel ${channelId}:`, error);
|
||||
|
||||
if (isRateLimitError(httpError)) {
|
||||
this.handleEditRateLimit(httpError, completed);
|
||||
} else {
|
||||
this.showEditErrorModal(httpError);
|
||||
completed(null);
|
||||
}
|
||||
} finally {
|
||||
this.abortControllers.delete(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
private buildEditRequestBody(content?: string, flags?: number): MessageEditRequest {
|
||||
const body: MessageEditRequest = {};
|
||||
|
||||
if (content !== undefined) {
|
||||
body.content = content;
|
||||
}
|
||||
|
||||
if (flags !== undefined) {
|
||||
body.flags = flags;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
private handleEditRateLimit(
|
||||
error: HttpError,
|
||||
completed: (err: RetryError | null, result?: HttpResponse<Message>) => void,
|
||||
): void {
|
||||
const retryAfterSeconds = getApiErrorBody(error)?.retry_after ?? 0;
|
||||
const retryAfterMs = retryAfterSeconds > 0 ? retryAfterSeconds * 1000 : undefined;
|
||||
|
||||
completed({retryAfter: retryAfterMs});
|
||||
|
||||
this.handleEditRateLimitError(retryAfterSeconds);
|
||||
}
|
||||
|
||||
private showEditErrorModal(error: HttpError): void {
|
||||
if (isFeatureDisabledError(error)) {
|
||||
ModalActionCreators.push(modal(() => <FeatureTemporarilyDisabledModal />));
|
||||
} else {
|
||||
ModalActionCreators.push(modal(() => <MessageEditFailedModal />));
|
||||
}
|
||||
}
|
||||
|
||||
private handleEditRateLimitError(retryAfter: number, onRetry?: () => void): void {
|
||||
ModalActionCreators.push(modal(() => <MessageEditTooQuickModal retryAfter={retryAfter} onRetry={onRetry} />));
|
||||
}
|
||||
}
|
||||
|
||||
export default new MessageQueue();
|
||||
Reference in new issue
Block a user