168 lines
5.2 KiB
TypeScript
168 lines
5.2 KiB
TypeScript
import { getQuery, setHeader } from "h3";
|
|
import { existsSync } from "node:fs";
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { getAuthContext } from "../../../utils/auth";
|
|
import { prisma } from "../../../utils/prisma";
|
|
import { requireTelegramBotToken, telegramApiBase, telegramBotApi } from "../../../utils/telegram";
|
|
|
|
const TELEGRAM_FILE_MARKER = "tg-file:";
|
|
const AVATAR_CACHE_DIR = path.join(process.cwd(), ".data", "telegram-avatars");
|
|
|
|
type TelegramFileMeta = {
|
|
file_id: string;
|
|
file_path?: string;
|
|
};
|
|
|
|
type TelegramChatPhoto = {
|
|
small_file_id?: string;
|
|
big_file_id?: string;
|
|
};
|
|
|
|
type TelegramGetChatResponse = {
|
|
ok?: boolean;
|
|
result?: {
|
|
photo?: TelegramChatPhoto;
|
|
};
|
|
};
|
|
|
|
type TelegramProfilePhotoSize = {
|
|
file_id?: string;
|
|
};
|
|
|
|
type TelegramGetUserProfilePhotosResponse = {
|
|
ok?: boolean;
|
|
result?: {
|
|
photos?: TelegramProfilePhotoSize[][];
|
|
};
|
|
};
|
|
|
|
function parseTelegramFileId(avatarUrl: string | null | undefined) {
|
|
const raw = String(avatarUrl ?? "").trim();
|
|
if (!raw.startsWith(TELEGRAM_FILE_MARKER)) return null;
|
|
const fileId = raw.slice(TELEGRAM_FILE_MARKER.length).trim();
|
|
return fileId || null;
|
|
}
|
|
|
|
function sanitizeCacheKey(input: string) {
|
|
return input.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
}
|
|
|
|
async function fetchTelegramAvatarFileIdByExternalId(externalId: string) {
|
|
const getChatRes = await telegramBotApi<TelegramGetChatResponse["result"]>("getChat", {
|
|
chat_id: externalId,
|
|
});
|
|
const fromChat = String(getChatRes?.photo?.small_file_id ?? getChatRes?.photo?.big_file_id ?? "").trim();
|
|
if (fromChat) return fromChat;
|
|
|
|
const getUserPhotosRes = await telegramBotApi<TelegramGetUserProfilePhotosResponse["result"]>("getUserProfilePhotos", {
|
|
user_id: externalId,
|
|
limit: 1,
|
|
});
|
|
const firstPhotoSizes = Array.isArray(getUserPhotosRes?.photos?.[0]) ? getUserPhotosRes.photos[0] : [];
|
|
const candidate = String(firstPhotoSizes.at(-1)?.file_id ?? firstPhotoSizes[0]?.file_id ?? "").trim();
|
|
return candidate || null;
|
|
}
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const auth = await getAuthContext(event);
|
|
const query = getQuery(event);
|
|
const contactId = String(query.contactId ?? "").trim();
|
|
|
|
if (!contactId) {
|
|
throw createError({ statusCode: 400, statusMessage: "contactId is required" });
|
|
}
|
|
|
|
const contact = await prisma.contact.findFirst({
|
|
where: {
|
|
id: contactId,
|
|
teamId: auth.teamId,
|
|
},
|
|
select: {
|
|
avatarUrl: true,
|
|
},
|
|
});
|
|
|
|
if (!contact) {
|
|
throw createError({ statusCode: 404, statusMessage: "contact not found" });
|
|
}
|
|
|
|
let fileId = parseTelegramFileId(contact.avatarUrl);
|
|
if (!fileId) {
|
|
const identity = await prisma.omniContactIdentity.findFirst({
|
|
where: {
|
|
teamId: auth.teamId,
|
|
contactId,
|
|
channel: "TELEGRAM",
|
|
},
|
|
select: {
|
|
externalId: true,
|
|
},
|
|
orderBy: {
|
|
updatedAt: "desc",
|
|
},
|
|
});
|
|
|
|
if (!identity?.externalId) {
|
|
throw createError({ statusCode: 404, statusMessage: "telegram identity is missing" });
|
|
}
|
|
|
|
const fetchedFileId = await fetchTelegramAvatarFileIdByExternalId(identity.externalId);
|
|
if (!fetchedFileId) {
|
|
throw createError({ statusCode: 404, statusMessage: "telegram avatar is missing" });
|
|
}
|
|
|
|
fileId = fetchedFileId;
|
|
await prisma.contact.update({
|
|
where: { id: contactId },
|
|
data: { avatarUrl: `${TELEGRAM_FILE_MARKER}${fileId}` },
|
|
});
|
|
}
|
|
|
|
const cacheKey = sanitizeCacheKey(fileId);
|
|
const cacheBodyPath = path.join(AVATAR_CACHE_DIR, `${cacheKey}.img`);
|
|
const cacheMetaPath = path.join(AVATAR_CACHE_DIR, `${cacheKey}.meta`);
|
|
|
|
if (existsSync(cacheBodyPath) && existsSync(cacheMetaPath)) {
|
|
const cachedBody = await readFile(cacheBodyPath);
|
|
const cachedContentType = (await readFile(cacheMetaPath, "utf8")).trim() || "image/jpeg";
|
|
|
|
setHeader(event, "content-type", cachedContentType);
|
|
setHeader(event, "cache-control", "private, max-age=300");
|
|
setHeader(event, "content-length", cachedBody.length);
|
|
return cachedBody;
|
|
}
|
|
|
|
const meta = await telegramBotApi<TelegramFileMeta>("getFile", { file_id: fileId });
|
|
const filePath = String(meta?.file_path ?? "").trim();
|
|
if (!filePath) {
|
|
throw createError({ statusCode: 502, statusMessage: "telegram file path is unavailable" });
|
|
}
|
|
|
|
const apiBase = telegramApiBase().replace(/\/+$/, "");
|
|
const token = requireTelegramBotToken();
|
|
const remote = await fetch(`${apiBase}/file/bot${token}/${filePath}`);
|
|
if (!remote.ok) {
|
|
throw createError({ statusCode: 502, statusMessage: `telegram file fetch failed (${remote.status})` });
|
|
}
|
|
|
|
const contentType = remote.headers.get("content-type") || "image/jpeg";
|
|
const contentLength = remote.headers.get("content-length");
|
|
const body = Buffer.from(await remote.arrayBuffer());
|
|
|
|
await mkdir(AVATAR_CACHE_DIR, { recursive: true });
|
|
await writeFile(cacheBodyPath, body);
|
|
await writeFile(cacheMetaPath, contentType, "utf8");
|
|
|
|
setHeader(event, "content-type", contentType);
|
|
setHeader(event, "cache-control", "private, max-age=300");
|
|
if (contentLength) {
|
|
const parsedLength = Number(contentLength);
|
|
if (Number.isFinite(parsedLength) && parsedLength >= 0) {
|
|
setHeader(event, "content-length", parsedLength);
|
|
}
|
|
}
|
|
|
|
return body;
|
|
});
|