390 строки
14 KiB
PHP
390 строки
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
const ROOT = __DIR__ . '/..';
|
|
const CONFIG_FILE = ROOT . '/storage/config.php';
|
|
const OFFSET_FILE = ROOT . '/storage/polling_offsets.json';
|
|
|
|
if (!is_file(CONFIG_FILE)) {
|
|
fwrite(STDERR, "BotShop is not installed: storage/config.php not found\n");
|
|
exit(1);
|
|
}
|
|
|
|
$config = require CONFIG_FILE;
|
|
|
|
function db(): PDO
|
|
{
|
|
global $config;
|
|
static $pdo = null;
|
|
if ($pdo instanceof PDO) {
|
|
return $pdo;
|
|
}
|
|
|
|
$dsn = 'mysql:host=' . $config['db_host'] . ';dbname=' . $config['db_name'] . ';charset=utf8mb4';
|
|
$pdo = new PDO($dsn, $config['db_user'], $config['db_pass'], [
|
|
PDO::ATTR_ERRMODE => 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 = "<b>Заказ №{$order['id']}</b>\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 .= "<b>К оплате:</b> {$order['final_amount']} ₽\n\n";
|
|
|
|
if ($method) {
|
|
$text .= "<b>Реквизиты:</b>\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<NOW()");
|
|
$bots = allRows("SELECT * FROM bots WHERE status='active' ORDER BY id ASC");
|
|
|
|
foreach ($bots as $bot) {
|
|
$botId = (string)$bot['id'];
|
|
if (empty($webhookDeleted[$botId])) {
|
|
tgRequest($bot, 'deleteWebhook', ['drop_pending_updates' => '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);
|
|
}
|
|
}
|