refactor: split monolith into 3 microservices (frontend, backend, discord-gateway)

- Extract services into services/{frontend,backend,discord-gateway}
- Create packages/shared/ for shared logger, errors, utils, types
- Setup Modular MVC pattern in backend (controller→service→repository)
- Setup event-driven architecture in discord-gateway with Redis pub/sub
- Move Docker files to infra/docker/ with per-service Dockerfiles
- Update docker-compose.yml to use Traefik-only routing (no port exposes)
- Update GitHub Actions deploy workflow for multi-service matrix build
- Fix all import paths and resolve type errors across all services
- All 3 services pass tsc --noEmit clean

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-01 21:44:29 +07:00
co-authored by Claude Opus 4.8
parent bda8304bb9
commit c48a0c5e3b
193 changed files with 16879 additions and 1158 deletions
@@ -0,0 +1,133 @@
import { config } from "../../shared/config/config.js";
import { createChildLogger } from "../../shared/logger/logger.js";
import { uploadToTele } from "./teleUpload.js";
import {
updateAttachmentAsFailedUpload,
updateAttachmentAsUploaded,
updateAttachmentDiscordUrl,
} from "../message-capture/messageStore.js";
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> {
try {
const result = await uploadToTele({
buffer: fileBuffer,
filename,
contentType,
uploadUrl: config.TELE_UPLOAD_URL,
timeoutMs: config.ATTACHMENT_UPLOAD_TIMEOUT_MS,
retries: config.ATTACHMENT_RETRY_ATTEMPTS,
logger,
});
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> {
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();
return Buffer.from(buffer);
} 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> {
try {
let currentDiscordUrl = discordUrl;
let buffer: Buffer;
try {
buffer = await downloadDiscordAttachment(currentDiscordUrl);
} catch (error) {
if (!options.refreshDiscordUrl || !shouldRefreshDiscordUrl(error)) {
throw error;
}
const freshUrl = await options.refreshDiscordUrl();
if (!freshUrl) throw error;
currentDiscordUrl = freshUrl;
await updateAttachmentDiscordUrl(attachmentId, freshUrl);
buffer = await downloadDiscordAttachment(currentDiscordUrl);
}
const sizeMb = buffer.length / (1024 * 1024);
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,
);
await updateAttachmentAsUploaded(attachmentId, uploadedUrl, Date.now());
} catch (error) {
const errorMsg = toErrorMessage(error);
await updateAttachmentAsFailedUpload(attachmentId, errorMsg);
logger.error({ attachmentId, error: errorMsg }, "Attachment upload failed");
}
}
@@ -0,0 +1,58 @@
import sharp from "sharp";
import { createChildLogger } from "../../shared/logger/logger.js";
const log = createChildLogger("imageResizer");
/**
* Resize an image buffer for optimal vision LLM analysis.
*
* - Resizes to maxDim x maxDim maintaining aspect ratio
* - Converts to JPEG at quality 85 for size reduction
* - Falls back to original buffer if sharp fails
*
* @param buf - Raw image buffer
* @param maxDim - Maximum dimension in pixels (default 1024)
* @returns Resized buffer with detected MIME type
*/
export async function resizeImageForVision(
buf: Buffer,
maxDim = 1024,
): Promise<{ data: Buffer; mimeType: string }> {
try {
const metadata = await sharp(buf).metadata();
const inputFormat = metadata.format ?? "jpeg";
// Skip resize if already smaller than maxDim
if ((metadata.width ?? 0) <= maxDim && (metadata.height ?? 0) <= maxDim) {
return { data: buf, mimeType: `image/${inputFormat}` };
}
const resized = await sharp(buf)
.resize(maxDim, maxDim, {
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: 85 })
.toBuffer();
log.debug(
{
originalSize: buf.length,
resizedSize: resized.length,
reductionPct: Math.round(
((buf.length - resized.length) / buf.length) * 100,
),
},
"Image resized for vision analysis",
);
return { data: resized, mimeType: "image/jpeg" };
} catch (error) {
log.warn(
{ error: error instanceof Error ? error.message : String(error) },
"Image resize failed — using original buffer",
);
// Fallback: return original buffer with best-effort MIME type
return { data: buf, mimeType: "image/jpeg" };
}
}
@@ -0,0 +1,2 @@
export { processAttachmentUpload } from "./attachmentUploader.js";
export { resizeImageForVision as resizeImage } from "./imageResizer.js";
@@ -0,0 +1,85 @@
import type { CustomLogger } from "../../shared/logger/logger.js";
import { retryWithBackoff } from "../../shared/utils/retry.js";
export interface TeleUploadResponse {
download_url: string;
public_id?: string;
file_name?: string;
size_bytes?: number;
}
export interface TeleUploadResult {
url: string;
publicId?: string;
filename?: string;
sizeBytes?: number;
}
export function parseTeleUploadResponse(
response: TeleUploadResponse,
): TeleUploadResult {
if (!response.download_url) {
throw new Error("Missing download_url in response");
}
return {
url: response.download_url,
publicId: response.public_id,
filename: response.file_name,
sizeBytes: response.size_bytes,
};
}
export async function uploadToTele(input: {
buffer: Buffer;
filename: string;
contentType: string;
uploadUrl: string;
timeoutMs?: number;
retries: number;
logger: CustomLogger;
}): Promise<TeleUploadResult> {
const {
buffer,
filename,
contentType,
uploadUrl,
timeoutMs,
retries,
logger,
} = input;
const response = await retryWithBackoff(
async () => {
const fileBlob = new Blob([new Uint8Array(buffer)], {
type: contentType,
});
const formData = new FormData();
formData.append("file", fileBlob, filename);
formData.append("fileName", filename);
const res = await fetch(uploadUrl, {
method: "POST",
headers: {
accept: "application/json",
},
body: formData,
...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
});
if (!res.ok) {
throw new Error(`Upload failed: Status ${res.status}`);
}
return (await res.json()) as TeleUploadResponse;
},
{
retries,
minTimeout: 1000,
maxTimeout: 5000,
logger,
},
);
return parseTeleUploadResponse(response);
}