525 lines
18 KiB
TypeScript
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);
|
|
});
|
|
}
|