diff --git a/scripts/polling.php b/scripts/polling.php
new file mode 100644
index 0000000..f332649
--- /dev/null
+++ b/scripts/polling.php
@@ -0,0 +1,389 @@
+ PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ ]);
+
+ return $pdo;
+}
+
+function q(string $sql, array $params = []): PDOStatement
+{
+ $st = db()->prepare($sql);
+ $st->execute($params);
+ return $st;
+}
+
+function one(string $sql, array $params = []): ?array
+{
+ $row = q($sql, $params)->fetch();
+ return $row ?: null;
+}
+
+function allRows(string $sql, array $params = []): array
+{
+ return q($sql, $params)->fetchAll();
+}
+
+function tgRequest(array $bot, string $method, array $payload = []): array
+{
+ $url = 'https://api.telegram.org/bot' . $bot['token'] . '/' . $method;
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => $payload,
+ CURLOPT_TIMEOUT => 35,
+ ]);
+ $raw = curl_exec($ch);
+ if ($raw === false) {
+ return ['ok' => false, 'description' => curl_error($ch)];
+ }
+ $json = json_decode($raw, true);
+ return is_array($json) ? $json : ['ok' => false, 'description' => 'Bad Telegram response'];
+}
+
+function sendMessage(array $bot, int $chatId, string $text, array $keyboard = []): void
+{
+ if ($chatId <= 0) {
+ return;
+ }
+ $payload = [
+ 'chat_id' => $chatId,
+ 'text' => $text,
+ 'parse_mode' => 'HTML',
+ ];
+ if ($keyboard) {
+ $payload['reply_markup'] = json_encode(['inline_keyboard' => $keyboard], JSON_UNESCAPED_UNICODE);
+ }
+ tgRequest($bot, 'sendMessage', $payload);
+}
+
+function answerCallback(array $bot, string $callbackId): void
+{
+ tgRequest($bot, 'answerCallbackQuery', ['callback_query_id' => $callbackId]);
+}
+
+function buttons(array $rows, string $prefix, string $labelField = 'name'): array
+{
+ $kb = [];
+ foreach ($rows as $r) {
+ $kb[] = [[
+ 'text' => (string)$r[$labelField],
+ 'callback_data' => $prefix . ':' . $r['id'],
+ ]];
+ }
+ return $kb;
+}
+
+function getBotUser(array $bot, array $from): array
+{
+ $tgId = (int)$from['id'];
+ $row = one('SELECT * FROM bot_users WHERE bot_id=? AND telegram_id=?', [$bot['id'], $tgId]);
+ if (!$row) {
+ q(
+ 'INSERT INTO bot_users (bot_id,telegram_id,username,first_name,last_name,last_seen_at) VALUES (?,?,?,?,?,NOW())',
+ [$bot['id'], $tgId, $from['username'] ?? null, $from['first_name'] ?? null, $from['last_name'] ?? null]
+ );
+ $row = one('SELECT * FROM bot_users WHERE bot_id=? AND telegram_id=?', [$bot['id'], $tgId]);
+ } else {
+ q(
+ 'UPDATE bot_users SET username=?, first_name=?, last_name=?, last_seen_at=NOW() WHERE id=?',
+ [$from['username'] ?? null, $from['first_name'] ?? null, $from['last_name'] ?? null, $row['id']]
+ );
+ }
+ return $row;
+}
+
+function setState(int $botUserId, string $state, array $data = []): void
+{
+ q('UPDATE bot_users SET state=?, state_data=? WHERE id=?', [
+ $state,
+ json_encode($data, JSON_UNESCAPED_UNICODE),
+ $botUserId,
+ ]);
+}
+
+function stateData(array $botUser): array
+{
+ $d = json_decode((string)($botUser['state_data'] ?? ''), true);
+ return is_array($d) ? $d : [];
+}
+
+function showCities(array $bot, int $chatId, array $botUser): void
+{
+ $rows = allRows('SELECT * FROM shop_cities WHERE bot_id=? AND is_active=1 ORDER BY sort_order,name', [$bot['id']]);
+ if (!$rows) {
+ sendMessage($bot, $chatId, 'Пока нет доступных городов. Добавьте город в админ-панели.');
+ return;
+ }
+ setState((int)$botUser['id'], 'choose_city');
+ sendMessage($bot, $chatId, $bot['welcome_text'] ?: 'Выберите город:', buttons($rows, 'city'));
+}
+
+function generateAmount(int $botId, int $base, int $min, int $max): array
+{
+ $busy = allRows("SELECT final_amount FROM shop_orders WHERE bot_id=? AND status='waiting_pay' AND expires_at>NOW()", [$botId]);
+ $busyMap = array_flip(array_map(fn($r) => (int)$r['final_amount'], $busy));
+
+ for ($i = 0; $i < 100; $i++) {
+ $add = random_int($min, $max);
+ $final = $base + $add;
+ if (!isset($busyMap[$final])) {
+ return [$add, $final];
+ }
+ }
+
+ throw new RuntimeException('Нет свободной уникальной суммы. Попробуйте позже.');
+}
+
+function createOrder(array $bot, array $botUser, array $data): array
+{
+ foreach (['city_id', 'district_id', 'product_id', 'variant_id'] as $key) {
+ if (empty($data[$key])) {
+ throw new RuntimeException('Неполные данные заказа. Начните заново через /start.');
+ }
+ }
+
+ $variant = one('SELECT * FROM shop_product_variants WHERE id=? AND bot_id=? AND is_active=1', [$data['variant_id'], $bot['id']]);
+ if (!$variant) {
+ throw new RuntimeException('Вариант товара не найден.');
+ }
+
+ $method = one('SELECT * FROM shop_payment_methods WHERE bot_id=? AND is_active=1 AND is_online=1 ORDER BY id ASC LIMIT 1', [$bot['id']]);
+ if (!$method) {
+ throw new RuntimeException('Нет активных реквизитов оплаты.');
+ }
+
+ [$add, $final] = generateAmount((int)$bot['id'], (int)$variant['price'], (int)$bot['min_add'], (int)$bot['max_add']);
+ $ttl = max(5, (int)$bot['order_ttl_minutes']);
+
+ q(
+ 'INSERT INTO shop_orders (bot_id,bot_user_id,telegram_id,city_id,district_id,product_id,variant_id,payment_method_id,base_amount,random_increment,final_amount,expires_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,DATE_ADD(NOW(), INTERVAL ' . $ttl . ' MINUTE))',
+ [$bot['id'], $botUser['id'], $botUser['telegram_id'], $data['city_id'], $data['district_id'], $data['product_id'], $data['variant_id'], $method['id'], $variant['price'], $add, $final]
+ );
+
+ return one('SELECT * FROM shop_orders WHERE id=?', [(int)db()->lastInsertId()]);
+}
+
+function textSafe(?string $value): string
+{
+ return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+}
+
+function orderText(array $order): string
+{
+ $city = one('SELECT name FROM shop_cities WHERE id=?', [$order['city_id']]);
+ $district = one('SELECT name FROM shop_districts WHERE id=?', [$order['district_id']]);
+ $product = one('SELECT name FROM shop_products WHERE id=?', [$order['product_id']]);
+ $variant = one('SELECT name FROM shop_product_variants WHERE id=?', [$order['variant_id']]);
+ $method = one('SELECT * FROM shop_payment_methods WHERE id=?', [$order['payment_method_id']]);
+
+ $text = "Заказ №{$order['id']}\n";
+ $text .= 'Город: ' . textSafe($city['name'] ?? '') . "\n";
+ $text .= 'Район: ' . textSafe($district['name'] ?? '') . "\n";
+ $text .= 'Товар: ' . textSafe($product['name'] ?? '') . "\n";
+ $text .= 'Вариант: ' . textSafe($variant['name'] ?? '') . "\n";
+ $text .= "Цена: {$order['base_amount']} ₽\n";
+ $text .= "Уникальная добавка: +{$order['random_increment']} ₽\n\n";
+ $text .= "К оплате: {$order['final_amount']} ₽\n\n";
+
+ if ($method) {
+ $text .= "Реквизиты:\n";
+ if ($method['bank_name']) {
+ $text .= 'Банк: ' . textSafe($method['bank_name']) . "\n";
+ }
+ if ($method['card_number']) {
+ $text .= 'Карта: ' . textSafe($method['card_number']) . "\n";
+ }
+ if ($method['sbp_phone']) {
+ $text .= 'СБП: ' . textSafe($method['sbp_phone']) . "\n";
+ }
+ if ($method['recipient_name']) {
+ $text .= 'Получатель: ' . textSafe($method['recipient_name']) . "\n";
+ }
+ }
+
+ $text .= "\nОплатите точную сумму. После проверки администратор подтвердит заказ.";
+ return $text;
+}
+
+function processUpdate(array $bot, array $payload): void
+{
+ q('INSERT INTO telegram_updates (bot_id, update_id, payload) VALUES (?,?,?)', [
+ $bot['id'],
+ $payload['update_id'] ?? null,
+ json_encode($payload, JSON_UNESCAPED_UNICODE),
+ ]);
+
+ $message = $payload['message'] ?? null;
+ $callback = $payload['callback_query'] ?? null;
+ $chatId = 0;
+
+ try {
+ if ($message) {
+ $chatId = (int)$message['chat']['id'];
+ $botUser = getBotUser($bot, $message['from'] ?? $message['chat']);
+ showCities($bot, $chatId, $botUser);
+ return;
+ }
+
+ if (!$callback) {
+ return;
+ }
+
+ answerCallback($bot, $callback['id']);
+ $chatId = (int)$callback['message']['chat']['id'];
+ $botUser = getBotUser($bot, $callback['from']);
+ [$action, $id] = array_pad(explode(':', (string)$callback['data'], 2), 2, null);
+ $data = stateData($botUser);
+
+ if ($action === 'city') {
+ $data = ['city_id' => (int)$id];
+ setState((int)$botUser['id'], 'choose_district', $data);
+ $rows = allRows('SELECT * FROM shop_districts WHERE bot_id=? AND city_id=? AND is_active=1 ORDER BY sort_order,name', [$bot['id'], $id]);
+ if (!$rows) {
+ sendMessage($bot, $chatId, 'Для этого города пока нет районов.');
+ return;
+ }
+ sendMessage($bot, $chatId, 'Выберите район:', buttons($rows, 'district'));
+ return;
+ }
+
+ if ($action === 'district') {
+ $data['district_id'] = (int)$id;
+ setState((int)$botUser['id'], 'choose_product', $data);
+ $rows = allRows('SELECT * FROM shop_products WHERE bot_id=? AND is_active=1 ORDER BY sort_order,name', [$bot['id']]);
+ if (!$rows) {
+ sendMessage($bot, $chatId, 'Пока нет доступных товаров.');
+ return;
+ }
+ sendMessage($bot, $chatId, 'Выберите товар:', buttons($rows, 'product'));
+ return;
+ }
+
+ if ($action === 'product') {
+ $data['product_id'] = (int)$id;
+ setState((int)$botUser['id'], 'choose_variant', $data);
+ $rows = allRows('SELECT id, CONCAT(name, " — ", price, " ₽") AS name FROM shop_product_variants WHERE bot_id=? AND product_id=? AND is_active=1 ORDER BY sort_order,name', [$bot['id'], $id]);
+ if (!$rows) {
+ sendMessage($bot, $chatId, 'Для этого товара пока нет вариантов.');
+ return;
+ }
+ sendMessage($bot, $chatId, 'Выберите вариант:', buttons($rows, 'variant'));
+ return;
+ }
+
+ if ($action === 'variant') {
+ $data['variant_id'] = (int)$id;
+ setState((int)$botUser['id'], 'confirm', $data);
+ sendMessage($bot, $chatId, 'Подтвердить заказ?', [
+ [['text' => 'Подтвердить', 'callback_data' => 'confirm:1']],
+ [['text' => 'Отмена', 'callback_data' => 'cancel:1']],
+ ]);
+ return;
+ }
+
+ if ($action === 'confirm') {
+ $order = createOrder($bot, $botUser, $data);
+ setState((int)$botUser['id'], 'ordered', ['order_id' => $order['id']]);
+ sendMessage($bot, $chatId, orderText($order));
+ return;
+ }
+
+ if ($action === 'cancel') {
+ setState((int)$botUser['id'], 'start');
+ showCities($bot, $chatId, $botUser);
+ }
+ } catch (Throwable $e) {
+ sendMessage($bot, $chatId, 'Ошибка: ' . $e->getMessage());
+ fwrite(STDERR, '[' . date('c') . '] Bot #' . $bot['id'] . ': ' . $e->getMessage() . PHP_EOL);
+ }
+}
+
+function loadOffsets(): array
+{
+ if (!is_file(OFFSET_FILE)) {
+ return [];
+ }
+ $json = json_decode((string)file_get_contents(OFFSET_FILE), true);
+ return is_array($json) ? $json : [];
+}
+
+function saveOffsets(array $offsets): void
+{
+ file_put_contents(OFFSET_FILE, json_encode($offsets, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
+}
+
+fwrite(STDOUT, '[' . date('c') . "] BotShop polling started\n");
+$offsets = loadOffsets();
+$webhookDeleted = [];
+
+while (true) {
+ try {
+ q("UPDATE shop_orders SET status='expired' WHERE status='waiting_pay' AND expires_at 'false']);
+ $webhookDeleted[$botId] = true;
+ }
+
+ $payload = [
+ 'timeout' => 10,
+ 'limit' => 30,
+ 'allowed_updates' => json_encode(['message', 'callback_query']),
+ ];
+ if (isset($offsets[$botId])) {
+ $payload['offset'] = (int)$offsets[$botId];
+ }
+
+ $response = tgRequest($bot, 'getUpdates', $payload);
+ if (!($response['ok'] ?? false)) {
+ fwrite(STDERR, '[' . date('c') . '] getUpdates error for bot #' . $botId . ': ' . ($response['description'] ?? 'unknown') . PHP_EOL);
+ continue;
+ }
+
+ foreach (($response['result'] ?? []) as $update) {
+ processUpdate($bot, $update);
+ if (isset($update['update_id'])) {
+ $offsets[$botId] = ((int)$update['update_id']) + 1;
+ }
+ }
+
+ saveOffsets($offsets);
+ }
+
+ if (!$bots) {
+ sleep(3);
+ }
+ } catch (Throwable $e) {
+ fwrite(STDERR, '[' . date('c') . '] polling loop error: ' . $e->getMessage() . PHP_EOL);
+ sleep(5);
+ }
+}