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