From 50b66278f53d778b865c5aa605c3957a97568956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B8=D0=BA=D1=82=D0=BE=D1=80?= <78488229+viktor138irk@users.noreply.github.com> Date: Mon, 18 May 2026 08:55:20 +0900 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=87=D0=B8=D0=B9=20=D0=BA=D0=B0=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=D1=81=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B8=20Telegram=20webhook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/index.php | 291 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 public/index.php diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..004f4d6 --- /dev/null +++ b/public/index.php @@ -0,0 +1,291 @@ + 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 csrf(): string { $_SESSION['csrf'] ??= bin2hex(random_bytes(16)); return $_SESSION['csrf']; } +function checkCsrf(): void { if (($_POST['_csrf'] ?? '') !== ($_SESSION['csrf'] ?? '')) { http_response_code(419); exit('CSRF token mismatch'); } } +function user(): ?array { return $_SESSION['user'] ?? null; } +function requireAuth(): void { if (!user()) redirect('/login'); } + +function layout(string $title, string $content): void { + $u = user(); + echo ''; + echo '' . e($title) . ' · BotShop'; + echo ''; + if ($u) { + echo '

'.e($title).'

'.e($u['email']).'
'.$content.'
'; + } else { + echo $content; + } + echo ''; +} + +function flash(?string $msg = null): ?string { + if ($msg !== null) { $_SESSION['flash'] = $msg; return null; } + $m = $_SESSION['flash'] ?? null; unset($_SESSION['flash']); return $m; +} +function notice(): string { $m = flash(); return $m ? '
'.e($m).'
' : ''; } + +function botOptions(?int $selected = null): string { + $html = ''; + foreach (allRows('SELECT id,name FROM bots ORDER BY id DESC') as $b) { + $html .= ''; + } + return $html; +} + +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=>10]); + $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 { + $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'=>$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 { + $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 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']]); + $pay = "\n\nК оплате: {$order['final_amount']} ₽\n"; + if ($method) { + $pay .= "\nРеквизиты:\n"; + if ($method['bank_name']) $pay .= 'Банк: ' . e($method['bank_name']) . "\n"; + if ($method['card_number']) $pay .= 'Карта: ' . e($method['card_number']) . "\n"; + if ($method['sbp_phone']) $pay .= 'СБП: ' . e($method['sbp_phone']) . "\n"; + if ($method['recipient_name']) $pay .= 'Получатель: ' . e($method['recipient_name']) . "\n"; + } + return "Заказ №{$order['id']}\nГород: ".e($city['name']??'')."\nРайон: ".e($district['name']??'')."\nТовар: ".e($product['name']??'')."\nВариант: ".e($variant['name']??'')."\nЦена: {$order['base_amount']} ₽\nУникальная добавка: +{$order['random_increment']} ₽" . $pay . "\nОплатите точную сумму. После проверки администратор подтвердит заказ."; +} + +function handleWebhook(string $secret): void { + $bot = one('SELECT * FROM bots WHERE webhook_secret=? AND status="active"', [$secret]); + if (!$bot) { http_response_code(404); echo 'bot not found'; return; } + $payload = json_decode(file_get_contents('php://input') ?: '{}', true) ?: []; + 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; + try { + if ($message) { + $chatId = (int)$message['chat']['id']; + $botUser = getBotUser($bot, $message['from'] ?? $message['chat']); + showCities($bot, $chatId, $botUser); + } elseif ($callback) { + 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]); + sendMessage($bot,$chatId,'Выберите район:', buttons($rows,'district')); + } elseif ($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']]); + sendMessage($bot,$chatId,'Выберите товар:', buttons($rows,'product')); + } elseif ($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]); + sendMessage($bot,$chatId,'Выберите вариант:', buttons($rows,'variant')); + } elseif ($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']]]); + } elseif ($action === 'confirm') { + $order = createOrder($bot,$botUser,$data); setState((int)$botUser['id'],'ordered',['order_id'=>$order['id']]); + sendMessage($bot,$chatId, orderText($order)); + } elseif ($action === 'cancel') { + setState((int)$botUser['id'],'start'); showCities($bot,$chatId,$botUser); + } + } + } catch (Throwable $e) { + sendMessage($bot, (int)($chatId ?? 0), 'Ошибка: ' . $e->getMessage()); + } + echo 'ok'; +} + +function installPage(): void { + if (installed()) redirect('/login'); + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $host=$_POST['db_host']??'localhost'; $name=$_POST['db_name']??''; $user=$_POST['db_user']??''; $pass=$_POST['db_pass']??''; + $pdo = new PDO('mysql:host='.$host.';dbname='.$name.';charset=utf8mb4', $user, $pass, [PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]); + $schema = file_get_contents(ROOT.'/database/schema.sql'); $pdo->exec($schema); + $adminName=$_POST['admin_name']?:'Admin'; $email=$_POST['admin_email']; $hash=password_hash($_POST['admin_pass'], PASSWORD_DEFAULT); + $st=$pdo->prepare('INSERT INTO users (name,email,password_hash,role) VALUES (?,?,?,"admin")'); $st->execute([$adminName,$email,$hash]); + if (!is_dir(ROOT.'/storage')) mkdir(ROOT.'/storage',0775,true); if (!is_dir(ROOT.'/storage/logs')) mkdir(ROOT.'/storage/logs',0775,true); + $cfg = "$host,'db_name'=>$name,'db_user'=>$user,'db_pass'=>$pass], true) . ";\n"; + file_put_contents(CONFIG_FILE, $cfg); redirect('/login'); + } + layout('Установка', '

Установка BotShop


'); +} + +function simpleCreate(string $table, array $fields, string $redirect): void { + checkCsrf(); $cols=[];$vals=[];$ph=[]; foreach($fields as $f){$cols[]=$f;$vals[]=$_POST[$f]??null;$ph[]='?';} + q('INSERT INTO '.$table.' ('.implode(',',$cols).') VALUES ('.implode(',',$ph).')',$vals); flash('Сохранено'); redirect($redirect); +} + +function loginPage(): void { + if ($_SERVER['REQUEST_METHOD']==='POST') { $u=one('SELECT * FROM users WHERE email=?',[$_POST['email']??'']); if($u && password_verify($_POST['password']??'', $u['password_hash'])){$_SESSION['user']=['id'=>$u['id'],'email'=>$u['email'],'name'=>$u['name']]; redirect('/');} flash('Неверный логин или пароль'); } + layout('Вход', '

BotShop

'.notice().'
'); +} + +function dashboard(): void { requireAuth(); + $stats = [ + 'Боты'=>one('SELECT COUNT(*) c FROM bots')['c']??0, + 'Пользователи'=>one('SELECT COUNT(*) c FROM bot_users')['c']??0, + 'Ожидают оплату'=>one("SELECT COUNT(*) c FROM shop_orders WHERE status='waiting_pay'")['c']??0, + 'Оплачено'=>one("SELECT COUNT(*) c FROM shop_orders WHERE status='paid'")['c']??0, + ]; + $html=notice().'
'; foreach($stats as $k=>$v)$html.='
'.e($k).'
'.e((string)$v).'
'; $html.='
BotShop v'.APP_VERSION.'

MVP мультибот-магазина без отправки чеков. Оплата подтверждается оператором в панели.

'; layout('Дашборд',$html); +} + +function botsPage(): void { requireAuth(); + if ($_SERVER['REQUEST_METHOD']==='POST') { + checkCsrf(); $token=trim($_POST['token']??''); $secret=bin2hex(random_bytes(16)); + $bot=['token'=>$token]; $me=tgRequest($bot,'getMe'); $username=$me['result']['username']??null; + q('INSERT INTO bots (name,username,token,webhook_secret,status,welcome_text,pay_text,min_add,max_add,order_ttl_minutes) VALUES (?,?,?,?,?,?,?,?,?,?)', [$_POST['name'],$username,$token,$secret,$_POST['status']??'paused',$_POST['welcome_text']??null,$_POST['pay_text']??null,(int)$_POST['min_add'],(int)$_POST['max_add'],(int)$_POST['order_ttl_minutes']]); + flash('Бот добавлен'); redirect('/bots'); + } + if (isset($_GET['setwebhook'])) { $b=one('SELECT * FROM bots WHERE id=?',[(int)$_GET['setwebhook']]); if($b){$url=(isset($_SERVER['HTTPS'])?'https':'http').'://'.$_SERVER['HTTP_HOST'].'/webhook/'.$b['webhook_secret']; $r=tgRequest($b,'setWebhook',['url'=>$url]); flash(($r['ok']??false)?'Webhook установлен: '.$url:'Ошибка webhook: '.($r['description']??'unknown'));} redirect('/bots'); } + $rows=allRows('SELECT * FROM bots ORDER BY id DESC'); $html=notice().'

Добавить бота

Боты

'; foreach($rows as $r){$html.=''; } $html.='
IDНазваниеUsernameСтатусWebhook
'.$r['id'].''.e($r['name']).'@'.e($r['username']).''.e($r['status']).'Установить webhook
'; layout('Боты',$html); +} + +function listPage(string $title, string $table, array $fields, array $labels): void { requireAuth(); if($_SERVER['REQUEST_METHOD']==='POST') simpleCreate($table,$fields,path()); + $rows=allRows('SELECT * FROM '.$table.' ORDER BY id DESC LIMIT 200'); $html=notice().'

Добавить

'; + foreach($fields as $f){ if($f==='bot_id')$html.=''; elseif($f==='city_id'){$cities=allRows('SELECT id, CONCAT(name," #",id) name FROM shop_cities ORDER BY id DESC');$html.='';} elseif($f==='product_id'){$ps=allRows('SELECT id, CONCAT(name," #",id) name FROM shop_products ORDER BY id DESC');$html.='';} elseif(str_contains($f,'is_'))$html.='
'; elseif($f==='description')$html.=''; elseif(in_array($f,['price','sort_order','stock','min_amount','max_amount','daily_limit'],true))$html.=''; else $html.=''; } + $html.='

'.e($title).'

'; foreach($labels as $l)$html.=''; $html.=''; foreach($rows as $r){$html.=''; foreach(array_keys($labels) as $k)$html.=''; $html.=''; } $html.='
'.e($l).'
'.e((string)($r[$k]??'')).'
'; layout($title,$html); +} + +function ordersPage(): void { requireAuth(); + if(isset($_GET['paid'])||isset($_GET['reject'])){ $id=(int)($_GET['paid']??$_GET['reject']); $status=isset($_GET['paid'])?'paid':'rejected'; q('UPDATE shop_orders SET status=?, paid_at=IF(?="paid",NOW(),paid_at) WHERE id=?',[$status,$status,$id]); $o=one('SELECT * FROM shop_orders WHERE id=?',[$id]); if($o){$bot=one('SELECT * FROM bots WHERE id=?',[$o['bot_id']]); if($bot) sendMessage($bot,(int)$o['telegram_id'],$status==='paid'?'Оплата подтверждена. Заказ №'.$id.' принят.':'Заказ №'.$id.' отклонён.');} flash('Статус обновлён'); redirect('/orders'); } + q("UPDATE shop_orders SET status='expired' WHERE status='waiting_pay' AND expires_at'; foreach($rows as $r){$html.=''; } $html.='
IDБотTGСуммаСтатусСозданДействия
'.$r['id'].''.e($r['bot_name']).''.$r['telegram_id'].''.$r['final_amount'].' ₽
'.$r['base_amount'].' + '.$r['random_increment'].'
'.e($r['status']).''.$r['created_at'].''; if($r['status']==='waiting_pay'){$html.='ОплаченоОтклонить';} $html.='
'; layout('Заказы',$html); +} + +function logsPage(): void { requireAuth(); $rows=allRows('SELECT * FROM telegram_updates ORDER BY id DESC LIMIT 100'); $html='
'; foreach($rows as $r)$html.=''; $html.='
IDБотUpdateДата
'.$r['id'].''.$r['bot_id'].''.$r['update_id'].''.$r['created_at'].'
'; layout('Логи',$html); } + +if (!installed() && path() !== '/install') redirect('/install'); +$p = path(); +if ($p === '/install') installPage(); +elseif (preg_match('#^/webhook/([a-f0-9]+)$#', $p, $m)) handleWebhook($m[1]); +elseif ($p === '/login') loginPage(); +elseif ($p === '/logout') { session_destroy(); redirect('/login'); } +elseif ($p === '/') dashboard(); +elseif ($p === '/bots') botsPage(); +elseif ($p === '/cities') listPage('Города','shop_cities',['bot_id','name','sort_order','is_active'],['id'=>'ID','bot_id'=>'Бот','name'=>'Название','sort_order'=>'Сортировка','is_active'=>'Активен']); +elseif ($p === '/districts') listPage('Районы','shop_districts',['bot_id','city_id','name','sort_order','is_active'],['id'=>'ID','bot_id'=>'Бот','city_id'=>'Город','name'=>'Название','sort_order'=>'Сортировка','is_active'=>'Активен']); +elseif ($p === '/products') listPage('Товары','shop_products',['bot_id','name','description','sort_order','is_active'],['id'=>'ID','bot_id'=>'Бот','name'=>'Название','description'=>'Описание','sort_order'=>'Сортировка','is_active'=>'Активен']); +elseif ($p === '/variants') listPage('Варианты','shop_product_variants',['bot_id','product_id','name','price','stock','sort_order','is_active'],['id'=>'ID','bot_id'=>'Бот','product_id'=>'Товар','name'=>'Название','price'=>'Цена','stock'=>'Остаток','sort_order'=>'Сортировка','is_active'=>'Активен']); +elseif ($p === '/payments') listPage('Реквизиты','shop_payment_methods',['bot_id','title','type','bank_name','card_number','recipient_name','sbp_phone','min_amount','max_amount','daily_limit','is_online','is_active'],['id'=>'ID','bot_id'=>'Бот','title'=>'Название','type'=>'Тип','bank_name'=>'Банк','card_number'=>'Карта','recipient_name'=>'Получатель','sbp_phone'=>'СБП','min_amount'=>'Мин','max_amount'=>'Макс','daily_limit'=>'Лимит','is_online'=>'Онлайн','is_active'=>'Активен']); +elseif ($p === '/orders') ordersPage(); +elseif ($p === '/logs') logsPage(); +else { http_response_code(404); echo '404'; }