Первичная реализация, команды /list и /info

This commit is contained in:
2025-06-09 01:11:10 +08:00
parent 6cebf7356c
commit 687ebc3fdc
9 changed files with 476 additions and 35 deletions

296
app/Core/Bot.php Normal file
View File

@@ -0,0 +1,296 @@
<?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,
);
}
}