Fetch Telegram avatars via API when webhook has no photo
This commit is contained in:
@@ -30,6 +30,7 @@ export const RECEIVER_FLOW_QUEUE_NAME = (process.env.RECEIVER_FLOW_QUEUE_NAME ||
|
||||
const TELEGRAM_PLACEHOLDER_PREFIX = "Telegram ";
|
||||
const TELEGRAM_AUDIO_FILE_MARKER = "tg-file:";
|
||||
const TELEGRAM_WAVE_BINS = 96;
|
||||
const TELEGRAM_AVATAR_FILE_MARKER = "tg-file:";
|
||||
|
||||
function redisConnectionFromEnv(): ConnectionOptions {
|
||||
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());
|
||||
}
|
||||
|
||||
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) {
|
||||
const fallback = buildFallbackWaveform(`${media.fileId ?? "none"}:${media.durationSec ?? "0"}:${text}`);
|
||||
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({
|
||||
where: { id: contactId },
|
||||
select: { name: true, avatarUrl: true },
|
||||
@@ -248,8 +312,9 @@ async function maybeHydrateContact(contactId: string, profile: ContactProfile) {
|
||||
}
|
||||
|
||||
const currentAvatar = asString(current.avatarUrl);
|
||||
if (profile.avatarUrl && profile.avatarUrl !== currentAvatar) {
|
||||
updates.avatarUrl = profile.avatarUrl;
|
||||
const resolvedAvatarUrl = profile.avatarUrl ?? (currentAvatar ? null : await fetchTelegramAvatarUrl(externalContactId));
|
||||
if (resolvedAvatarUrl && resolvedAvatarUrl !== currentAvatar) {
|
||||
updates.avatarUrl = resolvedAvatarUrl;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) return;
|
||||
@@ -306,15 +371,17 @@ async function resolveContact(input: {
|
||||
select: { contactId: true },
|
||||
});
|
||||
if (existingIdentity?.contactId) {
|
||||
await maybeHydrateContact(existingIdentity.contactId, input.profile);
|
||||
await maybeHydrateContact(existingIdentity.contactId, input.profile, input.externalContactId);
|
||||
return existingIdentity.contactId;
|
||||
}
|
||||
|
||||
const avatarUrl = input.profile.avatarUrl ?? (await fetchTelegramAvatarUrl(input.externalContactId));
|
||||
|
||||
const contact = await prisma.contact.create({
|
||||
data: {
|
||||
teamId: input.teamId,
|
||||
name: input.profile.displayName,
|
||||
avatarUrl: input.profile.avatarUrl,
|
||||
avatarUrl,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
@@ -342,7 +409,7 @@ async function resolveContact(input: {
|
||||
if (!concurrentIdentity?.contactId) throw error;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user