1<?php declare(strict_types=1); 2 3/* 4 * This file is part of the Monolog package. 5 * 6 * (c) Jordi Boggiano <j.boggiano@seld.be> 7 * 8 * For the full copyright and license information, please view the LICENSE 9 * file that was distributed with this source code. 10 */ 11 12namespace Monolog\Handler; 13 14use RuntimeException; 15use Monolog\Logger; 16use Monolog\Utils; 17 18/** 19 * Handler send logs to Telegram using Telegram Bot API. 20 * 21 * How to use: 22 * 1) Create telegram bot with https://telegram.me/BotFather 23 * 2) Create a telegram channel where logs will be recorded. 24 * 3) Add created bot from step 1 to the created channel from step 2. 25 * 26 * Use telegram bot API key from step 1 and channel name with '@' prefix from step 2 to create instance of TelegramBotHandler 27 * 28 * @link https://core.telegram.org/bots/api 29 * 30 * @author Mazur Alexandr <alexandrmazur96@gmail.com> 31 * 32 * @phpstan-import-type Record from \Monolog\Logger 33 */ 34class TelegramBotHandler extends AbstractProcessingHandler 35{ 36 private const BOT_API = 'https://api.telegram.org/bot'; 37 38 /** 39 * The available values of parseMode according to the Telegram api documentation 40 */ 41 private const AVAILABLE_PARSE_MODES = [ 42 'HTML', 43 'MarkdownV2', 44 'Markdown', // legacy mode without underline and strikethrough, use MarkdownV2 instead 45 ]; 46 47 /** 48 * The maximum number of characters allowed in a message according to the Telegram api documentation 49 */ 50 private const MAX_MESSAGE_LENGTH = 4096; 51 52 /** 53 * Telegram bot access token provided by BotFather. 54 * Create telegram bot with https://telegram.me/BotFather and use access token from it. 55 * @var string 56 */ 57 private $apiKey; 58 59 /** 60 * Telegram channel name. 61 * Since to start with '@' symbol as prefix. 62 * @var string 63 */ 64 private $channel; 65 66 /** 67 * The kind of formatting that is used for the message. 68 * See available options at https://core.telegram.org/bots/api#formatting-options 69 * or in AVAILABLE_PARSE_MODES 70 * @var ?string 71 */ 72 private $parseMode; 73 74 /** 75 * Disables link previews for links in the message. 76 * @var ?bool 77 */ 78 private $disableWebPagePreview; 79 80 /** 81 * Sends the message silently. Users will receive a notification with no sound. 82 * @var ?bool 83 */ 84 private $disableNotification; 85 86 /** 87 * True - split a message longer than MAX_MESSAGE_LENGTH into parts and send in multiple messages. 88 * False - truncates a message that is too long. 89 * @var bool 90 */ 91 private $splitLongMessages; 92 93 /** 94 * Adds 1-second delay between sending a split message (according to Telegram API to avoid 429 Too Many Requests). 95 * @var bool 96 */ 97 private $delayBetweenMessages; 98 99 /** 100 * @param string $apiKey Telegram bot access token provided by BotFather 101 * @param string $channel Telegram channel name 102 * @param bool $splitLongMessages Split a message longer than MAX_MESSAGE_LENGTH into parts and send in multiple messages 103 * @param bool $delayBetweenMessages Adds delay between sending a split message according to Telegram API 104 * @throws MissingExtensionException 105 */ 106 public function __construct( 107 string $apiKey, 108 string $channel, 109 $level = Logger::DEBUG, 110 bool $bubble = true, 111 string $parseMode = null, 112 bool $disableWebPagePreview = null, 113 bool $disableNotification = null, 114 bool $splitLongMessages = false, 115 bool $delayBetweenMessages = false 116 ) 117 { 118 if (!extension_loaded('curl')) { 119 throw new MissingExtensionException('The curl extension is needed to use the TelegramBotHandler'); 120 } 121 122 parent::__construct($level, $bubble); 123 124 $this->apiKey = $apiKey; 125 $this->channel = $channel; 126 $this->setParseMode($parseMode); 127 $this->disableWebPagePreview($disableWebPagePreview); 128 $this->disableNotification($disableNotification); 129 $this->splitLongMessages($splitLongMessages); 130 $this->delayBetweenMessages($delayBetweenMessages); 131 } 132 133 public function setParseMode(string $parseMode = null): self 134 { 135 if ($parseMode !== null && !in_array($parseMode, self::AVAILABLE_PARSE_MODES)) { 136 throw new \InvalidArgumentException('Unknown parseMode, use one of these: ' . implode(', ', self::AVAILABLE_PARSE_MODES) . '.'); 137 } 138 139 $this->parseMode = $parseMode; 140 141 return $this; 142 } 143 144 public function disableWebPagePreview(bool $disableWebPagePreview = null): self 145 { 146 $this->disableWebPagePreview = $disableWebPagePreview; 147 148 return $this; 149 } 150 151 public function disableNotification(bool $disableNotification = null): self 152 { 153 $this->disableNotification = $disableNotification; 154 155 return $this; 156 } 157 158 /** 159 * True - split a message longer than MAX_MESSAGE_LENGTH into parts and send in multiple messages. 160 * False - truncates a message that is too long. 161 * @param bool $splitLongMessages 162 * @return $this 163 */ 164 public function splitLongMessages(bool $splitLongMessages = false): self 165 { 166 $this->splitLongMessages = $splitLongMessages; 167 168 return $this; 169 } 170 171 /** 172 * Adds 1-second delay between sending a split message (according to Telegram API to avoid 429 Too Many Requests). 173 * @param bool $delayBetweenMessages 174 * @return $this 175 */ 176 public function delayBetweenMessages(bool $delayBetweenMessages = false): self 177 { 178 $this->delayBetweenMessages = $delayBetweenMessages; 179 180 return $this; 181 } 182 183 /** 184 * {@inheritDoc} 185 */ 186 public function handleBatch(array $records): void 187 { 188 /** @var Record[] $messages */ 189 $messages = []; 190 191 foreach ($records as $record) { 192 if (!$this->isHandling($record)) { 193 continue; 194 } 195 196 if ($this->processors) { 197 /** @var Record $record */ 198 $record = $this->processRecord($record); 199 } 200 201 $messages[] = $record; 202 } 203 204 if (!empty($messages)) { 205 $this->send((string)$this->getFormatter()->formatBatch($messages)); 206 } 207 } 208 209 /** 210 * @inheritDoc 211 */ 212 protected function write(array $record): void 213 { 214 $this->send($record['formatted']); 215 } 216 217 /** 218 * Send request to @link https://api.telegram.org/bot on SendMessage action. 219 * @param string $message 220 */ 221 protected function send(string $message): void 222 { 223 $messages = $this->handleMessageLength($message); 224 225 foreach ($messages as $key => $msg) { 226 if ($this->delayBetweenMessages && $key > 0) { 227 sleep(1); 228 } 229 230 $this->sendCurl($msg); 231 } 232 } 233 234 protected function sendCurl(string $message): void 235 { 236 $ch = curl_init(); 237 $url = self::BOT_API . $this->apiKey . '/SendMessage'; 238 curl_setopt($ch, CURLOPT_URL, $url); 239 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 240 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); 241 curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([ 242 'text' => $message, 243 'chat_id' => $this->channel, 244 'parse_mode' => $this->parseMode, 245 'disable_web_page_preview' => $this->disableWebPagePreview, 246 'disable_notification' => $this->disableNotification, 247 ])); 248 249 $result = Curl\Util::execute($ch); 250 if (!is_string($result)) { 251 throw new RuntimeException('Telegram API error. Description: No response'); 252 } 253 $result = json_decode($result, true); 254 255 if ($result['ok'] === false) { 256 throw new RuntimeException('Telegram API error. Description: ' . $result['description']); 257 } 258 } 259 260 /** 261 * Handle a message that is too long: truncates or splits into several 262 * @param string $message 263 * @return string[] 264 */ 265 private function handleMessageLength(string $message): array 266 { 267 $truncatedMarker = ' (...truncated)'; 268 if (!$this->splitLongMessages && strlen($message) > self::MAX_MESSAGE_LENGTH) { 269 return [Utils::substr($message, 0, self::MAX_MESSAGE_LENGTH - strlen($truncatedMarker)) . $truncatedMarker]; 270 } 271 272 return str_split($message, self::MAX_MESSAGE_LENGTH); 273 } 274} 275