Fetch Telegram avatars via API when webhook has no photo

This commit is contained in:
Ruslan Bakiev
2026-02-26 11:22:57 +07:00
parent 6bae0300c8
commit 97fb67f68c

View File

@@ -30,6 +30,7 @@ export const RECEIVER_FLOW_QUEUE_NAME = (process.env.RECEIVER_FLOW_QUEUE_NAME ||
const TELEGRAM_PLACEHOLDER_PREFIX = "Telegram "; const TELEGRAM_PLACEHOLDER_PREFIX = "Telegram ";
const TELEGRAM_AUDIO_FILE_MARKER = "tg-file:"; const TELEGRAM_AUDIO_FILE_MARKER = "tg-file:";
const TELEGRAM_WAVE_BINS = 96; const TELEGRAM_WAVE_BINS = 96;
const TELEGRAM_AVATAR_FILE_MARKER = "tg-file:";
function redisConnectionFromEnv(): ConnectionOptions { function redisConnectionFromEnv(): ConnectionOptions {
const raw = (process.env.REDIS_URL || "redis://localhost:6379").trim(); const raw = (process.env.REDIS_URL || "redis://localhost:6379").trim();
@@ -168,6 +169,69 @@ async function fetchTelegramFileBytes(fileId: string) {
return new Uint8Array(await fileRes.arrayBuffer()); return new Uint8Array(await fileRes.arrayBuffer());
} }
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 asTelegramAvatarUrl(fileId: string | null | undefined) {
const normalized = asString(fileId);
if (!normalized) return null;
return `${TELEGRAM_AVATAR_FILE_MARKER}${normalized}`;
}
async function fetchTelegramAvatarUrl(externalContactId: string) {
const token = String(process.env.TELEGRAM_BOT_TOKEN ?? "").trim();
if (!token) return null;
const base = String(process.env.TELEGRAM_API_BASE ?? "https://api.telegram.org").replace(/\/+$/, "");
const getChatRes = await fetch(`${base}/bot${token}/getChat`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ chat_id: externalContactId }),
});
const getChatJson = (await getChatRes.json()) as TelegramGetChatResponse;
if (getChatRes.ok && getChatJson.ok) {
const fromChat = asTelegramAvatarUrl(
getChatJson.result?.photo?.small_file_id ?? getChatJson.result?.photo?.big_file_id,
);
if (fromChat) return fromChat;
}
const getPhotosRes = await fetch(`${base}/bot${token}/getUserProfilePhotos`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ user_id: externalContactId, limit: 1 }),
});
const getPhotosJson = (await getPhotosRes.json()) as TelegramGetUserProfilePhotosResponse;
if (!getPhotosRes.ok || !getPhotosJson.ok) return null;
const firstPhotoSizes = Array.isArray(getPhotosJson.result?.photos?.[0])
? getPhotosJson.result?.photos?.[0]
: [];
const candidate = firstPhotoSizes.at(-1)?.file_id ?? firstPhotoSizes[0]?.file_id;
return asTelegramAvatarUrl(candidate);
}
async function resolveInboundWaveform(media: TelegramInboundMedia, text: string) { async function resolveInboundWaveform(media: TelegramInboundMedia, text: string) {
const fallback = buildFallbackWaveform(`${media.fileId ?? "none"}:${media.durationSec ?? "0"}:${text}`); const fallback = buildFallbackWaveform(`${media.fileId ?? "none"}:${media.durationSec ?? "0"}:${text}`);
const fileId = media.fileId; const fileId = media.fileId;
@@ -232,7 +296,7 @@ function buildContactProfile(
}; };
} }
async function maybeHydrateContact(contactId: string, profile: ContactProfile) { async function maybeHydrateContact(contactId: string, profile: ContactProfile, externalContactId: string) {
const current = await prisma.contact.findUnique({ const current = await prisma.contact.findUnique({
where: { id: contactId }, where: { id: contactId },
select: { name: true, avatarUrl: true }, select: { name: true, avatarUrl: true },
@@ -248,8 +312,9 @@ async function maybeHydrateContact(contactId: string, profile: ContactProfile) {
} }
const currentAvatar = asString(current.avatarUrl); const currentAvatar = asString(current.avatarUrl);
if (profile.avatarUrl && profile.avatarUrl !== currentAvatar) { const resolvedAvatarUrl = profile.avatarUrl ?? (currentAvatar ? null : await fetchTelegramAvatarUrl(externalContactId));
updates.avatarUrl = profile.avatarUrl; if (resolvedAvatarUrl && resolvedAvatarUrl !== currentAvatar) {
updates.avatarUrl = resolvedAvatarUrl;
} }
if (Object.keys(updates).length === 0) return; if (Object.keys(updates).length === 0) return;
@@ -306,15 +371,17 @@ async function resolveContact(input: {
select: { contactId: true }, select: { contactId: true },
}); });
if (existingIdentity?.contactId) { if (existingIdentity?.contactId) {
await maybeHydrateContact(existingIdentity.contactId, input.profile); await maybeHydrateContact(existingIdentity.contactId, input.profile, input.externalContactId);
return existingIdentity.contactId; return existingIdentity.contactId;
} }
const avatarUrl = input.profile.avatarUrl ?? (await fetchTelegramAvatarUrl(input.externalContactId));
const contact = await prisma.contact.create({ const contact = await prisma.contact.create({
data: { data: {
teamId: input.teamId, teamId: input.teamId,
name: input.profile.displayName, name: input.profile.displayName,
avatarUrl: input.profile.avatarUrl, avatarUrl,
}, },
select: { id: true }, select: { id: true },
}); });
@@ -342,7 +409,7 @@ async function resolveContact(input: {
if (!concurrentIdentity?.contactId) throw error; if (!concurrentIdentity?.contactId) throw error;
await prisma.contact.delete({ where: { id: contact.id } }).catch(() => undefined); await prisma.contact.delete({ where: { id: contact.id } }).catch(() => undefined);
await maybeHydrateContact(concurrentIdentity.contactId, input.profile); await maybeHydrateContact(concurrentIdentity.contactId, input.profile, input.externalContactId);
return concurrentIdentity.contactId; return concurrentIdentity.contactId;
} }