feat: add Telegram album purchase bot
This commit is contained in:
@@ -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)!;
|
||||
},
|
||||
};
|
||||
|
||||
+524
@@ -0,0 +1,524 @@
|
||||
import {
|
||||
albumDb,
|
||||
purchaseDb,
|
||||
telegramApproverDb,
|
||||
telegramSessionDb,
|
||||
telegramSettingsDb,
|
||||
} from "./db";
|
||||
import { Album, Purchase, PurchaseStatus, TelegramUserSession } from "./types";
|
||||
import { formatPrice } from "./utils";
|
||||
|
||||
interface TelegramResponse<T> {
|
||||
ok: boolean;
|
||||
result?: T;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface TelegramUser {
|
||||
id: number;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface TelegramMessage {
|
||||
message_id: number;
|
||||
chat: { id: number };
|
||||
from?: TelegramUser;
|
||||
text?: string;
|
||||
caption?: string;
|
||||
photo?: Array<{ file_id: string; width: number; height: number; file_size?: number }>;
|
||||
}
|
||||
|
||||
export interface TelegramUpdate {
|
||||
message?: TelegramMessage;
|
||||
callback_query?: {
|
||||
id: string;
|
||||
from: TelegramUser;
|
||||
data?: string;
|
||||
message?: TelegramMessage;
|
||||
};
|
||||
}
|
||||
|
||||
function getTelegramConfig(): { baseUrl: string; token: string } {
|
||||
const settings = telegramSettingsDb.get();
|
||||
if (!settings.botToken) {
|
||||
throw new Error("Telegram bot token is not configured in the admin panel");
|
||||
}
|
||||
return { baseUrl: settings.baseUrl.replace(/\/$/, ""), token: settings.botToken };
|
||||
}
|
||||
|
||||
export function isTelegramConfigured(): boolean {
|
||||
return Boolean(telegramSettingsDb.get().botToken);
|
||||
}
|
||||
|
||||
export async function callTelegram<T = unknown>(
|
||||
method: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const { baseUrl, token } = getTelegramConfig();
|
||||
const response = await fetch(`${baseUrl}/bot${token}/${method}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = (await response.json()) as TelegramResponse<T>;
|
||||
if (!response.ok || !body.ok) throw new Error(body.description || `Telegram ${method} failed`);
|
||||
return body.result as T;
|
||||
}
|
||||
|
||||
async function callTelegramMultipart<T = unknown>(method: string, form: FormData): Promise<T> {
|
||||
const { baseUrl, token } = getTelegramConfig();
|
||||
const response = await fetch(`${baseUrl}/bot${token}/${method}`, { method: "POST", body: form });
|
||||
const body = (await response.json()) as TelegramResponse<T>;
|
||||
if (!response.ok || !body.ok) throw new Error(body.description || `Telegram ${method} failed`);
|
||||
return body.result as T;
|
||||
}
|
||||
|
||||
export async function registerTelegramWebhook(appUrl?: string): Promise<void> {
|
||||
const publicUrl = (appUrl || process.env.NEXT_PUBLIC_APP_URL || "https://podzahr.com").replace(/\/$/, "");
|
||||
const secret = process.env.TELEGRAM_WEBHOOK_SECRET;
|
||||
if (!publicUrl.startsWith("https://")) {
|
||||
throw new Error("NEXT_PUBLIC_APP_URL must be a public HTTPS URL");
|
||||
}
|
||||
if (!secret) throw new Error("TELEGRAM_WEBHOOK_SECRET is not configured");
|
||||
|
||||
await callTelegram("setWebhook", {
|
||||
url: `${publicUrl}/api/telegram/webhook`,
|
||||
secret_token: secret,
|
||||
allowed_updates: ["message", "callback_query"],
|
||||
});
|
||||
}
|
||||
|
||||
function albumIdsForPurchase(purchase: Purchase): string[] {
|
||||
return purchase.albumIds?.length ? purchase.albumIds : [purchase.albumId];
|
||||
}
|
||||
|
||||
function albumsForPurchase(purchase: Purchase): Album[] {
|
||||
return albumIdsForPurchase(purchase)
|
||||
.map((id) => albumDb.getById(id))
|
||||
.filter((album): album is Album => Boolean(album));
|
||||
}
|
||||
|
||||
function totalForAlbums(albums: Album[]): number {
|
||||
return albums.reduce((total, album) => total + album.price, 0);
|
||||
}
|
||||
|
||||
function displayName(user: TelegramUser): string {
|
||||
const name = [user.first_name, user.last_name].filter(Boolean).join(" ").trim();
|
||||
return name || (user.username ? `@${user.username}` : String(user.id));
|
||||
}
|
||||
|
||||
function ensureSession(user: TelegramUser, chatId: number): TelegramUserSession {
|
||||
const existing = telegramSessionDb.get(String(user.id));
|
||||
return telegramSessionDb.upsert({
|
||||
userId: String(user.id),
|
||||
chatId: String(chatId),
|
||||
state: existing?.state || "browsing",
|
||||
cartAlbumIds: existing?.cartAlbumIds || [],
|
||||
displayName: displayName(user),
|
||||
});
|
||||
}
|
||||
|
||||
function mainMenuKeyboard() {
|
||||
return {
|
||||
inline_keyboard: [[
|
||||
{ text: "Browse albums", callback_data: "catalog" },
|
||||
{ text: "My cart", callback_data: "cart" },
|
||||
]],
|
||||
};
|
||||
}
|
||||
|
||||
async function showCatalog(chatId: number | string): Promise<void> {
|
||||
const albums = albumDb.getAll();
|
||||
if (albums.length === 0) {
|
||||
await callTelegram("sendMessage", { chat_id: chatId, text: "There are no albums available." });
|
||||
return;
|
||||
}
|
||||
await callTelegram("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "Choose albums to add to your cart:",
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
...albums.map((album) => [{
|
||||
text: `Add ${album.title} — ${formatPrice(album.price)}`,
|
||||
callback_data: `cart:add:${album.id}`,
|
||||
}]),
|
||||
[{ text: "View cart", callback_data: "cart" }],
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function showCart(userId: string, chatId: number | string): Promise<void> {
|
||||
const session = telegramSessionDb.get(userId);
|
||||
const albums = (session?.cartAlbumIds || [])
|
||||
.map((id) => albumDb.getById(id))
|
||||
.filter((album): album is Album => Boolean(album));
|
||||
if (albums.length === 0) {
|
||||
await callTelegram("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: "Your cart is empty.",
|
||||
reply_markup: { inline_keyboard: [[{ text: "Browse albums", callback_data: "catalog" }]] },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await callTelegram("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: [
|
||||
"Your cart:",
|
||||
...albums.map((album, index) => `${index + 1}. ${album.title} — ${formatPrice(album.price)}`),
|
||||
"",
|
||||
`Total: ${formatPrice(totalForAlbums(albums))}`,
|
||||
].join("\n"),
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
...albums.map((album) => [{ text: `Remove ${album.title}`, callback_data: `cart:remove:${album.id}` }]),
|
||||
[{ text: "Checkout", callback_data: "checkout" }],
|
||||
[
|
||||
{ text: "Add more", callback_data: "catalog" },
|
||||
{ text: "Clear cart", callback_data: "cart:clear" },
|
||||
],
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function beginCheckout(session: TelegramUserSession): Promise<void> {
|
||||
const settings = telegramSettingsDb.get();
|
||||
const albums = session.cartAlbumIds
|
||||
.map((id) => albumDb.getById(id))
|
||||
.filter((album): album is Album => Boolean(album));
|
||||
if (albums.length === 0) return showCart(session.userId, session.chatId);
|
||||
if (!settings.cardNumber || !settings.cardHolderName) {
|
||||
await callTelegram("sendMessage", {
|
||||
chat_id: session.chatId,
|
||||
text: "Card payment is not configured yet. Please try again later.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
telegramSessionDb.upsert({ ...session, state: "awaiting_receipt" });
|
||||
await callTelegram("sendMessage", {
|
||||
chat_id: session.chatId,
|
||||
text: [
|
||||
`Total: ${formatPrice(totalForAlbums(albums))}`,
|
||||
"",
|
||||
`Card number: ${settings.cardNumber}`,
|
||||
`Cardholder: ${settings.cardHolderName}`,
|
||||
"",
|
||||
"Transfer the amount, then send either the transaction ID as text or a screenshot of the receipt.",
|
||||
].join("\n"),
|
||||
});
|
||||
}
|
||||
|
||||
function purchaseMessage(purchase: Purchase): string {
|
||||
const albums = albumsForPurchase(purchase);
|
||||
return [
|
||||
"New Telegram purchase awaiting review",
|
||||
"",
|
||||
`Purchase: #${purchase.id}`,
|
||||
`Albums: ${albums.map((album) => album.title).join(", ") || purchase.albumId}`,
|
||||
`Amount: ${formatPrice(totalForAlbums(albums))}`,
|
||||
`Customer: ${purchase.customerName || "N/A"}`,
|
||||
`Telegram user: ${purchase.telegramUserId || "N/A"}`,
|
||||
`Transaction: ${purchase.transactionId}`,
|
||||
`Receipt: ${purchase.txReceipt || purchase.receiptType || "N/A"}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function reviewKeyboard(purchaseId: number) {
|
||||
return {
|
||||
inline_keyboard: [[
|
||||
{ text: "Approve", callback_data: `purchase:approved:${purchaseId}` },
|
||||
{ text: "Reject", callback_data: `purchase:rejected:${purchaseId}` },
|
||||
]],
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendPurchaseForReview(
|
||||
chatId: string | number,
|
||||
purchase: Purchase,
|
||||
): Promise<void> {
|
||||
if (!purchase.id) return;
|
||||
const payload = {
|
||||
chat_id: chatId,
|
||||
reply_markup: reviewKeyboard(purchase.id),
|
||||
};
|
||||
if (purchase.receiptType === "photo" && purchase.receiptTelegramFileId) {
|
||||
await callTelegram("sendPhoto", {
|
||||
...payload,
|
||||
photo: purchase.receiptTelegramFileId,
|
||||
caption: purchaseMessage(purchase),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await callTelegram("sendMessage", { ...payload, text: purchaseMessage(purchase) });
|
||||
}
|
||||
|
||||
export async function notifyPurchaseApprovers(purchase: Purchase): Promise<void> {
|
||||
if (!isTelegramConfigured() || purchase.approvalStatus !== "pending") return;
|
||||
const results = await Promise.allSettled(
|
||||
telegramApproverDb.getAll().map(({ userId }) => sendPurchaseForReview(userId, purchase)),
|
||||
);
|
||||
for (const result of results) {
|
||||
if (result.status === "rejected") {
|
||||
console.error("Telegram purchase notification failed:", result.reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveReceipt(message: TelegramMessage, session: TelegramUserSession): Promise<void> {
|
||||
const albums = session.cartAlbumIds
|
||||
.map((id) => albumDb.getById(id))
|
||||
.filter((album): album is Album => Boolean(album));
|
||||
if (albums.length === 0) {
|
||||
telegramSessionDb.upsert({ ...session, state: "browsing", cartAlbumIds: [] });
|
||||
await showCart(session.userId, session.chatId);
|
||||
return;
|
||||
}
|
||||
|
||||
const textReceipt = message.text?.trim();
|
||||
const photo = message.photo?.at(-1);
|
||||
if (!textReceipt && !photo) {
|
||||
await callTelegram("sendMessage", {
|
||||
chat_id: session.chatId,
|
||||
text: "Please send the transaction ID as text or upload a receipt screenshot.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const purchase = purchaseDb.create({
|
||||
albumId: albums[0].id,
|
||||
albumIds: albums.map((album) => album.id),
|
||||
transactionId: `TG-${session.userId}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`,
|
||||
customerName: session.displayName,
|
||||
txReceipt: textReceipt || message.caption?.trim() || "Telegram receipt screenshot",
|
||||
purchaseDate: Date.now(),
|
||||
approvalStatus: "pending",
|
||||
paymentMethod: "card-to-card",
|
||||
telegramUserId: session.userId,
|
||||
telegramChatId: session.chatId,
|
||||
receiptType: photo ? "photo" : "text",
|
||||
receiptTelegramFileId: photo?.file_id,
|
||||
});
|
||||
|
||||
telegramSessionDb.upsert({ ...session, state: "browsing", cartAlbumIds: [] });
|
||||
await callTelegram("sendMessage", {
|
||||
chat_id: session.chatId,
|
||||
text: `Receipt received for purchase #${purchase.id}. You will be notified after review.`,
|
||||
reply_markup: mainMenuKeyboard(),
|
||||
});
|
||||
await notifyPurchaseApprovers(purchase);
|
||||
}
|
||||
|
||||
function resolveFileUrl(url: string): string {
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://podzahr.com";
|
||||
return new URL(url, appUrl).toString();
|
||||
}
|
||||
|
||||
function safeFileName(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "track";
|
||||
}
|
||||
|
||||
async function uploadSong(chatId: string, album: Album, song: Album["songs"][number]): Promise<void> {
|
||||
const response = await fetch(resolveFileUrl(song.fullUrl));
|
||||
if (!response.ok) throw new Error(`Could not download ${album.title} / ${song.title}`);
|
||||
const blob = await response.blob();
|
||||
const extension = album.format || "mp3";
|
||||
const form = new FormData();
|
||||
form.set("chat_id", chatId);
|
||||
form.set("caption", `${album.title} — ${song.title}`);
|
||||
form.set("document", blob, `${safeFileName(album.title)}-${safeFileName(song.title)}.${extension}`);
|
||||
await callTelegramMultipart("sendDocument", form);
|
||||
}
|
||||
|
||||
export async function notifyCustomerOfDecision(purchase: Purchase): Promise<void> {
|
||||
if (!purchase.telegramChatId) return;
|
||||
if (purchase.approvalStatus === "rejected") {
|
||||
await callTelegram("sendMessage", {
|
||||
chat_id: purchase.telegramChatId,
|
||||
text: `Your purchase #${purchase.id} was rejected. Please contact support if you believe this is a mistake.`,
|
||||
reply_markup: mainMenuKeyboard(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (purchase.approvalStatus !== "approved") return;
|
||||
|
||||
await callTelegram("sendMessage", {
|
||||
chat_id: purchase.telegramChatId,
|
||||
text: `Congratulations! Purchase #${purchase.id} was approved. Your album files are being uploaded now.`,
|
||||
});
|
||||
|
||||
for (const album of albumsForPurchase(purchase)) {
|
||||
for (const song of album.songs) {
|
||||
try {
|
||||
await uploadSong(purchase.telegramChatId, album, song);
|
||||
} catch (error) {
|
||||
console.error("Telegram album delivery failed:", error);
|
||||
await callTelegram("sendMessage", {
|
||||
chat_id: purchase.telegramChatId,
|
||||
text: `Could not upload ${album.title} — ${song.title}. Please contact support.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await callTelegram("sendMessage", {
|
||||
chat_id: purchase.telegramChatId,
|
||||
text: "All available album files have been delivered. Thank you!",
|
||||
reply_markup: mainMenuKeyboard(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleTelegramUpdate(update: TelegramUpdate): Promise<void> {
|
||||
if (update.callback_query) {
|
||||
await handleCallback(update.callback_query);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = update.message;
|
||||
if (!message?.from) return;
|
||||
const session = ensureSession(message.from, message.chat.id);
|
||||
const command = message.text?.startsWith("/")
|
||||
? message.text.split(/\s+/)[0].split("@")[0]
|
||||
: undefined;
|
||||
|
||||
if (command === "/start") {
|
||||
const settings = telegramSettingsDb.get();
|
||||
telegramSessionDb.upsert({ ...session, state: "browsing" });
|
||||
await callTelegram("sendMessage", {
|
||||
chat_id: message.chat.id,
|
||||
text: settings.greetingText,
|
||||
reply_markup: mainMenuKeyboard(),
|
||||
});
|
||||
await showCatalog(message.chat.id);
|
||||
return;
|
||||
}
|
||||
if (command === "/id") {
|
||||
const authorized = telegramApproverDb.isAuthorized(session.userId);
|
||||
await callTelegram("sendMessage", {
|
||||
chat_id: message.chat.id,
|
||||
text: `Your Telegram user ID is ${session.userId}.\nApproval access: ${authorized ? "enabled" : "not enabled"}.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (command === "/pending") {
|
||||
if (!telegramApproverDb.isAuthorized(session.userId)) {
|
||||
await callTelegram("sendMessage", { chat_id: message.chat.id, text: "You are not authorized." });
|
||||
return;
|
||||
}
|
||||
const pending = purchaseDb.getAll().filter((purchase) => purchase.approvalStatus === "pending");
|
||||
if (pending.length === 0) {
|
||||
await callTelegram("sendMessage", { chat_id: message.chat.id, text: "No pending purchases." });
|
||||
} else {
|
||||
for (const purchase of pending.slice(0, 20)) await sendPurchaseForReview(message.chat.id, purchase);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (command === "/albums") return showCatalog(message.chat.id);
|
||||
if (command === "/cart") return showCart(session.userId, message.chat.id);
|
||||
if (session.state === "awaiting_receipt") return saveReceipt(message, session);
|
||||
|
||||
await callTelegram("sendMessage", {
|
||||
chat_id: message.chat.id,
|
||||
text: "Use the buttons below to browse albums or view your cart.",
|
||||
reply_markup: mainMenuKeyboard(),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCustomerCallback(
|
||||
callback: NonNullable<TelegramUpdate["callback_query"]>,
|
||||
): Promise<void> {
|
||||
const chatId = callback.message?.chat.id;
|
||||
if (!chatId) return;
|
||||
const session = ensureSession(callback.from, chatId);
|
||||
const data = callback.data || "";
|
||||
|
||||
await callTelegram("answerCallbackQuery", { callback_query_id: callback.id });
|
||||
if (data === "catalog") return showCatalog(chatId);
|
||||
if (data === "cart") return showCart(session.userId, chatId);
|
||||
if (data === "checkout") return beginCheckout(session);
|
||||
if (data === "cart:clear") {
|
||||
telegramSessionDb.upsert({ ...session, state: "browsing", cartAlbumIds: [] });
|
||||
return showCart(session.userId, chatId);
|
||||
}
|
||||
|
||||
const cartAction = data.match(/^cart:(add|remove):(.+)$/);
|
||||
if (!cartAction) return;
|
||||
const album = albumDb.getById(cartAction[2]);
|
||||
if (!album) {
|
||||
await callTelegram("sendMessage", { chat_id: chatId, text: "That album is no longer available." });
|
||||
return;
|
||||
}
|
||||
|
||||
const cart = new Set(session.cartAlbumIds);
|
||||
if (cartAction[1] === "add") cart.add(album.id);
|
||||
else cart.delete(album.id);
|
||||
telegramSessionDb.upsert({ ...session, state: "browsing", cartAlbumIds: [...cart] });
|
||||
|
||||
await callTelegram("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: cartAction[1] === "add" ? `${album.title} added to your cart.` : `${album.title} removed.`,
|
||||
reply_markup: mainMenuKeyboard(),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCallback(callback: NonNullable<TelegramUpdate["callback_query"]>) {
|
||||
const match = callback.data?.match(/^purchase:(approved|rejected):(\d+)$/);
|
||||
if (!match) return handleCustomerCallback(callback);
|
||||
|
||||
const userId = String(callback.from.id);
|
||||
if (!telegramApproverDb.isAuthorized(userId)) {
|
||||
await callTelegram("answerCallbackQuery", {
|
||||
callback_query_id: callback.id,
|
||||
text: "You are not authorized to review purchases.",
|
||||
show_alert: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const status = match[1] as PurchaseStatus;
|
||||
const purchaseId = Number(match[2]);
|
||||
const existing = purchaseDb.getById(purchaseId);
|
||||
if (!existing || existing.approvalStatus !== "pending") {
|
||||
await callTelegram("answerCallbackQuery", {
|
||||
callback_query_id: callback.id,
|
||||
text: existing ? `Already ${existing.approvalStatus}.` : "Purchase not found.",
|
||||
show_alert: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = purchaseDb.setStatus(purchaseId, status, userId);
|
||||
if (!updated) {
|
||||
await callTelegram("answerCallbackQuery", {
|
||||
callback_query_id: callback.id,
|
||||
text: "This purchase was reviewed by someone else.",
|
||||
show_alert: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await callTelegram("answerCallbackQuery", {
|
||||
callback_query_id: callback.id,
|
||||
text: `Purchase ${status}.`,
|
||||
});
|
||||
if (callback.message) {
|
||||
await callTelegram("editMessageReplyMarkup", {
|
||||
chat_id: callback.message.chat.id,
|
||||
message_id: callback.message.message_id,
|
||||
reply_markup: { inline_keyboard: [] },
|
||||
});
|
||||
await callTelegram("sendMessage", {
|
||||
chat_id: callback.message.chat.id,
|
||||
text: `Purchase #${purchaseId} was ${status} by Telegram user ${userId}.`,
|
||||
});
|
||||
}
|
||||
|
||||
void notifyCustomerOfDecision(updated).catch((error) => {
|
||||
console.error("Telegram customer notification failed:", error);
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export type PaymentMethod = 'ipg' | 'card-to-card';
|
||||
export interface Purchase {
|
||||
id?: number;
|
||||
albumId: string;
|
||||
albumIds?: string[];
|
||||
transactionId: string;
|
||||
customerName?: string;
|
||||
email?: string;
|
||||
@@ -37,4 +38,36 @@ export interface Purchase {
|
||||
purchaseDate: Date | number;
|
||||
approvalStatus?: PurchaseStatus; // pending, approved, rejected
|
||||
paymentMethod?: PaymentMethod; // ipg or card-to-card
|
||||
reviewedByTelegramId?: string;
|
||||
reviewedAt?: Date | number;
|
||||
telegramUserId?: string;
|
||||
telegramChatId?: string;
|
||||
receiptType?: 'text' | 'photo';
|
||||
receiptTelegramFileId?: string;
|
||||
}
|
||||
|
||||
export interface TelegramApprover {
|
||||
userId: string;
|
||||
label?: string;
|
||||
createdAt?: Date | number;
|
||||
}
|
||||
|
||||
export interface TelegramBotSettings {
|
||||
baseUrl: string;
|
||||
botTokenConfigured: boolean;
|
||||
greetingText: string;
|
||||
cardNumber: string;
|
||||
cardHolderName: string;
|
||||
updatedAt?: Date | number;
|
||||
}
|
||||
|
||||
export type TelegramSessionState = 'browsing' | 'awaiting_receipt';
|
||||
|
||||
export interface TelegramUserSession {
|
||||
userId: string;
|
||||
chatId: string;
|
||||
state: TelegramSessionState;
|
||||
cartAlbumIds: string[];
|
||||
displayName?: string;
|
||||
updatedAt: Date | number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user