feat: add Telegram album purchase bot
This commit is contained in:
+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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user