diff --git a/apps/discord-bot/package.json b/apps/discord-bot/package.json index cb5f3211b6a4..1867d4e91ffc 100644 --- a/apps/discord-bot/package.json +++ b/apps/discord-bot/package.json @@ -13,6 +13,7 @@ "test": "vp test run" }, "dependencies": { + "@azure/storage-blob": "^12.33.0", "@effect/platform-node": "catalog:", "@microsoft/teams.apps": "2.0.14", "@t3tools/client-runtime": "workspace:*", diff --git a/apps/discord-bot/src/config.test.ts b/apps/discord-bot/src/config.test.ts index bbe7d0fb7859..987113a8fcae 100644 --- a/apps/discord-bot/src/config.test.ts +++ b/apps/discord-bot/src/config.test.ts @@ -37,6 +37,10 @@ const baseConfig = { teamsPort: 3978, teamsMessagingEndpoint: "/api/messages" as const, teamsDefaultProjectShortName: undefined, + azureStorageConnectionString: undefined, + azureStorageAccountName: undefined, + azureStorageAccountKey: undefined, + azureStorageContainer: "discord-bot-attachments", } satisfies DiscordBotConfig; describe("preferredModelSelection", () => { diff --git a/apps/discord-bot/src/config.ts b/apps/discord-bot/src/config.ts index 198a64806817..40a0e67350f7 100644 --- a/apps/discord-bot/src/config.ts +++ b/apps/discord-bot/src/config.ts @@ -73,6 +73,15 @@ export interface DiscordBotConfig { readonly teamsMessagingEndpoint: `/${string}`; /** Fallback alias for personal/group chats that do not map to a configured channel. */ readonly teamsDefaultProjectShortName: string | undefined; + /** + * Optional Azure Blob offload for Discord attachments over ~10MB. + * Container must be private (not listable). Bot issues 3-day read SAS links. + * Prefer connection string; account name+key is also accepted. + */ + readonly azureStorageConnectionString: string | undefined; + readonly azureStorageAccountName: string | undefined; + readonly azureStorageAccountKey: string | undefined; + readonly azureStorageContainer: string; } const RuntimeModeConfig = Schema.Literals([ @@ -220,6 +229,23 @@ export const DiscordBotConfig: Effect.Effect (Option.isSome(value) ? Redacted.value(value.value) : undefined)), + ); + const azureStorageAccountName = yield* Config.string("AZURE_STORAGE_ACCOUNT_NAME").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ); + const azureStorageAccountKey = yield* Config.redacted("AZURE_STORAGE_ACCOUNT_KEY").pipe( + Config.option, + Config.map((value) => (Option.isSome(value) ? Redacted.value(value.value) : undefined)), + ); + const azureStorageContainer = yield* Config.string("AZURE_STORAGE_CONTAINER").pipe( + Config.withDefault("discord-bot-attachments"), + ); return { discordToken, @@ -255,6 +281,10 @@ export const DiscordBotConfig: Effect.Effect { expect(rewritten).toBe("report.csv (attached below)"); }); + it("rewrites oversized local files to temporary Azure download links", () => { + const rewritten = rewriteMarkdownLocalFileLinksForDiscord({ + text: "[big.mp4](/tmp/big.mp4)", + githubUrlsBySrc: new Map(), + attachedFileNames: new Set(), + oversizedByName: new Set(["big.mp4"]), + externalUrlsByName: new Map([ + ["big.mp4", "https://example.blob.core.windows.net/c/big?sv=1&sig=abc"], + ]), + }); + expect(rewritten).toBe("[big.mp4](https://example.blob.core.windows.net/c/big?sv=1&sig=abc)"); + }); + it("keeps attachable documents as attachments even when source refs become links", () => { const rewritten = rewriteMarkdownLocalFileLinksForDiscord({ text: [ diff --git a/apps/discord-bot/src/features/ResponseBridge.ts b/apps/discord-bot/src/features/ResponseBridge.ts index 163f4d9ea4ed..a61b83cfd264 100644 --- a/apps/discord-bot/src/features/ResponseBridge.ts +++ b/apps/discord-bot/src/features/ResponseBridge.ts @@ -35,6 +35,15 @@ import { streamHistoryHasAdditionalContent, unpostedAttachments, } from "../presentation/attachments.ts"; +import { + AzureBlobUploadError, + DISCORD_CONSERVATIVE_UPLOAD_LIMIT_BYTES, + formatOversizedAttachmentNote, + isAzureBlobUploadConfigured, + uploadOversizedFilesToAzureBlob, + type AzureBlobUploadConfig, + type UploadedAzureBlobLink, +} from "../presentation/azureBlobUpload.ts"; import { buildOmegentThreadMessageUrl } from "../presentation/discordPrAttribution.ts"; import { createMessageWithAttachments, @@ -107,7 +116,6 @@ import { upsertThreadInfoPin } from "./ThreadInfoPin.ts"; const DISCORD_LIMIT = 2000; const STREAM_CHUNK_LIMIT = inProgressChunkLimit(DISCORD_LIMIT); -const DISCORD_CONSERVATIVE_UPLOAD_LIMIT_BYTES = 10_000_000; interface BridgeState { /** T3 orchestration turn currently tracked by this bridge. */ @@ -2017,6 +2025,7 @@ function formatMarkdownLocalFileRefForDiscord(input: { readonly githubUrlsBySrc: ReadonlyMap; readonly attachedFileNames?: ReadonlySet | undefined; readonly oversizedByName?: ReadonlySet | undefined; + readonly externalUrlsByName?: ReadonlyMap | undefined; }): string { const display = input.ref.label.trim() !== "" ? input.ref.label : fileNameForLocalFileRef(input.ref); @@ -2026,13 +2035,17 @@ function formatMarkdownLocalFileRefForDiscord(input: { } const uploadName = fileNameForLocalFileRef(input.ref); + const externalUrl = input.externalUrlsByName?.get(uploadName); + if (externalUrl) { + return `[${display}](${externalUrl})`; + } if (input.oversizedByName?.has(uploadName)) { return `${display} (too large to attach in Discord)`; } if (input.attachedFileNames?.has(uploadName)) { return `${display} (attached below)`; } - if (input.attachedFileNames || input.oversizedByName) { + if (input.attachedFileNames || input.oversizedByName || input.externalUrlsByName) { return `${display} (attachment unavailable)`; } return input.ref.match; @@ -2043,6 +2056,7 @@ export function rewriteMarkdownLocalFileLinksForDiscord(input: { readonly githubUrlsBySrc: ReadonlyMap; readonly attachedFileNames?: ReadonlySet | undefined; readonly oversizedByName?: ReadonlySet | undefined; + readonly externalUrlsByName?: ReadonlyMap | undefined; }): string { return replaceMarkdownLocalFileLinks(input.text, (ref) => formatMarkdownLocalFileRefForDiscord({ @@ -2050,6 +2064,7 @@ export function rewriteMarkdownLocalFileLinksForDiscord(input: { githubUrlsBySrc: input.githubUrlsBySrc, attachedFileNames: input.attachedFileNames, oversizedByName: input.oversizedByName, + externalUrlsByName: input.externalUrlsByName, }), ); } @@ -3082,6 +3097,131 @@ export const runBridge = ( }; }; + const azureBlobConfigFromBot = (botConfig: { + readonly azureStorageConnectionString: string | undefined; + readonly azureStorageAccountName: string | undefined; + readonly azureStorageAccountKey: string | undefined; + readonly azureStorageContainer: string; + }): AzureBlobUploadConfig => ({ + connectionString: botConfig.azureStorageConnectionString, + accountName: botConfig.azureStorageAccountName, + accountKey: botConfig.azureStorageAccountKey, + containerName: botConfig.azureStorageContainer, + }); + + /** + * Offload files over Discord's ~10MB limit to a private Azure container and + * return 3-day read-only SAS links. No-ops gracefully when Azure is unset. + */ + const offloadOversizedFiles = (oversized: ReadonlyArray) => + Effect.gen(function* () { + if (oversized.length === 0) { + return { + uploaded: [] as ReadonlyArray, + failed: [] as ReadonlyArray<{ readonly fileName: string; readonly sizeBytes: number }>, + unconfigured: [] as ReadonlyArray<{ + readonly fileName: string; + readonly sizeBytes: number; + }>, + externalUrlsByName: new Map(), + }; + } + + const botConfig = yield* DiscordBotConfig; + const azureConfig = azureBlobConfigFromBot(botConfig); + if (!isAzureBlobUploadConfigured(azureConfig)) { + yield* Effect.logWarning( + "Oversized Discord attachments skipped (Azure blob upload not configured)", + { + files: oversized.map((file) => ({ + name: file.name, + bytes: file.data.byteLength, + })), + }, + ); + return { + uploaded: [] as ReadonlyArray, + failed: [] as ReadonlyArray<{ readonly fileName: string; readonly sizeBytes: number }>, + unconfigured: oversized.map((file) => ({ + fileName: file.name, + sizeBytes: file.data.byteLength, + })), + externalUrlsByName: new Map(), + }; + } + + yield* Effect.logInfo("Uploading oversized Discord attachments to Azure Blob", { + container: azureConfig.containerName, + files: oversized.map((file) => ({ + name: file.name, + bytes: file.data.byteLength, + })), + }); + + const result = yield* Effect.tryPromise({ + try: () => + uploadOversizedFilesToAzureBlob({ + config: azureConfig, + files: oversized, + }), + catch: (cause) => + cause instanceof AzureBlobUploadError + ? cause + : new AzureBlobUploadError( + cause instanceof Error ? cause.message : String(cause), + cause, + ), + }).pipe( + Effect.catch((cause) => + Effect.logError("Azure blob bulk upload failed").pipe( + Effect.andThen(Effect.logError(cause)), + Effect.as({ + uploaded: [] as ReadonlyArray, + failed: oversized.map((file) => ({ + fileName: file.name, + error: cause instanceof Error ? cause.message : String(cause), + })), + }), + ), + ), + ); + + for (const failure of result.failed) { + yield* Effect.logWarning("Azure blob upload failed for attachment", failure); + } + if (result.uploaded.length > 0) { + yield* Effect.logInfo("Azure blob upload complete", { + uploaded: result.uploaded.map((entry) => ({ + fileName: entry.fileName, + blobName: entry.blobName, + expiresAt: entry.expiresAt.toISOString(), + bytes: entry.sizeBytes, + })), + }); + } + + const failedWithSize = result.failed.map((failure) => { + const match = oversized.find((file) => file.name === failure.fileName); + return { + fileName: failure.fileName, + sizeBytes: match?.data.byteLength ?? 0, + }; + }); + const externalUrlsByName = new Map( + result.uploaded.map((entry) => [entry.fileName, entry.url] as const), + ); + + return { + uploaded: result.uploaded, + failed: failedWithSize, + unconfigured: [] as ReadonlyArray<{ + readonly fileName: string; + readonly sizeBytes: number; + }>, + externalUrlsByName, + }; + }); + /** * Create a Discord message. Binary files use native multipart FormData + fetch * (HTTP/1.1). dfx `withFiles` goes through Effect/Undici HTTP2 and dies with @@ -3679,7 +3819,26 @@ export const runBridge = ( ); const files = [...imageFiles, ...mdLoaded.files, ...linkedFilesLoaded.files]; if (files.length === 0) return; - const created = yield* createMessageWithFiles("", files); + const { batches: lateBatches, oversized: lateOversized } = + splitFilesForDiscordUpload(files); + const lateOffload = yield* offloadOversizedFiles(lateOversized); + const finalIds: string[] = []; + for (const batch of lateBatches) { + const created = yield* createMessageWithFiles("", batch); + finalIds.push(created.id); + } + if (lateOversized.length > 0) { + const note = formatOversizedAttachmentNote({ + uploaded: lateOffload.uploaded, + failed: lateOffload.failed, + unconfigured: lateOffload.unconfigured, + }); + if (note !== null) { + const created = yield* rest.createMessage(input.discordChannelId, { content: note }); + finalIds.push(created.id); + } + } + if (finalIds.length === 0) return; const postedFromFiles = pendingImages .slice(0, imageFiles.length) .map((entry) => entry.id); @@ -3692,7 +3851,7 @@ export const runBridge = ( postedMarkdownFileSrcs: [ ...new Set([...current.postedMarkdownFileSrcs, ...linkedFilesLoaded.loadedSrcs]), ], - finalDiscordMessageIds: [...current.finalDiscordMessageIds, created.id], + finalDiscordMessageIds: [...current.finalDiscordMessageIds, ...finalIds], })); return; } @@ -3771,9 +3930,15 @@ export const runBridge = ( const postedFromFiles = pendingImages.slice(0, imageFiles.length).map((entry) => entry.id); // Split once for local-file rewrite notes (no table .txt attachments). + // Oversized files go to private Azure Blob with 3-day SAS download links. const { batches: uploadBatches, oversized: oversizedFiles } = splitFilesForDiscordUpload(files); - const oversizedByName = new Set(oversizedFiles.map((file) => file.name)); + const oversizedOffload = yield* offloadOversizedFiles(oversizedFiles); + const oversizedByName = new Set( + oversizedFiles + .map((file) => file.name) + .filter((name) => !oversizedOffload.externalUrlsByName.has(name)), + ); const attachedFileNames = new Set( uploadBatches.flatMap((batch) => batch.map((file) => file.name)), ); @@ -3789,6 +3954,7 @@ export const runBridge = ( githubUrlsBySrc, attachedFileNames, oversizedByName, + externalUrlsByName: oversizedOffload.externalUrlsByName, }) .replace(/_\(attachment will attach when done\)_/giu, "") .replace(/_\(\d+ attachments will attach when done\)_/giu, "") @@ -3932,14 +4098,15 @@ export const runBridge = ( } if (oversizedFiles.length > 0) { - const note = [ - "**Some files could not be attached due to Discord upload limits:**", - ...oversizedFiles.map( - (file) => `- \`${file.name}\` (${Math.ceil(file.data.byteLength / 1_000_000)} MB)`, - ), - ].join("\n"); - const created = yield* rest.createMessage(input.discordChannelId, { content: note }); - ids.push(created.id); + const note = formatOversizedAttachmentNote({ + uploaded: oversizedOffload.uploaded, + failed: oversizedOffload.failed, + unconfigured: oversizedOffload.unconfigured, + }); + if (note !== null) { + const created = yield* rest.createMessage(input.discordChannelId, { content: note }); + ids.push(created.id); + } } return ids; }); diff --git a/apps/discord-bot/src/presentation/azureBlobUpload.test.ts b/apps/discord-bot/src/presentation/azureBlobUpload.test.ts new file mode 100644 index 000000000000..da5313d8a955 --- /dev/null +++ b/apps/discord-bot/src/presentation/azureBlobUpload.test.ts @@ -0,0 +1,135 @@ +// @effect-diagnostics globalDate:off +import { describe, expect, it } from "vite-plus/test"; + +import { + AZURE_BLOB_LINK_TTL_MS, + buildOpaqueBlobName, + DISCORD_CONSERVATIVE_UPLOAD_LIMIT_BYTES, + formatOversizedAttachmentNote, + isAzureBlobUploadConfigured, + sanitizeBlobFileName, + type UploadedAzureBlobLink, +} from "./azureBlobUpload.ts"; + +describe("isAzureBlobUploadConfigured", () => { + it("is false when nothing is set", () => { + expect( + isAzureBlobUploadConfigured({ + connectionString: undefined, + accountName: undefined, + accountKey: undefined, + containerName: "discord-bot-attachments", + }), + ).toBe(false); + expect(isAzureBlobUploadConfigured(undefined)).toBe(false); + }); + + it("accepts a connection string", () => { + expect( + isAzureBlobUploadConfigured({ + connectionString: "AccountName=demo;AccountKey=abc==;EndpointSuffix=core.windows.net", + accountName: undefined, + accountKey: undefined, + containerName: "discord-bot-attachments", + }), + ).toBe(true); + }); + + it("accepts account name + key", () => { + expect( + isAzureBlobUploadConfigured({ + connectionString: undefined, + accountName: "demo", + accountKey: "abc==", + containerName: "discord-bot-attachments", + }), + ).toBe(true); + }); + + it("rejects partial account credentials", () => { + expect( + isAzureBlobUploadConfigured({ + connectionString: undefined, + accountName: "demo", + accountKey: undefined, + containerName: "discord-bot-attachments", + }), + ).toBe(false); + }); +}); + +describe("sanitizeBlobFileName", () => { + it("keeps a safe stem and extension", () => { + expect(sanitizeBlobFileName("report.csv")).toEqual({ stem: "report", extension: ".csv" }); + }); + + it("strips unsafe characters", () => { + expect(sanitizeBlobFileName("../../evil name?.tar.gz")).toEqual({ + stem: "evil-name-.tar", + extension: ".gz", + }); + }); + + it("falls back for empty names", () => { + expect(sanitizeBlobFileName(" ")).toEqual({ stem: "attachment", extension: ".bin" }); + }); +}); + +describe("buildOpaqueBlobName", () => { + it("includes date, uuid directory, and random filename postfix", () => { + const name = buildOpaqueBlobName("Video.mp4", { + now: new Date("2026-08-11T12:00:00.000Z"), + randomUuid: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + randomPostfix: "deadbeefcafebabe", + }); + expect(name).toBe("2026/08/11/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/Video-deadbeefcafebabe.mp4"); + }); + + it("produces unique names across calls", () => { + const a = buildOpaqueBlobName("a.bin"); + const b = buildOpaqueBlobName("a.bin"); + expect(a).not.toBe(b); + }); +}); + +describe("formatOversizedAttachmentNote", () => { + const uploaded: UploadedAzureBlobLink = { + fileName: "big.mp4", + blobName: "2026/08/11/u/big-x.mp4", + url: "https://example.blob.core.windows.net/c/big?sv=1&sig=abc", + expiresAt: new Date("2026-08-14T00:00:00.000Z"), + sizeBytes: 12_500_000, + }; + + it("formats temporary download links for uploaded files", () => { + const note = formatOversizedAttachmentNote({ uploaded: [uploaded], failed: [] }); + expect(note).toContain("temporary download links (expire in 3 days)"); + expect(note).toContain("[`big.mp4`](https://example.blob.core.windows.net/c/big?sv=1&sig=abc)"); + expect(note).toContain("(13 MB)"); + }); + + it("formats failure-only notes when Azure is unavailable", () => { + const note = formatOversizedAttachmentNote({ + uploaded: [], + failed: [], + unconfigured: [{ fileName: "huge.zip", sizeBytes: 20_000_000 }], + }); + expect(note).toContain("could not be attached due to Discord upload limits"); + expect(note).toContain("`huge.zip`"); + expect(note).toContain("(20 MB)"); + }); + + it("returns null when there is nothing to report", () => { + expect(formatOversizedAttachmentNote({ uploaded: [], failed: [] })).toBeNull(); + }); +}); + +describe("constants", () => { + it("keeps the Discord offload threshold at 10MB", () => { + expect(DISCORD_CONSERVATIVE_UPLOAD_LIMIT_BYTES).toBe(10_000_000); + }); + + it("keeps the SAS TTL at three days", () => { + expect(AZURE_BLOB_LINK_TTL_MS).toBe(3 * 24 * 60 * 60 * 1000); + }); +}); diff --git a/apps/discord-bot/src/presentation/azureBlobUpload.ts b/apps/discord-bot/src/presentation/azureBlobUpload.ts new file mode 100644 index 000000000000..9edf1dc03fd4 --- /dev/null +++ b/apps/discord-bot/src/presentation/azureBlobUpload.ts @@ -0,0 +1,356 @@ +/** + * Upload oversized Discord attachments to a private Azure Blob container and + * return short-lived SAS read URLs. + * + * Security model: + * - Container is private (not listable / not anonymously enumerable) + * - Blob names are hard to guess (random UUID + random postfix on the filename) + * - Access is via a read-only HTTPS SAS link that expires after 3 days + */ +// @effect-diagnostics preferSchemaOverJson:off globalDate:off cryptoRandomUUID:off globalErrorInEffectCatch:off globalErrorInEffectFailure:off + +import { + BlobSASPermissions, + BlobServiceClient, + generateBlobSASQueryParameters, + SASProtocol, + StorageSharedKeyCredential, +} from "@azure/storage-blob"; + +/** Discord free-tier conservative limit; over this we offload to Azure. */ +export const DISCORD_CONSERVATIVE_UPLOAD_LIMIT_BYTES = 10_000_000; + +/** Public download links expire after three days. */ +export const AZURE_BLOB_LINK_TTL_MS = 3 * 24 * 60 * 60 * 1000; + +/** Default private container for bot attachment offload. */ +export const DEFAULT_AZURE_BLOB_CONTAINER = "discord-bot-attachments"; + +export interface AzureBlobUploadConfig { + /** Full connection string (`AccountName=…;AccountKey=…;…`). Preferred. */ + readonly connectionString: string | undefined; + /** Account name when not using a connection string. */ + readonly accountName: string | undefined; + /** Account key when not using a connection string. */ + readonly accountKey: string | undefined; + /** Private container name (must already exist, public access off). */ + readonly containerName: string; +} + +export interface AzureBlobUploadFile { + readonly name: string; + readonly mimeType: string; + readonly data: Uint8Array; +} + +export interface UploadedAzureBlobLink { + readonly fileName: string; + readonly blobName: string; + readonly url: string; + readonly expiresAt: Date; + readonly sizeBytes: number; +} + +export class AzureBlobUploadError extends Error { + readonly uploadCause: unknown | undefined; + + constructor(message: string, uploadCause?: unknown) { + super(message); + this.name = "AzureBlobUploadError"; + this.uploadCause = uploadCause; + } +} + +export function isAzureBlobUploadConfigured( + config: AzureBlobUploadConfig | null | undefined, +): boolean { + if (config === null || config === undefined) return false; + const connection = config.connectionString?.trim() ?? ""; + if (connection !== "") return true; + const account = config.accountName?.trim() ?? ""; + const key = config.accountKey?.trim() ?? ""; + return account !== "" && key !== ""; +} + +/** + * Sanitize a user/agent filename for blob path use while preserving the extension. + */ +export function sanitizeBlobFileName(fileName: string): { stem: string; extension: string } { + const trimmed = fileName.trim() || "attachment.bin"; + const lastDot = trimmed.lastIndexOf("."); + const hasExt = lastDot > 0 && lastDot < trimmed.length - 1; + const rawStem = hasExt ? trimmed.slice(0, lastDot) : trimmed; + const rawExt = hasExt ? trimmed.slice(lastDot) : ""; + const stem = + rawStem + .replace(/[^A-Za-z0-9._-]+/gu, "-") + .replace(/-+/gu, "-") + .replace(/^[.-]+|[.-]+$/gu, "") + .slice(0, 80) || "attachment"; + const extension = rawExt.replace(/[^A-Za-z0-9.]/gu, "").slice(0, 16); + return { stem, extension }; +} + +/** + * Opaque blob path: `yyyy/mm/dd/{uuid}/{stem}-{randomHex}{ext}`. + * Random UUID directory + random filename postfix make enumeration impractical + * even if someone somehow listed the container. + */ +export function buildOpaqueBlobName( + fileName: string, + options?: { + readonly now?: Date; + readonly randomUuid?: string; + readonly randomPostfix?: string; + }, +): string { + const now = options?.now ?? new Date(); + const yyyy = String(now.getUTCFullYear()); + const mm = String(now.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(now.getUTCDate()).padStart(2, "0"); + const uuid = options?.randomUuid ?? globalThis.crypto.randomUUID(); + const postfix = + options?.randomPostfix ?? + Array.from(globalThis.crypto.getRandomValues(new Uint8Array(8)), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + const { stem, extension } = sanitizeBlobFileName(fileName); + return `${yyyy}/${mm}/${dd}/${uuid}/${stem}-${postfix}${extension}`; +} + +function parseConnectionString(connectionString: string): { + accountName: string; + accountKey: string; + blobEndpoint: string | undefined; +} { + const parts = new Map(); + for (const segment of connectionString.split(";")) { + const trimmed = segment.trim(); + if (trimmed === "") continue; + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + parts.set(trimmed.slice(0, eq).toLowerCase(), trimmed.slice(eq + 1)); + } + const accountName = parts.get("accountname") ?? ""; + const accountKey = parts.get("accountkey") ?? ""; + const blobEndpoint = parts.get("blobendpoint"); + if (accountName === "" || accountKey === "") { + throw new AzureBlobUploadError( + "Azure connection string must include AccountName and AccountKey", + ); + } + return { + accountName, + accountKey, + blobEndpoint: blobEndpoint && blobEndpoint !== "" ? blobEndpoint : undefined, + }; +} + +function resolveCredentials(config: AzureBlobUploadConfig): { + accountName: string; + credential: StorageSharedKeyCredential; + serviceUrl: string; +} { + const connection = config.connectionString?.trim() ?? ""; + if (connection !== "") { + const parsed = parseConnectionString(connection); + const credential = new StorageSharedKeyCredential(parsed.accountName, parsed.accountKey); + const serviceUrl = + parsed.blobEndpoint?.replace(/\/+$/u, "") ?? + `https://${parsed.accountName}.blob.core.windows.net`; + return { accountName: parsed.accountName, credential, serviceUrl }; + } + + const accountName = config.accountName?.trim() ?? ""; + const accountKey = config.accountKey?.trim() ?? ""; + if (accountName === "" || accountKey === "") { + throw new AzureBlobUploadError( + "Azure blob upload requires AZURE_STORAGE_CONNECTION_STRING or account name+key", + ); + } + return { + accountName, + credential: new StorageSharedKeyCredential(accountName, accountKey), + serviceUrl: `https://${accountName}.blob.core.windows.net`, + }; +} + +function buildReadSasUrl(input: { + readonly accountName: string; + readonly credential: StorageSharedKeyCredential; + readonly containerName: string; + readonly blobName: string; + readonly serviceUrl: string; + readonly expiresAt: Date; + readonly startsOn: Date; +}): string { + const sas = generateBlobSASQueryParameters( + { + containerName: input.containerName, + blobName: input.blobName, + permissions: BlobSASPermissions.parse("r"), + startsOn: input.startsOn, + expiresOn: input.expiresAt, + protocol: SASProtocol.Https, + }, + input.credential, + ).toString(); + + const base = `${input.serviceUrl.replace(/\/+$/u, "")}/${encodeURIComponent(input.containerName).replace(/%2F/giu, "/")}/${input.blobName + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/")}`; + return `${base}?${sas}`; +} + +/** + * Upload one file to the private container and return a 3-day read-only SAS URL. + */ +export async function uploadFileToAzureBlob(input: { + readonly config: AzureBlobUploadConfig; + readonly file: AzureBlobUploadFile; + readonly now?: Date; + readonly ttlMs?: number; +}): Promise { + if (!isAzureBlobUploadConfigured(input.config)) { + throw new AzureBlobUploadError("Azure blob upload is not configured"); + } + + const containerName = input.config.containerName.trim() || DEFAULT_AZURE_BLOB_CONTAINER; + const now = input.now ?? new Date(); + const ttlMs = input.ttlMs ?? AZURE_BLOB_LINK_TTL_MS; + const expiresAt = new Date(now.getTime() + ttlMs); + // Allow small clock skew on the consumer side. + const startsOn = new Date(now.getTime() - 5 * 60 * 1000); + const blobName = buildOpaqueBlobName(input.file.name, { now }); + + try { + const { accountName, credential, serviceUrl } = resolveCredentials(input.config); + const service = new BlobServiceClient(serviceUrl, credential); + const container = service.getContainerClient(containerName); + const blockBlob = container.getBlockBlobClient(blobName); + + const body = Buffer.from( + input.file.data.buffer, + input.file.data.byteOffset, + input.file.data.byteLength, + ); + const safeName = sanitizeBlobFileName(input.file.name); + await blockBlob.upload(body, body.byteLength, { + blobHTTPHeaders: { + blobContentType: input.file.mimeType || "application/octet-stream", + // Encourage download with the original filename in browsers. + blobContentDisposition: `attachment; filename="${safeName.stem}${safeName.extension}"`, + }, + }); + + const url = buildReadSasUrl({ + accountName, + credential, + containerName, + blobName, + serviceUrl, + expiresAt, + startsOn, + }); + + return { + fileName: input.file.name, + blobName, + url, + expiresAt, + sizeBytes: input.file.data.byteLength, + }; + } catch (cause) { + if (cause instanceof AzureBlobUploadError) throw cause; + throw new AzureBlobUploadError( + `Azure blob upload failed for ${input.file.name}: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + ); + } +} + +/** + * Upload many oversized files. Failures are isolated per file so one bad upload + * does not block the rest. + */ +export async function uploadOversizedFilesToAzureBlob(input: { + readonly config: AzureBlobUploadConfig; + readonly files: ReadonlyArray; + readonly now?: Date; + readonly ttlMs?: number; +}): Promise<{ + readonly uploaded: ReadonlyArray; + readonly failed: ReadonlyArray<{ readonly fileName: string; readonly error: string }>; +}> { + const uploaded: UploadedAzureBlobLink[] = []; + const failed: Array<{ fileName: string; error: string }> = []; + + for (const file of input.files) { + try { + uploaded.push( + await uploadFileToAzureBlob({ + config: input.config, + file, + ...(input.now === undefined ? {} : { now: input.now }), + ...(input.ttlMs === undefined ? {} : { ttlMs: input.ttlMs }), + }), + ); + } catch (cause) { + failed.push({ + fileName: file.name, + error: cause instanceof Error ? cause.message : String(cause), + }); + } + } + + return { uploaded, failed }; +} + +/** Discord note for files that could not fit Discord's attachment limit. */ +export function formatOversizedAttachmentNote(input: { + readonly uploaded: ReadonlyArray; + readonly failed: ReadonlyArray<{ readonly fileName: string; readonly sizeBytes?: number }>; + readonly unconfigured?: ReadonlyArray<{ readonly fileName: string; readonly sizeBytes: number }>; +}): string | null { + const sections: string[] = []; + + if (input.uploaded.length > 0) { + sections.push( + [ + "**Files too large for Discord — temporary download links (expire in 3 days):**", + ...input.uploaded.map((entry) => { + const mb = Math.max(1, Math.ceil(entry.sizeBytes / 1_000_000)); + return `- [\`${entry.fileName}\`](${entry.url}) (${mb} MB)`; + }), + ].join("\n"), + ); + } + + const failed = [ + ...input.failed.map((entry) => { + const size = + entry.sizeBytes === undefined + ? "" + : ` (${Math.max(1, Math.ceil(entry.sizeBytes / 1_000_000))} MB)`; + return `- \`${entry.fileName}\`${size}`; + }), + ...(input.unconfigured ?? []).map((entry) => { + const mb = Math.max(1, Math.ceil(entry.sizeBytes / 1_000_000)); + return `- \`${entry.fileName}\` (${mb} MB)`; + }), + ]; + if (failed.length > 0) { + sections.push( + [ + input.uploaded.length > 0 + ? "**Could not offload these files:**" + : "**Some files could not be attached due to Discord upload limits:**", + ...failed, + ].join("\n"), + ); + } + + if (sections.length === 0) return null; + return sections.join("\n\n"); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75da172154eb..beaf28e8aaeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,9 @@ importers: apps/discord-bot: dependencies: + '@azure/storage-blob': + specifier: ^12.33.0 + version: 12.33.0 '@effect/platform-node': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) @@ -227,7 +230,7 @@ importers: version: link:../../packages/shared astro: specifier: ^7.0.3 - version: 7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.12.4)(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0)(jiti@2.7.0)(rollup@4.61.0)(terser@5.48.0)(tsx@4.23.9)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + version: 7.0.3(@astrojs/markdown-remark@7.2.0)(@azure/storage-blob@12.33.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.12.4)(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0)(jiti@2.7.0)(rollup@4.61.0)(terser@5.48.0)(tsx@4.23.9)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) devDependencies: '@astrojs/check': specifier: ^0.9.7 @@ -1280,6 +1283,53 @@ packages: resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} + '@azure/abort-controller@2.2.0': + resolution: {integrity: sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==} + engines: {node: '>=22.0.0'} + + '@azure/core-auth@1.11.0': + resolution: {integrity: sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==} + engines: {node: '>=22.0.0'} + + '@azure/core-client@1.11.0': + resolution: {integrity: sha512-JjQWO6akOck45PH/XBrxzsQGAiKrfFl4m5iggJ0ItMIz5omRufOXWpqCPpdjKN3vKDzlSUvFjaMb7Zwf0gvAdA==} + engines: {node: '>=22.0.0'} + + '@azure/core-http-compat@2.5.0': + resolution: {integrity: sha512-BoSmXPx2er1Ai+wKlDvj29jIQespCNBwEmKyZVHO2kEFsWbGjAjwMCGzug3DJM5/QYIV3vej0S1zcU5bq9fa8w==} + engines: {node: '>=22.0.0'} + peerDependencies: + '@azure/core-client': ^1.10.0 + '@azure/core-rest-pipeline': ^1.22.0 + + '@azure/core-lro@2.7.2': + resolution: {integrity: sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==} + engines: {node: '>=18.0.0'} + + '@azure/core-paging@1.7.0': + resolution: {integrity: sha512-7GEAoIsaoBr6KELNRb8nypowCqvk8dnCHFCYg4XD4lOQGY2GqjQg5IhkRjyBFRO18CGSMq05PaNqSOE9GQro3g==} + engines: {node: '>=22.0.0'} + + '@azure/core-rest-pipeline@1.25.0': + resolution: {integrity: sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==} + engines: {node: '>=22.0.0'} + + '@azure/core-tracing@1.4.0': + resolution: {integrity: sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ==} + engines: {node: '>=22.0.0'} + + '@azure/core-util@1.14.0': + resolution: {integrity: sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==} + engines: {node: '>=22.0.0'} + + '@azure/core-xml@1.6.0': + resolution: {integrity: sha512-e7lX/dk//F6Qf7BB6PTY4+p2yuOQtyOeHGyapYHNwqSp2OnYpwQt49A/Nin2XmKBQ69pwagR4k/lQBq8lbHQkA==} + engines: {node: '>=22.0.0'} + + '@azure/logger@1.4.0': + resolution: {integrity: sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==} + engines: {node: '>=22.0.0'} + '@azure/msal-common@16.12.0': resolution: {integrity: sha512-hgLgfRdbG2AmhXPygebf1KYJEvse86+ZZLWufdiTKaGRYEUqOzHdlf6AS1IiuUCHWbynkgbHc451jSNkbfhWlg==} engines: {node: '>=0.8.0'} @@ -1288,6 +1338,14 @@ packages: resolution: {integrity: sha512-A/2WIsuH0vsC6JVkkafjS4kHpi2LDR4AzDT0kJ+oIRtXYeYtvGQ2pwN2X88thQPhSek+82ela3MprsKXWQRrhQ==} engines: {node: '>=20'} + '@azure/storage-blob@12.33.0': + resolution: {integrity: sha512-2SX8oP8PyblUcAFZSg39c8Ls+tFjavM6sBeV+qpw33mRzRhI/5hrFJmJ/x0H9xx5l6ECPvgSP8uPxqTeVbHNIA==} + engines: {node: '>=22.0.0'} + + '@azure/storage-common@12.5.0': + resolution: {integrity: sha512-bttzuhQiCIwrkzjPDA+AtAR7dg19L/CC6ztcqJ5LfvWpXuys9mHp0UQ0udYnoUvv9SCT9KTR5kqFvFr0e6k0lQ==} + engines: {node: '>=22.0.0'} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -5094,6 +5152,10 @@ packages: engines: {node: '>=16.20.0'} hasBin: true + '@typespec/ts-http-runtime@0.3.8': + resolution: {integrity: sha512-bLMpVcWZNzq6lYOybwFwOAR1IXKcHnhUNqYeHjl1bET/qE3jFPFH+p8Wrh3rU4xwdnifPxmKNESBYnvnmc75aA==} + engines: {node: '>=22.0.0'} + '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} @@ -6657,6 +6719,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + eventsource-parser@3.1.0: resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} @@ -11200,6 +11266,85 @@ snapshots: '@aws/lambda-invoke-store@0.2.4': {} + '@azure/abort-controller@2.2.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-auth@1.11.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-util': 1.14.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-client@1.11.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-http-compat@2.5.0(@azure/core-client@1.11.0)(@azure/core-rest-pipeline@1.25.0)': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-client': 1.11.0 + '@azure/core-rest-pipeline': 1.25.0 + + '@azure/core-lro@2.7.2': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-paging@1.7.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-rest-pipeline@1.25.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + '@typespec/ts-http-runtime': 0.3.8 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-tracing@1.4.0': + dependencies: + tslib: 2.8.1 + + '@azure/core-util@1.14.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@typespec/ts-http-runtime': 0.3.8 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-xml@1.6.0': + dependencies: + fast-xml-parser: 5.8.0 + tslib: 2.8.1 + + '@azure/logger@1.4.0': + dependencies: + '@typespec/ts-http-runtime': 0.3.8 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@azure/msal-common@16.12.0': {} '@azure/msal-node@5.5.0': @@ -11207,6 +11352,40 @@ snapshots: '@azure/msal-common': 16.12.0 jsonwebtoken: 9.0.3 + '@azure/storage-blob@12.33.0': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-client': 1.11.0 + '@azure/core-http-compat': 2.5.0(@azure/core-client@1.11.0)(@azure/core-rest-pipeline@1.25.0) + '@azure/core-lro': 2.7.2 + '@azure/core-paging': 1.7.0 + '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/core-xml': 1.6.0 + '@azure/logger': 1.4.0 + '@azure/storage-common': 12.5.0(@azure/core-client@1.11.0) + events: 3.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/storage-common@12.5.0(@azure/core-client@1.11.0)': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-http-compat': 2.5.0(@azure/core-client@1.11.0)(@azure/core-rest-pipeline@1.25.0) + '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + events: 3.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@azure/core-client' + - supports-color + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -15496,6 +15675,14 @@ snapshots: '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260604.1 '@typescript/native-preview-win32-x64': 7.0.0-dev.20260604.1 + '@typespec/ts-http-runtime@0.3.8': + dependencies: + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@ungap/structured-clone@1.3.1': {} '@vercel/config@0.3.0': @@ -15972,7 +16159,7 @@ snapshots: assertion-error@2.0.1: {} - astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.12.4)(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0)(jiti@2.7.0)(rollup@4.61.0)(terser@5.48.0)(tsx@4.23.9)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0): + astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@azure/storage-blob@12.33.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.12.4)(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0)(jiti@2.7.0)(rollup@4.61.0)(terser@5.48.0)(tsx@4.23.9)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0): dependencies: '@astrojs/compiler-rs': 0.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) '@astrojs/internal-helpers': 0.10.0 @@ -16023,7 +16210,7 @@ snapshots: ultrahtml: 1.6.0 unifont: 0.7.4 unist-util-visit: 5.1.0 - unstorage: 1.17.5(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0) + unstorage: 1.17.5(@azure/storage-blob@12.33.0)(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0) vfile: 6.0.3 vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.9)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' vitefu: 1.1.3(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.9)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) @@ -17163,6 +17350,8 @@ snapshots: eventemitter3@5.0.4: {} + events@3.3.0: {} + eventsource-parser@3.1.0: {} eventsource@3.0.7: @@ -21764,7 +21953,7 @@ snapshots: rolldown: 1.0.0-rc.17 optional: true - unstorage@1.17.5(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0): + unstorage@1.17.5(@azure/storage-blob@12.33.0)(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -21775,6 +21964,7 @@ snapshots: ofetch: 1.5.1 ufo: 1.6.4 optionalDependencies: + '@azure/storage-blob': 12.33.0 aws4fetch: 1.0.20 idb-keyval: 6.2.1 ioredis: 5.11.0