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