Добавил polling-воркер для локальной работы Telegram-ботов
Этот коммит содержится в:
@@ -0,0 +1,389 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Ссылка в новой задаче
Block a user