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)!;
},
};
+524
View File
@@ -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);
});
}
+33
View File
@@ -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;
}