Files
web/app/Core/Bot.php

465 lines
17 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 m3u.su project
* 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
* @throws \TelegramBot\Api\Exception
* @throws Exception
*/
public function process(): bool
{
$commandText = $this->getBotCommandText();
return match (true) {
str_starts_with($commandText, '/start') => $this->processHelpCommand(),
str_starts_with($commandText, '/list') => $this->processListCommand(),
str_starts_with($commandText, '/info') => $this->processInfoCommand(),
str_starts_with($commandText, '/help') => $this->processHelpCommand(),
str_starts_with($commandText, '/links') => $this->processLinksCommand(),
str_starts_with($commandText, '/stats') => $this->processStatsCommand(),
default => true,
};
}
/**
* Обрабатывает команду /list
*
* @return bool
* @throws InvalidArgumentException
* @throws \TelegramBot\Api\Exception
* @throws Exception
*/
protected function processListCommand(): bool
{
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
$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' => base_url(),
],
[
'text' => 'FAQ',
'url' => base_url('faq'),
]
]
]
]));
}
/**
* Обрабатывает команду /info
*
* @return bool
* @throws InvalidArgumentException
* @throws \TelegramBot\Api\Exception
*/
protected function processInfoCommand(): bool
{
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
$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']) . '](' . base_url("$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 мин\. назад" : "только что");
if ($pls['isOnline'] === true) {
$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[] = "🏷️ *Теги:* нет";
}
} else {
$replyText[] = "❌ *Ошибка проверки:*\n**>" . $this->escape($pls['content']) . "||\n";
}
}
$replyText[] = "🔗 *Ссылка для ТВ:* \(скопируй подходящую\)";
if (config('app.mirror_url')) {
$replyText[] = '\- `' . mirror_url("$code") . '`';
$replyText[] = '\- `' . mirror_url("$code.m3u") . '`';
}
$replyText[] = '\- `' . base_url("$code") . '`';
$replyText[] = '\- `' . base_url("$code.m3u") . "`\n";
return $this->reply(
implode("\n", $replyText),
InlineKeyboardMarkup::fromResponse([
'inline_keyboard' => [
[
[
'text' => is_null($pls['isOnline']) ? 'Подробности' : 'Список каналов',
'url' => base_url("$code/details"),
],
[
'text' => 'FAQ',
'url' => base_url('faq'),
]
]
]
]),
);
}
/**
* Обрабатывает команду /help
*
* @return bool
* @throws \TelegramBot\Api\Exception
*/
protected function processHelpCommand(): bool
{
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
$replyText[] = 'Бот предоставляет короткую сводку о плейлистах, которые видны на сайте ' .
$this->escape(base_url()) . '\.';
$replyText[] = 'Плейлисты проверяются сервером автоматически\.';
$replyText[] = '';
$replyText[] = 'Команды бота:';
$replyText[] = '`/list` \- список кодов всех плейлистов;';
$replyText[] = '`/info <код>` \- информация о плейлисте с указанным кодом;';
$replyText[] = '`/help` \- данная справка\;';
$replyText[] = '`/links` \- ссылки на все страницы проекта\;';
$replyText[] = '`/stats` \- статистика по плейлистам и каналам\.';
$replyText[] = '';
$replyText[] = 'Статусы плейлистов:';
$replyText[] = '🟢 \- онлайн';
$replyText[] = '🔴 \- оффлайн';
$replyText[] = '⚪ \- не проверялось';
$replyText[] = '🔞 \- есть каналы для взрослых';
return $this->reply(implode("\n", $replyText));
}
/**
* Обрабатывает команду /links
*
* @return bool
* @throws \TelegramBot\Api\Exception
*/
protected function processLinksCommand(): bool
{
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
$replyText[] = '*Ресурсы и страницы*';
$replyText[] = '';
$replyText[] = '🌏 Сайт: ' . $this->escape(base_url());
$replyText[] = '👩‍💻 Исходный код: ' . $this->escape(config('app.repo_url'));
$replyText[] = '✈️ Telegram\-канал: @iptv\_aggregator';
$replyText[] = '✈️ Обсуждение: @iptv\_aggregator\_chat';
$replyText[] = '📚 Доп\. сведения:';
$replyText[] = '\- ' . $this->escape(config('app.repo_url') . '/.profile');
$replyText[] = '\- ' . $this->escape(base_url('faq'));
return $this->reply(implode("\n", $replyText));
}
/**
* Обрабатывает команду /stats
*
* @return bool
* @throws Exception
*/
protected function processStatsCommand(): bool
{
$this->bot->sendChatAction($this->update->getMessage()->getChat()->getId(), 'typing');
$allChannels = [];
foreach (ini()->getPlaylists() as $pls) {
$allChannels = array_merge($allChannels, $pls['channels'] ?? []);
}
$onlinePls = array_filter(
ini()->getPlaylists(),
static fn (array $pls) => $pls['isOnline'] === true,
);
$offlinePls = array_filter(
ini()->getPlaylists(),
static fn (array $pls) => $pls['isOnline'] === false,
);
$unknownPls = array_filter(
ini()->getPlaylists(),
static fn (array $pls) => $pls['isOnline'] === null,
);
$adultPls = array_filter(
$onlinePls,
static fn (array $pls) => in_array('adult', $pls['tags']),
);
$catchupPls = array_filter(
$onlinePls,
static fn (array $pls) => $pls['hasCatchup'] === true,
);
$tvgPls = array_filter(
$onlinePls,
static fn (array $pls) => $pls['hasTvg'] === true,
);
$grouppedPls = array_filter(
$onlinePls,
static fn (array $pls) => count($pls['groups'] ?? []) > 0
);
$onlineCh = $offlineCh = $adultCh = [];
foreach ($onlinePls as $pls) {
$tmpOnline = array_filter(
$pls['channels'] ?? [],
static fn (array $ch) => $ch['isOnline'] === true,
);
$tmpOffline = array_filter(
$pls['channels'] ?? [],
static fn (array $ch) => $ch['isOnline'] === false,
);
$tmpAdult = array_filter(
$pls['channels'] ?? [],
static fn (array $ch) => in_array('adult', $ch['tags']),
);
$onlineCh = array_merge($onlineCh, $tmpOnline);
$offlineCh = array_merge($offlineCh, $tmpOffline);
$adultCh = array_merge($adultCh, $tmpAdult);
}
$replyText[] = '📊 *Статистика*';
$replyText[] = '';
$replyText[] = '*Список изменён:* ' . $this->escape(ini()->updatedAt());
$replyText[] = '';
$replyText[] = '*Плейлистов:* ' . count(ini()->getPlaylists());
$replyText[] = '🟢 Онлайн \- ' . count($onlinePls);
$replyText[] = '🔴 Оффлайн \- ' . count($offlinePls);
$replyText[] = '⚪ В очереди \- ' . count($unknownPls);
$replyText[] = '🔞 Для взрослых \- ' . count($adultPls);
$replyText[] = '⏪ С перемоткой \- ' . count($catchupPls);
$replyText[] = '🗞️ С телепрограммой \- ' . count($tvgPls);
$replyText[] = '🗂️ С группировкой каналов \- ' . count($grouppedPls);
$replyText[] = '';
$replyText[] = '*Каналов:* ' . count($allChannels);
$replyText[] = '🟢 Онлайн \- ' . count($onlineCh);
$replyText[] = '🔴 Оффлайн \- ' . count($offlineCh);
$replyText[] = '🔞 Для взрослых \- ' . count($adultCh);
$replyText[] = '';
$replyText[] = '';
return $this->reply(implode("\n", $replyText));
}
/**
* Сверяет секретный заголовок с заданным в конфиге
*
* @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,
);
}
}