Track Telegram avatar changes in telegram backend state DB
This commit is contained in:
113
telegram_backend/src/profile-state.ts
Normal file
113
telegram_backend/src/profile-state.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user