├── .gitignore
├── .env.example
├── src
├── Exceptions
│ └── MessageNotificationException.php
├── Notify.php
├── Contracts
│ └── MessageNotifyInterface.php
├── Channel
│ ├── AbstractChannel.php
│ ├── WechatChannel.php
│ ├── MailChannel.php
│ ├── DingTalkChannel.php
│ └── FeiShuChannel.php
├── ConfigProvider.php
├── Template
│ ├── AbstractTemplate.php
│ ├── Text.php
│ └── Markdown.php
└── Client.php
├── phpstan.neon.dist
├── test
├── bootstrap.php
├── NotifyTest.php
└── MailChannelTest.php
├── phpunit.xml
├── LICENSE
├── composer.json
├── README.md
├── publish
└── message.php
└── .php-cs-fixer.php
/.gitignore:
--------------------------------------------------------------------------------
1 | !.gitignore
2 | !.gitattributes
3 | *.DS_Store
4 | *.idea
5 | *.svn
6 | *.git
7 | composer.lock
8 | *.cache
9 | vendor
10 | config
11 | .env
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # 钉钉群通知配置信息
2 | NOTIFY_DINGTALK_TOKEN=
3 | NOTIFY_DINGTALK_SECRET=
4 | NOTIFY_DINGTALK_KEYWORD=
5 |
6 | # 飞书群通知配置信息
7 | NOTIFY_FEISHU_TOKEN=
8 | NOTIFY_FEISHU_SECRET=
9 | NOTIFY_FEISHU_KEYWORD=
10 |
11 | # 微信群通知配置信息
12 | NOTIFY_WECHAT_TOKEN=
--------------------------------------------------------------------------------
/src/Exceptions/MessageNotificationException.php:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 | ./test
14 |
15 |
16 |
17 |
18 | ./src
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/src/Channel/AbstractChannel.php:
--------------------------------------------------------------------------------
1 | get('message.channels.' . get_class($this));
23 | }
24 |
25 | throw new MessageNotificationException('ApplicationContext is not exist');
26 | }
27 |
28 | abstract public function send(AbstractTemplate $template);
29 | }
30 |
--------------------------------------------------------------------------------
/src/ConfigProvider.php:
--------------------------------------------------------------------------------
1 | [
18 | MessageNotifyInterface::class => Client::class,
19 | ],
20 | 'annotations' => [
21 | 'scan' => [
22 | 'paths' => [
23 | __DIR__,
24 | ],
25 | ],
26 | ],
27 | 'publish' => [
28 | [
29 | 'id' => 'config',
30 | 'description' => 'The config of message client.',
31 | 'source' => __DIR__ . '/../publish/message.php',
32 | 'destination' => BASE_PATH . '/config/autoload/message.php',
33 | ],
34 | ],
35 | ];
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) Vinchan
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/test/NotifyTest.php:
--------------------------------------------------------------------------------
1 | setChannel(DingTalkChannel::class)
35 | ->setAt(['all'])
36 | ->setTitle('标题')
37 | ->setText('测试')
38 | ->setPipeline(MessageNotifyInterface::INFO)
39 | ->setTemplate(Markdown::class)
40 | ->send();
41 |
42 | $this->assertTrue($notify);
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vinchan/message-notify",
3 | "description": "MIT",
4 | "license": "MIT",
5 | "authors": [
6 | {
7 | "name": "Vinchan",
8 | "email": "ademo@vip.qq.com"
9 | }
10 | ],
11 | "autoload": {
12 | "psr-4": {
13 | "MessageNotify\\": "src/"
14 | }
15 | },
16 | "autoload-dev": {
17 | "psr-4": {
18 | "MessageNotifyTest\\": "test/"
19 | }
20 | },
21 | "require": {
22 | "php": ">=8.1",
23 | "ext-json": "*",
24 | "hyperf/guzzle": "~3.1.0",
25 | "symfony/mailer": "^6.0"
26 | },
27 | "require-dev": {
28 | "friendsofphp/php-cs-fixer": "^3.0",
29 | "hyperf/config": "~3.1.0",
30 | "hyperf/di": "~3.1.0",
31 | "hyperf/ide-helper": "~3.1.0",
32 | "hyperf/utils": "~3.1.0",
33 | "phpstan/phpstan": "^1.11",
34 | "phpunit/phpunit": "^9.4"
35 | },
36 | "scripts": {
37 | "test": "phpunit -c phpunit.xml --colors=always",
38 | "cs-fix": "./vendor/bin/php-cs-fixer fix",
39 | "analyse": "phpstan analyse --memory-limit 4028M"
40 | },
41 | "config": {
42 | "sort-packages": true
43 | },
44 | "extra": {
45 | "hyperf": {
46 | "config": "MessageNotify\\ConfigProvider"
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## 消息通知组件
2 |
3 | ## 功能
4 |
5 | * 监控发送应用异常
6 | * 支持多种通道(钉钉群机器人、飞书群机器人、邮件、QQ 频道机器人、企业微信群机器人)
7 | * 支持扩展自定义通道
8 |
9 | ## 环境要求
10 |
11 | | 组件版本 | 框架版本 |
12 | |------|--------------|
13 | | v2.0 | hyperf 2.0.* |
14 | | v3.0 | hyperf 3.0.* |
15 | | v3.0 | hyperf 3.1.* |
16 | *
17 |
18 | ## 安装
19 |
20 | ```bash
21 | composer require vinchan/message-notify -vvv
22 | ```
23 |
24 | ## 配置文件
25 |
26 | 发布配置文件`config/message.php`
27 |
28 | ```bash
29 | hyperf vendor:publish vinchan/message-notify
30 | ```
31 |
32 |
33 | ## 使用
34 | ```php
35 | Notify::make()->setChannel(DingTalkChannel::class)
36 | ->setTemplate(Text::class)
37 | ->setTitle('标题')->setText('内容')->setAt(['all'])->setPipeline('info')
38 | ->send();
39 | ```
40 |
41 | ## 通道
42 |
43 | | 通道名称 | 命名空间 | 支持格式 |
44 | |-------|----------------------------------------|---------------|
45 | | 钉钉群 | \MessageNotify\Channel\DingTalkChannel | Text、Markdown |
46 | | 飞书群 | \MessageNotify\Channel\FeiShuChannel | Text、Markdown |
47 | | 企业微信群 | \MessageNotify\Channel\WechatChannel | Text、Markdown |
48 | | 邮件 | \MessageNotify\Channel\MailChannel | Text、Markdown |
49 |
50 | ## 格式
51 |
52 | | 格式名称 | 命名空间 |
53 | |----------|----------------------------------|
54 | | Text | \MessageNotify\Template\Text |
55 | | Markdown | \MessageNotify\Template\Markdown |
56 |
57 | ## 协议
58 |
59 | MIT 许可证(MIT)。有关更多信息,请参见[协议文件](LICENSE)。
--------------------------------------------------------------------------------
/src/Template/AbstractTemplate.php:
--------------------------------------------------------------------------------
1 | text;
25 | }
26 |
27 | public function setText(string $text): AbstractTemplate
28 | {
29 | $this->text = $text;
30 | return $this;
31 | }
32 |
33 | public function getTitle(): string
34 | {
35 | return $this->title;
36 | }
37 |
38 | public function setTitle(string $title): AbstractTemplate
39 | {
40 | $this->title = $title;
41 | return $this;
42 | }
43 |
44 | public function getPipeline(): string
45 | {
46 | return $this->pipeline;
47 | }
48 |
49 | public function setPipeline(string $pipeline): AbstractTemplate
50 | {
51 | $this->pipeline = $pipeline;
52 | return $this;
53 | }
54 |
55 | public function setAt(array $at = []): AbstractTemplate
56 | {
57 | $this->at = $at;
58 | return $this;
59 | }
60 |
61 | public function getAt(): array
62 | {
63 | return $this->at;
64 | }
65 |
66 | public function isAtAll(): bool
67 | {
68 | return in_array('all', $this->at) || in_array('ALL', $this->at);
69 | }
70 |
71 | abstract public function getBody();
72 | }
73 |
--------------------------------------------------------------------------------
/src/Channel/WechatChannel.php:
--------------------------------------------------------------------------------
1 | getClient($template->getPipeline());
28 |
29 | $option = [
30 | RequestOptions::HEADERS => [],
31 | RequestOptions::JSON => $template->wechatBody(),
32 | ];
33 | $request = $client->post('', $option);
34 | $result = json_decode($request->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR);
35 |
36 | if ($result['errcode'] !== 0) {
37 | throw new MessageNotificationException($result['errmsg']);
38 | }
39 |
40 | return true;
41 | }
42 |
43 | private function getClient(string $pipeline)
44 | {
45 | $config = $this->config($pipeline);
46 |
47 | $uri['base_uri'] = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=' . $config['token'];
48 |
49 | if (class_exists(ApplicationContext::class)) {
50 | return make(Client::class, [$uri]);
51 | }
52 |
53 | return new Client($config);
54 | }
55 |
56 | private function config(string $pipeline)
57 | {
58 | $config = $this->getConfig();
59 | return $config['pipeline'][$pipeline] ?? $config['pipeline'][$config['default']];
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/Channel/MailChannel.php:
--------------------------------------------------------------------------------
1 | getConfig();
25 | $config = $config['pipeline'][$template->getPipeline()] ?? $config['pipeline'][$config['default']];
26 |
27 | if (empty($config['dsn']) || empty($config['from']) || empty($config['to'])) {
28 | throw new MessageNotificationException('Mail configuration is incomplete');
29 | }
30 |
31 | try {
32 | $transport = Transport::fromDsn($config['dsn']);
33 | $mailer = new Mailer($transport);
34 |
35 | $email = new Email();
36 | $email->from($config['from'])
37 | ->to(...(is_array($config['to']) ? $config['to'] : [$config['to']]))
38 | ->subject($template->getTitle());
39 |
40 | // 根据模板类型设置邮件内容
41 | if ($template instanceof MarkdownTemplate) {
42 | // 对于Markdown模板,使用HTML格式
43 | $email->html($template->getText());
44 | } else {
45 | // 对于其他模板类型,使用纯文本格式
46 | $email->text($template->getText());
47 | }
48 |
49 | $mailer->send($email);
50 | return true;
51 | } catch (\Throwable $e) {
52 | throw new MessageNotificationException('Failed to send email: ' . $e->getMessage());
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/Template/Text.php:
--------------------------------------------------------------------------------
1 | 'text',
21 | 'text' => [
22 | 'content' => $this->getText(),
23 | ],
24 | 'at' => [
25 | 'isAtAll' => $this->isAtAll(),
26 | 'atMobiles' => $this->getAt(),
27 | ],
28 | ];
29 | }
30 |
31 | public function feiShuBody(): array
32 | {
33 | return [
34 | 'msg_type' => 'text',
35 | 'content' => [
36 | 'text' => $this->getText() . $this->getFeiShuAt(),
37 | ],
38 | ];
39 | }
40 |
41 | public function wechatBody(): array
42 | {
43 | return [
44 | 'msgtype' => 'text',
45 | 'text' => [
46 | 'content' => $this->getText(),
47 | 'mentioned_list' => in_array('all', $this->getAt()) ? [] : [$this->getAt()],
48 | 'mentioned_mobile_list' => in_array('all', $this->getAt()) ? ['@all'] : [$this->getAt()],
49 | ],
50 | ];
51 | }
52 |
53 | private function getFeiShuAt(): string
54 | {
55 | if ($this->isAtAll()) {
56 | return '所有人';
57 | }
58 |
59 | $at = $this->getAt();
60 | $result = '';
61 | foreach ($at as $item) {
62 | if (! str_contains($item, '@')) {
63 | $result .= '' . $item . '';
64 | } else {
65 | $result .= '' . $item . '';
66 | }
67 | }
68 | return $result;
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/Channel/DingTalkChannel.php:
--------------------------------------------------------------------------------
1 | getQuery($template->getPipeline());
28 |
29 | $client = $this->getClient($query);
30 |
31 | $option = [
32 | RequestOptions::HEADERS => [],
33 | RequestOptions::JSON => $template->dingTalkBody(),
34 | ];
35 | $request = $client->post('', $option);
36 | $result = json_decode($request->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR);
37 |
38 | if ($result['errcode'] !== 0) {
39 | throw new MessageNotificationException($result['errmsg']);
40 | }
41 |
42 | return true;
43 | }
44 |
45 | public function getClient(string $query)
46 | {
47 | $config['base_uri'] = 'https://oapi.dingtalk.com/robot/send' . $query;
48 |
49 | if (class_exists(ApplicationContext::class)) {
50 | return make(Client::class, [$config]);
51 | }
52 |
53 | return new Client($config);
54 | }
55 |
56 | private function getQuery(string $pipeline): string
57 | {
58 | $timestamp = time() * 1000;
59 |
60 | $config = $this->getConfig();
61 | $config = $config['pipeline'][$pipeline] ?? $config['pipeline'][$config['default']];
62 |
63 | $secret = hash_hmac('sha256', $timestamp . "\n" . $config['secret'], $config['secret'], true);
64 | $sign = urlencode(base64_encode($secret));
65 | return "?access_token={$config['token']}×tamp={$timestamp}&sign={$sign}";
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/Channel/FeiShuChannel.php:
--------------------------------------------------------------------------------
1 | getClient($template->getPipeline());
28 |
29 | $timestamp = time();
30 | $config = [
31 | 'timestamp' => $timestamp,
32 | 'sign' => $this->getSign($timestamp, $template->getPipeline()),
33 | ];
34 |
35 | $option = [
36 | RequestOptions::HEADERS => [],
37 | RequestOptions::JSON => array_merge($config, $template->feiShuBody()),
38 | ];
39 |
40 | $request = $client->post('', $option);
41 | $result = json_decode($request->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR);
42 |
43 | if (! isset($result['StatusCode']) || $result['StatusCode'] !== 0) {
44 | throw new MessageNotificationException($result['msg']);
45 | }
46 |
47 | return true;
48 | }
49 |
50 | public function getClient(string $pipeline)
51 | {
52 | $config = $this->config($pipeline);
53 |
54 | $uri['base_uri'] = 'https://open.feishu.cn/open-apis/bot/v2/hook/' . $config['token'];
55 |
56 | if (class_exists(ApplicationContext::class)) {
57 | return make(Client::class, [$uri]);
58 | }
59 |
60 | return new Client($config);
61 | }
62 |
63 | private function getSign(int $timestamp, string $pipeline): string
64 | {
65 | $config = $this->config($pipeline);
66 | $secret = hash_hmac('sha256', '', $timestamp . "\n" . $config['secret'], true);
67 | return base64_encode($secret);
68 | }
69 |
70 | private function config(string $pipeline)
71 | {
72 | $config = $this->getConfig();
73 | return $config['pipeline'][$pipeline] ?? $config['pipeline'][$config['default']];
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/publish/message.php:
--------------------------------------------------------------------------------
1 | env('NOTIFY_DEFAULT_CHANNEL', 'mail'),
17 | 'channels' => [
18 | // 钉钉群机器人
19 | DingTalkChannel::class => [
20 | 'default' => MessageNotifyInterface::INFO,
21 | 'pipeline' => [
22 | // 业务信息告警群
23 | MessageNotifyInterface::INFO => [
24 | 'token' => env('NOTIFY_DINGTALK_TOKEN', ''),
25 | 'secret' => env('NOTIFY_DINGTALK_SECRET', ''),
26 | 'keyword' => env('NOTIFY_DINGTALK_KEYWORD', []),
27 | ],
28 | // 错误信息告警群
29 | MessageNotifyInterface::ERROR => [
30 | 'token' => env('NOTIFY_DINGTALK_TOKEN', ''),
31 | 'secret' => env('NOTIFY_DINGTALK_SECRET', ''),
32 | 'keyword' => env('NOTIFY_DINGTALK_KEYWORD', []),
33 | ],
34 | ],
35 | ],
36 |
37 | // 飞书群机器人
38 | FeiShuChannel::class => [
39 | 'default' => MessageNotifyInterface::INFO,
40 | 'pipeline' => [
41 | 'info' => [
42 | 'token' => env('NOTIFY_FEISHU_TOKEN', ''),
43 | 'secret' => env('NOTIFY_FEISHU_SECRET', ''),
44 | 'keyword' => env('NOTIFY_FEISHU_KEYWORD'),
45 | ],
46 | ],
47 | ],
48 |
49 | // 邮件
50 | MailChannel::class => [
51 | 'default' => MessageNotifyInterface::INFO,
52 | 'pipeline' => [
53 | 'info' => [
54 | 'dsn' => env('NOTIFY_MAIL_DSN'), // SMTP连接字符串,例如:smtp://user:pass@smtp.example.com:587
55 | 'from' => env('NOTIFY_MAIL_FROM'),
56 | 'to' => env('NOTIFY_MAIL_TO'),
57 | ],
58 | ],
59 | ],
60 |
61 | // 企业微信群机器人
62 | WechatChannel::class => [
63 | 'default' => MessageNotifyInterface::INFO,
64 | 'pipeline' => [
65 | 'info' => [
66 | 'token' => env('NOTIFY_WECHAT_TOKEN'),
67 | ],
68 | ],
69 | ],
70 | ],
71 | ];
72 |
--------------------------------------------------------------------------------
/.php-cs-fixer.php:
--------------------------------------------------------------------------------
1 | setRiskyAllowed(true)
14 | ->setRules([
15 | '@PSR2' => true,
16 | '@Symfony' => true,
17 | '@DoctrineAnnotation' => true,
18 | '@PhpCsFixer' => true,
19 | 'header_comment' => [
20 | 'comment_type' => 'PHPDoc',
21 | 'header' => $header,
22 | 'separate' => 'none',
23 | 'location' => 'after_declare_strict',
24 | ],
25 | 'array_syntax' => [
26 | 'syntax' => 'short',
27 | ],
28 | 'list_syntax' => [
29 | 'syntax' => 'short',
30 | ],
31 | 'concat_space' => [
32 | 'spacing' => 'one',
33 | ],
34 | 'blank_line_before_statement' => [
35 | 'statements' => [
36 | 'declare',
37 | ],
38 | ],
39 | 'general_phpdoc_annotation_remove' => [
40 | 'annotations' => [
41 | 'author',
42 | ],
43 | ],
44 | 'ordered_imports' => [
45 | 'imports_order' => [
46 | 'class', 'function', 'const',
47 | ],
48 | 'sort_algorithm' => 'alpha',
49 | ],
50 | 'single_line_comment_style' => [
51 | 'comment_types' => [
52 | ],
53 | ],
54 | 'yoda_style' => [
55 | 'always_move_variable' => false,
56 | 'equal' => false,
57 | 'identical' => false,
58 | ],
59 | 'phpdoc_align' => [
60 | 'align' => 'left',
61 | ],
62 | 'multiline_whitespace_before_semicolons' => [
63 | 'strategy' => 'no_multi_line',
64 | ],
65 | 'constant_case' => [
66 | 'case' => 'lower',
67 | ],
68 | 'class_attributes_separation' => true,
69 | 'combine_consecutive_unsets' => true,
70 | 'declare_strict_types' => true,
71 | 'linebreak_after_opening_tag' => true,
72 | 'lowercase_static_reference' => true,
73 | 'no_useless_else' => true,
74 | 'no_unused_imports' => true,
75 | 'not_operator_with_successor_space' => true,
76 | 'not_operator_with_space' => false,
77 | 'ordered_class_elements' => true,
78 | 'php_unit_strict' => false,
79 | 'phpdoc_separation' => false,
80 | 'single_quote' => true,
81 | 'standardize_not_equals' => true,
82 | 'multiline_comment_opening_closing' => true,
83 | ])
84 | ->setFinder(
85 | Finder::create()
86 | ->exclude('public')
87 | ->exclude('runtime')
88 | ->exclude('vendor')
89 | ->in(__DIR__)
90 | )
91 | ->setUsingCache(false);
92 |
--------------------------------------------------------------------------------
/src/Template/Markdown.php:
--------------------------------------------------------------------------------
1 | 'markdown',
21 | 'markdown' => [
22 | 'title' => $this->getTitle(),
23 | 'text' => $this->getText(),
24 | ],
25 | 'at' => [
26 | 'isAtAll' => $this->isAtAll(),
27 | 'atMobiles' => $this->getAt(),
28 | ],
29 | ];
30 | }
31 |
32 | public function feiShuBody(): array
33 | {
34 | return [
35 | 'msg_type' => 'post',
36 | 'content' => [
37 | 'post' => [
38 | 'zh_cn' => [
39 | 'title' => $this->getTitle(),
40 | 'content' => [$this->getFeiShuText()],
41 | ],
42 | ],
43 | ],
44 | ];
45 | }
46 |
47 | public function wechatBody(): array
48 | {
49 | return [
50 | 'msgtype' => 'markdown',
51 | 'markdown' => [
52 | 'content' => $this->getTitle() . $this->getText(),
53 | 'mentioned_list' => in_array('all', $this->getAt()) ? [] : [$this->getAt()],
54 | 'mentioned_mobile_list' => in_array('all', $this->getAt()) ? ['@all'] : [$this->getAt()],
55 | ],
56 | ];
57 | }
58 |
59 | private function getFeiShuText(): array
60 | {
61 | $text = is_array($this->getText()) ? $this->getText() : json_decode($this->getText(), true) ?? [
62 | [
63 | 'tag' => 'text',
64 | 'text' => $this->getText(),
65 | ],
66 | ];
67 |
68 | $at = $this->getFeiShuAt();
69 |
70 | return array_merge($text, $at);
71 | }
72 |
73 | private function getFeiShuAt(): array
74 | {
75 | $result = [];
76 | if ($this->isAtAll()) {
77 | $result[] = [
78 | 'tag' => 'at',
79 | 'user_id' => 'all',
80 | ];
81 |
82 | return $result;
83 | }
84 |
85 | $at = $this->getAt();
86 | foreach ($at as $item) {
87 | // TODO::需要加入邮箱与收集@人
88 | if (strchr($item, '@') === false) {
89 | $result[] = [
90 | 'tag' => 'at',
91 | 'email' => $item,
92 | ];
93 | } else {
94 | $result[] = [
95 | 'tag' => 'at',
96 | 'user_id' => $item,
97 | ];
98 | }
99 | }
100 |
101 | return $result;
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/src/Client.php:
--------------------------------------------------------------------------------
1 | channel;
37 | }
38 |
39 | public function getTemplate(): AbstractTemplate
40 | {
41 | return $this->template ?? new Text();
42 | }
43 |
44 | public function getAt(): array
45 | {
46 | return $this->at;
47 | }
48 |
49 | public function getTitle(): string
50 | {
51 | return $this->title;
52 | }
53 |
54 | public function getText(): string
55 | {
56 | return $this->text;
57 | }
58 |
59 | public function setChannel($channel = null): Client
60 | {
61 | if (! $channel instanceof AbstractChannel) {
62 | $channel = make($channel);
63 | }
64 |
65 | $this->channel = $channel;
66 | return $this;
67 | }
68 |
69 | public function setTemplate($template = ''): Client
70 | {
71 | if (! $template instanceof AbstractChannel) {
72 | $template = make($template);
73 | }
74 |
75 | $this->template = $template;
76 | return $this;
77 | }
78 |
79 | public function getPipeline(): string
80 | {
81 | return $this->pipeline;
82 | }
83 |
84 | public function setPipeline(string $pipeline = ''): Client
85 | {
86 | $this->pipeline = $pipeline ?? MessageNotifyInterface::INFO;
87 | return $this;
88 | }
89 |
90 | public function setAt(array $at = []): Client
91 | {
92 | $this->at = $at;
93 | return $this;
94 | }
95 |
96 | public function setTitle(string $title = ''): Client
97 | {
98 | $this->title = $title;
99 | return $this;
100 | }
101 |
102 | public function setText(string $text = ''): Client
103 | {
104 | $this->text = $text;
105 | return $this;
106 | }
107 |
108 | public function send(): bool
109 | {
110 | try {
111 | $template = $this->getTemplate()
112 | ->setAt($this->getAt())
113 | ->setTitle($this->getTitle())
114 | ->setText($this->getText())
115 | ->setPipeline($this->getPipeline());
116 |
117 | $this->getChannel()->send($template);
118 | return true;
119 | } catch (\Throwable $throwable) {
120 | throw new MessageNotificationException($throwable->getMessage());
121 | }
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/test/MailChannelTest.php:
--------------------------------------------------------------------------------
1 | loadEnvConfig();
36 |
37 | // 创建测试用MailChannel
38 | $this->mailChannel = new class($this->config) extends MailChannel {
39 | private array $testConfig;
40 |
41 | public function __construct(array $config)
42 | {
43 | $this->testConfig = $config;
44 | }
45 |
46 | public function getConfig(): array
47 | {
48 | return $this->testConfig;
49 | }
50 | };
51 | }
52 |
53 | /**
54 | * 测试Text模板邮件发送
55 | */
56 | public function testSendTextMail(): void
57 | {
58 | // 检查是否配置了环境变量
59 | if (empty($this->config)) {
60 | $this->markTestSkipped('没有配置邮件环境变量,跳过测试');
61 | }
62 |
63 | $template = new Text();
64 | $template->setTitle('测试邮件 - Text格式')
65 | ->setText('这是一封测试邮件,用于测试邮件发送功能。')
66 | ->setPipeline(MessageNotifyInterface::INFO);
67 |
68 | try {
69 | $result = $this->mailChannel->send($template);
70 | $this->assertTrue($result, '邮件发送成功');
71 | } catch (\Throwable $e) {
72 | $this->fail('邮件发送失败: ' . $e->getMessage());
73 | }
74 | }
75 |
76 | /**
77 | * 测试Markdown模板邮件发送
78 | */
79 | public function testSendMarkdownMail(): void
80 | {
81 | // 检查是否配置了环境变量
82 | if (empty($this->config)) {
83 | $this->markTestSkipped('没有配置邮件环境变量,跳过测试');
84 | }
85 |
86 | $template = new Markdown();
87 | $template->setTitle('测试邮件 - Markdown格式')
88 | ->setText("## 测试内容\n\n**这是测试内容**\n\n发送时间: " . date('Y-m-d H:i:s'))
89 | ->setPipeline(MessageNotifyInterface::INFO);
90 |
91 | try {
92 | $result = $this->mailChannel->send($template);
93 | $this->assertTrue($result, '邮件发送成功');
94 | } catch (\Throwable $e) {
95 | $this->fail('邮件发送失败: ' . $e->getMessage());
96 | }
97 | }
98 |
99 | /**
100 | * 测试错误配置抛出异常.
101 | */
102 | public function testInvalidConfigThrowsException(): void
103 | {
104 | $invalidChannel = new class extends MailChannel {
105 | public function getConfig(): array
106 | {
107 | return [
108 | 'default' => MessageNotifyInterface::INFO,
109 | 'pipeline' => [
110 | MessageNotifyInterface::INFO => [
111 | // 缺少必须的配置项
112 | ],
113 | ],
114 | ];
115 | }
116 | };
117 |
118 | $template = new Text();
119 | $template->setTitle('测试标题')
120 | ->setText('这是测试内容')
121 | ->setPipeline(MessageNotifyInterface::INFO);
122 |
123 | $this->expectException(MessageNotificationException::class);
124 | $invalidChannel->send($template);
125 | }
126 |
127 | /**
128 | * 从.env加载邮件配置.
129 | */
130 | private function loadEnvConfig(): void
131 | {
132 | // 尝试加载.env文件
133 | $envFile = dirname(__DIR__) . '/.env';
134 |
135 | if (! file_exists($envFile)) {
136 | $this->config = [];
137 | return;
138 | }
139 |
140 | try {
141 | // 解析.env文件
142 | $lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
143 | $env = [];
144 | foreach ($lines as $line) {
145 | // 跳过注释和无效行
146 | if (str_starts_with(trim($line), '#') || ! str_contains($line, '=')) {
147 | continue;
148 | }
149 |
150 | [$name, $value] = explode('=', $line, 2);
151 | $name = trim($name);
152 | $value = trim($value);
153 |
154 | // 移除引号
155 | if (str_starts_with($value, '"') && str_ends_with($value, '"')) {
156 | $value = substr($value, 1, -1);
157 | } elseif (str_starts_with($value, "'") && str_ends_with($value, "'")) {
158 | $value = substr($value, 1, -1);
159 | }
160 |
161 | $env[$name] = $value;
162 | }
163 |
164 | // 检查是否有邮件配置
165 | if (! isset($env['NOTIFY_MAIL_DSN']) || ! isset($env['NOTIFY_MAIL_FROM']) || ! isset($env['NOTIFY_MAIL_TO'])) {
166 | $this->config = [];
167 | return;
168 | }
169 |
170 | // 设置测试配置
171 | $this->config = [
172 | 'default' => MessageNotifyInterface::INFO,
173 | 'pipeline' => [
174 | MessageNotifyInterface::INFO => [
175 | 'dsn' => $env['NOTIFY_MAIL_DSN'],
176 | 'from' => $env['NOTIFY_MAIL_FROM'],
177 | 'to' => $env['NOTIFY_MAIL_TO'],
178 | ],
179 | MessageNotifyInterface::ERROR => [
180 | 'dsn' => $env['NOTIFY_MAIL_DSN'] ?? $env['NOTIFY_MAIL_ERROR_DSN'] ?? '',
181 | 'from' => $env['NOTIFY_MAIL_FROM'] ?? $env['NOTIFY_MAIL_ERROR_FROM'] ?? '',
182 | 'to' => $env['NOTIFY_MAIL_TO'] ?? $env['NOTIFY_MAIL_ERROR_TO'] ?? '',
183 | ],
184 | ],
185 | ];
186 | } catch (\Throwable $e) {
187 | $this->config = [];
188 | }
189 | }
190 | }
191 |
--------------------------------------------------------------------------------