commit 62cfeb1772d1c501fd848f456496560cc3934c86 Author: ykxiao Date: Wed Jan 31 09:30:04 2024 +0800 消息组件初始版本 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8585396 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +!.gitignore +!.gitattributes +*.DS_Store +*.idea +*.svn +*.git +composer.lock +*.cache +vendor +config \ No newline at end of file diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php new file mode 100644 index 0000000..5d18ceb --- /dev/null +++ b/.php-cs-fixer.php @@ -0,0 +1,85 @@ +setRiskyAllowed(true) + ->setRules([ + '@PSR2' => true, + '@Symfony' => true, + '@DoctrineAnnotation' => true, + '@PhpCsFixer' => true, + 'header_comment' => [ + 'comment_type' => 'PHPDoc', + 'header' => $header, + 'separate' => 'none', + 'location' => 'after_declare_strict', + ], + 'array_syntax' => [ + 'syntax' => 'short', + ], + 'list_syntax' => [ + 'syntax' => 'short', + ], + 'concat_space' => [ + 'spacing' => 'one', + ], + 'blank_line_before_statement' => [ + 'statements' => [ + 'declare', + ], + ], + 'general_phpdoc_annotation_remove' => [ + 'annotations' => [ + 'author', + ], + ], + 'ordered_imports' => [ + 'imports_order' => [ + 'class', 'function', 'const', + ], + 'sort_algorithm' => 'alpha', + ], + 'single_line_comment_style' => [ + 'comment_types' => [ + ], + ], + 'yoda_style' => [ + 'always_move_variable' => false, + 'equal' => false, + 'identical' => false, + ], + 'phpdoc_align' => [ + 'align' => 'left', + ], + 'multiline_whitespace_before_semicolons' => [ + 'strategy' => 'no_multi_line', + ], + 'constant_case' => [ + 'case' => 'lower', + ], + 'class_attributes_separation' => true, + 'combine_consecutive_unsets' => true, + 'declare_strict_types' => true, + 'linebreak_after_opening_tag' => true, + 'lowercase_static_reference' => true, + 'no_useless_else' => true, + 'no_unused_imports' => true, + 'not_operator_with_successor_space' => true, + 'not_operator_with_space' => false, + 'ordered_class_elements' => true, + 'php_unit_strict' => false, + 'phpdoc_separation' => false, + 'single_quote' => true, + 'standardize_not_equals' => true, + 'multiline_comment_opening_closing' => true, + ]) + ->setFinder( + \PhpCsFixer\Finder::create() + ->exclude('public') + ->exclude('runtime') + ->exclude('vendor') + ->in(__DIR__) + ) + ->setUsingCache(false) + ; diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e498939 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Vinchan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..eb79289 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ + +## 消息通知组件 + +## 功能 + +* 监控发送应用异常 +* 支持多种通道(钉钉群机器人、飞书群机器人、邮件、QQ 频道机器人、企业微信群机器人) +* 支持扩展自定义通道 + +## 环境要求 + +* hyperf >= 2.0 + +## 安装 + +```bash +composer require vinchan/message-notify -vvv +``` + +## 配置文件 + +发布配置文件`config/message.php` + +```bash +hyperf vendor:publish vinchan/message-notify +``` + + +## 使用 +```php +Notify::make()->setChannel(DingTalkChannel::class) +->setTemplate(Text::class) +->setTitle('标题')->setText('内容')->setAt(['all'])->setPipeline('info') +->send(); +``` + +## 通道 + +| 通道名称 | 命名空间 | 支持格式 | +|-------|----------------------------------------|---------------| +| 钉钉群 | \MessageNotify\Channel\DingTalkChannel | Text、Markdown | +| 飞书群 | \MessageNotify\Channel\FeiShuChannel | Text、Markdown | +| 企业微信群 | \MessageNotify\Channel\WechatChannel | Text、Markdown | + +## 格式 + +| 格式名称 | 命名空间 | +|----------|----------------------------------| +| Text | \MessageNotify\Template\Text | +| Markdown | \MessageNotify\Template\Markdown | + +## 协议 + +MIT 许可证(MIT)。有关更多信息,请参见[协议文件](LICENSE)。 \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a199ccd --- /dev/null +++ b/composer.json @@ -0,0 +1,46 @@ +{ + "name": "ykxiao/easy-message", + "description": "Modify the extended fork version", + "license": "MIT", + "authors": [ + { + "name": "ykxiao", + "email": "yk_9001@icloud.com" + } + ], + "autoload": { + "psr-4": { + "EasyMessage\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "EasyMessageTest\\": "test/" + } + }, + "require": { + "php": ">=7.4", + "ext-json": "*", + "hyperf/guzzle": "^1.1|^2.1|^3.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.0", + "phpunit/phpunit": "^9.4", + "hyperf/di": "^2.2", + "hyperf/utils": "^2.0", + "hyperf/config": "*", + "hyperf/ide-helper": "v2.2.*" + }, + "scripts": { + "test": "phpunit -c phpunit.xml --colors=always", + "cs-fix": "./vendor/bin/php-cs-fixer fix" + }, + "config": { + "sort-packages": true + }, + "extra": { + "hyperf": { + "config": "EasyMessage\\ConfigProvider" + } + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..35e65b3 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,21 @@ + + + + + ./test + + + + + ./src + + + diff --git a/publish/message.php b/publish/message.php new file mode 100644 index 0000000..e6d80fb --- /dev/null +++ b/publish/message.php @@ -0,0 +1,67 @@ + env('NOTIFY_DEFAULT_CHANNEL', 'mail'), + 'channels' => [ + // 钉钉群机器人 + DingTalkChannel::class => [ + 'default' => MessageNotifyInterface::INFO, + 'pipeline' => [ + // 业务信息告警群 + MessageNotifyInterface::INFO => [ + 'token' => env('NOTIFY_DINGTALK_TOKEN', ''), + 'secret' => env('NOTIFY_DINGTALK_SECRET', ''), + 'keyword' => env('NOTIFY_DINGTALK_KEYWORD', []), + ], + // 错误信息告警群 + MessageNotifyInterface::ERROR => [ + 'token' => env('NOTIFY_DINGTALK_TOKEN', ''), + 'secret' => env('NOTIFY_DINGTALK_SECRET', ''), + 'keyword' => env('NOTIFY_DINGTALK_KEYWORD', []), + ], + ], + ], + + // 飞书群机器人 + FeiShuChannel::class => [ + 'default' => MessageNotifyInterface::INFO, + 'pipeline' => [ + 'info' => [ + 'token' => env('NOTIFY_FEISHU_TOKEN', ''), + 'secret' => env('NOTIFY_FEISHU_SECRET', ''), + 'keyword' => env('NOTIFY_FEISHU_KEYWORD'), + ], + ], + ], + + // 邮件 + MailChannel::class => [ + 'default' => MessageNotifyInterface::INFO, + 'pipeline' => [ + 'info' => [ + 'dsn' => env('NOTIFY_MAIL_DSN'), + 'from' => env('NOTIFY_MAIL_FROM'), + 'to' => env('NOTIFY_MAIL_TO'), + ], + ], + ], + + // 企业微信群机器人 + WechatChannel::class => [ + 'default' => MessageNotifyInterface::INFO, + 'pipeline' => [ + 'info' => [ + 'token' => env('NOTIFY_WECHAT_TOKEN'), + ], + ], + ], + ], +]; diff --git a/src/Channel/AbstractChannel.php b/src/Channel/AbstractChannel.php new file mode 100644 index 0000000..b22ee7f --- /dev/null +++ b/src/Channel/AbstractChannel.php @@ -0,0 +1,25 @@ +get('message.channels.' . get_class($this)); + } + + throw new MessageNotificationException('ApplicationContext is not exist'); + } + + abstract public function send(AbstractTemplate $template); +} diff --git a/src/Channel/DingTalkChannel.php b/src/Channel/DingTalkChannel.php new file mode 100644 index 0000000..089bd6d --- /dev/null +++ b/src/Channel/DingTalkChannel.php @@ -0,0 +1,60 @@ +getQuery($template->getPipeline()); + + $client = $this->getClient($query); + + $option = [ + RequestOptions::HEADERS => [], + RequestOptions::JSON => $template->dingTalkBody(), + ]; + $request = $client->post('', $option); + $result = json_decode($request->getBody()->getContents(), true); + + if ($result['errcode'] !== 0) { + throw new MessageNotificationException($result['errmsg']); + } + + return true; + } + + public function getClient(string $query) + { + $config['base_uri'] = 'https://oapi.dingtalk.com/robot/send' . $query; + + if (class_exists(\Hyperf\Utils\ApplicationContext::class)) { + return make(Client::class, [$config]); + } + + return new Client($config); + } + + private function getQuery(string $pipeline): string + { + $timestamp = time() * 1000; + + $config = $this->getConfig(); + $config = $config['pipeline'][$pipeline] ?? $config['pipeline'][$config['default']]; + + $secret = hash_hmac('sha256', $timestamp . "\n" . $config['secret'], $config['secret'], true); + $sign = urlencode(base64_encode($secret)); + return "?access_token={$config['token']}×tamp={$timestamp}&sign={$sign}"; + } +} diff --git a/src/Channel/FeiShuChannel.php b/src/Channel/FeiShuChannel.php new file mode 100644 index 0000000..d23babf --- /dev/null +++ b/src/Channel/FeiShuChannel.php @@ -0,0 +1,68 @@ +getClient($template->getPipeline()); + + $timestamp = time(); + $config = [ + 'timestamp' => $timestamp, + 'sign' => $this->getSign($timestamp, $template->getPipeline()), + ]; + + $option = [ + RequestOptions::HEADERS => [], + RequestOptions::JSON => array_merge($config, $template->feiShuBody()), + ]; + + $request = $client->post('', $option); + $result = json_decode($request->getBody()->getContents(), true); + + if (! isset($result['StatusCode']) || $result['StatusCode'] !== 0) { + throw new MessageNotificationException($result['msg']); + } + + return true; + } + + public function getClient(string $pipeline) + { + $config = $this->config($pipeline); + + $uri['base_uri'] = 'https://open.feishu.cn/open-apis/bot/v2/hook/' . $config['token']; + + if (class_exists(\Hyperf\Utils\ApplicationContext::class)) { + return make(Client::class, [$uri]); + } + + return new Client($config); + } + + private function getSign(int $timestamp, string $pipeline): string + { + $config = $this->config($pipeline); + $secret = hash_hmac('sha256', '', $timestamp . "\n" . $config['secret'], true); + return base64_encode($secret); + } + + private function config(string $pipeline) + { + $config = $this->getConfig(); + return $config['pipeline'][$pipeline] ?? $config['pipeline'][$config['default']]; + } +} diff --git a/src/Channel/MailChannel.php b/src/Channel/MailChannel.php new file mode 100644 index 0000000..bc5cb4b --- /dev/null +++ b/src/Channel/MailChannel.php @@ -0,0 +1,15 @@ +getClient($template->getPipeline()); + + $option = [ + RequestOptions::HEADERS => [], + RequestOptions::JSON => $template->wechatBody(), + ]; + $request = $client->post('', $option); + $result = json_decode($request->getBody()->getContents(), true); + + if ($result['errcode'] !== 0) { + throw new MessageNotificationException($result['errmsg']); + } + + return true; + } + + private function getClient(string $pipeline) + { + $config = $this->config($pipeline); + + $uri['base_uri'] = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=' . $config['token']; + + if (class_exists(\Hyperf\Utils\ApplicationContext::class)) { + return make(Client::class, [$uri]); + } + + return new Client($config); + } + + private function config(string $pipeline) + { + $config = $this->getConfig(); + return $config['pipeline'][$pipeline] ?? $config['pipeline'][$config['default']]; + } +} diff --git a/src/Client.php b/src/Client.php new file mode 100644 index 0000000..b9a664f --- /dev/null +++ b/src/Client.php @@ -0,0 +1,122 @@ +channel; + } + + public function getTemplate(): AbstractTemplate + { + return $this->template ?? new Text(); + } + + public function getAt(): array + { + return $this->at; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getText(): string + { + return $this->text; + } + + public function setChannel($channel = null): Client + { + if (! $channel instanceof AbstractChannel) { + $channel = make($channel); + } + + $this->channel = $channel; + return $this; + } + + public function setTemplate($template = ''): Client + { + if (! $template instanceof AbstractChannel) { + $template = make($template); + } + + $this->template = $template; + return $this; + } + + public function getPipeline(): string + { + return $this->pipeline; + } + + public function setPipeline(string $pipeline = ''): Client + { + $this->pipeline = $pipeline ?? MessageNotifyInterface::INFO; + return $this; + } + + public function setAt(array $at = []): Client + { + $this->at = $at; + return $this; + } + + public function setTitle(string $title = ''): Client + { + $this->title = $title; + return $this; + } + + public function setText(string $text = ''): Client + { + $this->text = $text; + return $this; + } + + public function send(): bool + { + try { + $template = $this->getTemplate()->setAt($this->getAt()) + ->setTitle($this->getTitle())->setText($this->getText()) + ->setPipeline($this->getPipeline()); + + $this->getChannel()->send($template); + return true; + } catch (MessageNotificationException $exception) { + $this->errorMessage = $exception->getMessage(); + return false; + } + } + + public function getErrorMessage(): string + { + return $this->errorMessage; + } +} diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php new file mode 100644 index 0000000..b658214 --- /dev/null +++ b/src/ConfigProvider.php @@ -0,0 +1,34 @@ + [ + MessageNotifyInterface::class => Client::class, + ], + 'annotations' => [ + 'scan' => [ + 'paths' => [ + __DIR__, + ], + ], + ], + 'publish' => [ + [ + 'id' => 'config', + 'description' => 'The config of message client.', + 'source' => __DIR__ . '/../publish/message.php', + 'destination' => BASE_PATH . '/config/autoload/message.php', + ], + ], + ]; + } +} diff --git a/src/Contracts/MessageNotifyInterface.php b/src/Contracts/MessageNotifyInterface.php new file mode 100644 index 0000000..04f21b9 --- /dev/null +++ b/src/Contracts/MessageNotifyInterface.php @@ -0,0 +1,24 @@ +text; + } + + public function setText(string $text): AbstractTemplate + { + $this->text = $text; + return $this; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): AbstractTemplate + { + $this->title = $title; + return $this; + } + + public function getPipeline(): string + { + return $this->pipeline; + } + + public function setPipeline(string $pipeline): AbstractTemplate + { + $this->pipeline = $pipeline; + return $this; + } + + public function setAt(array $at = []): AbstractTemplate + { + $this->at = $at; + return $this; + } + + public function getAt(): array + { + return $this->at; + } + + public function isAtAll(): bool + { + return in_array('all', $this->at) || in_array('ALL', $this->at); + } + + abstract public function getBody(); +} diff --git a/src/Template/Markdown.php b/src/Template/Markdown.php new file mode 100644 index 0000000..ce30564 --- /dev/null +++ b/src/Template/Markdown.php @@ -0,0 +1,100 @@ + 'markdown', + 'markdown' => [ + 'title' => $this->getTitle(), + 'text' => $this->getText(), + ], + 'at' => [ + 'isAtAll' => $this->isAtAll(), + 'atMobiles' => $this->getAt(), + ], + ]; + } + + public function feiShuBody(): array + { + return [ + 'msg_type' => 'post', + 'content' => [ + 'post' => [ + 'zh_cn' => [ + 'title' => $this->getTitle(), + 'content' => [$this->getFeiShuText()], + ], + ], + ], + ]; + } + + public function wechatBody(): array + { + return [ + 'msgtype' => 'markdown', + 'markdown' => [ + 'content' => $this->getTitle() . $this->getText(), + 'mentioned_list' => in_array('all', $this->getAt()) ? [] : [$this->getAt()], + 'mentioned_mobile_list' => in_array('all', $this->getAt()) ? ['@all'] : [$this->getAt()], + ], + ]; + } + + private function getFeiShuText(): array + { + $text = is_array($this->getText()) ? $this->getText() : json_decode($this->getText(), true) ?? [ + [ + 'tag' => 'text', + 'text' => $this->getText(), + ], + ]; + + $at = $this->getFeiShuAt(); + + return array_merge($text, $at); + } + + private function getFeiShuAt(): array + { + $result = []; + if ($this->isAtAll()) { + $result[] = [ + 'tag' => 'at', + 'user_id' => 'all', + ]; + + return $result; + } + + $at = $this->getAt(); + foreach ($at as $item) { + // TODO::需要加入邮箱与收集@人 + if (strchr($item, '@') === false) { + $result[] = [ + 'tag' => 'at', + 'email' => $item, + ]; + } else { + $result[] = [ + 'tag' => 'at', + 'user_id' => $item, + ]; + } + } + + return $result; + } +} diff --git a/src/Template/Text.php b/src/Template/Text.php new file mode 100644 index 0000000..92ad6af --- /dev/null +++ b/src/Template/Text.php @@ -0,0 +1,67 @@ + 'text', + 'text' => [ + 'content' => $this->getText(), + ], + 'at' => [ + 'isAtAll' => $this->isAtAll(), + 'atMobiles' => $this->getAt(), + ], + ]; + } + + public function feiShuBody(): array + { + return [ + 'msg_type' => 'text', + 'content' => [ + 'text' => $this->getText() . $this->getFeiShuAt(), + ], + ]; + } + + public function wechatBody(): array + { + return [ + 'msgtype' => 'text', + 'text' => [ + 'content' => $this->getText(), + 'mentioned_list' => in_array('all', $this->getAt()) ? [] : [$this->getAt()], + 'mentioned_mobile_list' => in_array('all', $this->getAt()) ? ['@all'] : [$this->getAt()], + ], + ]; + } + + private function getFeiShuAt(): string + { + if ($this->isAtAll()) { + return '所有人'; + } + + $at = $this->getAt(); + $result = ''; + foreach ($at as $item) { + if (strchr($item, '@') === false) { + $result .= '' . $item . ''; + } else { + $result .= '' . $item . ''; + } + } + return $result; + } +} diff --git a/test/NotifyTest.php b/test/NotifyTest.php new file mode 100644 index 0000000..44a20b0 --- /dev/null +++ b/test/NotifyTest.php @@ -0,0 +1,41 @@ +setChannel(DingTalkChannel::class) + ->setAt(['all']) + ->setTitle('标题') + ->setText('测试') + ->setPipeline(MessageNotifyInterface::INFO) + ->setTemplate(Markdown::class) + ->send(); + + $this->assertEquals($notify, true); + } +} diff --git a/test/bootstrap.php b/test/bootstrap.php new file mode 100644 index 0000000..ac5a911 --- /dev/null +++ b/test/bootstrap.php @@ -0,0 +1,12 @@ +