<?php
namespace App\Library\TelegramBot;
use App\Entity\TelegramMessage;
use App\Entity\TelegramUser;
use App\Exception\TelegramBot\BotWasKickedFromSupergroupChat;
use App\Exception\TelegramBot\MessageCantBeDeleted;
use App\Exception\TelegramBot\MessageCantBeDeletedForEveryone;
use App\Exception\TelegramBot\MessageEditedBySameTextException;
use App\Exception\TelegramBot\MessageIdentifierIsNotSpecified;
use App\Exception\TelegramBot\MessageToDeleteNotFoundException;
use App\Exception\TelegramBot\MessageToEditNotFoundException;
use App\Library\TelegramBot\TelegramObject\Message;
use App\Library\Utils\Other\Other;
use App\Library\Utils\RemoteGetter;
use App\Service\EntityManagerService;
use App\Service\TelegramMessage\TelegramMessageService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use App\Library\TelegramBot\TelegramObject\Update;
abstract class AbstractTelegramBot
{
private $apiToken;
private $baseUrl;
/**
* @var string[]
*/
private $botTelegramIds = [];
/**
* @var LoggerInterface
*/
protected $logger;
/**
* @var EntityManagerInterface
*/
protected $em;
/**
* @var TelegramMessageService
*/
protected $telegramMessageService;
/**
* @var EntityManagerService
*/
protected $emService;
public function __construct(LoggerInterface $logger, EntityManagerInterface $em,
TelegramMessageService $telegramMessageService, EntityManagerService $emService)
{
$this->logger = $logger;
$this->em = $em;
$this->telegramMessageService = $telegramMessageService;
$this->emService = $emService;
$this->logger->setHandlers([new \Monolog\Handler\StreamHandler(Other::getVarPath("log/telegram_bot.log"))]);
}
public function setApiToken($apiToken)
{
if (!$apiToken) {
throw new \Exception("API token is not set");
}
if (str_contains($apiToken, "_____")) {
$apiToken = substr($apiToken, 0, strpos($apiToken, "_____"));
}
$this->apiToken = $apiToken;
$this->baseUrl = "https://api.telegram.org/bot{$this->apiToken}/";
}
/**
* @param string[] $ids
* @return void
*/
public function setBotTelegramIds(array $ids)
{
$this->botTelegramIds = $ids;
}
public function isThisBotTelegramId(string $id): bool
{
return in_array($id, $this->botTelegramIds);
}
/**
* Обработка полученных данных от бота (сообщения пользователей и т.д.).
* @param array $update
* @return void
* @throws \Exception
*/
abstract public function handleUpdate($update);
/**
* @return void
*/
abstract public function onUpdatesHandled();
/**
* @return void
*/
abstract public function onMessageSend(TelegramMessage $message);
public function handleUpdates()
{
try {
$this->getAllUpdates(function ($updates) {
foreach ($updates as $update) {
$chatId = $update["message"]["chat"]["id"] ?? null;
$text = $update["message"]["text"] ?? null;
$this->logger->info("Received message from chat #$chatId: $text");
$updateObj = new Update($update);
$this->_handleUpdate($updateObj);
$this->handleUpdate($updateObj);
// dd(1);
// echo var_export($update, true); return;
}
});
} catch (\Exception $exception) {
$this->logger->error($exception->getMessage());
dump(__FILE__ . ":" . __LINE__ . ": ", $exception);
};
$this->onUpdatesHandled();
}
public function sendInlineKeyboardMessage($chatId, $text, array $buttons) {
$replyMarkup = $this->createReplyMarkupFromButtonsArray($buttons);
return $this->sendMessage($chatId, $text, $replyMarkup);
}
public function createReplyMarkupFromButtonsArray(array $buttons): array
{
$inlineKeyboard = [];
foreach ($buttons as $key => $button) {
$inlineKeyboard[] = [
[
"text" => $button,
"callback_data" => $key,
]
];
}
$replyMarkup = [
'inline_keyboard' => $inlineKeyboard
];
return $replyMarkup;
}
private function _handleUpdate(Update $update)
{
if ($this->isThisBotAddedToGroupUpdate($update) && $this->getUpdateMessageChatId($update)
&& $this->getUpdateFromUserId($update))
{
$this->onBotAddedToGroup($update, $this->getUpdateMessageChatId($update), $this->getUpdateFromUserId($update));
}
}
protected function onBotAddedToGroup(Update $update, string $chatId, string $userId)
{
// Override in child class if needed
}
private function isThisBotAddedToGroupUpdate(Update $update): bool
{
return $update->getMessage() && $update->getMessage()->getNewChatParticipant()
&& $update->getMessage()->getNewChatParticipant()->getIsBot() === true
&& $update->getMessage()->getNewChatParticipant()->getId()
&& $this->isThisBotTelegramId($update->getMessage()->getNewChatParticipant()->getId());
}
public function getUpdateMessageChatId(Update $update): ?string
{
if ($update->getMessage() && $update->getMessage()->getChat() && $update->getMessage()->getChat()->getId()) {
return $update->getMessage()->getChat()->getId();
}
return null;
}
public function getUpdateFromUserId(Update $update): ?string
{
if ($update->getMessage() && $update->getMessage()->getFrom() && $update->getMessage()->getFrom()->getId()) {
return $update->getMessage()->getFrom()->getId();
}
return null;
}
private function getUpdates($offset = null, $limit = null, $timeout = null)
{
$url = $this->baseUrl . 'getUpdates';
$params = [
'offset' => $offset,
'limit' => $limit,
'timeout' => $timeout,
];
return $this->sendRequest($url, $params)["result"];
}
private function getLatestUpdates($limit = 1000)
{
$offset = 0;
$updates = [];
while (true) {
$batch = $this->getUpdates($offset, $limit);
if (empty($batch)) {
break;
}
$updates = array_merge($updates, $batch);
$offset = end($batch)["update_id"] + 1;
}
return $updates;
}
public function getChatIds(array $updates)
{
$chatIds = array_map(function ($update) {
return $update['message']['chat']['id'];
}, $updates);
$uniqueChatIds = array_unique($chatIds);
return $uniqueChatIds;
}
/**
* @param int|array $chatId
* @param $text
* @throws \Exception
*/
public function sendMessage($chatId, $text, $replyMarkup = null, string $imageFileId = null): TelegramMessage
{
if (!is_array($chatId)) {
$chatId = [$chatId];
}
$result = null;
try {
foreach ($chatId as $currentChatId) {
$url = $this->baseUrl . 'sendMessage';
$params = array_merge(
[
'chat_id' => (int)$currentChatId,
]
);
if ($text) {
$params['text'] = $text;
$params['parse_mode'] = "HTML";
}
if ($imageFileId) {
$url = $this->baseUrl . 'sendPhoto';
$params['photo'] = $imageFileId;
}
if ($replyMarkup) {
$params['reply_markup'] = json_encode($replyMarkup);
}
try {
$result = $this->sendRequest($url, $params);
} catch (\Exception $exception) {
$this->trySendWarningMessage($exception, (int)$currentChatId);
throw $exception;
}
$this->logger->info("Send message to chat #$currentChatId: $text");
}
} catch (\Exception $exception) {
$this->logger->error($exception->getMessage());
throw $exception;
}
$message = $this->telegramMessageService->createOrGet($result["result"]);
$this->onMessageSend($message);
return $message;
}
private function trySendWarningMessage(\Exception $exception, int $chatId)
{
try {
$text = "WARNING: Send message error: " . substr($exception->getMessage(), 0, 100);
$this->sendMessage($chatId, $text);
} catch (\Exception $exception) {
//do nothing
}
}
/**
* @param $text
* @param int|array $chatId
* @return void
* @throws \Exception
*/
public function trySendMessage($text, $chatId)
{
try {
$this->sendMessage($chatId, $text);
} catch (\Exception $exception) {
//do nothing
}
}
private function sendRequest($url, $params = null, $returnFile = false,
string $filePath = null)
{
$tunnelUrl = "https://webiskit.ru/make-request";
$params = [
"url" => $url,
"post" => $params,
'returnFile' => $returnFile,
];
$url = $tunnelUrl;
// $ch = curl_init($url);
//
// if ($params) {
// curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
// curl_setopt($ch, CURLOPT_POST, 1);
// curl_setopt($ch, CURLOPT_POSTFIELDS, $params);
// }
//
// $result = curl_exec($ch);
//
// if (curl_errno($ch)) {
// echo 'Error: ' . curl_error($ch);
// }
//
// curl_close($ch);
//
// $result = json_decode($result, true);
$asJson = !Other::hasArrayCurlFile($params);
if ($returnFile) {
$result = file_get_contents($url, false, stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'content' => json_encode($params),
],
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
]
]));
file_put_contents($filePath, $result, FILE_BINARY);
} else {
Other::appendTestLog([array_key_last(array_flip(explode("/", __FILE__))) . ":" . __LINE__,
$params]);
$result = RemoteGetter::get($url, $params, true, false, null,
null, false, false, false, $asJson);
}
// if ($returnFile) {
// $base64 = $result["file"]["base64"] ?? null;
// $extension = $result["file"]["extension"] ?? null;
// if (!$base64 || !$extension) {
// throw new \Exception("File data is not valid: " . json_encode($result));
// }
// $fileData = base64_decode($base64);
// $result = file_put_contents($filePath, $fileData, FILE_BINARY);
// if (!$result) {
// throw new \Exception("File did not downloaded");
// }
// return $result;
// }
if (!isset($result["ok"]) || !$result["ok"]) {
$error = (isset($result['description']) ? "Telegram bot error: " . $result['description'] : "Unknown telegram bot error: " . $result
. ". URL: " . $url);
Other::appendTgBotLog([$error, Other::getBacktrace()]);
throw new \Exception($error);
}
return $result;
}
public function getAllUpdates(callable $callback, $limit = 100)
{
$offset = 0;
$updates = [];
while (true) {
$batch = $this->getUpdates($offset, $limit);
if (empty($batch)) {
break;
}
$updates = array_merge($updates, $batch);
$newOffset = end($batch)['update_id'] + 1;
if ($newOffset !== $offset && is_callable($callback)) {
$callback($updates);
$offset = $newOffset;
}
}
return $updates;
}
public function setWebhook($webhookUrl)
{
$url = $this->baseUrl . 'setWebhook';
$params = [
'url' => $webhookUrl,
];
try {
$result = $this->sendRequest($url, $params);
} catch (\Exception $exception) {
$this->logger->error($exception->getMessage());
throw $exception;
}
return $result;
}
public function clearWebhook()
{
return $this->setWebhook(null);
}
public function deleteRemoteMessage(string $chatId, string $messageId)
{
$url = $this->baseUrl . 'deleteMessage';
$params = [
'chat_id' => (int)$chatId,
'message_id' => (int)$messageId
];
try {
$result = $this->sendRequest($url, $params);
} catch (\Exception $exception) {
$this->logger->error($exception->getMessage());
if ($exception->getMessage() == MessageToDeleteNotFoundException::message) {
throw new MessageToDeleteNotFoundException();
}
if ($exception->getMessage() == MessageCantBeDeletedForEveryone::message) {
throw new MessageCantBeDeletedForEveryone();
}
if ($exception->getMessage() == MessageCantBeDeleted::message) {
throw new MessageCantBeDeleted();
}
if ($exception->getMessage() == MessageIdentifierIsNotSpecified::message) {
throw new MessageIdentifierIsNotSpecified();
}
if ($exception->getMessage() == BotWasKickedFromSupergroupChat::message) {
throw new BotWasKickedFromSupergroupChat();
}
throw $exception;
}
return $result;
}
public function sendImage($chatId, string $imagePath): Message
{
if (!file_exists($imagePath)) {
throw new \Exception("Image file not found: " . $imagePath);
}
$url = $this->baseUrl . 'sendPhoto';
$params = [
'chat_id' => $chatId,
'photo' => new \CURLFile($imagePath),
];
try {
$result = $this->sendRequest($url, $params);
} catch (\Exception $exception) {
$this->logger->error($exception->getMessage());
throw $exception;
}
return new Message($result["result"]);
}
public function editRemoteMessage($chatId, $messageId, string $text = null, array $replyMarkup = null, string $imageFileId = null)
{
$url = $this->baseUrl . 'editMessageText';
$params = [
'chat_id' => (int)$chatId,
'message_id' => (int)$messageId,
];
if ($text) {
$params['text'] = $text;
$params['parse_mode'] = "HTML";
}
if ($replyMarkup) {
$params['reply_markup'] = json_encode($replyMarkup);
}
if ($imageFileId) {
$url = $this->baseUrl . 'editMessageMedia';
$params['media'] = json_encode([
'type' => 'photo',
'media' => $imageFileId,
]);
}
try {
$result = $this->sendRequest($url, $params);
} catch (\Exception $exception) {
$this->logger->error($exception->getMessage());
if (strpos($exception->getMessage(), MessageEditedBySameTextException::message)) {
throw new MessageEditedBySameTextException();
}
if (strpos($exception->getMessage(), MessageToEditNotFoundException::message)) {
throw new MessageToEditNotFoundException();
}
throw $exception;
}
return $result;
}
public function downloadMessagePhoto(Update $update)
{
$photoSizes = $update->getMessage()->getPhoto();
if (!$photoSizes || !is_array($photoSizes) || count($photoSizes) == 0) {
throw new \Exception("No photo sizes found in message");
}
$fileId = end($photoSizes)->getFileId();
if (!$fileId) {
throw new \Exception("No file id found in photo sizes");
}
$relativePath = "images/message_photo/{$fileId}.*";
$existingFiles = glob(Other::getRootPath("/public/" . $relativePath));
if ($existingFiles && count($existingFiles) > 0) {
$relativePath = str_replace(Other::getRootPath("/public/"), "", $existingFiles[0]);
return $relativePath;
}
$url = $this->baseUrl . 'getFile';
$params = [
'file_id' => $fileId,
];
try {
$result = $this->sendRequest($url, $params);
} catch (\Exception $exception) {
$this->logger->error($exception->getMessage());
throw $exception;
}
$url = "https://api.telegram.org/file/bot{$this->apiToken}/{$result["result"]["file_path"]}";
$ext = null;
preg_match("/\.[^.]+$/", $url, $ext);
$relativeImagePath = "images/message_photo/" . $fileId . $ext[0];
$imagePath = Other::getRootPath() . "/public/" . $relativeImagePath;
$dir = dirname($imagePath);
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
}
//rename
$this->sendRequest($url, null, true, $imagePath);
return $relativeImagePath;
}
public function getUserProfileImage($userId): ?string
{
$url = $this->baseUrl . 'getUserProfilePhotos';
$params = [
'user_id' => (int)$userId,
];
try {
$result = $this->sendRequest($url, $params);
} catch (\Exception $exception) {
$this->logger->error($exception->getMessage());
throw $exception;
}
$totalCount = $result['result']['total_count'];
if (!$totalCount) {
return null;
}
$fileId = $result['result']['photos'][0][1]['file_id'];
$url = $this->baseUrl . 'getFile';
$params = [
'file_id' => $fileId,
];
try {
$result = $this->sendRequest($url, $params);
} catch (\Exception $exception) {
$this->logger->error($exception->getMessage());
throw $exception;
}
$url = "https://api.telegram.org/file/bot{$this->apiToken}/{$result["result"]["file_path"]}";
$ext = null;
preg_match("/\.[^.]+$/", $url, $ext);
$relativeImagePath = "images/user_profile/" . $userId . $ext[0];
$imagePath = Other::getRootPath("/public/") . $relativeImagePath;
$result = file_put_contents($imagePath, file_get_contents($url), FILE_BINARY);
if (!$result) {
throw new \Exception("Image did not downloaded");
}
return $relativeImagePath;
}
/**
* @param $commandParam
* @param $type
* @param $chatId
* @param TelegramUser $user
* @param $from
* @param $to
* @param $canBeNegative
* @return bool|string
* @throws \Exception
*/
protected function checkCommandParamType($commandParam, $type, $chatId, TelegramUser $user,
$from = null, $to = null, $canBeNegative = false,
array $literals = null, $yesNoLiterals = false)
{
if (mb_strtolower($commandParam, "utf8") == "отмена") {
$this->sendMessage($chatId, "Ввод отменен");
$user->setWaitBotCommandParam(null);
$this->emService->save($user);
return "canceled";
}
$yesLiterals = null;
$noLiterals = null;
if ($yesNoLiterals) {
$yesLiterals = ["да", "д", "yes", "y"];
$noLiterals = ["нет", "н", "no", "n"];
$yesNoLiterals = array_merge($yesLiterals, $noLiterals);
if ($yesNoLiterals) {
$literals = array_merge(($literals ?: []), ["да", "нет"]);
}
}
if ($from === null && !$commandParam) {
$from = 0;
}
$redoMessage = "";
switch ($type) {
case "string":
break;
case "int":
if (!preg_match("/^\d+$/", $commandParam) ||
($from !== null && (int)$commandParam < $from) ||
($to !== null && (int)$commandParam > $to) ||
(!$canBeNegative && (int)$commandParam < 0))
{
$redoMessage = "Введите целое число";
$redoMessage .=
($from !== null ? " от " . $from : "") .
($to !== null ? " до " . $to : "");
}
break;
default:
throw new \Exception("Unknown type: " . $type);
}
if ($literals && !array_filter(array_merge($literals, $yesNoLiterals), function ($current) use ($commandParam) {
return mb_strtolower($current) == mb_strtolower($commandParam);
})) {
$redoMessage .= ($redoMessage ? ". " : "") . "Введите одно из значений (без кавычек): " .
array_reduce($literals, function ($all, $current) {
return $all .= ($all ? ", " : "") . "\"" . $current . "\"";
});
}
if ($redoMessage) {
$redoMessage .= ". Либо введите слово \"отмена\" (без кавычек) для отмены ввода";
$this->sendMessage($chatId, $redoMessage);
return false;
}
if ($yesNoLiterals) {
return (in_array(mb_strtolower($commandParam), $yesLiterals) ? "yes" :
(in_array(mb_strtolower($commandParam), $noLiterals) ? "no" :
$commandParam));
}
return true;
}
public function initUserRequestDataParams(TelegramUser $user)
{
$waitBotCommandParamData = $user->getWaitBotCommandParam();
if (!$waitBotCommandParamData) {
$waitBotCommandParamData = [];
}
if (isset($waitBotCommandParamData["userRequestData"])) {
throw new \Exception("Can't set userRequestData of user property waitBotCommandParam because it" .
" is already set");
}
$waitBotCommandParamData["userRequestData"] = [
"currentParamIndex" => -1,
"enteredData" => [],
];
$user->setWaitBotCommandParam($waitBotCommandParamData);
$this->emService->save($user);
}
public function getRequestNextDataFromUserOptions($options) {
return array_merge([
"dataNames" => [], //array|\Closure
// Example:
// Variant 1: ["name1", ...].
// Variant 2: [["name" => "name1", "description" => "descr1"], ...]
"askUserEnterDataText" => null,
"enterDataTextDescription" => null,
"onUserEnterCancel" => null,
"onCurrentDataEnterComplete" => null,
"onAllDataEnterComplete" => null,
"clearWaitBotCommandParamOnAllEnterComplete" => true,
"sendThankMessageOnAllEnteredData" => true,
], $options);
}
public function requestNextDataFromUser(array &$sendMessages, TelegramUser $user, $text, $chatId,
array $options)
{
if (!is_array($options["dataNames"])) {
$options["dataNames"] = $options["dataNames"]();
} else {
foreach ($options["dataNames"] as &$dataName) {
if (!is_array($dataName)) {
$dataName = ["name" => $dataName];
}
}
}
if (!$options["askUserEnterDataText"]) {
$options["askUserEnterDataText"] = "Введите: ";
}
if (!$user->getWaitBotCommandParam() || !isset($user->getWaitBotCommandParam()["userRequestData"])) {
throw new \Exception("userRequestData of user property waitBotCommandParam is not initialized" .
". Call initUserRequestDataParams method first");
}
$userRequestData = $user->getWaitBotCommandParam()["userRequestData"];
$saveUserRequestData = function ($userRequestData) use ($user) {
$data = $user->getWaitBotCommandParam();
$data['userRequestData'] = $userRequestData;
$user->setWaitBotCommandParam($data);
$this->emService->save($user);
};
$sendNextRequest = function ($paramName) use ($options, &$sendMessages, &$userRequestData, $user,
$saveUserRequestData) {
$sendMessages[] = $options["askUserEnterDataText"] . $paramName["name"] .
($options["enterDataTextDescription"] ? ". " . $options["enterDataTextDescription"] : "") .
(isset($paramName["description"]) && $paramName["description"] ? ". {$paramName["description"]}" : "") .
". Либо введите слово \"отмена\""
. " (без кавычек) для отмены ввода";
$saveUserRequestData($userRequestData);
};
if ($userRequestData["currentParamIndex"] != -1) {
$result = $this->checkCommandParamType($text, "string", $chatId, $user);
if ($result === false || $result === "canceled") {
if ($options["onUserEnterCancel"]) {
$options["onUserEnterCancel"]();
}
return;
}
$userRequestData["enteredData"][$options["dataNames"][$userRequestData["currentParamIndex"]]["name"]] = $text;
$userRequestData["currentParamIndex"]++;
$saveUserRequestData($userRequestData);
$sendMessages[] = "Принято: " . $text;
if ($userRequestData["currentParamIndex"] >= count($options["dataNames"])) {
if ($options["sendThankMessageOnAllEnteredData"]) {
$sendMessages[] = "Все данные заполнены. Спасибо!";
}
$thrownException = null;
if ($options["onAllDataEnterComplete"]) {
try {
$options["onAllDataEnterComplete"]();
} catch (\Exception $exception) {
$thrownException = $exception;
}
}
if ($options["clearWaitBotCommandParamOnAllEnterComplete"]) {
$user->setWaitBotCommandParam(null);
$this->emService->save($user);
} else {
$data = $user->getWaitBotCommandParam();
unset($data["userRequestData"]);
$user->setWaitBotCommandParam($data);
$this->emService->save($user);
}
if ($thrownException) {
throw $thrownException;
}
return;
} else {
if ($options["onCurrentDataEnterComplete"]) {
$options["onCurrentDataEnterComplete"]();
}
$sendNextRequest($options["dataNames"][$userRequestData["currentParamIndex"]]);
}
} else {
$userRequestData["currentParamIndex"]++;
$saveUserRequestData($userRequestData);
$sendNextRequest($options["dataNames"][$userRequestData["currentParamIndex"]]);
}
}
public function getBotCommandUpdateSample()
{
return array (
'update_id' => 954338453,
'message' =>
array (
'message_id' => 15,
'from' =>
array (
'id' => "1720609_81",
'is_bot' => false,
'first_name' => 'Александр',
'last_name' => 'Орлов',
'username' => 'sariato',
'language_code' => 'ru',
),
'chat' =>
array (
'id' => "17206_0981",
'first_name' => 'Александр',
'last_name' => 'Орлов',
'username' => 'sariato',
'type' => 'private',
),
'date' => 1707404913,
'text' => '/sdfsf',
'entities' =>
array (
0 =>
array (
'offset' => 0,
'length' => 6,
'type' => 'bot_command',
),
),
),
);
}
public function createBotCommandUpdate($userId, $command)
{
return array (
'update_id' => 0,
'message' =>
array (
'message_id' => 0,
'from' =>
array (
'id' => $userId,
'is_bot' => false,
'first_name' => '',
'last_name' => '',
'username' => '',
'language_code' => 'ru',
),
'chat' =>
array (
'id' => $userId,
'first_name' => '',
'last_name' => '',
'username' => '',
'type' => 'private',
),
'date' => (new \DateTime())->getTimestamp(),
'text' => '/' . $command,
'entities' =>
array (
0 =>
array (
'offset' => 0,
'length' => 6,
'type' => 'bot_command',
),
),
),
);
}
public function sendFile($chatId, $filePath, $caption = null, $fileType = 'document')
{
if (!file_exists($filePath)) {
throw new \Exception("File does not exist: {$filePath}");
}
$url = $this->baseUrl . "send{$fileType}";
$params = [
'chat_id' => (int)$chatId,
$fileType => new \CURLFile(realpath($filePath)),
];
if ($caption) {
$params['caption'] = $caption;
}
try {
$result = $this->sendRequest($url, $params);
} catch (\Exception $exception) {
$this->logger->error($exception->getMessage());
throw $exception;
}
return $result;
}
}