198 lines
5.5 KiB
TypeScript
198 lines
5.5 KiB
TypeScript
import { Pool } from "pg";
|
|
import type { OmniInboundEnvelopeV1 } from "./types";
|
|
|
|
const TELEGRAM_FILE_MARKER = "tg-file:";
|
|
const TELEGRAM_API_BASE_DEFAULT = "https://api.telegram.org";
|
|
|
|
type AvatarStateRow = {
|
|
avatar_fingerprint: string | null;
|
|
};
|
|
|
|
function asString(value: unknown) {
|
|
if (typeof value !== "string") return null;
|
|
const normalized = value.trim();
|
|
return normalized || null;
|
|
}
|
|
|
|
function requiredEnv(name: string) {
|
|
const value = asString(process.env[name]);
|
|
if (!value) {
|
|
throw new Error(`${name} is required`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function parseTelegramFileId(avatarUrl: string | null) {
|
|
const raw = asString(avatarUrl);
|
|
if (!raw || !raw.startsWith(TELEGRAM_FILE_MARKER)) return null;
|
|
const fileId = raw.slice(TELEGRAM_FILE_MARKER.length).trim();
|
|
return fileId || null;
|
|
}
|
|
|
|
function parseTelegramUserId(value: string | null) {
|
|
const raw = asString(value);
|
|
if (!raw || !/^\d+$/.test(raw)) return null;
|
|
const userId = Number.parseInt(raw, 10);
|
|
if (!Number.isFinite(userId) || userId <= 0) return null;
|
|
return userId;
|
|
}
|
|
|
|
function telegramApiBase() {
|
|
return asString(process.env.TELEGRAM_API_BASE) ?? TELEGRAM_API_BASE_DEFAULT;
|
|
}
|
|
|
|
type TelegramUserProfilePhotosResponse = {
|
|
ok?: boolean;
|
|
result?: {
|
|
photos?: Array<Array<{ file_id?: string }>>;
|
|
};
|
|
description?: string;
|
|
};
|
|
|
|
function pickProfilePhotoFileId(payload: TelegramUserProfilePhotosResponse) {
|
|
const groups = payload.result?.photos;
|
|
if (!Array.isArray(groups) || !groups.length) return null;
|
|
const firstGroup = groups[0];
|
|
if (!Array.isArray(firstGroup) || !firstGroup.length) return null;
|
|
|
|
for (let index = firstGroup.length - 1; index >= 0; index -= 1) {
|
|
const fileId = asString(firstGroup[index]?.file_id);
|
|
if (fileId) return fileId;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function resolveProfileAvatarFileId(contactExternalId: string | null) {
|
|
const userId = parseTelegramUserId(contactExternalId);
|
|
if (!userId) return null;
|
|
|
|
const token = asString(process.env.TELEGRAM_BOT_TOKEN);
|
|
if (!token) return null;
|
|
|
|
const response = await fetch(`${telegramApiBase().replace(/\/+$/, "")}/bot${token}/getUserProfilePhotos`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
user_id: userId,
|
|
offset: 0,
|
|
limit: 1,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) return null;
|
|
const payload = (await response.json()) as TelegramUserProfilePhotosResponse;
|
|
if (!payload.ok) return null;
|
|
|
|
return pickProfilePhotoFileId(payload);
|
|
}
|
|
|
|
const pool = new Pool({
|
|
connectionString: requiredEnv("TELEGRAM_PROFILE_STATE_DATABASE_URL"),
|
|
});
|
|
|
|
let initPromise: Promise<void> | null = null;
|
|
|
|
async function ensureInitialized() {
|
|
if (!initPromise) {
|
|
initPromise = pool
|
|
.query(`
|
|
CREATE TABLE IF NOT EXISTS telegram_contact_profile_state (
|
|
contact_external_id TEXT PRIMARY KEY,
|
|
avatar_fingerprint TEXT,
|
|
avatar_file_id TEXT,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
)
|
|
`)
|
|
.then(() => undefined);
|
|
}
|
|
await initPromise;
|
|
}
|
|
|
|
async function detectAvatarChange(input: {
|
|
contactExternalId: string | null;
|
|
avatarFileId: string | null;
|
|
avatarFingerprint: string | null;
|
|
}) {
|
|
const contactExternalId = asString(input.contactExternalId);
|
|
const avatarFileId = asString(input.avatarFileId);
|
|
const avatarFingerprint = asString(input.avatarFingerprint) ?? avatarFileId;
|
|
|
|
if (!contactExternalId || !avatarFileId || !avatarFingerprint) {
|
|
return { changed: false };
|
|
}
|
|
|
|
await ensureInitialized();
|
|
|
|
const row = await pool.query<AvatarStateRow>(
|
|
`
|
|
SELECT avatar_fingerprint
|
|
FROM telegram_contact_profile_state
|
|
WHERE contact_external_id = $1
|
|
`,
|
|
[contactExternalId],
|
|
);
|
|
const previousFingerprint = asString(row.rows[0]?.avatar_fingerprint);
|
|
const changed = previousFingerprint !== avatarFingerprint;
|
|
|
|
if (changed) {
|
|
await pool.query(
|
|
`
|
|
INSERT INTO telegram_contact_profile_state (
|
|
contact_external_id,
|
|
avatar_fingerprint,
|
|
avatar_file_id,
|
|
updated_at
|
|
)
|
|
VALUES ($1, $2, $3, NOW())
|
|
ON CONFLICT (contact_external_id) DO UPDATE SET
|
|
avatar_fingerprint = EXCLUDED.avatar_fingerprint,
|
|
avatar_file_id = EXCLUDED.avatar_file_id,
|
|
updated_at = NOW()
|
|
`,
|
|
[contactExternalId, avatarFingerprint, avatarFileId],
|
|
);
|
|
}
|
|
|
|
return { changed };
|
|
}
|
|
|
|
export async function applyAvatarProfileState(envelope: OmniInboundEnvelopeV1): Promise<OmniInboundEnvelopeV1> {
|
|
const payload = envelope.payloadNormalized;
|
|
const contactExternalId = asString(payload.contactExternalId);
|
|
let contactAvatarUrl = asString(payload.contactAvatarUrl);
|
|
let contactAvatarFingerprint = asString(payload.contactAvatarFingerprint);
|
|
|
|
if (!contactAvatarUrl) {
|
|
const profileFileId = await resolveProfileAvatarFileId(contactExternalId);
|
|
if (profileFileId) {
|
|
contactAvatarUrl = `${TELEGRAM_FILE_MARKER}${profileFileId}`;
|
|
if (!contactAvatarFingerprint) {
|
|
contactAvatarFingerprint = profileFileId;
|
|
}
|
|
}
|
|
}
|
|
|
|
const avatarFileId = parseTelegramFileId(contactAvatarUrl);
|
|
|
|
const avatarState = await detectAvatarChange({
|
|
contactExternalId,
|
|
avatarFileId,
|
|
avatarFingerprint: contactAvatarFingerprint,
|
|
});
|
|
|
|
return {
|
|
...envelope,
|
|
payloadNormalized: {
|
|
...payload,
|
|
contactAvatarChanged: avatarState.changed,
|
|
contactAvatarUrl: avatarState.changed ? contactAvatarUrl : null,
|
|
contactAvatarFingerprint,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function closeProfileState() {
|
|
await pool.end();
|
|
}
|