feat: add Telegram album purchase bot

This commit is contained in:
2026-08-21 18:07:50 +03:30
parent 9478aa319f
commit 1e7b752532
21 changed files with 1399 additions and 459 deletions
+299 -50
View File
@@ -1,39 +1,29 @@
import { Database } from "bun:sqlite";
import path from "path";
import { mkdirSync } from "node:fs";
import path from "node:path";
import { albums as initialAlbums } from "./data";
import { Album, Purchase } from "./types";
import { Album, Purchase, PurchaseStatus, TelegramApprover, TelegramUserSession } from "./types";
// Database path
const dbPath = path.join(process.cwd(), "data", "parsa.db");
// Initialize database
let db: any;
let db: Database | null = null;
function createDatabase() {
// Use Bun's native SQLite
const { Database } = require("bun:sqlite");
return new Database(dbPath, { create: true });
}
export function getDatabase(): any {
export function getDatabase(): Database {
if (!db) {
// Create data directory if it doesn't exist
const fs = require("fs");
const dataDir = path.join(process.cwd(), "data");
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
mkdirSync(dataDir, { recursive: true });
db = createDatabase();
db = new Database(dbPath, { create: true });
db.exec("PRAGMA journal_mode = WAL");
initializeDatabase();
initializeDatabase(db);
}
return db;
}
function initializeDatabase() {
function initializeDatabase(database: Database) {
// Create albums table
db.exec(`
database.exec(`
CREATE TABLE IF NOT EXISTS albums (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
@@ -52,10 +42,11 @@ function initializeDatabase() {
`);
// Create purchases table
db.exec(`
database.exec(`
CREATE TABLE IF NOT EXISTS purchases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
albumId TEXT NOT NULL,
albumIds TEXT,
transactionId TEXT NOT NULL UNIQUE,
customerName TEXT,
email TEXT,
@@ -64,13 +55,19 @@ function initializeDatabase() {
purchaseDate INTEGER NOT NULL,
approvalStatus TEXT DEFAULT 'pending',
paymentMethod TEXT DEFAULT 'card-to-card',
reviewedByTelegramId TEXT,
reviewedAt INTEGER,
telegramUserId TEXT,
telegramChatId TEXT,
receiptType TEXT,
receiptTelegramFileId TEXT,
createdAt INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (albumId) REFERENCES albums(id) ON DELETE CASCADE
)
`);
// Create payment authorities table for ZarinPal tracking
db.exec(`
database.exec(`
CREATE TABLE IF NOT EXISTS payment_authorities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
authority TEXT NOT NULL UNIQUE,
@@ -89,20 +86,81 @@ function initializeDatabase() {
)
`);
database.exec(`
CREATE TABLE IF NOT EXISTS telegram_approvers (
userId TEXT PRIMARY KEY,
label TEXT NOT NULL DEFAULT '',
createdAt INTEGER NOT NULL
)
`);
database.exec(`
CREATE TABLE IF NOT EXISTS telegram_bot_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
baseUrl TEXT NOT NULL DEFAULT 'https://api.telegram.org',
botToken TEXT,
greetingText TEXT NOT NULL DEFAULT 'Welcome! Choose albums from the catalog below.',
cardNumber TEXT NOT NULL DEFAULT '',
cardHolderName TEXT NOT NULL DEFAULT '',
updatedAt INTEGER NOT NULL
)
`);
database.prepare(`
INSERT OR IGNORE INTO telegram_bot_settings (id, baseUrl, botToken, updatedAt)
VALUES (1, 'https://api.telegram.org', NULL, ?)
`).run(Date.now());
database.exec(`
CREATE TABLE IF NOT EXISTS telegram_user_sessions (
userId TEXT PRIMARY KEY,
chatId TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'browsing',
cartAlbumIds TEXT NOT NULL DEFAULT '[]',
displayName TEXT NOT NULL DEFAULT '',
updatedAt INTEGER NOT NULL
)
`);
// Add columns if they don't exist (migration)
try {
db.exec(`ALTER TABLE purchases ADD COLUMN approvalStatus TEXT DEFAULT 'pending'`);
database.exec(`ALTER TABLE purchases ADD COLUMN approvalStatus TEXT DEFAULT 'pending'`);
} catch (e) {
// Column already exists
}
try {
db.exec(`ALTER TABLE purchases ADD COLUMN paymentMethod TEXT DEFAULT 'card-to-card'`);
database.exec(`ALTER TABLE purchases ADD COLUMN paymentMethod TEXT DEFAULT 'card-to-card'`);
} catch (e) {
// Column already exists
}
try {
database.exec(`ALTER TABLE purchases ADD COLUMN reviewedByTelegramId TEXT`);
} catch (e) {
// Column already exists
}
try {
database.exec(`ALTER TABLE purchases ADD COLUMN reviewedAt INTEGER`);
} catch (e) {
// Column already exists
}
for (const migration of [
`ALTER TABLE purchases ADD COLUMN albumIds TEXT`,
`ALTER TABLE purchases ADD COLUMN telegramUserId TEXT`,
`ALTER TABLE purchases ADD COLUMN telegramChatId TEXT`,
`ALTER TABLE purchases ADD COLUMN receiptType TEXT`,
`ALTER TABLE purchases ADD COLUMN receiptTelegramFileId TEXT`,
`ALTER TABLE telegram_bot_settings ADD COLUMN greetingText TEXT NOT NULL DEFAULT 'Welcome! Choose albums from the catalog below.'`,
`ALTER TABLE telegram_bot_settings ADD COLUMN cardNumber TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE telegram_bot_settings ADD COLUMN cardHolderName TEXT NOT NULL DEFAULT ''`,
]) {
try {
database.exec(migration);
} catch {
// Column already exists
}
}
// Create indexes
db.exec(`
database.exec(`
CREATE INDEX IF NOT EXISTS idx_purchases_albumId ON purchases(albumId);
CREATE INDEX IF NOT EXISTS idx_purchases_transactionId ON purchases(transactionId);
CREATE INDEX IF NOT EXISTS idx_purchases_approvalStatus ON purchases(approvalStatus);
@@ -111,21 +169,21 @@ function initializeDatabase() {
`);
// Seed initial data if albums table is empty
const count = db.prepare("SELECT COUNT(*) as count FROM albums").get() as {
const count = database.prepare("SELECT COUNT(*) as count FROM albums").get() as {
count: number;
};
if (count.count === 0) {
seedInitialData();
seedInitialData(database);
}
}
function seedInitialData() {
const insert = db.prepare(`
function seedInitialData(database: Database) {
const insert = database.prepare(`
INSERT INTO albums (id, title, coverImage, year, genre, description, price, tag, format, bitrate, songs)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const insertMany = db.transaction((albums: Album[]) => {
const insertMany = database.transaction((albums: Album[]) => {
for (const album of albums) {
insert.run(
album.id,
@@ -228,6 +286,7 @@ export const purchaseDb = {
return rows.map((row: any) => ({
id: row.id,
albumId: row.albumId,
albumIds: row.albumIds ? JSON.parse(row.albumIds) : [row.albumId],
transactionId: row.transactionId,
customerName: row.customerName,
email: row.email,
@@ -236,28 +295,19 @@ export const purchaseDb = {
purchaseDate: new Date(row.purchaseDate),
approvalStatus: row.approvalStatus,
paymentMethod: row.paymentMethod,
reviewedByTelegramId: row.reviewedByTelegramId || undefined,
reviewedAt: row.reviewedAt ? new Date(row.reviewedAt) : undefined,
telegramUserId: row.telegramUserId || undefined,
telegramChatId: row.telegramChatId || undefined,
receiptType: row.receiptType || undefined,
receiptTelegramFileId: row.receiptTelegramFileId || undefined,
}));
},
getByAlbumId(albumId: string): Purchase[] {
const db = getDatabase();
const rows = db
.prepare(
"SELECT * FROM purchases WHERE albumId = ? ORDER BY purchaseDate DESC",
)
.all(albumId);
return rows.map((row: any) => ({
id: row.id,
albumId: row.albumId,
transactionId: row.transactionId,
customerName: row.customerName,
email: row.email,
phoneNumber: row.phoneNumber,
txReceipt: row.txReceipt,
purchaseDate: new Date(row.purchaseDate),
approvalStatus: row.approvalStatus,
paymentMethod: row.paymentMethod,
}));
return this.getAll().filter((purchase) =>
(purchase.albumIds || [purchase.albumId]).includes(albumId),
);
},
getByTransactionId(transactionId: string): Purchase | null {
@@ -269,6 +319,7 @@ export const purchaseDb = {
return {
id: row.id,
albumId: row.albumId,
albumIds: row.albumIds ? JSON.parse(row.albumIds) : [row.albumId],
transactionId: row.transactionId,
customerName: row.customerName,
email: row.email,
@@ -277,6 +328,37 @@ export const purchaseDb = {
purchaseDate: new Date(row.purchaseDate),
approvalStatus: row.approvalStatus,
paymentMethod: row.paymentMethod,
reviewedByTelegramId: row.reviewedByTelegramId || undefined,
reviewedAt: row.reviewedAt ? new Date(row.reviewedAt) : undefined,
telegramUserId: row.telegramUserId || undefined,
telegramChatId: row.telegramChatId || undefined,
receiptType: row.receiptType || undefined,
receiptTelegramFileId: row.receiptTelegramFileId || undefined,
};
},
getById(id: number): Purchase | null {
const db = getDatabase();
const row = db.prepare("SELECT * FROM purchases WHERE id = ?").get(id) as any;
if (!row) return null;
return {
id: row.id,
albumId: row.albumId,
albumIds: row.albumIds ? JSON.parse(row.albumIds) : [row.albumId],
transactionId: row.transactionId,
customerName: row.customerName,
email: row.email,
phoneNumber: row.phoneNumber,
txReceipt: row.txReceipt,
purchaseDate: new Date(row.purchaseDate),
approvalStatus: row.approvalStatus,
paymentMethod: row.paymentMethod,
reviewedByTelegramId: row.reviewedByTelegramId || undefined,
reviewedAt: row.reviewedAt ? new Date(row.reviewedAt) : undefined,
telegramUserId: row.telegramUserId || undefined,
telegramChatId: row.telegramChatId || undefined,
receiptType: row.receiptType || undefined,
receiptTelegramFileId: row.receiptTelegramFileId || undefined,
};
},
@@ -285,12 +367,17 @@ export const purchaseDb = {
const result = db
.prepare(
`
INSERT INTO purchases (albumId, transactionId, customerName, email, phoneNumber, txReceipt, purchaseDate, approvalStatus, paymentMethod)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO purchases (
albumId, albumIds, transactionId, customerName, email, phoneNumber, txReceipt,
purchaseDate, approvalStatus, paymentMethod, telegramUserId, telegramChatId,
receiptType, receiptTelegramFileId
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
purchase.albumId,
JSON.stringify(purchase.albumIds || [purchase.albumId]),
purchase.transactionId,
purchase.customerName || null,
purchase.email || null,
@@ -301,6 +388,10 @@ export const purchaseDb = {
: purchase.purchaseDate,
purchase.approvalStatus || 'pending',
purchase.paymentMethod || 'card-to-card',
purchase.telegramUserId || null,
purchase.telegramChatId || null,
purchase.receiptType || null,
purchase.receiptTelegramFileId || null,
);
return {
@@ -313,4 +404,162 @@ export const purchaseDb = {
const db = getDatabase();
db.prepare("DELETE FROM purchases WHERE id = ?").run(id);
},
setStatus(id: number, status: PurchaseStatus, telegramUserId?: string): Purchase | null {
const db = getDatabase();
const result = db.prepare(`
UPDATE purchases
SET approvalStatus = ?, reviewedByTelegramId = ?, reviewedAt = ?
WHERE id = ? AND approvalStatus = 'pending'
`).run(status, telegramUserId || null, Date.now(), id);
return result.changes === 0 ? null : this.getById(id);
},
};
export const telegramApproverDb = {
getAll(): TelegramApprover[] {
const rows = getDatabase()
.prepare("SELECT userId, label, createdAt FROM telegram_approvers ORDER BY createdAt ASC")
.all() as Array<{ userId: string; label: string; createdAt: number }>;
return rows.map((row) => ({
userId: row.userId,
label: row.label || undefined,
createdAt: new Date(row.createdAt),
}));
},
isAuthorized(userId: string): boolean {
return Boolean(
getDatabase()
.prepare("SELECT 1 FROM telegram_approvers WHERE userId = ?")
.get(userId),
);
},
replaceAll(approvers: TelegramApprover[]): void {
const db = getDatabase();
const replace = db.transaction((items: TelegramApprover[]) => {
db.exec("DELETE FROM telegram_approvers");
const insert = db.prepare(
"INSERT INTO telegram_approvers (userId, label, createdAt) VALUES (?, ?, ?)",
);
for (const approver of items) {
insert.run(approver.userId, approver.label || "", Date.now());
}
});
replace(approvers);
},
};
export const telegramSettingsDb = {
get(): {
baseUrl: string;
botToken: string | null;
greetingText: string;
cardNumber: string;
cardHolderName: string;
updatedAt: Date;
} {
const row = getDatabase()
.prepare(`
SELECT baseUrl, botToken, greetingText, cardNumber, cardHolderName, updatedAt
FROM telegram_bot_settings WHERE id = 1
`)
.get() as {
baseUrl: string;
botToken: string | null;
greetingText: string;
cardNumber: string;
cardHolderName: string;
updatedAt: number;
};
return {
baseUrl: row.baseUrl,
botToken: row.botToken,
greetingText: row.greetingText,
cardNumber: row.cardNumber,
cardHolderName: row.cardHolderName,
updatedAt: new Date(row.updatedAt),
};
},
update(settings: {
baseUrl: string;
botToken?: string;
greetingText: string;
cardNumber: string;
cardHolderName: string;
}): void {
const db = getDatabase();
if (settings.botToken !== undefined) {
db.prepare(`
UPDATE telegram_bot_settings
SET baseUrl = ?, botToken = ?, greetingText = ?, cardNumber = ?, cardHolderName = ?, updatedAt = ?
WHERE id = 1
`).run(
settings.baseUrl,
settings.botToken,
settings.greetingText,
settings.cardNumber,
settings.cardHolderName,
Date.now(),
);
return;
}
db.prepare(`
UPDATE telegram_bot_settings
SET baseUrl = ?, greetingText = ?, cardNumber = ?, cardHolderName = ?, updatedAt = ?
WHERE id = 1
`).run(
settings.baseUrl,
settings.greetingText,
settings.cardNumber,
settings.cardHolderName,
Date.now(),
);
},
};
export const telegramSessionDb = {
get(userId: string): TelegramUserSession | null {
const row = getDatabase()
.prepare("SELECT * FROM telegram_user_sessions WHERE userId = ?")
.get(userId) as any;
if (!row) return null;
return {
userId: row.userId,
chatId: row.chatId,
state: row.state,
cartAlbumIds: JSON.parse(row.cartAlbumIds),
displayName: row.displayName || undefined,
updatedAt: new Date(row.updatedAt),
};
},
upsert(session: Omit<TelegramUserSession, "updatedAt">): TelegramUserSession {
const db = getDatabase();
db.prepare(`
INSERT INTO telegram_user_sessions (userId, chatId, state, cartAlbumIds, displayName, updatedAt)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(userId) DO UPDATE SET
chatId = excluded.chatId,
state = excluded.state,
cartAlbumIds = excluded.cartAlbumIds,
displayName = excluded.displayName,
updatedAt = excluded.updatedAt
`).run(
session.userId,
session.chatId,
session.state,
JSON.stringify(session.cartAlbumIds),
session.displayName || "",
Date.now(),
);
return this.get(session.userId)!;
},
};