Files
clientsflow/telegram_backend/src/profile-state.ts
2026-03-12 18:44:46 +07:00

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();
}