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\Slack;
13
14use Monolog\Logger;
15use Monolog\Utils;
16use Monolog\Formatter\NormalizerFormatter;
17use Monolog\Formatter\FormatterInterface;
18
19/**
20 * Slack record utility helping to log to Slack webhooks or API.
21 *
22 * @author Greg Kedzierski <greg@gregkedzierski.com>
23 * @author Haralan Dobrev <hkdobrev@gmail.com>
24 * @see    https://api.slack.com/incoming-webhooks
25 * @see    https://api.slack.com/docs/message-attachments
26 *
27 * @phpstan-import-type FormattedRecord from \Monolog\Handler\AbstractProcessingHandler
28 * @phpstan-import-type Record from \Monolog\Logger
29 */
30class SlackRecord
31{
32    public const COLOR_DANGER = 'danger';
33
34    public const COLOR_WARNING = 'warning';
35
36    public const COLOR_GOOD = 'good';
37
38    public const COLOR_DEFAULT = '#e3e4e6';
39
40    /**
41     * Slack channel (encoded ID or name)
42     * @var string|null
43     */
44    private $channel;
45
46    /**
47     * Name of a bot
48     * @var string|null
49     */
50    private $username;
51
52    /**
53     * User icon e.g. 'ghost', 'http://example.com/user.png'
54     * @var string|null
55     */
56    private $userIcon;
57
58    /**
59     * Whether the message should be added to Slack as attachment (plain text otherwise)
60     * @var bool
61     */
62    private $useAttachment;
63
64    /**
65     * Whether the the context/extra messages added to Slack as attachments are in a short style
66     * @var bool
67     */
68    private $useShortAttachment;
69
70    /**
71     * Whether the attachment should include context and extra data
72     * @var bool
73     */
74    private $includeContextAndExtra;
75
76    /**
77     * Dot separated list of fields to exclude from slack message. E.g. ['context.field1', 'extra.field2']
78     * @var string[]
79     */
80    private $excludeFields;
81
82    /**
83     * @var ?FormatterInterface
84     */
85    private $formatter;
86
87    /**
88     * @var NormalizerFormatter
89     */
90    private $normalizerFormatter;
91
92    /**
93     * @param string[] $excludeFields
94     */
95    public function __construct(
96        ?string $channel = null,
97        ?string $username = null,
98        bool $useAttachment = true,
99        ?string $userIcon = null,
100        bool $useShortAttachment = false,
101        bool $includeContextAndExtra = false,
102        array $excludeFields = array(),
103        FormatterInterface $formatter = null
104    ) {
105        $this
106            ->setChannel($channel)
107            ->setUsername($username)
108            ->useAttachment($useAttachment)
109            ->setUserIcon($userIcon)
110            ->useShortAttachment($useShortAttachment)
111            ->includeContextAndExtra($includeContextAndExtra)
112            ->excludeFields($excludeFields)
113            ->setFormatter($formatter);
114
115        if ($this->includeContextAndExtra) {
116            $this->normalizerFormatter = new NormalizerFormatter();
117        }
118    }
119
120    /**
121     * Returns required data in format that Slack
122     * is expecting.
123     *
124     * @phpstan-param FormattedRecord $record
125     * @phpstan-return mixed[]
126     */
127    public function getSlackData(array $record): array
128    {
129        $dataArray = array();
130        $record = $this->removeExcludedFields($record);
131
132        if ($this->username) {
133            $dataArray['username'] = $this->username;
134        }
135
136        if ($this->channel) {
137            $dataArray['channel'] = $this->channel;
138        }
139
140        if ($this->formatter && !$this->useAttachment) {
141            /** @phpstan-ignore-next-line */
142            $message = $this->formatter->format($record);
143        } else {
144            $message = $record['message'];
145        }
146
147        if ($this->useAttachment) {
148            $attachment = array(
149                'fallback'    => $message,
150                'text'        => $message,
151                'color'       => $this->getAttachmentColor($record['level']),
152                'fields'      => array(),
153                'mrkdwn_in'   => array('fields'),
154                'ts'          => $record['datetime']->getTimestamp(),
155                'footer'      => $this->username,
156                'footer_icon' => $this->userIcon,
157            );
158
159            if ($this->useShortAttachment) {
160                $attachment['title'] = $record['level_name'];
161            } else {
162                $attachment['title'] = 'Message';
163                $attachment['fields'][] = $this->generateAttachmentField('Level', $record['level_name']);
164            }
165
166            if ($this->includeContextAndExtra) {
167                foreach (array('extra', 'context') as $key) {
168                    if (empty($record[$key])) {
169                        continue;
170                    }
171
172                    if ($this->useShortAttachment) {
173                        $attachment['fields'][] = $this->generateAttachmentField(
174                            (string) $key,
175                            $record[$key]
176                        );
177                    } else {
178                        // Add all extra fields as individual fields in attachment
179                        $attachment['fields'] = array_merge(
180                            $attachment['fields'],
181                            $this->generateAttachmentFields($record[$key])
182                        );
183                    }
184                }
185            }
186
187            $dataArray['attachments'] = array($attachment);
188        } else {
189            $dataArray['text'] = $message;
190        }
191
192        if ($this->userIcon) {
193            if (filter_var($this->userIcon, FILTER_VALIDATE_URL)) {
194                $dataArray['icon_url'] = $this->userIcon;
195            } else {
196                $dataArray['icon_emoji'] = ":{$this->userIcon}:";
197            }
198        }
199
200        return $dataArray;
201    }
202
203    /**
204     * Returns a Slack message attachment color associated with
205     * provided level.
206     */
207    public function getAttachmentColor(int $level): string
208    {
209        switch (true) {
210            case $level >= Logger::ERROR:
211                return static::COLOR_DANGER;
212            case $level >= Logger::WARNING:
213                return static::COLOR_WARNING;
214            case $level >= Logger::INFO:
215                return static::COLOR_GOOD;
216            default:
217                return static::COLOR_DEFAULT;
218        }
219    }
220
221    /**
222     * Stringifies an array of key/value pairs to be used in attachment fields
223     *
224     * @param mixed[] $fields
225     */
226    public function stringify(array $fields): string
227    {
228        /** @var Record $fields */
229        $normalized = $this->normalizerFormatter->format($fields);
230
231        $hasSecondDimension = count(array_filter($normalized, 'is_array'));
232        $hasNonNumericKeys = !count(array_filter(array_keys($normalized), 'is_numeric'));
233
234        return $hasSecondDimension || $hasNonNumericKeys
235            ? Utils::jsonEncode($normalized, JSON_PRETTY_PRINT|Utils::DEFAULT_JSON_FLAGS)
236            : Utils::jsonEncode($normalized, Utils::DEFAULT_JSON_FLAGS);
237    }
238
239    /**
240     * Channel used by the bot when posting
241     *
242     * @param ?string $channel
243     *
244     * @return static
245     */
246    public function setChannel(?string $channel = null): self
247    {
248        $this->channel = $channel;
249
250        return $this;
251    }
252
253    /**
254     * Username used by the bot when posting
255     *
256     * @param ?string $username
257     *
258     * @return static
259     */
260    public function setUsername(?string $username = null): self
261    {
262        $this->username = $username;
263
264        return $this;
265    }
266
267    public function useAttachment(bool $useAttachment = true): self
268    {
269        $this->useAttachment = $useAttachment;
270
271        return $this;
272    }
273
274    public function setUserIcon(?string $userIcon = null): self
275    {
276        $this->userIcon = $userIcon;
277
278        if (\is_string($userIcon)) {
279            $this->userIcon = trim($userIcon, ':');
280        }
281
282        return $this;
283    }
284
285    public function useShortAttachment(bool $useShortAttachment = false): self
286    {
287        $this->useShortAttachment = $useShortAttachment;
288
289        return $this;
290    }
291
292    public function includeContextAndExtra(bool $includeContextAndExtra = false): self
293    {
294        $this->includeContextAndExtra = $includeContextAndExtra;
295
296        if ($this->includeContextAndExtra) {
297            $this->normalizerFormatter = new NormalizerFormatter();
298        }
299
300        return $this;
301    }
302
303    /**
304     * @param string[] $excludeFields
305     */
306    public function excludeFields(array $excludeFields = []): self
307    {
308        $this->excludeFields = $excludeFields;
309
310        return $this;
311    }
312
313    public function setFormatter(?FormatterInterface $formatter = null): self
314    {
315        $this->formatter = $formatter;
316
317        return $this;
318    }
319
320    /**
321     * Generates attachment field
322     *
323     * @param string|mixed[] $value
324     *
325     * @return array{title: string, value: string, short: false}
326     */
327    private function generateAttachmentField(string $title, $value): array
328    {
329        $value = is_array($value)
330            ? sprintf('```%s```', substr($this->stringify($value), 0, 1990))
331            : $value;
332
333        return array(
334            'title' => ucfirst($title),
335            'value' => $value,
336            'short' => false,
337        );
338    }
339
340    /**
341     * Generates a collection of attachment fields from array
342     *
343     * @param mixed[] $data
344     *
345     * @return array<array{title: string, value: string, short: false}>
346     */
347    private function generateAttachmentFields(array $data): array
348    {
349        /** @var Record $data */
350        $normalized = $this->normalizerFormatter->format($data);
351
352        $fields = array();
353        foreach ($normalized as $key => $value) {
354            $fields[] = $this->generateAttachmentField((string) $key, $value);
355        }
356
357        return $fields;
358    }
359
360    /**
361     * Get a copy of record with fields excluded according to $this->excludeFields
362     *
363     * @phpstan-param FormattedRecord $record
364     *
365     * @return mixed[]
366     */
367    private function removeExcludedFields(array $record): array
368    {
369        foreach ($this->excludeFields as $field) {
370            $keys = explode('.', $field);
371            $node = &$record;
372            $lastKey = end($keys);
373            foreach ($keys as $key) {
374                if (!isset($node[$key])) {
375                    break;
376                }
377                if ($lastKey === $key) {
378                    unset($node[$key]);
379                    break;
380                }
381                $node = &$node[$key];
382            }
383        }
384
385        return $record;
386    }
387}
388