From 2ed436ef0e4041757143dcb732b577e699a56098 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sat, 9 May 2026 17:23:59 +0800 Subject: [PATCH] feat(bot): add Telegram media ingest into /data/media --- apps/bot/src/media/ingest.ts | 89 ++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 apps/bot/src/media/ingest.ts diff --git a/apps/bot/src/media/ingest.ts b/apps/bot/src/media/ingest.ts new file mode 100644 index 0000000..e78b38d --- /dev/null +++ b/apps/bot/src/media/ingest.ts @@ -0,0 +1,89 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { createHash } from "node:crypto"; +import { mediaFiles } from "@cmbot/db"; +import { newMediaPath, absoluteMediaPath } from "@cmbot/shared"; +import { db } from "../db.js"; +import { env } from "../env.js"; +import { logger } from "../logger.js"; + +export type IngestInput = { + operatorId: string; + filenameOriginal: string; + mimeType: string; + buffer: Buffer; +}; + +export type IngestResult = { + mediaId: string; + storagePath: string; +}; + +export async function ingestMediaBuffer(input: IngestInput): Promise { + const sha256 = createHash("sha256").update(input.buffer).digest("hex"); + const storagePath = newMediaPath(input.filenameOriginal); + const absolute = absoluteMediaPath(storagePath, env.MEDIA_DIR); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, input.buffer); + + const [row] = await db + .insert(mediaFiles) + .values({ + operatorId: input.operatorId, + filenameOriginal: input.filenameOriginal, + mimeType: input.mimeType, + sizeBytes: input.buffer.byteLength, + sha256, + storagePath, + }) + .returning({ id: mediaFiles.id }); + + logger.info( + { mediaId: row!.id, sizeBytes: input.buffer.byteLength, sha256 }, + "media: ingested", + ); + + return { mediaId: row!.id, storagePath }; +} + +/** + * Download a Telegram file by file_id and ingest it. Returns the new media row. + */ +export async function ingestTelegramFile( + operatorId: string, + apiBase: string, + botToken: string, + fileId: string, + defaultFilename: string, + mimeType: string, +): Promise { + // 1. getFile — Telegram returns a file_path + const getFileUrl = `${apiBase}/bot${botToken}/getFile?file_id=${encodeURIComponent(fileId)}`; + const getFileRes = await fetch(getFileUrl); + if (!getFileRes.ok) { + throw new Error(`Telegram getFile failed: ${getFileRes.status} ${getFileRes.statusText}`); + } + const getFileJson = (await getFileRes.json()) as { + ok: boolean; + result?: { file_path?: string }; + }; + if (!getFileJson.ok || !getFileJson.result?.file_path) { + throw new Error("Telegram getFile: missing file_path in response"); + } + // 2. Download bytes + const downloadUrl = `${apiBase}/file/bot${botToken}/${getFileJson.result.file_path}`; + const dl = await fetch(downloadUrl); + if (!dl.ok) { + throw new Error(`Telegram file download failed: ${dl.status} ${dl.statusText}`); + } + const buffer = Buffer.from(await dl.arrayBuffer()); + + // The Telegram-side filename can be missing; fall back to defaultFilename. + const filename = getFileJson.result.file_path.split("/").pop() ?? defaultFilename; + return ingestMediaBuffer({ + operatorId, + filenameOriginal: filename, + mimeType, + buffer, + }); +}