2026-06-02 21:23:12 +07:00
|
|
|
import { createChildLogger } from "@bete/shared/logger";
|
2026-06-04 18:52:56 +07:00
|
|
|
import { config } from "../../shared/config/config.js";
|
2026-07-27 21:54:31 +07:00
|
|
|
import { uploadToTele } from "../../shared/uploader.js";
|
|
|
|
|
import { messageStore } from "../message-capture/messageStore.js";
|
2026-06-01 21:44:29 +07:00
|
|
|
|
|
|
|
|
const logger = createChildLogger("attachment-uploader");
|
|
|
|
|
|
|
|
|
|
class AttachmentDownloadError extends Error {
|
|
|
|
|
constructor(
|
|
|
|
|
message: string,
|
|
|
|
|
readonly status: number,
|
|
|
|
|
) {
|
|
|
|
|
super(message);
|
|
|
|
|
this.name = "AttachmentDownloadError";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type RefreshDiscordAttachmentUrl = () => Promise<string | null>;
|
|
|
|
|
|
|
|
|
|
function toErrorMessage(error: unknown): string {
|
|
|
|
|
return error instanceof Error ? error.message : String(error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function shouldRefreshDiscordUrl(error: unknown): boolean {
|
|
|
|
|
return (
|
|
|
|
|
error instanceof AttachmentDownloadError &&
|
|
|
|
|
(error.status === 403 || error.status === 404)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function uploadAttachmentToTele(
|
|
|
|
|
fileBuffer: Buffer,
|
|
|
|
|
filename: string,
|
|
|
|
|
contentType = "application/octet-stream",
|
|
|
|
|
): Promise<string> {
|
2026-06-09 17:34:18 +07:00
|
|
|
logger.debug(
|
|
|
|
|
{ filename, sizeBytes: fileBuffer.length },
|
|
|
|
|
"Starting attachment upload to tele",
|
|
|
|
|
);
|
2026-06-01 21:44:29 +07:00
|
|
|
try {
|
|
|
|
|
const result = await uploadToTele({
|
|
|
|
|
buffer: fileBuffer,
|
|
|
|
|
filename,
|
|
|
|
|
contentType,
|
|
|
|
|
uploadUrl: config.TELE_UPLOAD_URL,
|
|
|
|
|
timeoutMs: config.ATTACHMENT_UPLOAD_TIMEOUT_MS,
|
2026-06-02 11:29:52 +07:00
|
|
|
retries: 0,
|
2026-06-01 21:44:29 +07:00
|
|
|
});
|
|
|
|
|
|
2026-06-09 17:34:18 +07:00
|
|
|
logger.info(
|
|
|
|
|
{ filename, url: result.url },
|
|
|
|
|
"Attachment uploaded to tele successfully",
|
|
|
|
|
);
|
2026-06-01 21:44:29 +07:00
|
|
|
return result.url;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
{
|
|
|
|
|
filename,
|
|
|
|
|
error: toErrorMessage(error),
|
|
|
|
|
},
|
|
|
|
|
"Failed to upload attachment",
|
|
|
|
|
);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function downloadDiscordAttachment(url: string): Promise<Buffer> {
|
2026-06-09 17:34:18 +07:00
|
|
|
logger.debug({ url }, "Starting Discord attachment download");
|
2026-06-01 21:44:29 +07:00
|
|
|
try {
|
|
|
|
|
const response = await fetch(url, {
|
|
|
|
|
signal: AbortSignal.timeout(config.ATTACHMENT_UPLOAD_TIMEOUT_MS),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new AttachmentDownloadError(
|
|
|
|
|
`Download failed with status ${response.status}`,
|
|
|
|
|
response.status,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const buffer = await response.arrayBuffer();
|
2026-06-09 17:34:18 +07:00
|
|
|
const result = Buffer.from(buffer);
|
|
|
|
|
logger.debug(
|
|
|
|
|
{ url, sizeBytes: result.length },
|
|
|
|
|
"Discord attachment downloaded successfully",
|
|
|
|
|
);
|
|
|
|
|
return result;
|
2026-06-01 21:44:29 +07:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
{ url, error: toErrorMessage(error) },
|
|
|
|
|
"Failed to download Discord attachment",
|
|
|
|
|
);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function processAttachmentUpload(
|
|
|
|
|
attachmentId: string,
|
|
|
|
|
discordUrl: string,
|
|
|
|
|
filename: string,
|
|
|
|
|
options: {
|
|
|
|
|
refreshDiscordUrl?: RefreshDiscordAttachmentUrl;
|
|
|
|
|
contentType?: string;
|
|
|
|
|
} = {},
|
|
|
|
|
): Promise<void> {
|
2026-06-09 17:34:18 +07:00
|
|
|
logger.info({ attachmentId, filename }, "processAttachmentUpload called");
|
2026-06-01 21:44:29 +07:00
|
|
|
try {
|
|
|
|
|
let currentDiscordUrl = discordUrl;
|
|
|
|
|
let buffer: Buffer;
|
|
|
|
|
try {
|
|
|
|
|
buffer = await downloadDiscordAttachment(currentDiscordUrl);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (!options.refreshDiscordUrl || !shouldRefreshDiscordUrl(error)) {
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 17:34:18 +07:00
|
|
|
logger.warn(
|
|
|
|
|
{ attachmentId, filename },
|
|
|
|
|
"Discord URL expired, refreshing and retrying",
|
|
|
|
|
);
|
2026-06-01 21:44:29 +07:00
|
|
|
const freshUrl = await options.refreshDiscordUrl();
|
|
|
|
|
if (!freshUrl) throw error;
|
|
|
|
|
currentDiscordUrl = freshUrl;
|
2026-07-27 21:54:31 +07:00
|
|
|
await messageStore.updateAttachmentDiscordUrl(attachmentId, freshUrl);
|
2026-06-01 21:44:29 +07:00
|
|
|
buffer = await downloadDiscordAttachment(currentDiscordUrl);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sizeMb = buffer.length / (1024 * 1024);
|
2026-06-09 17:34:18 +07:00
|
|
|
logger.debug(
|
|
|
|
|
{ attachmentId, sizeMb: sizeMb.toFixed(2) },
|
|
|
|
|
"Attachment size check",
|
|
|
|
|
);
|
2026-06-01 21:44:29 +07:00
|
|
|
if (sizeMb > config.ATTACHMENT_MAX_SIZE_MB) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`File size ${sizeMb.toFixed(2)}MB exceeds limit of ${config.ATTACHMENT_MAX_SIZE_MB}MB`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const uploadedUrl = await uploadAttachmentToTele(
|
|
|
|
|
buffer,
|
|
|
|
|
filename,
|
|
|
|
|
options.contentType,
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-27 21:54:31 +07:00
|
|
|
await messageStore.updateAttachmentAsUploaded(
|
|
|
|
|
attachmentId,
|
|
|
|
|
uploadedUrl,
|
|
|
|
|
Date.now(),
|
|
|
|
|
);
|
2026-06-09 17:34:18 +07:00
|
|
|
logger.info(
|
|
|
|
|
{ attachmentId, url: uploadedUrl },
|
|
|
|
|
"Attachment upload completed successfully",
|
|
|
|
|
);
|
2026-06-01 21:44:29 +07:00
|
|
|
} catch (error) {
|
|
|
|
|
const errorMsg = toErrorMessage(error);
|
2026-07-27 21:54:31 +07:00
|
|
|
await messageStore.updateAttachmentAsFailedUpload(attachmentId, errorMsg);
|
2026-06-01 21:44:29 +07:00
|
|
|
logger.error({ attachmentId, error: errorMsg }, "Attachment upload failed");
|
|
|
|
|
}
|
|
|
|
|
}
|