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 Monolog\Formatter\FormatterInterface;
15use Monolog\Logger;
16use Monolog\Utils;
17use Monolog\Handler\Slack\SlackRecord;
18
19/**
20 * Sends notifications through Slack API
21 *
22 * @author Greg Kedzierski <greg@gregkedzierski.com>
23 * @see    https://api.slack.com/
24 *
25 * @phpstan-import-type FormattedRecord from AbstractProcessingHandler
26 */
27class SlackHandler extends SocketHandler
28{
29    /**
30     * Slack API token
31     * @var string
32     */
33    private $token;
34
35    /**
36     * Instance of the SlackRecord util class preparing data for Slack API.
37     * @var SlackRecord
38     */
39    private $slackRecord;
40
41    /**
42     * @param  string                    $token                  Slack API token
43     * @param  string                    $channel                Slack channel (encoded ID or name)
44     * @param  string|null               $username               Name of a bot
45     * @param  bool                      $useAttachment          Whether the message should be added to Slack as attachment (plain text otherwise)
46     * @param  string|null               $iconEmoji              The emoji name to use (or null)
47     * @param  bool                      $useShortAttachment     Whether the context/extra messages added to Slack as attachments are in a short style
48     * @param  bool                      $includeContextAndExtra Whether the attachment should include context and extra data
49     * @param  string[]                  $excludeFields          Dot separated list of fields to exclude from slack message. E.g. ['context.field1', 'extra.field2']
50     * @throws MissingExtensionException If no OpenSSL PHP extension configured
51     */
52    public function __construct(
53        string $token,
54        string $channel,
55        ?string $username = null,
56        bool $useAttachment = true,
57        ?string $iconEmoji = null,
58        $level = Logger::CRITICAL,
59        bool $bubble = true,
60        bool $useShortAttachment = false,
61        bool $includeContextAndExtra = false,
62        array $excludeFields = array(),
63        bool $persistent = false,
64        float $timeout = 0.0,
65        float $writingTimeout = 10.0,
66        ?float $connectionTimeout = null,
67        ?int $chunkSize = null
68    ) {
69        if (!extension_loaded('openssl')) {
70            throw new MissingExtensionException('The OpenSSL PHP extension is required to use the SlackHandler');
71        }
72
73        parent::__construct(
74            'ssl://slack.com:443',
75            $level,
76            $bubble,
77            $persistent,
78            $timeout,
79            $writingTimeout,
80            $connectionTimeout,
81            $chunkSize
82        );
83
84        $this->slackRecord = new SlackRecord(
85            $channel,
86            $username,
87            $useAttachment,
88            $iconEmoji,
89            $useShortAttachment,
90            $includeContextAndExtra,
91            $excludeFields
92        );
93
94        $this->token = $token;
95    }
96
97    public function getSlackRecord(): SlackRecord
98    {
99        return $this->slackRecord;
100    }
101
102    public function getToken(): string
103    {
104        return $this->token;
105    }
106
107    /**
108     * {@inheritDoc}
109     */
110    protected function generateDataStream(array $record): string
111    {
112        $content = $this->buildContent($record);
113
114        return $this->buildHeader($content) . $content;
115    }
116
117    /**
118     * Builds the body of API call
119     *
120     * @phpstan-param FormattedRecord $record
121     */
122    private function buildContent(array $record): string
123    {
124        $dataArray = $this->prepareContentData($record);
125
126        return http_build_query($dataArray);
127    }
128
129    /**
130     * @phpstan-param FormattedRecord $record
131     * @return string[]
132     */
133    protected function prepareContentData(array $record): array
134    {
135        $dataArray = $this->slackRecord->getSlackData($record);
136        $dataArray['token'] = $this->token;
137
138        if (!empty($dataArray['attachments'])) {
139            $dataArray['attachments'] = Utils::jsonEncode($dataArray['attachments']);
140        }
141
142        return $dataArray;
143    }
144
145    /**
146     * Builds the header of the API Call
147     */
148    private function buildHeader(string $content): string
149    {
150        $header = "POST /api/chat.postMessage HTTP/1.1\r\n";
151        $header .= "Host: slack.com\r\n";
152        $header .= "Content-Type: application/x-www-form-urlencoded\r\n";
153        $header .= "Content-Length: " . strlen($content) . "\r\n";
154        $header .= "\r\n";
155
156        return $header;
157    }
158
159    /**
160     * {@inheritDoc}
161     */
162    protected function write(array $record): void
163    {
164        parent::write($record);
165        $this->finalizeWrite();
166    }
167
168    /**
169     * Finalizes the request by reading some bytes and then closing the socket
170     *
171     * If we do not read some but close the socket too early, slack sometimes
172     * drops the request entirely.
173     */
174    protected function finalizeWrite(): void
175    {
176        $res = $this->getResource();
177        if (is_resource($res)) {
178            @fread($res, 2048);
179        }
180        $this->closeSocket();
181    }
182
183    public function setFormatter(FormatterInterface $formatter): HandlerInterface
184    {
185        parent::setFormatter($formatter);
186        $this->slackRecord->setFormatter($formatter);
187
188        return $this;
189    }
190
191    public function getFormatter(): FormatterInterface
192    {
193        $formatter = parent::getFormatter();
194        $this->slackRecord->setFormatter($formatter);
195
196        return $formatter;
197    }
198
199    /**
200     * Channel used by the bot when posting
201     */
202    public function setChannel(string $channel): self
203    {
204        $this->slackRecord->setChannel($channel);
205
206        return $this;
207    }
208
209    /**
210     * Username used by the bot when posting
211     */
212    public function setUsername(string $username): self
213    {
214        $this->slackRecord->setUsername($username);
215
216        return $this;
217    }
218
219    public function useAttachment(bool $useAttachment): self
220    {
221        $this->slackRecord->useAttachment($useAttachment);
222
223        return $this;
224    }
225
226    public function setIconEmoji(string $iconEmoji): self
227    {
228        $this->slackRecord->setUserIcon($iconEmoji);
229
230        return $this;
231    }
232
233    public function useShortAttachment(bool $useShortAttachment): self
234    {
235        $this->slackRecord->useShortAttachment($useShortAttachment);
236
237        return $this;
238    }
239
240    public function includeContextAndExtra(bool $includeContextAndExtra): self
241    {
242        $this->slackRecord->includeContextAndExtra($includeContextAndExtra);
243
244        return $this;
245    }
246
247    /**
248     * @param string[] $excludeFields
249     */
250    public function excludeFields(array $excludeFields): self
251    {
252        $this->slackRecord->excludeFields($excludeFields);
253
254        return $this;
255    }
256}
257