Language

Get in touch

SaaS · since 2025

Botoscope®

SaaS that turns a WooCommerce store into a Telegram bot with catalog, cart, checkout and orders synced with the store.

Launched
2025
Status
Closed in 2026
Platform
Telegram
Store
WooCommerce
  • Node.js (ES modules)
  • TDLib via tdl / tdl-tdlib-addon
  • MySQL / MariaDB (knex, mysql2)
  • Redis (ioredis)
  • PM2
  • axios
  • moment
  • Telegram Web Apps
  • WooCommerce via Botoscope proxy
Open botoscope.com botoscope.com

About

Botoscope runs a WooCommerce store as a Telegram bot. Development started in 2024; the service launched publicly in 2025. Customers browse categories, pick variations, fill a cart, check out and pay inside the chat, and the order lands in WooCommerce. The engine described here is the Node.js process that serves one bot. It talks to Telegram through TDLib in bot mode, to the store through a Botoscope proxy, keeps a MySQL copy of the catalog and holds per-chat state in Redis.

Most of the difficulty comes from the chat itself. Message IDs change after sending, reply keyboards cannot be edited, every button press is a short callback payload, typed text can be either input or a menu label, and old messages pile up. A large part of the engine exists to make a stream of messages behave like screens of an app.

The rest is consistency with a store on another server: a catalog import that does not run at startup, per-product updates pushed from WordPress, stock and prices that match the store, one WooCommerce order per checkout that follows the customer's edits, and payment through card processors, crypto gateways and Telegram Stars recorded on that order.

Botoscope · store in Telegram

what was solved

CLOSEDsince 2025

Engine and process model

in the product

What it givesEach store got its own bot, kept apart from other stores, that ran for weeks on a small server and picked up changes the owner made in the shop.

Problem
Each store needs its own bot with its own Telegram session, catalog and settings, from one code base. The process has to run for weeks on small servers and accept commands from the store side.
Solution

One engine instance per bot: a TDLib client, a MySQL catalog copy and Redis state, configured by environment and run under PM2 with memory limits. Modules talk over an event bus and hook registries; paid features load as extensions; the store side reaches the bot through a small HTTP command endpoint.

Stack
  • Node.js
  • TDLib (tdl)
  • EventEmitter
  • PM2
  • knex
  • ioredis
Tasks · 6
Isolate stores without a multi-tenant code pathOne process per bot; token, store domain, keys and plan limits come from the environment

There is no tenant ID anywhere in the engine. init() logs in one TDLib bot client, opens one knex pool and one Redis connection, and reads the store domain, client API key, product limit and free-plan flag from environment variables. Isolation between stores is a deployment matter: each bot gets its own instance with its own TDLib database directory and MySQL schema.

This keeps queries and Redis keys simple (chat:<id>:<key>) at the cost of one process per store. Redis keys carry no bot prefix, so two instances must not share one Redis database.

Run the bot on TDLib instead of the HTTP Bot APITDLib client in bot mode: one persistent connection, one update stream for both directions, local caches

The bot logs in through TDLib (tdl with tdl-tdlib-addon) with a bot token and keeps one long-lived connection to Telegram. Updates are pushed into client.on('update'), so the engine needs no webhook URL reachable from Telegram and no getUpdates polling loop.

The main reason is the screen model. TDLib reports the bot's own outgoing messages as updateNewMessage and updateMessageSendSucceeded in the same stream as the customer's messages. One handler registers every message ID in a private chat, sent or received, and when a screen changes the engine deletes them with deleteMessages (revoke: true). The chat keeps only the current screen, and typed inputs such as a search query or an address disappear with it. The same stream lets the engine wait until a photo album has actually arrived before it sends the product panel.

TDLib also covers calls the HTTP Bot API does not have: getMessage reads back the text of a message the bot sent earlier, which the grouped-product panel uses to redraw its buttons. getChat and getUser are answered by the TDLib client with its chat-info database switched on, and media go out from local paths or by remote file ID through the file database. The message database is off because the bot keeps no history, the storage optimizer prunes TDLib's files, and TDLib runs one worker thread.

The cost is that TDLib semantics leak into the app: message IDs are temporary until the server confirms them (see the ID mapping task), and the session directory ties a bot to one process.

Possible next steps that TDLib already offers: turning on its message database for per-chat history and search, and sending files larger than the 50 MB upload limit of the HTTP Bot API. The engine does neither today; it caps documents at 50 MB.

Many modules react to the same updates and screensEvent bus for updates plus per-chat and global hook registries ordered by priority

Answer turns TDLib updates into bus events: do_callback_query for button presses, do_command for text, pre_checkout_query and successful_payment for payments. Cart, favourites, booking, support and the rest subscribe and handle only their own actions, so they do not import each other's handlers.

For UI composition there are two hook registries. Hooks keeps callbacks per chat_id (the current context menu, extra product buttons) sorted by priority, where adding a hook with an existing priority replaces it. GlobalHooks does the same for all chats; the support extension uses it to add a button to product panels it does not own. Per-chat hooks are dropped when the chat is deleted.

Turn plan features on and off per storeExtension list synced from the store; class built from slug; PM2 restart to unload

The list of active extensions (coupons, shipping, support, booking, marketing, b2b and others) is synced into the options table. Extensions.init() converts each slug to a class name and instantiates it before the core modules are built, so their hooks exist first. Core modules check for an extension through optional getters and change behaviour; for example the checkout drops the promocode step when coupons are off.

Disabling an extension cannot unload code that already registered listeners, so resetting the extensions cache restarts the process through PM2. The code comments note this interrupts users who are mid-session.

Let the store side push changes and queries into a running botHTTP /command endpoint; known commands handled directly, the rest routed to modules through the bus

Commander runs a plain http server with /health and /command. Known commands (payment_success, order_is_done, reset_cache, update_product_cache) are handled directly. Anything else is emitted as exec_command; the module that owns it writes is_executed and answer into the shared body object, which the endpoint returns after a short fixed wait.

reset_cache refreshes one settings cache or all of them, with extensions always last because that one restarts the process. Each module's reset_db_cache() re-pulls its JSON blob into the options table and clears its in-memory copy.

Keep one long-running process healthy on a small serverPM2 fork mode, 450 MB heap, restart at 1 GB, 2 to 5 DB connections, timed cache resets

ecosystem.config.cjs runs a single fork instance (cluster mode is commented out; one bot token means one TDLib session), caps the V8 old space, restarts on memory growth and needs 30 s of uptime before a start counts as stable. SIGTERM, SIGINT and SIGQUIT handlers destroy the knex pool before exit, and a timer logs pool usage when there is activity.

In-memory caches such as carts and selected currencies are cleared on timers, and the message ID map is emptied on every screen change.

Chat as an application UI

in the product

What it givesCustomers moved through catalog, cart and checkout like screens in an app, and the chat stayed tidy instead of filling up with old messages.

Problem
A Telegram chat is a stream of messages, but the product needs screens: a catalog page, a cart, a checkout step, each replacing the previous one, with buttons that update in place.
Solution

Track every message per chat, swap screens by deleting the previous set after the new one is drawn, map TDLib's temporary IDs to server IDs for later edits, and keep navigation state in a per-chat context in Redis.

Stack
  • TDLib
  • Redis
  • Telegram reply and inline keyboards
Tasks · 6
Edit a message whose ID changes after sendingMap temporary IDs to server IDs from updateMessageSendSucceeded, per chat in RAM

sendMessage in TDLib returns a temporary local ID; the real ID arrives later in updateMessageSendSucceeded together with old_message_id. The engine stores the ID returned by send() in the chat context (top_message_id, taxonomies_btn_mgs_id and similar) and resolves it through MessageMapping when it needs to edit or delete that message.

The mapping first lived in Redis and was moved to a per-chat map in memory, cleared on every screen change so it stays small. Without it, editing an inline keyboard after a click would target an ID that no longer exists.

Make a group of messages behave like one screenLog sent IDs in Redis; on a new screen move them aside and delete them after drawing

Every new message ID in a private chat is pushed to chat:<id>:messages. make_messages_castling() moves that list to chat:<id>:old_messages and resets the ID mapping, the module draws the new screen, and clear_display() then deletes the old messages. The new content appears before the old disappears, so the chat never goes blank between screens.

A full chat clear was tried and kept in the code only as a warning: on some phones it threw the user out of the bot.

Reply keyboard messages cannot be editedSend a new top message with the updated keyboard, then delete the old one

The persistent bottom menu shows a cart button with a live item count. Telegram does not allow editing the text of a message that carries a reply keyboard, so Cart.redraw_menu() sends a fresh top message and deletes the previous one by its mapped ID, while the bottom summary, an inline message, is edited in place after a short delay.

The current menu is kept as a per-chat context_menu hook. change_menu_buttons_text() rewrites only the button that starts with the cart emoji in the serialized menu, so any screen can refresh the cart count without knowing its own layout.

Store texts are HTML; TDLib wants plain text with entity offsetsOwn converter: decode entities, strip a tag subset, emit textEntity offsets

format_string_before_send() handles strong, em, u, s, code, pre and a href. It finds the nearest opening tag, removes it and its closing tag, and records start and end positions in the cleaned string; those become textEntity offsets in formattedText. Named and numeric HTML entities from WooCommerce are decoded first. A malformed or unclosed tag makes the function return the original text without entities instead of sending broken formatting.

Two layout tricks sit next to it: a run of Braille blank characters pads messages so product cards render at the same width, and a combining overlay character draws a struck-through old price inside button labels, where entities are not allowed.

Typed text can be user input or a localized menu buttonPer-chat flag for awaited input; known button labels always break out of it

Reply keyboard buttons arrive as plain text in the user's language, so routing compares incoming text with labels built by get_menu_command_text() for that chat. When a step waits for free text (search query, SKU, address, promo code, support message), the context holds is_waiting_user_input and the command to resume. Answer saves the text as user_message_text and replays that command, unless the text is a slash command or a navigation label.

Checkout input handlers repeat the check against their own step labels, so pressing Back while an address is expected navigates back instead of saving the word as the address.

Product cards must arrive in order and survive fast pagingWait for the album's own update before its panel; allow one paging action at a time

A product card is a media album followed by a text panel with buttons. print_products_blocks() sends the album, waits up to 10 s for the new_message_coming event of that chat, then pauses before sending the panel, so the panel never lands above its photos. Short fixed pauses before each send and delete spread requests out.

Fast taps on next and previous would otherwise start a second screen swap before the first one has deleted its messages. A per-chat nowdo flag ignores navigation while a screen is being replaced and is released one second after the old messages are deleted.

WooCommerce catalog sync

in the product

What it givesCustomers paged the catalog quickly and saw current prices and stock. Edits in the shop reached the bot on their own, and subscribers heard when items were back.

Problem
The store runs on a WordPress site on another server. The bot must page through the catalog quickly, show current prices and stock, and pick up edits without querying the store on every tap.
Solution

Keep a MySQL copy of products and category links, filled by a paged full import and updated per product when the store pushes a change. Store settings arrive as JSON blobs in an options table with in-memory caches.

Stack
  • MySQL (MyISAM)
  • knex
  • WooCommerce REST via proxy
  • Telegram Web Apps
Tasks · 6
Reach each store's data without holding its REST credentialsAll store calls go to one proxy; domain, endpoint, API key and HMAC pass in headers

get_rest_data() and send_remote_data() post to a single Botoscope proxy URL. The store is named in a client-domain header, the endpoint in another header, and the request is authenticated with the client API key and a pass derived with HMAC-SHA256. File downloads use the same proxy through its fetch-file endpoint.

The engine never needs the store's own REST keys, and the WordPress side only has to answer the proxy. Failures return null and callers treat null as no data, so a store outage degrades to empty lists instead of crashing a screen.

Import a full catalog on demand, not on every restartTruncate, disable keys, pull pages of 100, insert in batches, re-enable keys in finally

Products.reset_db_cache() reads the product count, truncates products and products_vs_terms, runs ALTER TABLE ... DISABLE KEYS on both MyISAM tables and pulls products/{offset}/{limit} in pages of 100. Each page is inserted in batches of 100 rows; an error on one page is logged and the import moves on to the next page. MyISAM has no transactions, so rows already written from a failed page stay. Keys are re-enabled in a finally block, so a failed import still leaves indexed tables. While the import runs, the bot shows the part of the catalog loaded so far.

The import does not run at startup. A comment explains that with a large catalog it could stall the server after every restart, so it runs only on an explicit reset_cache command from the store.

Product payloads differ by type and keep growingColumns for what is filtered or sorted, everything else in a JSON data column

insert_product_batch() destructures the fields the engine queries (price, sale_price, stock flags, type, parent_id, publish_date, menu_order, cart minimum, maximum and step) into columns and serializes the rest of the payload into data. Media lists, downloads, access days or an external product URL can be added on the store side without a schema change. Category IDs go both into a comma list on the row and into products_vs_terms for indexed category lookups.

install.sql runs on every start with CREATE TABLE IF NOT EXISTS, so a new instance provisions its own schema.

Apply single-product edits without a full reimportStore pushes update_product_cache; product, variations and terms re-written from one store response

When a product changes in WooCommerce the store side sends update_product_cache to /command. update_product_cache() fetches products_filter/{id}, which returns the product with its variations, then deletes the product row, rows with that parent_id and its category links, and inserts the new rows. One request carries the product and its variations, so the rows are rewritten once per change.

The plan limit counts only parent products. The full import trims main products per page and always keeps their variations; the incremental path skips a new product once the distinct product count in products_vs_terms reaches the limit.

Stock flags drift and customers want back-in-stock alertsReconcile in-stock flags from quantity every 3 min; notify subscribers every 2 h

For products with managed stock, a timer every three minutes sets is_in_stock from stock_quantity in both directions, so the buy button follows the actual quantity even when a pushed flag was stale. Customers can subscribe to an out-of-stock product or a single variation; stock_notify stores product_id and chat_id. Every two hours the engine finds subscribed products that are back in stock, sends the card to each subscriber again and removes the subscription.

Every catalog query also compares publish_date with the current time, so scheduled products stay hidden until their date.

Faceted filtering does not fit into chat buttonsOpen the store's filter page as a Telegram Web App; it sends matched IDs back

The /filter command and the Filter buttons open filter_page_url as a Web App with the chat_id appended. Filtering runs on the WordPress side. The result returns to the engine as a filtered_products_ids command on /command, and Products prints those IDs as a normal paged catalog in that chat.

This keeps facet counting and attribute logic in one place, on the store, while the chat still shows the results in its own card format.

Cart, variations and checkout

in the product

What it givesThe bot followed the shop's own rules for quantities, variations, bookings and discounts, and the order in the shop matched what the customer saw in the chat.

Problem
Cart and checkout must enforce store rules (quantity steps, minimums, stock, variations, booking slots, discounts) with only buttons and short text replies, and the WooCommerce order has to match what the customer saw.
Solution

Carts live in Redis behind a RAM cache, variations are chosen with a cascading inline picker, and checkout is a step machine pruned by cart contents and enabled extensions. The order is created once and patched as the customer edits.

Stack
  • Redis
  • Node.js
  • moment
  • WooCommerce orders via proxy
Tasks · 6
The cart is read on almost every renderRedis per chat behind a write-through RAM cache; expired and deleted items pruned

Cart.get() serves from a static per-chat map and falls back to Redis (chat:<id>:cart); update() writes both. The cart button label, totals and every product panel read the cart, so the cache saves a Redis round trip each time. Items may carry an expiry, used by booking holds, and are dropped on read once expired. Drawing the cart first removes IDs that no longer exist in the local catalog, so a product deleted in WooCommerce cannot reach checkout.

Quantity buttons respect quantity_step, min_cart_count and managed stock, and a variation without its own values inherits them from the parent product.

Pick a variation by buttons when not every combination existsCascading attribute rows; each row offers only terms that still form a real variation

get_buy_buttons_panel() reads each variation's attribute combination and orders attributes by the store's attribute order. The first row lists every term in use; each following row lists only terms that, together with the choices above, match at least one existing variation. An attribute with three or more options can render as a compact previous / current / next row instead of a grid.

Changing an upper attribute clears the lower ones and snaps to the first matching combination. A product opens on its cheapest variation. The choice is stored per chat and product in the context, and the panel is edited in place on every tap.

Checkout steps depend on what is in the cartStep list pruned per chat: no shipping for digital carts, no payment step when free

The base steps are promo code, shipping address, shipping method, payment method, comment and confirm. Steps of disabled extensions are removed at startup. get_checkout_steps() then inspects the cart, walking grouped products and the variations of their variable children to find any physical item; a cart without one skips both shipping steps. A zero total switches to the gift gate and skips payment choice, a store with one gateway preselects it, and paying in Stars forces the Stars gate.

Required steps block next() until a value is saved, and any step can be reopened in edit mode from the confirm screen and return straight to it.

The store order must follow edits made after it existsCreate the order once at the pay step, keep its ID in context, push each later edit

generate_payment_button() calls create_order only when the context has no order_id yet, then stores the ID. If the customer goes back and changes the payment method, address, promo code or comment, each handler calls orders.edit(order_id, ...) so the WooCommerce order is patched instead of duplicated.

The order carries the Telegram chat_id and bot name, so order history is fetched per chat and a pay-for-me link can locate the order later.

Two bot customers must not book the same time slotIn-memory holds per slot for 600 s, counted against the slot's remaining capacity

Slots come from the store per product and day with capacity and capacity_used, and slots the store marks as reserved are filtered out. When a customer picks a slot, the engine records a hold with an expiry and adds the booking to the cart with the same holding time. is_freezed() counts active holds for that slot, date and key and blocks the slot once holds reach capacity minus used places. Removing the item from the cart releases its hold through the done_remove_from_cart event.

Calendar dates and past-slot filtering use the shop time zone from settings, not the server clock. Holds live in the bot's process memory, so they apply only to customers of this bot and reset on restart; bookings made on the website at the same moment are not held.

Promotions like 'every 3rd item 25% off' need a rule formatSmall formula language: item-count conditions; discount; nth, cheapest or dearest, cap

Marketing campaigns use formulas such as 'c >= 6 & c < 12; 25%; 3n, me, cap=3'. parse_formula() splits them into item-count conditions, a percent or fixed discount, and modifiers (every nth unit, cheapest or most expensive first, a cap). get_applied_amount() expands the cart into one entry per unit of campaign products, applies the formulas in order and marks used units so a later formula cannot discount them again.

Campaign windows are checked in the shop time zone, test-mode campaigns apply only to the admin chat, and the campaign discount is skipped when a coupon already applies. Product-scoped coupons extend to the children of grouped and variable products.

Payments and currencies

in the product

What it givesCustomers saw prices in their own currency and paid by card, crypto or Telegram Stars, or asked a friend to pay. The shop received a paid order.

Problem
Payment starts in the chat, finishes on a gateway page or inside Telegram, and must end as a paid WooCommerce order, across several gateways and display currencies.
Solution

Amounts stay in the store's base currency and are converted only for display and charging. The engine creates gateway sessions itself and polls gateways that have no webhook for the bot.

Stack
  • PayPal (sandbox)
  • Stripe Checkout
  • LiqPay
  • CoinGate
  • CryptoBot
  • Telegram Stars (TDLib invoices)
Tasks · 5
Prices shown in one currency, charged in anotherAmounts kept in base currency, converted at display and at charge, orders normalized back

Cart totals, discounts and shipping are computed in the base currency. format_price() converts to the chat's selected currency with the synced rate and applies symbol position and decimals, rounding up when cents are hidden. generate_payment_button() recounts the total into the selected currency before calling a gateway. Orders fetched from WooCommerce in another currency are divided by their stored order rate, so history and totals match the bot's own arithmetic.

When the store does not allow paying in other currencies, the selection is reset to base at the payment step and the customer is told why.

Accept Telegram Stars as a payment methodStars as an XTR store currency with a rate; TDLib invoice whose payload names the order

Stars are configured as a currency named XTR in the store's currency list, so their rate arrives through the same sync. The amount is the base total times the XTR rate, rounded up. The invoice is sent as inputMessageInvoice with an empty provider token and a base64 JSON payload holding order_id, chat_id, amount and gateway. The pre-checkout query is answered, and on messagePaymentSuccessfulBot the payload is decoded and the store is notified that the order is paid.

Without an XTR currency the Stars option reports an error instead of an invoice.

Crypto gateway with no webhook registered for the botPer-chat polling every 8 s for up to 15 min, cancelled when the cart is reopened

For CryptoBot the engine creates a fiat-denominated invoice with a 900 s expiry and polls getInvoices every 8 s. A Map of timers keyed by chat_id keeps one active poll per customer, and opening the cart cancels it. On paid the order is set to processing and the cart cleared; on expired or cancelled the customer gets a message.

CoinGate goes the other way: its callback URL points to the Botoscope proxy, which forwards the webhook to /command as payment_success, and the order is finished when the webhook reports paid.

Card gateways each need their own session formatStripe and LiqPay sessions built in the engine, opened as Web App or URL; PayPal in sandbox

Each gateway has its own builder: Stripe through a Checkout Session, LiqPay through a base64 data blob signed with SHA-1 over private key, data and private key. The link is sent as a Web App or URL button, optionally preceded by refund, privacy and shipping policy buttons. The PayPal builder (OAuth client credentials, then a payment with return and cancel URLs) posts the payment to the PayPal sandbox endpoint in this code, so it works for testing only.

Offline methods (bank card details, SWIFT, gift, pay-for-me) show translated instructions and a confirm button instead of a link.

Let another Telegram user pay for an orderPay-for-me deep link opens the order in the payer's chat and notifies the buyer

Choosing pay-for-me ends checkout with a t.me link that starts the bot with payforme_<order_id>. When another user opens it, the engine loads the order from the store, applies the order's currency, removes offline and pay-for-me methods and shows the remaining gateways for the order total. After payment the buyer's chat gets a message naming the payer.

Media, files and languages

in the product

What it givesProduct photos and videos opened quickly, bought files arrived right in the chat, and customers read the menu and catalog in their own language.

Problem
Product images and files live on the store, and sending them through Telegram on every view is slow. The bot also downloads URLs that come from store data. Interface and catalog have to read in the customer's language.
Solution

A hashed local media cache, guarded downloads through the proxy, reuse of files already hosted by Telegram, and layered translations: bundled interface strings, owner overrides, and per-field translations on catalog data.

Stack
  • TDLib file API
  • Node streams
  • crypto (MD5, HMAC)
  • JSON language packs
Tasks · 6
Re-downloading product photos for every card viewLocal cache keyed by MD5 of the URL, split into two-hex-char folders, sent as local files

send_media_gallery() hashes each media URL, saves the file under media_files_cache/<first two hex chars>/<hash> on first use and afterwards hands TDLib the local path. Zero-byte files are deleted and skipped, an album is capped at 8 items, and a variation without its own images falls back to the parent product's media.

Links to t.me posts skip the cache and go to TDLib as remote files.

The bot downloads URLs taken from store dataDomain allowlist, blocked extensions, size caps per type, partial files removed

downloadFileToLocal() accepts only http(s) URLs on the client's own domain or Telegram hosts, rejects executable and script extensions in direct links, sends a HEAD request to reject files over the cap (10 MB for images and audio, 50 MB for video and documents), then streams the file through the proxy's fetch-file endpoint with the same cap. Empty, oversized and failed downloads are deleted from disk.

Large product videos are slow to send from the storePost the video in a channel; the bot replies with its Telegram file ID for reuse

Answer watches channel posts. When a video appears in a channel the bot belongs to, the bot replies with the video's remote file ID. A t.me post link or that ID can then be used as product media, and the engine sends it as a remote file, so Telegram serves it from its own storage with no upload per customer.

Deliver paid digital files inside the chatAfter payment send each download through the guarded fetch, cached under a masked name

When the cart held downloadable products and the order is paid online or is free, do_after_pay() sends every download of those products as a document. Files are cached under a name derived from the download ID by shiftUUID(), so the stored name is not the store's ID, and documents over 50 MB are refused. Products with limited access show days left, computed from the order's paid date.

PDF invoices from the store's invoice endpoint go through the same path.

Interface text in 7 languages, editable by the store ownerBundled JSON per language, overlaid by synced owner overrides, falling back to English

Seven language files (en, es, pt, ru, pl, kz, ua) with the same 215 keys load at startup. __() merges the chat's language file with overrides the owner entered in the admin (synced as interface_translations and indexed as s<number>); if a key is still missing it retries with English plus overrides. The chosen language is stored per chat; on first contact it comes from the Telegram client's language_code when that language is enabled, otherwise from the store default.

Because menu buttons are matched by their localized text, the labels for a chat are resolved in parallel with Promise.all on each routing check.

Catalog content and search must work in each languagePer-field translations on products and terms; search matches title or translations

Products, categories, brands, meta fields, shipping and payment methods carry a translations object. translate_to() returns 0 when the chat uses the default language, so the common case skips every lookup; otherwise get_field_translation() picks the translated field and falls back to the original. Title search runs LIKE over both the title column and the stored translations JSON with a 3-character minimum, so a query in any active language finds the product.

Dates in product meta and access periods are formatted with the chat's language as locale.

Numbers

from the product
  • Bot languages 7 English, Spanish, Portuguese, Russian, Polish, Kazakh, Ukrainian.
  • Online payment methods 5 Stripe, LiqPay, CoinGate, CryptoBot and Telegram Stars.
  • Response path push, no polling TDLib keeps one persistent connection to Telegram: every tap reaches the bot at once, with no webhook hop and no polling delay.