Track Telegram avatar changes in telegram backend state DB

This commit is contained in:
Ruslan Bakiev
2026-03-12 16:53:16 +07:00
parent 44f4e9d90d
commit 64e0d7565f
7 changed files with 584 additions and 2 deletions

View File

@@ -0,0 +1,113 @@
import { mkdirSync } from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import type { OmniInboundEnvelopeV1 } from "./types";
const TELEGRAM_FILE_MARKER = "tg-file:";
const DEFAULT_STATE_DB_PATH = path.join(process.cwd(), ".data", "telegram_backend", "state.sqlite");
type AvatarStateRow = {
avatarFingerprint: string | null;
};
function asString(value: unknown) {
if (typeof value !== "string") return null;
const normalized = value.trim();
return normalized || null;
}
function stateDbPath() {
const fromEnv = asString(process.env.TELEGRAM_PROFILE_STATE_DB_PATH);
return fromEnv ?? DEFAULT_STATE_DB_PATH;
}
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 openStateDb() {
const dbPath = stateDbPath();
const dbDir = path.dirname(dbPath);
mkdirSync(dbDir, { recursive: true });
const db = new Database(dbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS telegram_contact_profile_state (
contact_external_id TEXT PRIMARY KEY,
avatar_fingerprint TEXT,
avatar_file_id TEXT,
updated_at TEXT NOT NULL
)
`);
return db;
}
const db = openStateDb();
const selectStateStatement = db.prepare(`
SELECT avatar_fingerprint AS avatarFingerprint
FROM telegram_contact_profile_state
WHERE contact_external_id = ?
`);
const upsertStateStatement = db.prepare(`
INSERT INTO telegram_contact_profile_state (
contact_external_id,
avatar_fingerprint,
avatar_file_id,
updated_at
)
VALUES (?, ?, ?, ?)
ON CONFLICT(contact_external_id) DO UPDATE SET
avatar_fingerprint = excluded.avatar_fingerprint,
avatar_file_id = excluded.avatar_file_id,
updated_at = excluded.updated_at
`);
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 };
}
const row = selectStateStatement.get(contactExternalId) as AvatarStateRow | undefined;
const previousFingerprint = asString(row?.avatarFingerprint);
const changed = previousFingerprint !== avatarFingerprint;
if (changed) {
upsertStateStatement.run(contactExternalId, avatarFingerprint, avatarFileId, new Date().toISOString());
}
return { changed };
}
export function applyAvatarProfileState(envelope: OmniInboundEnvelopeV1): OmniInboundEnvelopeV1 {
const payload = envelope.payloadNormalized;
const contactExternalId = asString(payload.contactExternalId);
const contactAvatarUrl = asString(payload.contactAvatarUrl);
const contactAvatarFingerprint = asString(payload.contactAvatarFingerprint);
const avatarFileId = parseTelegramFileId(contactAvatarUrl);
const avatarState = detectAvatarChange({
contactExternalId,
avatarFileId,
avatarFingerprint: contactAvatarFingerprint,
});
return {
...envelope,
payloadNormalized: {
...payload,
contactAvatarChanged: avatarState.changed,
contactAvatarUrl: avatarState.changed ? contactAvatarUrl : null,
},
};
}

View File

@@ -2,6 +2,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
import { buildSchema, graphql } from "graphql";
import { parseTelegramBusinessUpdate } from "./telegram";
import { enqueueTelegramInboundTask, enqueueTelegramOutboundTask } from "./hatchet/tasks";
import { applyAvatarProfileState } from "./profile-state";
const PORT = Number(process.env.PORT || 8080);
const MAX_BODY_SIZE_BYTES = Number(process.env.MAX_BODY_SIZE_BYTES || 1024 * 1024);
@@ -205,7 +206,7 @@ export function startServer() {
try {
const body = await readJsonBody(req);
const envelope = parseTelegramBusinessUpdate(body);
const envelope = applyAvatarProfileState(parseTelegramBusinessUpdate(body));
const run = await enqueueTelegramInboundTask(envelope);
writeJson(res, 200, {

View File

@@ -73,6 +73,16 @@ function pickTelegramChatPhotoFileId(source: JsonObject | null | undefined) {
return normalizeString(photo.small_file_id) ?? normalizeString(photo.big_file_id);
}
function pickTelegramChatPhotoFingerprint(source: JsonObject | null | undefined) {
const photo = asObject(asObject(source).photo);
return (
normalizeString(photo.small_file_unique_id) ??
normalizeString(photo.big_file_unique_id) ??
normalizeString(photo.small_file_id) ??
normalizeString(photo.big_file_id)
);
}
type TelegramMediaInfo = {
kind: "voice" | "audio" | "video_note" | null;
fileId: string | null;
@@ -226,6 +236,7 @@ export function parseTelegramBusinessUpdate(raw: unknown): OmniInboundEnvelopeV1
const fallbackContactSource = contactSource === chat ? from : chat;
const contactAvatarFileId = pickTelegramChatPhotoFileId(contactSource);
const contactAvatarFingerprint = pickTelegramChatPhotoFingerprint(contactSource);
const threadExternalId =
chat.id != null
@@ -301,6 +312,7 @@ export function parseTelegramBusinessUpdate(raw: unknown): OmniInboundEnvelopeV1
contactLastName: normalizeString(contactSource.last_name),
contactTitle: normalizeString(contactSource.title),
contactAvatarUrl: contactAvatarFileId ? `${TELEGRAM_FILE_MARKER}${contactAvatarFileId}` : null,
contactAvatarFingerprint,
fromUsername: typeof from.username === "string" ? from.username : null,
fromFirstName: typeof from.first_name === "string" ? from.first_name : null,
fromLastName: typeof from.last_name === "string" ? from.last_name : null,