' : ''; }
+
+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().'
'; layout('Заказы',$html);
+}
+
+function logsPage(): void { requireAuth(); $rows=allRows('SELECT * FROM telegram_updates ORDER BY id DESC LIMIT 100'); $html='