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_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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user