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 <noreply@anthropic.com>
This commit is contained in:
parent
24b70e6e25
commit
088750326c
1 changed files with 60 additions and 12 deletions
|
|
@ -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<string, TelegramUser>();
|
||||
/** Local file-backed store, keyed by telegramUserId */
|
||||
private localStore = new Map<string, TelegramUser>();
|
||||
private localStoreLoaded = false;
|
||||
|
||||
constructor(@Inject(DatabaseService) private readonly db: DatabaseService) {}
|
||||
|
||||
/** Find user by Telegram user ID */
|
||||
async findByTelegramUserId(telegramUserId: string): Promise<TelegramUser | null> {
|
||||
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<TelegramUserRow[]>(
|
||||
|
|
@ -49,7 +57,8 @@ export class TelegramUserStore {
|
|||
/** Find user by device ID */
|
||||
async findByDeviceId(deviceId: string): Promise<TelegramUser | null> {
|
||||
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<TelegramUser> {
|
||||
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<void> {
|
||||
if (this.localStoreLoaded) return;
|
||||
this.localStoreLoaded = true;
|
||||
|
||||
try {
|
||||
const data = await readFile(LOCAL_STORE_PATH, "utf-8");
|
||||
const records = JSON.parse(data) as Record<string, TelegramUser>;
|
||||
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<void> {
|
||||
const obj: Record<string, TelegramUser> = {};
|
||||
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<TelegramUser> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue