Files
web/app/Core/Bot.php

297 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/*
* Copyright (c) 2025, Антон Аксенов
* This file is part of iptv.axenov.dev web interface
* MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
*/
declare(strict_types=1);
namespace App\Core;
use App\Errors\InvalidTelegramSecretException;
use App\Errors\PlaylistNotFoundException;
use DateTimeImmutable;
use Exception;
use JsonException;
use Psr\Http\Message\ServerRequestInterface;
use TelegramBot\Api\BotApi;
use TelegramBot\Api\InvalidArgumentException;
use TelegramBot\Api\Types\ForceReply;
use TelegramBot\Api\Types\Inline\InlineKeyboardMarkup;
use TelegramBot\Api\Types\MessageEntity;
use TelegramBot\Api\Types\ReplyKeyboardMarkup;
use TelegramBot\Api\Types\ReplyKeyboardRemove;
use TelegramBot\Api\Types\Update;
use Throwable;
/**
* Обработчик команд бота
*/
class Bot
{
/**
* @var BotApi Объект Telegram Bot API
*/
protected BotApi $bot;
/**
* @var Update Объект обновления бота
*/
protected Update $update;
/**
* Конструктор
*
* @param ServerRequestInterface $request
* @throws InvalidTelegramSecretException
* @throws JsonException
* @throws InvalidArgumentException
* @throws Exception
*/
public function __construct(ServerRequestInterface $request)
{
$this->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,
);
}
}