diff --git a/frontend/server/api/omni/telegram/avatar.get.ts b/frontend/server/api/omni/telegram/avatar.get.ts new file mode 100644 index 0000000..4a3d78f --- /dev/null +++ b/frontend/server/api/omni/telegram/avatar.get.ts @@ -0,0 +1,75 @@ +import { getQuery, setHeader } from "h3"; +import { getAuthContext } from "../../../utils/auth"; +import { prisma } from "../../../utils/prisma"; +import { requireTelegramBotToken, telegramApiBase, telegramBotApi } from "../../../utils/telegram"; + +const TELEGRAM_FILE_MARKER = "tg-file:"; + +type TelegramFileMeta = { + file_id: string; + file_path?: string; +}; + +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; +} + +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" }); + } + + const fileId = parseTelegramFileId(contact.avatarUrl); + if (!fileId) { + throw createError({ statusCode: 404, statusMessage: "telegram avatar is missing" }); + } + + const meta = await telegramBotApi("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()); + + 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; +}); diff --git a/frontend/server/graphql/schema.ts b/frontend/server/graphql/schema.ts index 38be195..8937bad 100644 --- a/frontend/server/graphql/schema.ts +++ b/frontend/server/graphql/schema.ts @@ -120,6 +120,18 @@ function resolveContactMessageAudioUrl(message: { return raw; } +function resolveContactAvatarUrl(contact: { + id: string; + avatarUrl: string | null; +}) { + const raw = String(contact.avatarUrl ?? "").trim(); + if (!raw) return ""; + if (raw.startsWith(TELEGRAM_AUDIO_FILE_MARKER)) { + return `/api/omni/telegram/avatar?contactId=${encodeURIComponent(contact.id)}`; + } + return raw; +} + async function upsertContactInbox(input: { teamId: string; contactId: string; @@ -484,7 +496,7 @@ async function getContacts(auth: AuthContext | null) { .map((c) => ({ id: c.id, name: c.name, - avatar: c.avatarUrl ?? "", + avatar: resolveContactAvatarUrl(c), channels: Array.from(channelsByContactId.get(c.id) ?? []), lastContactAt: c.messages[0]?.occurredAt?.toISOString?.() ?? c.updatedAt.toISOString(), lastMessageText: c.messages[0]?.content ?? "", diff --git a/omni_inbound/src/telegram.ts b/omni_inbound/src/telegram.ts index d1ad734..d7a4d56 100644 --- a/omni_inbound/src/telegram.ts +++ b/omni_inbound/src/telegram.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import type { JsonObject, OmniInboundEnvelopeV1 } from "./types"; const MAX_TEXT_LENGTH = 4096; +const TELEGRAM_FILE_MARKER = "tg-file:"; function asObject(value: unknown): JsonObject { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : {}; @@ -67,6 +68,11 @@ function normalizeId(value: unknown) { return text || null; } +function pickTelegramChatPhotoFileId(source: JsonObject | null | undefined) { + const photo = asObject(asObject(source).photo); + return normalizeString(photo.small_file_id) ?? normalizeString(photo.big_file_id); +} + type TelegramMediaInfo = { kind: "voice" | "audio" | "video_note" | null; fileId: string | null; @@ -219,6 +225,7 @@ export function parseTelegramBusinessUpdate(raw: unknown): OmniInboundEnvelopeV1 } const fallbackContactSource = contactSource === chat ? from : chat; + const contactAvatarFileId = pickTelegramChatPhotoFileId(contactSource); const threadExternalId = chat.id != null @@ -293,7 +300,7 @@ export function parseTelegramBusinessUpdate(raw: unknown): OmniInboundEnvelopeV1 contactFirstName: normalizeString(contactSource.first_name), contactLastName: normalizeString(contactSource.last_name), contactTitle: normalizeString(contactSource.title), - contactAvatarUrl: normalizeString(contactSource.photo_url), + contactAvatarUrl: contactAvatarFileId ? `${TELEGRAM_FILE_MARKER}${contactAvatarFileId}` : null, fromUsername: typeof from.username === "string" ? from.username : null, fromFirstName: typeof from.first_name === "string" ? from.first_name : null, fromLastName: typeof from.last_name === "string" ? from.last_name : null,