diff --git a/.env.example b/.env.example index 2707157..f52e69c 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,10 @@ APP_TITLE="IPTV Плейлисты" APP_TIMEZONE=Europe/Moscow PAGE_SIZE=10 +# config/bot.php +TG_BOT_TOKEN= +TG_BOT_SECRET= + # config/cache.php CACHE_HOST="keydb" CACHE_PORT=6379 diff --git a/app/Controllers/BotController.php b/app/Controllers/BotController.php new file mode 100644 index 0000000..4325216 --- /dev/null +++ b/app/Controllers/BotController.php @@ -0,0 +1,38 @@ +process(); + + return $response; + } +} diff --git a/app/Core/Bot.php b/app/Core/Bot.php new file mode 100644 index 0000000..0f5d104 --- /dev/null +++ b/app/Core/Bot.php @@ -0,0 +1,296 @@ +checkSecret($request); + + $body = json_decode((string)$request->getBody(), true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new JsonException(json_last_error_msg()); + } + + $this->bot = new BotApi(config('bot.token')); + $this->update = Update::fromResponse($body); + } + + /** + * Запсукает обработку команды + * + * @return bool + * @throws InvalidArgumentException + */ + public function process(): bool + { + $commandText = $this->getBotCommandText(); + return match ($commandText) { + '/list', '/list@iptv_aggregator_bot' => $this->processListCommand(), + '/info', '/info@iptv_aggregator_bot' => $this->processInfoCommand(), + default => true, + }; + } + + /** + * Обрабатывает команду /list + * + * @return bool + * @throws InvalidArgumentException + */ + protected function processListCommand(): bool + { + $playlists = ini()->getPlaylists(); + if (empty($playlists)) { + $replyText = 'Плейлистов нет'; + } else { + $replyText = []; + foreach ($playlists as $code => $pls) { + $statusEmoji = match ($pls['isOnline']) { + true => '🟢', + false => '🔴', + default => '⚪', + }; + + in_array('adult', $pls['tags'] ?? []) === true && $statusEmoji .= "🔞"; + $replyText[] = "$statusEmoji \[$code\]"; + } + $replyText = "Полный список плейлистов:\n\n**>" . implode("\n>", $replyText) . "||\n"; + } + + return $this->reply($replyText, InlineKeyboardMarkup::fromResponse([ + 'inline_keyboard' => [ + [ + [ + 'text' => 'Список на сайте', + 'url' => "https://iptv.axenov.dev/", + ], + [ + 'text' => 'FAQ', + 'url' => 'https://iptv.axenov.dev/faq', + ] + ] + ] + ])); + } + + /** + * Обрабатывает команду /info + * + * @return bool + * @throws InvalidArgumentException + */ + protected function processInfoCommand(): bool + { + $message = $this->update->getMessage(); + $text = $message->getText(); + $command = $this->getBotCommand(); + + $code = mb_substr($text, $command->getLength() + 1 , mb_strlen($text)); + if (empty($code)) { + return $this->reply('Укажите код плейлиста с сайта, чтобы получить результаты его проверки, ' . + 'например, `/info ru` или `/info@iptv_aggregator_bot ru`'); + } + + try { + $pls = ini()->getPlaylist($code); + } catch (PlaylistNotFoundException) { + return $this->reply("Плейлист `$code` не найден"); + } + + $statusEmoji = match ($pls['isOnline']) { + true => '🟢', + false => '🔴', + default => '⚪', + }; + + in_array('adult', $pls['tags']) && $statusEmoji .= "🔞"; + $replyText[] = $statusEmoji . ' [' . $this->escape($pls['name']) . "](https://m3u.su/$code/details)\n"; + empty($pls['description']) || $replyText[] = $this->escape($pls['description']) . "\n"; + + if ($pls['isOnline'] === null) { + $replyText[] = "⏲️ *Проверка:* в очереди"; + } else { + $now = DateTimeImmutable::createFromTimestamp(time()); + $checkedAt = DateTimeImmutable::createFromTimestamp($pls['checkedAt']); + $minutes = $checkedAt->diff($now)->i; + $replyText[] = "⏲️ *Проверка:* " . ($minutes > 1 ? "$minutes мин\. назад" : "только что"); + $replyText[] = "📺 *Каналов:* " . count($pls['channels']) . + ' \(онлайн ' . $pls['onlineCount'] . ', оффлайн ' . $pls['offlineCount'] . "\)"; + $replyText[] = "⏪ *Перемотка:* " . ($pls['hasCatchup'] === true ? '*есть*' : 'нет'); + $replyText[] = "🗞️ *Телепрограмма:* " . ($pls['hasTvg'] === true ? '*есть*' : 'нет'); + + if (count($pls['groups'] ?? []) > 0) { + $groups = array_map(fn (array $group) => $this->escape($group['name']), $pls['groups']); + $replyText[] = "\n🗂️ *Группы* \(" . count($pls['groups']) . "\):\n**>\- " . + implode("\n>\- ", $groups) . '||'; + } else { + $replyText[] = "🗂️ *Группы:* нет"; + } + + if (count($pls['tags'] ?? []) > 0) { + $replyText[] = "\n🏷️ *Теги* \(" . count($pls['tags']) . "\):\n**>" . + $this->escape(trim(implode(' ', $pls['tags']))) . "||\n"; + } else { + $replyText[] = "🏷️ *Теги:* нет"; + } + } + + $replyText[] = "🔗 *Ссылка для ТВ:* \(скопируй подходящую\)"; + $replyText[] = "\- `https://m3u.su/$code`"; + $replyText[] = "\- `https://m3u.su/$code.m3u`"; + $replyText[] = "\- `https://iptv.axenov.dev/$code`"; + $replyText[] = "\- `https://iptv.axenov.dev/$code.m3u`\n"; + + return $this->reply( + implode("\n", $replyText), + InlineKeyboardMarkup::fromResponse([ + 'inline_keyboard' => [ + [ + [ + 'text' => is_null($pls['isOnline']) ? 'Подробности' : 'Список каналов', + 'url' => "https://iptv.axenov.dev/$code/details", + ], + [ + 'text' => 'FAQ', + 'url' => 'https://iptv.axenov.dev/faq', + ] + ] + ] + ]), + ); + } + + /** + * Сверяет секретный заголовок с заданным в конфиге + * + * @param ServerRequestInterface $request + * @return void + * @throws InvalidTelegramSecretException + */ + protected function checkSecret(ServerRequestInterface $request): void + { + $secret = config('bot.secret'); + if (empty($secret)) { + return; + } + + $header = $request->getHeaderLine('X-Telegram-Bot-Api-Secret-Token'); + if (empty($header) || $header !== $secret) { + throw new InvalidTelegramSecretException(); + } + } + + /** + * Возвращает объект, описывающий команду бота + * + * @return MessageEntity|null + */ + protected function getBotCommand(): ?MessageEntity + { + return array_filter( + $this->update->getMessage()->getEntities(), + static fn (MessageEntity $entity) => $entity->getType() === 'bot_command', + )[0] ?? null; + } + + /** + * Возвращает текст команды бота + * + * @return string|null + */ + protected function getBotCommandText(): ?string + { + $text = $this->update->getMessage()->getText(); + $command = $this->getBotCommand(); + return $command ? mb_substr($text, 0, $command->getLength()) : null; + } + + /** + * Отправляет текстовое сообщение в ответ на команду, отправленную пользователем + * + * @param string $text + * @param InlineKeyboardMarkup|ReplyKeyboardMarkup|ReplyKeyboardRemove|ForceReply|null $keyboard + * @return bool + */ + protected function reply( + string $text, + InlineKeyboardMarkup|ReplyKeyboardMarkup|ReplyKeyboardRemove|ForceReply|null $keyboard = null, + ): bool { + try { + $this->bot->sendMessage( + chatId: $this->update->getMessage()->getChat()->getId(), + text: $text, + parseMode: 'MarkdownV2', + replyToMessageId: $this->update->getMessage()->getMessageId(), + replyMarkup: $keyboard, + ); + } catch (Throwable $e) { + error_log($e->getMessage()); + return false; + } + + return true; + } + + /** + * Экранирует служебные символы в строке + * + * @param string $string + * @return string + */ + protected function escape(string $string): string + { + return str_replace( + ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'], + ['\_', '\*', '\[', '\]', '\(', '\)', '\~', '\`', '\>', '\#', '\+', '\-', '\=', '\|', '\{', '\}', '\.', '\!'], + $string, + ); + } +} diff --git a/app/Core/IniFile.php b/app/Core/IniFile.php index e115fc8..fa3bb27 100644 --- a/app/Core/IniFile.php +++ b/app/Core/IniFile.php @@ -59,7 +59,7 @@ class IniFile 'offlineCount' => 0, 'checkedAt' => null, ]; - } else if (!isset($data['attributes'])) { + } elseif (!isset($data['attributes'])) { $data['attributes'] = []; } diff --git a/app/Errors/InvalidTelegramSecretException.php b/app/Errors/InvalidTelegramSecretException.php new file mode 100644 index 0000000..2956d1b --- /dev/null +++ b/app/Errors/InvalidTelegramSecretException.php @@ -0,0 +1,20 @@ +=5.5.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.16", + "symfony/phpunit-bridge": "*", + "vimeo/psalm": "^5.9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.5-dev" + } + }, + "autoload": { + "psr-4": { + "TelegramBot\\Api\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ilya Gusev", + "email": "mail@igusev.ru", + "homepage": "https://php-cat.com", + "role": "Developer" + } + ], + "description": "PHP Wrapper for Telegram Bot API", + "homepage": "https://github.com/TelegramBot/Api", + "keywords": [ + "bot", + "bot api", + "php", + "telegram" + ], + "support": { + "issues": "https://github.com/TelegramBot/Api/issues", + "source": "https://github.com/TelegramBot/Api/tree/v2.5.0" + }, + "time": "2023-08-09T13:53:12+00:00" }, { "name": "twig/twig", @@ -2445,7 +2507,7 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.3", + "php": "^8.4", "ext-curl": "*", "ext-fileinfo": "*", "ext-json": "*", diff --git a/config/bot.php b/config/bot.php new file mode 100644 index 0000000..783b26c --- /dev/null +++ b/config/bot.php @@ -0,0 +1,13 @@ + env('TG_BOT_TOKEN'), + 'secret' => env('TG_BOT_SECRET'), +]; diff --git a/config/routes.php b/config/routes.php index 4d4cb73..1dc3031 100644 --- a/config/routes.php +++ b/config/routes.php @@ -6,16 +6,29 @@ */ use App\Controllers\ApiController; -use App\Controllers\BasicController; +use App\Controllers\BotController; use App\Controllers\WebController; return [ + /* |-------------------------------------------------------------------------- | Web routes |-------------------------------------------------------------------------- */ + [ + 'method' => ['GET', 'POST'], + 'path' => '/bot/webhook', + 'handler' => [BotController::class, 'webhook'], + 'name' => 'bot::webhook', + ], + [ + 'method' => ['GET', 'POST'], + 'path' => '/bot/update', + 'handler' => [BotController::class, 'update'], + 'name' => 'bot::update', + ], [ 'method' => 'GET', 'path' => '/[page/{page:[0-9]+}]', @@ -59,12 +72,6 @@ return [ 'handler' => [ApiController::class, 'makeQrCode'], 'name' => 'api::makeQrCode', ], -// [ -// 'method' => 'GET', -// 'path' => '/{code:[0-9a-zA-Z]+}/logo/{hash:[0-9a-z]+}', -// 'handler' => [ApiController::class, 'logo'], -// 'name' => 'api::getChannelLogo', -// ], /* |-------------------------------------------------------------------------- @@ -74,7 +81,7 @@ return [ [ 'method' => '*', - 'path' => '/{path:.*}', + 'path' => '/{path:.+}', 'handler' => [BasicController::class, 'notFound'], 'name' => 'not-found', ],