Botoscope®
SaaS, що перетворює магазин WooCommerce на Telegram-бота з каталогом, кошиком, оформленням замовлення та замовленнями, синхронізованими з магазином.
- Запущено
- 2025
- Статус
- Закрито в 2026
- Платформа
- Telegram
- Магазин
- WooCommerce
Про мене
Botoscope запускає магазин WooCommerce як Telegram-бота. Розробка почалася у 2024 році; сервіс публічно запущено у 2025-му. Покупці переглядають категорії, вибирають варіації, наповнюють кошик, оформлюють замовлення й платять просто в чаті, а замовлення потрапляє у WooCommerce. Описаний тут рушій — це процес Node.js, що обслуговує одного бота. Він спілкується з Telegram через TDLib у режимі бота, з магазином — через проксі Botoscope, зберігає копію каталогу в MySQL і тримає стан кожного чату в Redis.
Більша частина складнощів іде від самого чату. ID повідомлень змінюються після надсилання, клавіатури відповідей не можна редагувати, кожне натискання кнопки — це короткий callback-payload, набраний текст може бути і введенням, і назвою пункту меню, а старі повідомлення накопичуються. Значна частина рушія існує для того, щоб потік повідомлень поводився як екрани застосунку.
Решта — узгодженість із магазином на іншому сервері: імпорт каталогу, який не запускається при старті, оновлення окремих товарів, які надсилає WordPress, залишки й ціни, що збігаються з магазином, одне замовлення WooCommerce на одне оформлення, яке слідує за правками покупця, і оплата через карткові процесори, криптошлюзи та Telegram Stars, зафіксована в цьому замовленні.
Botoscope · магазин у Telegram
що розв'язано
Рушій і модель процесів
у продуктіЩо це даєКожен магазин отримав власного бота, відокремленого від інших магазинів, який працював тижнями на малому сервері й підхоплював зміни, внесені власником у магазині.
- Проблема
- Кожному магазину потрібен власний бот зі своєю сесією Telegram, каталогом і налаштуваннями, з однієї кодової бази. Процес має працювати тижнями на малих серверах і приймати команди з боку магазину.
- Рішення
Один екземпляр рушія на бота: клієнт TDLib, копія каталогу в MySQL і стан у Redis, що налаштовуються середовищем і запускаються під PM2 з лімітами пам'яті. Модулі спілкуються через шину подій і реєстри хуків; платні функції завантажуються як розширення; магазин звертається до бота через невелику HTTP-точку команд.
- Стек
Ізолювати магазини без мультиорендного шляху в кодіОдин процес на бота; токен, домен магазину, ключі та ліміти тарифу беруться зі змінних середовища
У рушії ніде немає ID орендаря. init() входить одним клієнтом-ботом TDLib, відкриває один пул knex і одне з'єднання Redis та читає домен магазину, API-ключ клієнта, ліміт товарів і прапорець безкоштовного тарифу зі змінних середовища. Ізоляція між магазинами — питання розгортання: кожен бот отримує власний екземпляр зі своєю текою бази TDLib і схемою MySQL.
Так запити й ключі Redis лишаються простими (chat:<id>:<key>) ціною одного процесу на магазин. Ключі Redis не мають префікса бота, тому два екземпляри не повинні ділити одну базу Redis.
Запустити бота на TDLib замість HTTP Bot APIКлієнт TDLib у режимі бота: одне постійне з'єднання, один потік оновлень в обидва боки, локальні кеші
Бот входить через TDLib (tdl з tdl-tdlib-addon) за токеном бота й тримає одне довге з'єднання з Telegram. Оновлення надходять у client.on('update'), тож рушію не потрібен ні URL вебхука, доступний із Telegram, ні цикл опитування getUpdates.
Головна причина — модель екранів. TDLib повідомляє про власні вихідні повідомлення бота як updateNewMessage та updateMessageSendSucceeded в тому самому потоці, що й повідомлення покупця. Один обробник реєструє кожен ID повідомлення в приватному чаті, надісланого чи отриманого, а коли екран змінюється, рушій видаляє їх через deleteMessages (revoke: true). У чаті лишається тільки поточний екран, а набрані вводи, як пошуковий запит чи адреса, зникають разом із ним. Той самий потік дає рушію змогу дочекатися, поки фотоальбом справді надійде, перш ніж надсилати панель товару.
TDLib також покриває виклики, яких немає в HTTP Bot API: getMessage зчитує текст повідомлення, яке бот надіслав раніше, і панель згрупованих товарів використовує це, щоб перемалювати свої кнопки. На getChat і getUser відповідає клієнт TDLib з увімкненою базою інформації про чати, а медіа надсилаються з локальних шляхів або за віддаленим ID файлу через базу файлів. База повідомлень вимкнена, бо бот не веде історії, оптимізатор сховища очищає файли TDLib, а TDLib працює в одному робочому потоці.
Ціна в тому, що семантика TDLib просочується в застосунок: ID повідомлень тимчасові, доки сервер їх не підтвердить (див. завдання про зіставлення ID), а тека сесії прив'язує бота до одного процесу.
Можливі наступні кроки, які TDLib уже пропонує: увімкнути його базу повідомлень для історії та пошуку по чатах і надсилати файли більші за ліміт вивантаження 50 MB в HTTP Bot API. Рушій наразі не робить ні того, ні іншого; документи обмежені 50 MB.
Багато модулів реагують на одні й ті самі оновлення та екраниШина подій для оновлень плюс реєстри хуків на чат і глобальні з упорядкуванням за пріоритетом
Answer перетворює оновлення TDLib на події шини: do_callback_query для натискань кнопок, do_command для тексту, pre_checkout_query і successful_payment для платежів. Кошик, обране, бронювання, підтримка та інші підписуються й обробляють лише власні дії, тож не імпортують обробники одне одного.
Для компонування інтерфейсу є два реєстри хуків. Hooks зберігає колбеки на chat_id (поточне контекстне меню, додаткові кнопки товару), відсортовані за пріоритетом, де додавання хука з наявним пріоритетом замінює його. GlobalHooks робить те саме для всіх чатів; розширення підтримки використовує його, щоб додати кнопку на панелі товарів, які йому не належать. Хуки чату скидаються, коли чат видаляється.
Вмикати й вимикати функції тарифу для кожного магазинуСписок розширень синхронізується з магазину; клас будується зі slug; для вивантаження — перезапуск PM2
Список активних розширень (купони, доставка, підтримка, бронювання, маркетинг, b2b та інші) синхронізується в таблицю опцій. Extensions.init() перетворює кожен slug на ім'я класу й створює екземпляр до побудови основних модулів, щоб їхні хуки існували першими. Основні модулі перевіряють наявність розширення через необов'язкові гетери та змінюють поведінку; наприклад, оформлення замовлення пропускає крок промокоду, коли купони вимкнено.
Вимкнення розширення не може вивантажити код, що вже зареєстрував слухачів, тому скидання кешу розширень перезапускає процес через PM2. У коментарях коду зазначено, що це перериває користувачів посеред сесії.
Дозволити магазину надсилати зміни й запити в запущеного ботаHTTP-точка /command; відомі команди обробляються напряму, решта маршрутизується до модулів через шину
Commander запускає звичайний http-сервер з /health і /command. Відомі команди (payment_success, order_is_done, reset_cache, update_product_cache) обробляються напряму. Усе інше надсилається як exec_command; модуль-власник записує is_executed і answer у спільний об'єкт body, який точка повертає після короткого фіксованого очікування.
reset_cache оновлює один кеш налаштувань або всі, причому розширення завжди останні, бо саме воно перезапускає процес. reset_db_cache() кожного модуля повторно зчитує свій JSON-блоб у таблицю опцій і очищає копію в пам'яті.
Підтримувати здоровим один довгоживучий процес на малому серверіPM2 у режимі fork, купа 450 MB, перезапуск на 1 GB, від 2 до 5 з'єднань із БД, скидання кешів за таймером
ecosystem.config.cjs запускає один fork-екземпляр (режим cluster закоментований; один токен бота означає одну сесію TDLib), обмежує old space V8, перезапускає при зростанні пам'яті й вимагає 30 с роботи, перш ніж запуск вважається стабільним. Обробники SIGTERM, SIGINT і SIGQUIT знищують пул knex перед виходом, а таймер логує використання пулу, коли є активність.
Кеші в пам'яті, як-от кошики та вибрані валюти, очищаються за таймерами, а мапа ID повідомлень спорожнюється при кожній зміні екрана.
Чат як інтерфейс застосунку
у продуктіЩо це даєПокупці рухалися каталогом, кошиком і оформленням, як екранами застосунку, а чат лишався охайним замість того, щоб заповнюватися старими повідомленнями.
- Проблема
- Чат Telegram — це потік повідомлень, а продукту потрібні екрани: сторінка каталогу, кошик, крок оформлення, кожен замінює попередній, з кнопками, що оновлюються на місці.
- Рішення
Відстежувати кожне повідомлення в чаті, міняти екрани, видаляючи попередній набір після того, як намальовано новий, зіставляти тимчасові ID TDLib із серверними для подальшого редагування і тримати стан навігації в контексті чату в Redis.
- Стек
Редагувати повідомлення, ID якого змінюється після надсиланняЗіставляти тимчасові ID із серверними з updateMessageSendSucceeded, на чат у RAM
sendMessage у TDLib повертає тимчасовий локальний ID; справжній ID приходить пізніше в updateMessageSendSucceeded разом з old_message_id. Рушій зберігає ID, який повернув send(), у контексті чату (top_message_id, taxonomies_btn_mgs_id тощо) і розв'язує його через MessageMapping, коли потрібно відредагувати або видалити це повідомлення.
Спершу зіставлення жило в Redis, потім було перенесене в мапу на чат у пам'яті, яка очищається при кожній зміні екрана, щоб лишатися малою. Без нього редагування inline-клавіатури після кліку зверталося б до ID, якого вже не існує.
Змусити групу повідомлень поводитися як один екранЛогувати надіслані ID у Redis; на новому екрані відкладати їх убік і видаляти після малювання
Кожен новий ID повідомлення в приватному чаті додається в chat:<id>:messages. make_messages_castling() переносить цей список у chat:<id>:old_messages і скидає зіставлення ID, модуль малює новий екран, а clear_display() потім видаляє старі повідомлення. Новий вміст з'являється раніше, ніж зникає старий, тож чат ніколи не стає порожнім між екранами.
Повне очищення чату пробували й лишили в коді лише як попередження: на деяких телефонах воно викидало користувача з бота.
Повідомлення з клавіатурою відповідей не можна редагуватиНадіслати нове верхнє повідомлення з оновленою клавіатурою, потім видалити старе
Постійне нижнє меню показує кнопку кошика з живим лічильником товарів. Telegram не дозволяє редагувати текст повідомлення, що несе клавіатуру відповідей, тож Cart.redraw_menu() надсилає свіже верхнє повідомлення й видаляє попереднє за його зіставленим ID, а нижній підсумок, inline-повідомлення, редагується на місці після короткої затримки.
Поточне меню зберігається як хук context_menu на чат. change_menu_buttons_text() переписує лише кнопку, що починається з емодзі кошика, у серіалізованому меню, тож будь-який екран може оновити лічильник кошика, не знаючи власного компонування.
Тексти магазину — це HTML; TDLib хоче простий текст зі зміщеннями сутностейВласний конвертер: декодувати сутності, зрізати підмножину тегів, видати зміщення textEntity
format_string_before_send() обробляє strong, em, u, s, code, pre та a href. Він знаходить найближчий відкривний тег, видаляє його разом із закривним і записує початкову та кінцеву позиції в очищеному рядку; вони стають зміщеннями textEntity у formattedText. Іменовані й числові HTML-сутності з WooCommerce декодуються першими. Некоректний або незакритий тег змушує функцію повернути вихідний текст без сутностей замість надсилання зламаного форматування.
Поруч два прийоми верстки: послідовність порожніх символів Брайля вирівнює повідомлення, щоб картки товарів мали однакову ширину, а об'єднувальний накладний символ малює закреслену стару ціну всередині підписів кнопок, де сутності заборонені.
Набраний текст може бути введенням користувача або локалізованою кнопкою менюПрапорець очікуваного введення на чат; відомі підписи кнопок завжди виводять з нього
Кнопки клавіатури відповідей приходять як звичайний текст мовою користувача, тож маршрутизація порівнює вхідний текст із підписами, що будує get_menu_command_text() для цього чату. Коли крок чекає на вільний текст (пошуковий запит, SKU, адреса, промокод, повідомлення в підтримку), контекст містить is_waiting_user_input і команду для продовження. Answer зберігає текст як user_message_text і відтворює цю команду, якщо тільки текст не є командою зі слешем або підписом навігації.
Обробники введення при оформленні повторюють перевірку щодо підписів власного кроку, тож натискання «Назад» під час очікування адреси веде назад, а не зберігає це слово як адресу.
Картки товарів мають приходити по порядку й витримувати швидке гортанняЧекати на власне оновлення альбому перед його панеллю; дозволяти одну дію гортання за раз
Картка товару — це медіаальбом, за яким іде текстова панель із кнопками. print_products_blocks() надсилає альбом, чекає до 10 с на подію new_message_coming цього чату, потім робить паузу перед надсиланням панелі, тож панель ніколи не опиняється вище за свої фото. Короткі фіксовані паузи перед кожним надсиланням і видаленням розподіляють запити в часі.
Швидкі натискання «далі» й «назад» інакше запустили б другу заміну екрана, поки перша не видалила свої повідомлення. Прапорець nowdo на чат ігнорує навігацію, поки екран замінюється, і знімається через секунду після видалення старих повідомлень.
Синхронізація каталогу WooCommerce
у продуктіЩо це даєПокупці швидко гортали каталог і бачили актуальні ціни та залишки. Правки в магазині доходили до бота самі, а підписники дізнавалися, коли товари поверталися.
- Проблема
- Магазин працює на сайті WordPress на іншому сервері. Бот має швидко гортати каталог, показувати актуальні ціни й залишки та підхоплювати правки, не опитуючи магазин при кожному дотику.
- Рішення
Тримати копію товарів і зв'язків із категоріями в MySQL, що наповнюється постраничним повним імпортом і оновлюється по товару, коли магазин надсилає зміну. Налаштування магазину приходять як JSON-блоби в таблиці опцій з кешами в пам'яті.
- Стек
Діставати дані кожного магазину, не тримаючи його REST-облікових данихУсі виклики магазину йдуть на один проксі; домен, кінцева точка, API-ключ і HMAC передаються в заголовках
get_rest_data() і send_remote_data() надсилають POST на єдиний URL проксі Botoscope. Магазин названий у заголовку client-domain, кінцева точка — в іншому заголовку, а запит автентифікується API-ключем клієнта й проходом, виведеним через HMAC-SHA256. Завантаження файлів користуються тим самим проксі через його кінцеву точку fetch-file.
Рушію ніколи не потрібні власні REST-ключі магазину, а стороні WordPress лишається тільки відповідати проксі. Збої повертають null, а викликачі трактують null як відсутність даних, тож збій магазину деградує до порожніх списків, а не обвалює екран.
Імпортувати повний каталог на вимогу, а не при кожному перезапускуTruncate, вимкнути ключі, брати сторінки по 100, вставляти пакетами, увімкнути ключі у finally
Products.reset_db_cache() читає кількість товарів, виконує truncate для products і products_vs_terms, запускає ALTER TABLE ... DISABLE KEYS на обох таблицях MyISAM і витягує products/{offset}/{limit} сторінками по 100. Кожна сторінка вставляється пакетами по 100 рядків; помилка на одній сторінці логується, а імпорт переходить до наступної. MyISAM не має транзакцій, тож рядки, уже записані з невдалої сторінки, лишаються. Ключі знову вмикаються в блоці finally, тож навіть невдалий імпорт лишає індексовані таблиці. Поки йде імпорт, бот показує частину каталогу, завантажену на цей момент.
Імпорт не запускається при старті. Коментар пояснює, що для великого каталогу він міг би завісити сервер після кожного перезапуску, тому запускається лише за явною командою reset_cache від магазину.
Дані товарів залежать від типу й постійно ростутьКолонки для того, за чим фільтрують чи сортують, решта — у JSON-колонці data
insert_product_batch() розкладає поля, за якими рушій запитує (price, sale_price, прапорці залишків, type, parent_id, publish_date, menu_order, мінімум, максимум і крок кошика), у колонки, а решту даних серіалізує в data. Списки медіа, завантаження, дні доступу чи зовнішній URL товару можна додати на боці магазину без зміни схеми. ID категорій потрапляють і в список через кому в рядку, і в products_vs_terms для індексованого пошуку за категоріями.
install.sql виконується при кожному старті з CREATE TABLE IF NOT EXISTS, тож новий екземпляр сам створює свою схему.
Застосовувати правки окремих товарів без повного реімпортуМагазин надсилає update_product_cache; товар, варіації та терміни перезаписуються з однієї відповіді магазину
Коли товар змінюється у WooCommerce, сторона магазину надсилає update_product_cache на /command. update_product_cache() отримує products_filter/{id}, що повертає товар з його варіаціями, потім видаляє рядок товару, рядки з цим parent_id і його зв'язки з категоріями та вставляє нові рядки. Один запит несе товар і його варіації, тож рядки переписуються один раз на зміну.
Ліміт тарифу рахує лише батьківські товари. Повний імпорт обрізає основні товари на сторінку й завжди зберігає їхні варіації; інкрементальний шлях пропускає новий товар, коли кількість різних товарів у products_vs_terms досягає ліміту.
Прапорці наявності розходяться, а покупці хочуть сповіщень про повернення в наявністьЗвіряти прапорці наявності за кількістю кожні 3 хв; сповіщати підписників кожні 2 год
Для товарів з обліком залишків таймер кожні три хвилини виставляє is_in_stock за stock_quantity в обидва боки, тож кнопка купівлі слідує за фактичною кількістю, навіть коли надісланий прапорець застарів. Покупці можуть підписатися на товар, якого немає в наявності, або на окрему варіацію; stock_notify зберігає product_id і chat_id. Кожні дві години рушій знаходить підписані товари, що знову в наявності, повторно надсилає картку кожному підписнику й видаляє підписку.
Кожен запит до каталогу також порівнює publish_date з поточним часом, тож заплановані товари лишаються прихованими до своєї дати.
Фасетна фільтрація не вміщується в кнопки чатуВідкрити сторінку фільтра магазину як Telegram Web App; вона надсилає знайдені ID назад
Команда /filter і кнопки Filter відкривають filter_page_url як Web App з дописаним chat_id. Фільтрація виконується на боці WordPress. Результат повертається до рушія як команда filtered_products_ids на /command, а Products виводить ці ID як звичайний постраничний каталог у цьому чаті.
Так підрахунок фасетів і логіка атрибутів лишаються в одному місці, у магазині, а чат усе одно показує результати у власному форматі карток.
Кошик, варіації та оформлення замовлення
у продуктіЩо це даєБот дотримувався правил самого магазину щодо кількостей, варіацій, бронювань і знижок, а замовлення в магазині відповідало тому, що покупець бачив у чаті.
- Проблема
- Кошик і оформлення мають забезпечувати правила магазину (кроки кількості, мінімуми, залишки, варіації, слоти бронювання, знижки) лише кнопками й короткими текстовими відповідями, а замовлення WooCommerce має відповідати тому, що бачив покупець.
- Рішення
Кошики живуть у Redis за RAM-кешем, варіації вибираються каскадним inline-вибирачем, а оформлення — це машина кроків, обрізана за вмістом кошика та ввімкненими розширеннями. Замовлення створюється один раз і правиться, коли покупець редагує.
- Стек
Кошик читається майже при кожному відображенніRedis на чат за RAM-кешем зі скрізним записом; прострочені та видалені позиції прибираються
Cart.get() віддає зі статичної мапи на чат і звертається до Redis (chat:<id>:cart); update() пише в обидва. Підпис кнопки кошика, підсумки та кожна панель товару читають кошик, тож кеш щоразу заощаджує звернення до Redis. Позиції можуть мати термін дії, який використовують утримання бронювань, і при читанні відкидаються після спливу. Малюючи кошик, спершу прибирають ID, яких уже немає в локальному каталозі, тож товар, видалений у WooCommerce, не може потрапити в оформлення.
Кнопки кількості поважають quantity_step, min_cart_count і облік залишків, а варіація без власних значень успадковує їх від батьківського товару.
Вибрати варіацію кнопками, коли існують не всі комбінаціїКаскадні рядки атрибутів; кожен рядок пропонує лише терміни, що ще утворюють справжню варіацію
get_buy_buttons_panel() читає комбінацію атрибутів кожної варіації й упорядковує атрибути за порядком атрибутів магазину. Перший рядок перелічує кожен використаний термін; кожен наступний — лише терміни, що разом із вибором вище збігаються принаймні з однією наявною варіацією. Атрибут із трьома чи більше варіантами може відображатися як компактний рядок «попередній / поточний / наступний» замість сітки.
Зміна верхнього атрибута скидає нижні й переходить на першу відповідну комбінацію. Товар відкривається на найдешевшій варіації. Вибір зберігається в контексті на чат і товар, а панель редагується на місці при кожному дотику.
Кроки оформлення залежать від того, що в кошикуСписок кроків обрізається на чат: без доставки для цифрових кошиків, без кроку оплати, коли безкоштовно
Базові кроки: промокод, адреса доставки, спосіб доставки, спосіб оплати, коментар і підтвердження. Кроки вимкнених розширень видаляються при старті. Потім get_checkout_steps() оглядає кошик, обходячи згруповані товари та варіації їхніх змінних дочірніх, щоб знайти будь-яку фізичну позицію; кошик без неї пропускає обидва кроки доставки. Нульова сума перемикає на подарунковий шлюз і пропускає вибір оплати, магазин з одним шлюзом вибирає його наперед, а оплата в Stars примусово вмикає шлюз Stars.
Обов'язкові кроки блокують next(), доки значення не збережено, а будь-який крок можна знову відкрити в режимі редагування з екрана підтвердження й повернутися одразу до нього.
Замовлення в магазині має слідувати за правками, зробленими після його створенняСтворити замовлення один раз на кроці оплати, тримати його ID у контексті, надсилати кожну подальшу правку
generate_payment_button() викликає create_order, лише коли в контексті ще немає order_id, потім зберігає ID. Якщо покупець повертається й змінює спосіб оплати, адресу, промокод чи коментар, кожен обробник викликає orders.edit(order_id, ...), тож замовлення WooCommerce правиться, а не дублюється.
Замовлення несе chat_id Telegram і назву бота, тож історія замовлень береться по чату, а посилання «оплатити за мене» може пізніше знайти замовлення.
Двоє покупців бота не повинні забронювати один слот часуУтримання в пам'яті на слот на 600 с, що рахуються проти залишкової місткості слота
Слоти приходять із магазину на товар і день з capacity та capacity_used, а слоти, які магазин позначає зарезервованими, відфільтровуються. Коли покупець вибирає слот, рушій записує утримання з терміном дії й додає бронювання в кошик з тим самим часом утримання. is_freezed() рахує активні утримання для цього слота, дати й ключа та блокує слот, коли утримання досягають місткості мінус зайняті місця. Видалення позиції з кошика знімає її утримання через подію done_remove_from_cart.
Календарні дати та фільтрація минулих слотів використовують часовий пояс магазину з налаштувань, а не годинник сервера. Утримання живуть у пам'яті процесу бота, тож діють лише для покупців цього бота й скидаються при перезапуску; бронювання, зроблені на сайті в ту саму мить, не утримуються.
Акціям на кшталт «кожен 3-й товар зі знижкою 25%» потрібен формат правилМала мова формул: умови за кількістю товарів; знижка; n-й, найдешевший чи найдорожчий, ліміт
Маркетингові кампанії використовують формули на кшталт 'c >= 6 & c < 12; 25%; 3n, me, cap=3'. parse_formula() розбиває їх на умови за кількістю товарів, відсоткову чи фіксовану знижку й модифікатори (кожна n-та одиниця, спершу найдешевша чи найдорожча, ліміт). get_applied_amount() розгортає кошик у по одному запису на одиницю товарів кампанії, застосовує формули по порядку й позначає використані одиниці, щоб пізніша формула не знижувала їх повторно.
Вікна кампаній перевіряються в часовому поясі магазину, кампанії в тестовому режимі діють лише для чату адміністратора, а знижка кампанії пропускається, коли вже діє купон. Купони, прив'язані до товару, поширюються на дочірні згрупованих і змінних товарів.
Платежі та валюти
у продуктіЩо це даєПокупці бачили ціни у своїй валюті й платили карткою, криптою чи Telegram Stars або просили друга заплатити. Магазин отримував оплачене замовлення.
- Проблема
- Оплата починається в чаті, завершується на сторінці шлюзу або всередині Telegram і має закінчитися оплаченим замовленням WooCommerce, через кілька шлюзів і валют відображення.
- Рішення
Суми лишаються в базовій валюті магазину й конвертуються лише для показу та списання. Рушій сам створює сесії шлюзів і опитує шлюзи, що не мають вебхука для бота.
- Стек
Ціни показано в одній валюті, а списано в іншійСуми зберігаються в базовій валюті, конвертуються при показі та списанні, замовлення нормалізуються назад
Підсумки кошика, знижки та доставка рахуються в базовій валюті. format_price() конвертує у вибрану валюту чату за синхронізованим курсом і застосовує позицію символу та десяткові знаки, округлюючи вгору, коли копійки приховано. generate_payment_button() перераховує суму у вибрану валюту перед викликом шлюзу. Замовлення, отримані з WooCommerce в іншій валюті, діляться на збережений курс замовлення, тож історія й підсумки збігаються з власною арифметикою бота.
Коли магазин не дозволяє платити в інших валютах, на кроці оплати вибір скидається до базової, і покупцеві пояснюють чому.
Приймати Telegram Stars як спосіб оплатиStars як валюта магазину XTR із курсом; рахунок TDLib, чий payload називає замовлення
Stars налаштовуються як валюта з назвою XTR у списку валют магазину, тож їхній курс приходить через ту саму синхронізацію. Сума — це базова сума помножена на курс XTR, округлена вгору. Рахунок надсилається як inputMessageInvoice з порожнім токеном провайдера та JSON-payload у base64, що містить order_id, chat_id, amount і gateway. На pre-checkout запит відповідають, а на messagePaymentSuccessfulBot payload декодується, і магазину повідомляється, що замовлення оплачено.
Без валюти XTR варіант Stars повідомляє про помилку замість рахунку.
Криптошлюз без вебхука, зареєстрованого для ботаОпитування на чат кожні 8 с до 15 хв, скасовується, коли кошик відкрито знову
Для CryptoBot рушій створює рахунок у фіатній валюті з терміном дії 900 с і опитує getInvoices кожні 8 с. Map таймерів за chat_id тримає одне активне опитування на покупця, а відкриття кошика його скасовує. Після оплати замовлення переходить у processing, а кошик очищається; при простроченні чи скасуванні покупець отримує повідомлення.
CoinGate працює навпаки: його callback URL вказує на проксі Botoscope, який пересилає вебхук на /command як payment_success, і замовлення завершується, коли вебхук повідомляє про оплату.
Кожному картковому шлюзу потрібен власний формат сесіїСесії Stripe і LiqPay будуються в рушії й відкриваються як Web App або URL; PayPal у sandbox
Кожен шлюз має власний конструктор: Stripe — через Checkout Session, LiqPay — через base64-блоб даних, підписаний SHA-1 над приватним ключем, даними й приватним ключем. Посилання надсилається як кнопка Web App або URL, за бажанням з кнопками політик повернення, конфіденційності та доставки перед нею. Конструктор PayPal (облікові дані клієнта OAuth, потім платіж із URL повернення й скасування) у цьому коді надсилає платіж на кінцеву точку sandbox PayPal, тож працює лише для тестування.
Офлайн-методи (реквізити банківської картки, SWIFT, подарунок, «оплатити за мене») показують перекладені інструкції та кнопку підтвердження замість посилання.
Дозволити іншому користувачеві Telegram оплатити замовленняГлибоке посилання «оплатити за мене» відкриває замовлення в чаті платника й сповіщає покупця
Вибір «оплатити за мене» завершує оформлення посиланням t.me, що запускає бота з payforme_<order_id>. Коли інший користувач його відкриває, рушій завантажує замовлення з магазину, застосовує валюту замовлення, прибирає офлайн-методи й метод «оплатити за мене» та показує решту шлюзів для суми замовлення. Після оплати чат покупця отримує повідомлення з іменем платника.
Медіа, файли та мови
у продуктіЩо це даєФото й відео товарів відкривалися швидко, куплені файли приходили просто в чат, а покупці читали меню й каталог своєю мовою.
- Проблема
- Зображення й файли товарів лежать у магазині, а надсилати їх через Telegram при кожному перегляді повільно. Бот також завантажує URL, що походять із даних магазину. Інтерфейс і каталог мають читатися мовою покупця.
- Рішення
Локальний медіакеш із хешами, захищені завантаження через проксі, повторне використання файлів, що вже розміщені в Telegram, і багатошарові переклади: вбудовані рядки інтерфейсу, перевизначення власника та переклади по полях у даних каталогу.
- Стек
Повторне завантаження фото товару при кожному перегляді карткиЛокальний кеш за MD5 від URL, розбитий на теки з двох hex-символів, надсилається як локальні файли
send_media_gallery() хешує кожен URL медіа, при першому використанні зберігає файл у media_files_cache/<перші два hex-символи>/<hash>, а далі передає TDLib локальний шлях. Файли нульового розміру видаляються й пропускаються, альбом обмежений 8 елементами, а варіація без власних зображень бере медіа батьківського товару.
Посилання на пости t.me оминають кеш і йдуть у TDLib як віддалені файли.
Бот завантажує URL, взяті з даних магазинуБілий список доменів, заблоковані розширення, ліміти розміру за типом, часткові файли видаляються
downloadFileToLocal() приймає лише URL http(s) на власному домені клієнта або хостах Telegram, відхиляє розширення виконуваних файлів і скриптів у прямих посиланнях, надсилає запит HEAD, щоб відхилити файли понад ліміт (10 MB для зображень і аудіо, 50 MB для відео та документів), потім передає файл потоком через кінцеву точку fetch-file проксі з тим самим лімітом. Порожні, завеликі та невдалі завантаження видаляються з диска.
Великі відео товарів повільно надсилати з магазинуОпублікувати відео в каналі; бот відповідає його ID файлу Telegram для повторного використання
Answer стежить за постами каналів. Коли відео з'являється в каналі, де бот є учасником, бот відповідає віддаленим ID файлу цього відео. Посилання на пост t.me або цей ID потім можна використати як медіа товару, і рушій надсилає його як віддалений файл, тож Telegram віддає його зі свого сховища без вивантаження для кожного покупця.
Доставляти платні цифрові файли всередині чатуПісля оплати надсилати кожне завантаження через захищене отримання, кешоване під замаскованим іменем
Коли кошик містив товари для завантаження, а замовлення оплачене онлайн або безкоштовне, do_after_pay() надсилає кожне завантаження цих товарів як документ. Файли кешуються під ім'ям, виведеним з ID завантаження через shiftUUID(), тож збережене ім'я — не ID магазину, а документи понад 50 MB відхиляються. Товари з обмеженим доступом показують залишок днів, обчислений від дати оплати замовлення.
PDF-рахунки з кінцевої точки рахунків магазину йдуть тим самим шляхом.
Текст інтерфейсу 7 мовами, який може редагувати власник магазинуВбудований JSON на мову, поверх нього синхронізовані перевизначення власника, запасний варіант — англійська
Сім мовних файлів (en, es, pt, ru, pl, kz, ua) з однаковими 215 ключами завантажуються при старті. __() зливає мовний файл чату з перевизначеннями, які власник ввів в адмінці (синхронізуються як interface_translations та індексуються як s<number>); якщо ключа все ще немає, повторює спробу з англійською плюс перевизначення. Вибрана мова зберігається на чат; при першому контакті вона береться з language_code клієнта Telegram, якщо ця мова ввімкнена, інакше — зі значення магазину за замовчуванням.
Оскільки кнопки меню зіставляються за їхнім локалізованим текстом, підписи для чату розв'язуються паралельно через Promise.all при кожній перевірці маршрутизації.
Вміст каталогу та пошук мають працювати кожною мовоюПереклади по полях у товарів і термінів; пошук збігається за заголовком або перекладами
Товари, категорії, бренди, meta-поля, способи доставки й оплати несуть об'єкт translations. translate_to() повертає 0, коли чат використовує мову за замовчуванням, тож у типовому випадку всі пошуки пропускаються; інакше get_field_translation() вибирає перекладене поле й повертається до оригіналу. Пошук за заголовком виконує LIKE і по колонці title, і по збереженому JSON перекладів із мінімумом 3 символи, тож запит будь-якою активною мовою знаходить товар.
Дати в meta товарів і періоди доступу форматуються з мовою чату як локаллю.
Цифри
з продукту- Мови бота 7 Англійська, іспанська, португальська, російська, польська, казахська, українська.
- Способи онлайн-оплати 5 Stripe, LiqPay, CoinGate, CryptoBot і Telegram Stars.
- Шлях відповіді push, без опитування TDLib тримає одне постійне з'єднання з Telegram: кожен дотик одразу доходить до бота, без переходу через вебхук і без затримки опитування.