消息组件初始版本

This commit is contained in:
ykxiao 2024-01-31 09:30:04 +08:00
commit 62cfeb1772
22 changed files with 1019 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
!.gitignore
!.gitattributes
*.DS_Store
*.idea
*.svn
*.git
composer.lock
*.cache
vendor
config

85
.php-cs-fixer.php Normal file
View File

@ -0,0 +1,85 @@
<?php
$header = '';
return (new \PhpCsFixer\Config())
->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)
;

21
LICENSE Normal file
View File

@ -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.

54
README.md Normal file
View File

@ -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)。

46
composer.json Normal file
View File

@ -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"
}
}
}

21
phpunit.xml Normal file
View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
backupStaticAttributes="false"
bootstrap="./test/bootstrap.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false">
<testsuites>
<testsuite name="Tests">
<directory suffix="Test.php">./test</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./src</directory>
</include>
</coverage>
</phpunit>

67
publish/message.php Normal file
View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
use MessageNotify\Channel\DingTalkChannel;
use MessageNotify\Channel\FeiShuChannel;
use MessageNotify\Channel\MailChannel;
use MessageNotify\Channel\WechatChannel;
use MessageNotify\Contracts\MessageNotifyInterface;
return [
'default' => 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'),
],
],
],
],
];

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace MessageNotify\Channel;
use Hyperf\Contract\ConfigInterface;
use MessageNotify\Exceptions\MessageNotificationException;
use MessageNotify\Template\AbstractTemplate;
abstract class AbstractChannel
{
public function getConfig()
{
if (class_exists(\Hyperf\Utils\ApplicationContext::class)) {
$configContext = make(ConfigInterface::class);
return $configContext->get('message.channels.' . get_class($this));
}
throw new MessageNotificationException('ApplicationContext is not exist');
}
abstract public function send(AbstractTemplate $template);
}

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace MessageNotify\Channel;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\RequestOptions;
use MessageNotify\Exceptions\MessageNotificationException;
use MessageNotify\Template\AbstractTemplate;
class DingTalkChannel extends AbstractChannel
{
/**
* @throws GuzzleException
*/
public function send(AbstractTemplate $template): bool
{
$query = $this->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']}&timestamp={$timestamp}&sign={$sign}";
}
}

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace MessageNotify\Channel;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\RequestOptions;
use MessageNotify\Exceptions\MessageNotificationException;
use MessageNotify\Template\AbstractTemplate;
class FeiShuChannel extends AbstractChannel
{
/**
* @throws GuzzleException
*/
public function send(AbstractTemplate $template): bool
{
$client = $this->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']];
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MessageNotify\Channel;
use MessageNotify\Template\AbstractTemplate;
class MailChannel extends AbstractChannel
{
public function send(AbstractTemplate $template)
{
// TODO: Implement send() method.
}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace MessageNotify\Channel;
use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
use MessageNotify\Exceptions\MessageNotificationException;
use MessageNotify\Template\AbstractTemplate;
class WechatChannel extends AbstractChannel
{
public function send(AbstractTemplate $template): bool
{
$client = $this->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']];
}
}

122
src/Client.php Normal file
View File

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace MessageNotify;
use MessageNotify\Channel\AbstractChannel;
use MessageNotify\Contracts\MessageNotifyInterface;
use MessageNotify\Exceptions\MessageNotificationException;
use MessageNotify\Template\AbstractTemplate;
use MessageNotify\Template\Text;
class Client
{
protected AbstractChannel $channel;
protected AbstractTemplate $template;
protected array $at = [];
protected string $pipeline = MessageNotifyInterface::INFO;
protected string $title = '';
protected string $text = '';
private string $errorMessage;
public function getChannel(): AbstractChannel
{
return $this->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;
}
}

34
src/ConfigProvider.php Normal file
View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace MessageNotify;
use MessageNotify\Contracts\MessageNotifyInterface;
class ConfigProvider
{
public function __invoke(): array
{
return [
'dependencies' => [
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',
],
],
];
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace MessageNotify\Contracts;
interface MessageNotifyInterface
{
public const INFO = 'info';
public const ERROR = 'error';
public const EMERGENCY = 'emergency';
public const ALERT = 'alert';
public const CRITICAL = 'critical';
public const WARNING = 'warning';
public const NOTICE = 'notice';
public const DEBUG = 'debug';
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace MessageNotify\Exceptions;
class MessageNotificationException extends \RuntimeException
{
}

19
src/Notify.php Normal file
View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace MessageNotify;
use Hyperf\Utils\ApplicationContext;
class Notify
{
public static function make(): Client
{
if (class_exists(ApplicationContext::class)) {
return make(Client::class);
}
return new Client();
}
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace MessageNotify\Template;
use MessageNotify\Contracts\MessageNotifyInterface;
abstract class AbstractTemplate
{
protected array $at = [];
protected string $pipeline = MessageNotifyInterface::INFO;
protected string $text = '';
protected string $title = '';
public function getText(): string
{
return $this->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();
}

100
src/Template/Markdown.php Normal file
View File

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace MessageNotify\Template;
class Markdown extends AbstractTemplate
{
public function getBody(): array
{
return [];
}
public function dingTalkBody(): array
{
return [
'msgtype' => '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;
}
}

67
src/Template/Text.php Normal file
View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace MessageNotify\Template;
class Text extends AbstractTemplate
{
public function getBody(): array
{
return [];
}
public function dingTalkBody(): array
{
return [
'msgtype' => '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 user_id="all">所有人</at>';
}
$at = $this->getAt();
$result = '';
foreach ($at as $item) {
if (strchr($item, '@') === false) {
$result .= '<at phone="' . $item . '">' . $item . '</at>';
} else {
$result .= '<at email="' . $item . '">' . $item . '</at>';
}
}
return $result;
}
}

41
test/NotifyTest.php Normal file
View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace MessageNotifyTest;
use MessageNotify\Channel\DingTalkChannel;
use MessageNotify\Channel\FeiShuChannel;
use MessageNotify\Channel\WechatChannel;
use MessageNotify\Contracts\MessageNotifyInterface;
use MessageNotify\Notify;
use MessageNotify\Template\Markdown;
use MessageNotify\Template\Text;
use PHPUnit\Framework\TestCase;
/**
* @internal
* @coversNothing
*/
class NotifyTest extends TestCase
{
public function testCase()
{
$dingTalkChannel = new DingTalkChannel();
$feiShuChannel = new FeiShuChannel();
$wechatChannel = new WechatChannel();
$markdown = new Markdown();
$text = new Text();
$notify = Notify::make()->setChannel(DingTalkChannel::class)
->setAt(['all'])
->setTitle('标题')
->setText('测试')
->setPipeline(MessageNotifyInterface::INFO)
->setTemplate(Markdown::class)
->send();
$this->assertEquals($notify, true);
}
}

12
test/bootstrap.php Normal file
View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
use Hyperf\Di\Container;
use Hyperf\Di\Definition\DefinitionSourceFactory;
use Hyperf\Utils\ApplicationContext;
! defined('BASE_PATH') && define('BASE_PATH', dirname(__DIR__, 1));
$container = new Container((new DefinitionSourceFactory(true))());
ApplicationContext::setContainer($container);