Files

525 lines
18 KiB
TypeScript

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);
});
}