465 lines
17 KiB
PHP
465 lines
17 KiB
PHP
<?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,
|
||
);
|
||
}
|
||
}
|