From 088750326cc56cf2ec088a17de7db225cb3bc92e Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Sat, 14 Feb 2026 00:46:54 +0800 Subject: [PATCH] fix(gateway): persist Telegram users to JSON file when no database Replaces in-memory Map with file-backed store at ~/.super-multica/gateway/telegram-users.json so user bindings survive gateway restarts during local development. Co-Authored-By: Claude Opus 4.6 --- apps/gateway/telegram/telegram-user.store.ts | 72 ++++++++++++++++---- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/apps/gateway/telegram/telegram-user.store.ts b/apps/gateway/telegram/telegram-user.store.ts index 2bf48383..3d0a1f13 100644 --- a/apps/gateway/telegram/telegram-user.store.ts +++ b/apps/gateway/telegram/telegram-user.store.ts @@ -2,11 +2,14 @@ * Telegram user store. * * Uses MySQL when MYSQL_DSN is set (production). - * Falls back to in-memory storage when database is unavailable (local development). + * Falls back to JSON file persistence when database is unavailable (local development). + * File stored at ~/.super-multica/gateway/telegram-users.json. */ import { Inject, Injectable, Logger } from "@nestjs/common"; -import { generateEncryptedId } from "@multica/utils"; +import { generateEncryptedId, DATA_DIR } from "@multica/utils"; +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; import type { RowDataPacket } from "mysql2/promise"; import { DatabaseService } from "../database/database.service.js"; import type { TelegramUser, TelegramUserCreate } from "./types.js"; @@ -23,18 +26,23 @@ interface TelegramUserRow extends RowDataPacket { telegram_last_name: string | null; } +const LOCAL_STORE_DIR = join(DATA_DIR, "gateway"); +const LOCAL_STORE_PATH = join(LOCAL_STORE_DIR, "telegram-users.json"); + @Injectable() export class TelegramUserStore { private readonly logger = new Logger(TelegramUserStore.name); - /** In-memory fallback, keyed by telegramUserId */ - private readonly memoryStore = new Map(); + /** Local file-backed store, keyed by telegramUserId */ + private localStore = new Map(); + private localStoreLoaded = false; constructor(@Inject(DatabaseService) private readonly db: DatabaseService) {} /** Find user by Telegram user ID */ async findByTelegramUserId(telegramUserId: string): Promise { if (!this.db.isAvailable()) { - return this.memoryStore.get(telegramUserId) ?? null; + await this.ensureLocalStoreLoaded(); + return this.localStore.get(telegramUserId) ?? null; } const rows = await this.db.query( @@ -49,7 +57,8 @@ export class TelegramUserStore { /** Find user by device ID */ async findByDeviceId(deviceId: string): Promise { if (!this.db.isAvailable()) { - for (const user of this.memoryStore.values()) { + await this.ensureLocalStoreLoaded(); + for (const user of this.localStore.values()) { if (user.deviceId === deviceId) return user; } return null; @@ -67,7 +76,7 @@ export class TelegramUserStore { /** Create or update a Telegram user */ async upsert(data: TelegramUserCreate): Promise { if (!this.db.isAvailable()) { - return this.upsertMemory(data); + return this.upsertLocal(data); } // Check if user exists @@ -122,9 +131,47 @@ export class TelegramUserStore { return created!; } - /** In-memory upsert for local development */ - private upsertMemory(data: TelegramUserCreate): TelegramUser { - const existing = this.memoryStore.get(data.telegramUserId); + // ── Local file-backed store (local development only) ── + // + // When MYSQL_DSN is not set the methods below provide a simple JSON-file + // persistence layer so Telegram user bindings survive gateway restarts. + // This is NOT intended for production use — production always uses MySQL. + + /** Load store from JSON file on first access */ + private async ensureLocalStoreLoaded(): Promise { + if (this.localStoreLoaded) return; + this.localStoreLoaded = true; + + try { + const data = await readFile(LOCAL_STORE_PATH, "utf-8"); + const records = JSON.parse(data) as Record; + for (const [key, user] of Object.entries(records)) { + // Restore Date objects from JSON strings + user.createdAt = new Date(user.createdAt); + user.updatedAt = new Date(user.updatedAt); + this.localStore.set(key, user); + } + this.logger.log(`Loaded ${this.localStore.size} Telegram user(s) from ${LOCAL_STORE_PATH}`); + } catch { + // File doesn't exist or is invalid — start fresh + } + } + + /** Persist store to JSON file */ + private async saveLocalStore(): Promise { + const obj: Record = {}; + for (const [key, user] of this.localStore) { + obj[key] = user; + } + await mkdir(LOCAL_STORE_DIR, { recursive: true }); + await writeFile(LOCAL_STORE_PATH, JSON.stringify(obj, null, 2), "utf-8"); + } + + /** Upsert to local file store */ + private async upsertLocal(data: TelegramUserCreate): Promise { + await this.ensureLocalStoreLoaded(); + + const existing = this.localStore.get(data.telegramUserId); const now = new Date(); const user: TelegramUser = { @@ -139,8 +186,9 @@ export class TelegramUserStore { telegramLastName: data.telegramLastName, }; - this.memoryStore.set(data.telegramUserId, user); - this.logger.debug(`In-memory upsert: telegramUserId=${data.telegramUserId}`); + this.localStore.set(data.telegramUserId, user); + await this.saveLocalStore(); + this.logger.debug(`Local upsert: telegramUserId=${data.telegramUserId}`); return user; }