import { albumDb, purchaseDb, telegramApproverDb, telegramSessionDb, telegramSettingsDb, } from "./db"; import { Album, Purchase, PurchaseStatus, TelegramUserSession } from "./types"; import { formatPrice } from "./utils"; interface TelegramResponse { 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( method: string, payload: Record, ): Promise { 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; if (!response.ok || !body.ok) throw new Error(body.description || `Telegram ${method} failed`); return body.result as T; } async function callTelegramMultipart(method: string, form: FormData): Promise { const { baseUrl, token } = getTelegramConfig(); const response = await fetch(`${baseUrl}/bot${token}/${method}`, { method: "POST", body: form }); const body = (await response.json()) as TelegramResponse; 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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, ): Promise { 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) { 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); }); }