--- YNO_Telegram_Bot_Single_File_v1.3/bot.php 2026-07-25 23:24:36.513555790 +0000 +++ YNO_Telegram_Bot_Single_File_v1.4/bot.php 2026-07-25 23:31:23.797570518 +0000 @@ -25,11 +25,11 @@ const DB_FILE = __DIR__ . '/yno_bot.sqlite'; const API_BASE = 'https://api.telegram.org/bot'; -const APP_VERSION = '1.3.0'; +const APP_VERSION = '1.4.0'; ini_set('display_errors', '0'); ini_set('log_errors', '0'); -set_time_limit(25); +set_time_limit(120); ignore_user_abort(true); date_default_timezone_set('Asia/Tehran'); @@ -168,6 +168,11 @@ source_message_id INTEGER NOT NULL, media_group_id TEXT NULL, batch_token TEXT NULL, + media_type TEXT NULL, + media_file_id TEXT NULL, + caption TEXT NULL, + collage_file_id TEXT NULL, + collage_hash TEXT NULL, added_by TEXT NOT NULL, added_at INTEGER NOT NULL, active INTEGER NOT NULL DEFAULT 1, @@ -219,6 +224,17 @@ if (!in_array('batch_token', $inventoryColumnNames, true)) { $pdo->exec('ALTER TABLE inventory ADD COLUMN batch_token TEXT NULL'); } + foreach ([ + 'media_type' => 'TEXT NULL', + 'media_file_id' => 'TEXT NULL', + 'caption' => 'TEXT NULL', + 'collage_file_id' => 'TEXT NULL', + 'collage_hash' => 'TEXT NULL', + ] as $columnName => $columnDefinition) { + if (!in_array($columnName, $inventoryColumnNames, true)) { + $pdo->exec("ALTER TABLE inventory ADD COLUMN {$columnName} {$columnDefinition}"); + } + } $pdo->exec('CREATE INDEX IF NOT EXISTS idx_inventory_batch ON inventory(batch_token, id)'); $defaults = [ @@ -438,6 +454,397 @@ return $copiedIds !== [] ? $copiedIds : null; } +function sendPhotoToResult(string $chatId, mixed $photo, string $caption = '', ?array $inlineKeyboardRows = null): array +{ + $params = [ + 'chat_id' => $chatId, + 'photo' => $photo, + ]; + $caption = normalizeTelegramCaption($caption); + if ($caption !== '') { + $params['caption'] = $caption; + } + if ($inlineKeyboardRows !== null && $inlineKeyboardRows !== []) { + $params['reply_markup'] = inlineKeyboard($inlineKeyboardRows); + } + + $result = tg('sendPhoto', $params); + if (($result['ok'] ?? false) !== true && (int)($result['error_code'] ?? 0) === 403) { + markUserBlocked($chatId); + } + return $result; +} + +function normalizeTelegramCaption(string $caption): string +{ + $caption = trim($caption); + if ($caption === '') { + return ''; + } + if (mb_strlen($caption) <= 1024) { + return $caption; + } + return rtrim(mb_substr($caption, 0, 1020)) . '…'; +} + +function inventoryPhotoFileId(array $message): ?string +{ + if (!isset($message['photo']) || !is_array($message['photo']) || $message['photo'] === []) { + return null; + } + $largest = end($message['photo']); + if (!is_array($largest) || empty($largest['file_id'])) { + return null; + } + return (string)$largest['file_id']; +} + +function downloadTelegramFileToTemp(string $fileId): ?string +{ + $fileInfo = tg('getFile', ['file_id' => $fileId]); + $filePath = (string)($fileInfo['result']['file_path'] ?? ''); + if (($fileInfo['ok'] ?? false) !== true || $filePath === '') { + logError('inventory:collage_get_file_failed', (string)($fileInfo['description'] ?? 'getFile failed'), [ + 'file_id_hash' => hash('sha256', $fileId), + 'error_code' => $fileInfo['error_code'] ?? null, + ]); + return null; + } + + $tempPath = tempnam(sys_get_temp_dir(), 'yno_img_'); + if ($tempPath === false) { + logError('inventory:collage_temp_failed', 'Could not allocate temporary image file'); + return null; + } + + $encodedPath = implode('/', array_map('rawurlencode', explode('/', $filePath))); + $url = 'https://api.telegram.org/file/bot' . BOT_TOKEN . '/' . $encodedPath; + $handle = fopen($tempPath, 'wb'); + if ($handle === false) { + @unlink($tempPath); + return null; + } + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_FILE => $handle, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 25, + CURLOPT_HTTPHEADER => ['Expect:'], + CURLOPT_USERAGENT => 'YNO-PHP-Bot/' . APP_VERSION, + ]); + $ok = curl_exec($ch); + $curlError = curl_error($ch); + $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + fclose($handle); + + if ($ok !== true || $httpCode < 200 || $httpCode >= 300 || !is_file($tempPath) || filesize($tempPath) < 100) { + logError('inventory:collage_download_failed', $curlError !== '' ? $curlError : 'Telegram file download failed', [ + 'http_code' => $httpCode, + 'file_path' => $filePath, + ]); + @unlink($tempPath); + return null; + } + + return $tempPath; +} + +function inventoryCollageLayout(int $count): array +{ + $count = max(1, min(10, $count)); + $gap = 10; + + if ($count === 2) { + return [1200, 900, [ + [$gap, $gap, 585, 880], + [605, $gap, 585, 880], + ]]; + } + + if ($count === 3) { + return [1200, 1200, [ + [$gap, $gap, 1180, 700], + [$gap, 720, 585, 470], + [605, 720, 585, 470], + ]]; + } + + if ($count === 4) { + return [1200, 1200, [ + [$gap, $gap, 585, 585], + [605, $gap, 585, 585], + [$gap, 605, 585, 585], + [605, 605, 585, 585], + ]]; + } + + $columns = $count <= 6 ? 3 : ($count <= 8 ? 4 : 5); + $rows = (int)ceil($count / $columns); + $canvasWidth = 1200; + $canvasHeight = max(800, min(1500, $rows * 430)); + $cellWidth = $canvasWidth / $columns; + $cellHeight = $canvasHeight / $rows; + $tiles = []; + for ($index = 0; $index < $count; $index++) { + $column = $index % $columns; + $row = intdiv($index, $columns); + $x = (int)round($column * $cellWidth) + $gap; + $y = (int)round($row * $cellHeight) + $gap; + $width = (int)round($cellWidth) - ($gap * 2); + $height = (int)round($cellHeight) - ($gap * 2); + $tiles[] = [$x, $y, max(1, $width), max(1, $height)]; + } + return [$canvasWidth, $canvasHeight, $tiles]; +} + +function buildInventoryCollage(array $imagePaths): ?string +{ + $imagePaths = array_values(array_filter($imagePaths, static fn(string $path): bool => is_file($path))); + if (count($imagePaths) < 2) { + return null; + } + $imagePaths = array_slice($imagePaths, 0, 10); + [$canvasWidth, $canvasHeight, $tiles] = inventoryCollageLayout(count($imagePaths)); + + $outputPath = tempnam(sys_get_temp_dir(), 'yno_collage_'); + if ($outputPath === false) { + return null; + } + + if (class_exists('Imagick')) { + try { + $canvas = new Imagick(); + $canvas->newImage($canvasWidth, $canvasHeight, new ImagickPixel('white'), 'jpeg'); + foreach ($imagePaths as $index => $imagePath) { + [$x, $y, $width, $height] = $tiles[$index]; + $image = new Imagick($imagePath); + if ($image->getNumberImages() > 1) { + $image->setIteratorIndex(0); + } + if (method_exists($image, 'autoOrientImage')) { + $image->autoOrientImage(); + } + $image->setImageBackgroundColor('white'); + $image->cropThumbnailImage($width, $height); + $image->setImagePage(0, 0, 0, 0); + $canvas->compositeImage($image, Imagick::COMPOSITE_OVER, $x, $y); + $image->clear(); + $image->destroy(); + } + $canvas->setImageFormat('jpeg'); + $canvas->setImageCompression(Imagick::COMPRESSION_JPEG); + $canvas->setImageCompressionQuality(88); + $canvas->stripImage(); + $written = $canvas->writeImage($outputPath); + $canvas->clear(); + $canvas->destroy(); + if ($written && is_file($outputPath) && filesize($outputPath) > 1000) { + return $outputPath; + } + } catch (Throwable $e) { + logError('inventory:collage_imagick_failed', $e->getMessage()); + } + @unlink($outputPath); + $outputPath = tempnam(sys_get_temp_dir(), 'yno_collage_'); + if ($outputPath === false) { + return null; + } + } + + if (extension_loaded('gd') && function_exists('imagecreatetruecolor') && function_exists('imagecreatefromstring')) { + $canvas = imagecreatetruecolor($canvasWidth, $canvasHeight); + if ($canvas === false) { + @unlink($outputPath); + return null; + } + $white = imagecolorallocate($canvas, 255, 255, 255); + imagefilledrectangle($canvas, 0, 0, $canvasWidth, $canvasHeight, $white); + + foreach ($imagePaths as $index => $imagePath) { + $bytes = @file_get_contents($imagePath); + $source = is_string($bytes) ? @imagecreatefromstring($bytes) : false; + if ($source === false) { + imagedestroy($canvas); + @unlink($outputPath); + return null; + } + + [$x, $y, $targetWidth, $targetHeight] = $tiles[$index]; + $sourceWidth = imagesx($source); + $sourceHeight = imagesy($source); + $sourceRatio = $sourceWidth / max(1, $sourceHeight); + $targetRatio = $targetWidth / max(1, $targetHeight); + if ($sourceRatio > $targetRatio) { + $cropHeight = $sourceHeight; + $cropWidth = (int)round($sourceHeight * $targetRatio); + $sourceX = (int)max(0, floor(($sourceWidth - $cropWidth) / 2)); + $sourceY = 0; + } else { + $cropWidth = $sourceWidth; + $cropHeight = (int)round($sourceWidth / max(0.0001, $targetRatio)); + $sourceX = 0; + $sourceY = (int)max(0, floor(($sourceHeight - $cropHeight) / 2)); + } + imagecopyresampled( + $canvas, + $source, + $x, + $y, + $sourceX, + $sourceY, + $targetWidth, + $targetHeight, + max(1, $cropWidth), + max(1, $cropHeight) + ); + imagedestroy($source); + } + + $written = imagejpeg($canvas, $outputPath, 88); + imagedestroy($canvas); + if ($written && is_file($outputPath) && filesize($outputPath) > 1000) { + return $outputPath; + } + } + + @unlink($outputPath); + return null; +} + +function inventoryCollageCaption(array $rows): string +{ + foreach ($rows as $row) { + $caption = trim((string)($row['caption'] ?? '')); + if ($caption !== '') { + return normalizeTelegramCaption($caption); + } + } + return ''; +} + +function inventoryCollageHash(array $rows): string +{ + $fileIds = array_map(static fn(array $row): string => (string)($row['media_file_id'] ?? ''), $rows); + return hash('sha256', 'yno-collage-v1|' . implode('|', $fileIds)); +} + +function updateInventoryCollageCache(int $inventoryId, string $fileId, string $hash): void +{ + $stmt = db()->prepare('UPDATE inventory SET collage_file_id = :file_id, collage_hash = :hash WHERE id = :id'); + $stmt->execute([ + ':file_id' => $fileId !== '' ? $fileId : null, + ':hash' => $hash !== '' ? $hash : null, + ':id' => $inventoryId, + ]); +} + +function sentPhotoFileId(array $telegramResult): string +{ + $photos = $telegramResult['result']['photo'] ?? []; + if (!is_array($photos) || $photos === []) { + return ''; + } + $largest = end($photos); + return is_array($largest) ? (string)($largest['file_id'] ?? '') : ''; +} + +function trySendInventoryCollage(string $chatId, int $inventoryId, array $rows, ?array $keyboard): ?array +{ + if (count($rows) < 2) { + return null; + } + foreach ($rows as $row) { + if ((string)($row['media_type'] ?? '') !== 'photo' || (string)($row['media_file_id'] ?? '') === '') { + return null; + } + } + if (!class_exists('Imagick') && !extension_loaded('gd')) { + logError('inventory:collage_library_missing', 'Neither Imagick nor GD is available', ['inventory_id' => $inventoryId]); + return null; + } + + $caption = inventoryCollageCaption($rows); + $hash = inventoryCollageHash($rows); + $representative = null; + foreach ($rows as $row) { + if ((int)$row['id'] === $inventoryId) { + $representative = $row; + break; + } + } + $representative ??= end($rows) ?: []; + $cachedFileId = (string)($representative['collage_file_id'] ?? ''); + $cachedHash = (string)($representative['collage_hash'] ?? ''); + + if ($cachedFileId !== '' && hash_equals($hash, $cachedHash)) { + $cachedResult = sendPhotoToResult($chatId, $cachedFileId, $caption, $keyboard); + if (($cachedResult['ok'] ?? false) === true && isset($cachedResult['result']['message_id'])) { + return [ + 'message_id' => (int)$cachedResult['result']['message_id'], + 'button_attached' => $keyboard !== null, + 'cached' => true, + ]; + } + updateInventoryCollageCache($inventoryId, '', ''); + logError('inventory:collage_cached_send_failed', (string)($cachedResult['description'] ?? 'Cached collage send failed'), [ + 'inventory_id' => $inventoryId, + 'error_code' => $cachedResult['error_code'] ?? null, + ]); + } + + $downloadedPaths = []; + $collagePath = null; + try { + foreach ($rows as $row) { + $path = downloadTelegramFileToTemp((string)$row['media_file_id']); + if ($path === null) { + return null; + } + $downloadedPaths[] = $path; + } + + $collagePath = buildInventoryCollage($downloadedPaths); + if ($collagePath === null) { + logError('inventory:collage_build_failed', 'Could not create collage image', [ + 'inventory_id' => $inventoryId, + 'image_count' => count($downloadedPaths), + ]); + return null; + } + + $upload = new CURLFile($collagePath, 'image/jpeg', 'yno-product-' . $inventoryId . '.jpg'); + $sendResult = sendPhotoToResult($chatId, $upload, $caption, $keyboard); + if (($sendResult['ok'] ?? false) !== true || !isset($sendResult['result']['message_id'])) { + logError('inventory:collage_send_failed', (string)($sendResult['description'] ?? 'sendPhoto failed'), [ + 'inventory_id' => $inventoryId, + 'error_code' => $sendResult['error_code'] ?? null, + ]); + return null; + } + + $sentFileId = sentPhotoFileId($sendResult); + if ($sentFileId !== '') { + updateInventoryCollageCache($inventoryId, $sentFileId, $hash); + } + return [ + 'message_id' => (int)$sendResult['result']['message_id'], + 'button_attached' => $keyboard !== null, + 'cached' => false, + ]; + } finally { + foreach ($downloadedPaths as $path) { + @unlink($path); + } + if ($collagePath !== null) { + @unlink($collagePath); + } + } +} + + function replyKeyboard(array $rows): string { $keyboard = []; @@ -1234,7 +1641,7 @@ function getInventoryProductRows(int $inventoryId, bool $activeOnly = false): array { - $stmt = db()->prepare('SELECT id, source_chat_id, source_message_id, media_group_id, active + $stmt = db()->prepare('SELECT id, source_chat_id, source_message_id, media_group_id, media_type, media_file_id, caption, collage_file_id, collage_hash, active FROM inventory WHERE id = :id' . ($activeOnly ? ' AND active = 1' : '')); $stmt->execute([':id' => $inventoryId]); $representative = $stmt->fetch(); @@ -1244,7 +1651,7 @@ $whereActive = $activeOnly ? ' AND active = 1' : ''; if (!empty($representative['media_group_id'])) { - $rows = db()->prepare('SELECT id, source_chat_id, source_message_id, media_group_id, active + $rows = db()->prepare('SELECT id, source_chat_id, source_message_id, media_group_id, media_type, media_file_id, caption, collage_file_id, collage_hash, active FROM inventory WHERE source_chat_id = :chat AND media_group_id = :group_id' . $whereActive . ' ORDER BY source_message_id ASC, id ASC'); @@ -1390,6 +1797,19 @@ return $result; } + // Photo albums registered by v1.4+ are rendered as one collage message. + // This makes the caption and inline order button belong to the whole product. + $collageDelivery = trySendInventoryCollage($chatId, $inventoryId, $rows, $keyboard); + if ($collageDelivery !== null) { + $result['copied_ids'][] = (int)$collageDelivery['message_id']; + $result['button_attached'] = (bool)$collageDelivery['button_attached']; + $result['complete'] = true; + $result['ok'] = true; + recordInventoryDelivery($deliveryToken, $chatId, $inventoryId, $result['copied_ids']); + return $result; + } + + // Legacy/non-photo albums fall back to their original Telegram messages. // copyMessages keeps album grouping but doesn't accept reply_markup. To keep // the glass button attached to the product without a risky edit request, // copy the leading album items first and copy the final item with its button. @@ -1611,18 +2031,55 @@ $pdo->beginTransaction(); try { $mediaGroupId = isset($message['media_group_id']) ? (string)$message['media_group_id'] : ''; + $mediaType = null; + $mediaFileId = null; + if (isset($message['photo'])) { + $mediaType = 'photo'; + $mediaFileId = inventoryPhotoFileId($message); + } else { + foreach (['video', 'document', 'animation', 'audio', 'voice', 'video_note', 'sticker'] as $candidateType) { + if (isset($message[$candidateType])) { + $mediaType = $candidateType; + $candidate = $message[$candidateType]; + if (is_array($candidate) && isset($candidate['file_id'])) { + $mediaFileId = (string)$candidate['file_id']; + } + break; + } + } + } + $caption = isset($message['caption']) ? (string)$message['caption'] : (isset($message['text']) ? (string)$message['text'] : ''); + $stmt = $pdo->prepare('INSERT OR IGNORE INTO inventory( - source_chat_id, source_message_id, media_group_id, batch_token, added_by, added_at, active - ) VALUES(:chat, :message, :group_id, :batch, :admin, :time, 1)'); + source_chat_id, source_message_id, media_group_id, batch_token, + media_type, media_file_id, caption, added_by, added_at, active + ) VALUES(:chat, :message, :group_id, :batch, :media_type, :media_file_id, :caption, :admin, :time, 1)'); $stmt->execute([ ':chat' => (string)$message['chat']['id'], ':message' => (int)$message['message_id'], ':group_id' => $mediaGroupId !== '' ? $mediaGroupId : null, ':batch' => $batchToken, + ':media_type' => $mediaType, + ':media_file_id' => $mediaFileId, + ':caption' => $caption !== '' ? $caption : null, ':admin' => $adminId, ':time' => time(), ]); $stored = $stmt->rowCount() === 1; + if (!$stored && ($mediaType !== null || $mediaFileId !== null || $caption !== '')) { + $metadataUpdate = $pdo->prepare("UPDATE inventory SET + media_type = COALESCE(:media_type, media_type), + media_file_id = COALESCE(:media_file_id, media_file_id), + caption = CASE WHEN :caption <> '' THEN :caption ELSE caption END + WHERE source_chat_id = :chat AND source_message_id = :message"); + $metadataUpdate->execute([ + ':media_type' => $mediaType, + ':media_file_id' => $mediaFileId, + ':caption' => $caption, + ':chat' => (string)$message['chat']['id'], + ':message' => (int)$message['message_id'], + ]); + } $newProduct = $stored; if ($stored && $mediaGroupId !== '') { @@ -2223,6 +2680,7 @@ 'version' => APP_VERSION, 'php' => PHP_VERSION, 'database_writable' => is_writable(__DIR__), + 'collage_engine' => class_exists('Imagick') ? 'Imagick' : (extension_loaded('gd') ? 'GD' : 'missing'), 'webhook' => $webhook['result'] ?? $webhook, 'channel_chat' => setting('channel_chat', ''), ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);