Forráskód Böngészése

支持通过企业微信送信

luolongfei 4 éve
szülő
commit
7c3405e447

+ 5 - 2
.env.example

@@ -70,10 +70,13 @@ TELEGRAM_PROXY=''
 
 ######################  企业微信  #########################
 # 企业 ID Corporate ID
-CORP_ID=''
+WECHAT_CORP_ID=''
 
 # 应用的凭证密钥 Credential keys for enterprise WeChat applications
-CORP_SECRET=''
+WECHAT_CORP_SECRET=''
+
+# 应用 ID
+WECHAT_AGENT_ID=''
 
 # 是否启用企业微信送信功能 1:启用 0:不启用 Whether to enable the enterprise WeChat message push function 1: Enable 0: Do not enable
 WECHAT_ENABLE=0

+ 1 - 0
.gitignore

@@ -2,3 +2,4 @@
 app/tmp/
 app/num_limit/
 .idea/
+app/Data/

+ 2 - 2
README.md

@@ -9,7 +9,7 @@
 Documentation: [English version](https://github.com/luolongfei/freenom/blob/main/README_EN.md) | 中文版
 </div>
 
-[📢 注意](#-注意)
+[📢 望周知](#-望周知)
 
 [📃 引言](#-引言)
 
@@ -41,7 +41,7 @@ Documentation: [English version](https://github.com/luolongfei/freenom/blob/main
 
 [🥝 开源协议](#-开源协议)
 
-### 📢 注意
+### 📢 望周知
 
 - 之前因为 GitHub Action 事件导致本项目被封禁,而后我短暂将项目转移到了 https://github.com/luolongfei/next-freenom
   仓库,然后在 [@Mattraks](https://github.com/Mattraks) 的提醒下,通过特别的方式恢复了本仓库。

+ 0 - 0
app/Data/.gitignore


+ 13 - 0
config.php

@@ -47,6 +47,19 @@ return [
 
             'proxy' => env('TELEGRAM_PROXY'),
         ],
+
+        /**
+         * 企业微信
+         */
+        'wechat' => [
+            'corp_id' => env('WECHAT_CORP_ID'), // 企业 ID
+            'corp_secret' => env('WECHAT_CORP_SECRET'), // 企业微信应用的凭证密钥
+            'agent_id' => (int)env('WECHAT_AGENT_ID'), // 企业微信应用 ID
+            'enable' => (int)env('WECHAT_ENABLE'), // 是否启用,默认不启用
+
+            'class' => \Luolongfei\Libs\MessageServices\WeChat::class,
+            'name' => '企业微信',
+        ],
     ],
 
     'locale' => 'zh', // 指定语言包,位于resources/lang/目录下

+ 19 - 0
libs/Connector/MessageGateway.php

@@ -23,4 +23,23 @@ abstract class MessageGateway implements MessageServiceInterface
 
         return call_user_func_array('sprintf', $data);
     }
+
+    /**
+     * 参数数据检查
+     *
+     * @param string $content
+     * @param array $data
+     *
+     * @throws \Exception
+     */
+    public function check(string $content, array $data)
+    {
+        if ($content === '' && empty($data)) {
+            throw new \Exception(lang('error_msg.100002'));
+        }
+
+        if ($content !== '' && $data) {
+            throw new \Exception(lang('error_msg.100004'));
+        }
+    }
 }

+ 5 - 7
libs/MessageServices/Mail.php

@@ -127,6 +127,10 @@ class Mail extends MessageGateway
      */
     public function genDomainStatusHtml(array $domainStatus)
     {
+        if (empty($domainStatus)) {
+            return "无数据。";
+        }
+
         $domainStatusHtml = '';
 
         foreach ($domainStatus as $domain => $daysLeft) {
@@ -160,13 +164,7 @@ class Mail extends MessageGateway
             throw new LlfException(34520012);
         }
 
-        if ($content === '' && empty($data)) {
-            throw new \Exception(lang('error_msg.100002'));
-        }
-
-        if ($content !== '' && $data) {
-            throw new \Exception(lang('error_msg.100004'));
-        }
+        $this->check($content, $data);
 
         $this->phpMailerInstance->addAddress($recipient, config('message.mail.recipient_name', '主人')); // 添加收件人,参数2选填
         $this->phpMailerInstance->addReplyTo(config('message.mail.reply_to', '[email protected]'), config('message.mail.reply_to_name', '作者')); // 备用回复地址,收到的回复的邮件将被发到此地址

+ 15 - 7
libs/MessageServices/TelegramBot.php

@@ -92,6 +92,10 @@ class TelegramBot extends MessageGateway
      */
     public function genDomainStatusMarkDownText(array $domainStatus)
     {
+        if (empty($domainStatus)) {
+            return "无数据。\n";
+        }
+
         $domainStatusMarkDownText = '';
 
         foreach ($domainStatus as $domain => $daysLeft) {
@@ -103,6 +107,16 @@ class TelegramBot extends MessageGateway
         return $domainStatusMarkDownText;
     }
 
+    /**
+     * 生成域名续期结果 MarkDown 文本
+     *
+     * @param string $username
+     * @param array $renewalSuccessArr
+     * @param array $renewalFailuresArr
+     * @param array $domainStatus
+     *
+     * @return string
+     */
     public function genDomainRenewalResultsMarkDownText(string $username, array $renewalSuccessArr, array $renewalFailuresArr, array $domainStatus)
     {
         $text = sprintf("账户 [%s](#) 这次续期的结果如下\n\n", $username);
@@ -215,13 +229,7 @@ class TelegramBot extends MessageGateway
      */
     public function send(string $content, string $subject = '', int $type = 1, array $data = [], ?string $recipient = null, ...$params)
     {
-        if ($content === '' && empty($data)) {
-            throw new \Exception(lang('error_msg.100002'));
-        }
-
-        if ($content !== '' && $data) {
-            throw new \Exception(lang('error_msg.100004'));
-        }
+        $this->check($content, $data);
 
         if ($type === 1) {
             // Do nothing

+ 344 - 0
libs/MessageServices/WeChat.php

@@ -0,0 +1,344 @@
+<?php
+/**
+ * 企业微信
+ *
+ * @author mybsdc <[email protected]>
+ * @date 2021/11/1
+ * @time 17:38
+ */
+
+namespace Luolongfei\Libs\MessageServices;
+
+use GuzzleHttp\Client;
+use Luolongfei\Libs\Connector\MessageGateway;
+
+class WeChat extends MessageGateway
+{
+    const TIMEOUT = 33;
+
+    /**
+     * @var string 企业 ID
+     */
+    protected $corpId;
+
+    /**
+     * @var string 企业微信应用的凭证密钥
+     */
+    protected $corpSecret;
+
+    /**
+     * @var integer 企业微信应用 ID
+     */
+    protected $agentId;
+
+    /**
+     * @var Client
+     */
+    protected $client;
+
+    /**
+     * @var string 缓存 access_token 的文件
+     */
+    protected $accessTokenFile;
+
+    public function __construct()
+    {
+        $this->corpId = config('message.wechat.corp_id');
+        $this->corpSecret = config('message.wechat.corp_secret');
+        $this->agentId = config('message.wechat.agent_id');
+
+        $this->accessTokenFile = DATA_PATH . DS . 'wechat_access_token.txt';
+
+        $this->client = new Client([
+            'cookies' => false,
+            'timeout' => self::TIMEOUT,
+            'verify' => config('verify_ssl'),
+            'debug' => config('debug'),
+        ]);
+    }
+
+    /**
+     * 获取 access_token 缓存
+     *
+     * @return string|null
+     */
+    protected function getAccessTokenCache()
+    {
+        if (!file_exists($this->accessTokenFile)) {
+            return null;
+        }
+
+        $accessTokenFile = file_get_contents($this->accessTokenFile);
+
+        if (!preg_match('/^WECHAT_ACCESS_TOKEN_EXPIRES_AT=(?P<expires_at>.*?)$/im', $accessTokenFile, $m)) {
+            return null;
+        }
+        $expiresAt = (int)$m['expires_at'];
+
+        if (!preg_match('/^WECHAT_ACCESS_TOKEN=(?P<access_token>.*?)$/im', $accessTokenFile, $m)) {
+            return null;
+        }
+
+        if (time() + 5 > $expiresAt) {
+            return null;
+        }
+
+        return $m['access_token'];
+    }
+
+    /**
+     * 获取 access_token
+     *
+     * @param bool $force
+     *
+     * @return mixed|string
+     * @throws \Exception
+     */
+    protected function getAccessToken($force = false)
+    {
+        if (!$force) {
+            $accessToken = $this->getAccessTokenCache();
+
+            if (!is_null($accessToken)) {
+                return $accessToken;
+            }
+        }
+
+        $resp = $this->client->get('https://qyapi.weixin.qq.com/cgi-bin/gettoken', [
+            'query' => [
+                'corpid' => $this->corpId,
+                'corpsecret' => $this->corpSecret
+            ],
+        ]);
+
+        $resp = $resp->getBody()->getContents();
+        $resp = (array)json_decode($resp, true);
+
+        if (isset($resp['errcode']) && $resp['errcode'] === 0 && isset($resp['access_token']) && isset($resp['expires_in'])) {
+            $accessTokenFileText = sprintf("WECHAT_ACCESS_TOKEN=%s\nWECHAT_ACCESS_TOKEN_EXPIRES_AT=%s\n", $resp['access_token'], time() + $resp['expires_in']);
+            if (file_put_contents($this->accessTokenFile, $accessTokenFileText) === false) {
+                throw new \Exception('企业微信 access_token 写入失败:' . $this->accessTokenFile);
+            }
+
+            return $resp['access_token'];
+        }
+
+        throw new \Exception('获取企业微信 access_token 失败:' . ($resp['errmsg'] ?? '未知原因'));
+    }
+
+    /**
+     * 生成域名文本
+     *
+     * @param array $domains
+     *
+     * @return string
+     */
+    public function genDomainsText(array $domains)
+    {
+        $domainsText = '';
+
+        foreach ($domains as $domain) {
+            $domainsText .= sprintf('<a href="http://%s">%s</a> ', $domain, $domain);
+        }
+
+        $domainsText = trim($domainsText, ' ') . "\n";
+
+        return $domainsText;
+    }
+
+    /**
+     * 获取页脚
+     *
+     * @return string
+     */
+    public function getFooter()
+    {
+        $footer = '';
+
+        $footer .= "\n更多信息可以参考 <a href=\"https://my.freenom.com/domains.php?a=renewals\">Freenom官网</a> 哦~";
+        $footer .= "\n\n(如果你不想每次执行都收到推送,请将 .env 中 NOTICE_FREQ 的值设为 0,使程序只在有续期操作时才推送)";
+
+        return $footer;
+    }
+
+    /**
+     * 生成域名状态文本
+     *
+     * @param array $domainStatus
+     *
+     * @return string
+     */
+    public function genDomainStatusText(array $domainStatus)
+    {
+        if (empty($domainStatus)) {
+            return "无数据。\n";
+        }
+
+        $domainStatusText = '';
+
+        foreach ($domainStatus as $domain => $daysLeft) {
+            $domainStatusText .= sprintf('<a href="http://%s">%s</a> 还有 <a href="http://%s">%d</a> 天到期,', $domain, $domain, $domain, $daysLeft);
+        }
+
+        $domainStatusText = rtrim($domainStatusText, ',') . "。\n";
+
+        return $domainStatusText;
+    }
+
+    /**
+     * 生成域名续期结果文本
+     *
+     * @param string $username
+     * @param array $renewalSuccessArr
+     * @param array $renewalFailuresArr
+     * @param array $domainStatus
+     *
+     * @return string
+     */
+    public function genDomainRenewalResultsText(string $username, array $renewalSuccessArr, array $renewalFailuresArr, array $domainStatus)
+    {
+        $text = sprintf("账户 <a href=\"https://www.google.com\">%s</a> 这次续期的结果如下\n\n", $username);
+
+        if ($renewalSuccessArr) {
+            $text .= '续期成功:';
+            $text .= $this->genDomainsText($renewalSuccessArr);
+        }
+
+        if ($renewalFailuresArr) {
+            $text .= '续期出错:';
+            $text .= $this->genDomainsText($renewalFailuresArr);
+        }
+
+        $text .= "\n今次无需续期的域名及其剩余天数如下所示:\n\n";
+        $text .= $this->genDomainStatusText($domainStatus);
+
+        $text .= $this->getFooter();
+
+        return $text;
+    }
+
+    /**
+     * 生成域名状态完整文本
+     *
+     * @param string $username
+     * @param array $domainStatus
+     *
+     * @return string
+     */
+    public function genDomainStatusFullText(string $username, array $domainStatus)
+    {
+        $markDownText = sprintf("我刚刚帮小主看了一下,账户 <a href=\"https://www.google.com\">%s</a> 今天并没有需要续期的域名。所有域名情况如下:\n\n", $username);
+
+        $markDownText .= $this->genDomainStatusText($domainStatus);
+
+        $markDownText .= $this->getFooter();
+
+        return $markDownText;
+    }
+
+    /**
+     * 送信
+     *
+     * 由于腾讯要求 markdown 语法消息必须使用 企业微信 APP 才能查看,然而我并不想单独安装 企业微信 APP,故本方法不使用 markdown 语法,
+     * 而是直接使用纯文本 text 类型,纯文本类型里腾讯额外支持 a 标签,所以基本满足需求
+     *
+     * 参考:
+     * https://work.weixin.qq.com/api/doc/90000/90135/91039
+     * https://work.weixin.qq.com/api/doc/90000/90135/90236#%E6%96%87%E6%9C%AC%E6%B6%88%E6%81%AF
+     *
+     * @param string $content
+     * @param string $subject
+     * @param int $type
+     * @param array $data
+     * @param string|null $recipient
+     * @param mixed ...$params
+     *
+     * @return bool
+     * @throws \Exception
+     */
+    public function send(string $content, string $subject = '', int $type = 1, array $data = [], ?string $recipient = null, ...$params)
+    {
+        $this->check($content, $data);
+
+        if ($type === 1) {
+            // Do nothing
+        } else if ($type === 2) {
+            $content = $this->genDomainRenewalResultsText($data['username'], $data['renewalSuccessArr'], $data['renewalFailuresArr'], $data['domainStatusArr']);
+        } else if ($type === 3) {
+            $content = $this->genDomainStatusFullText($data['username'], $data['domainStatusArr']);
+        } else {
+            throw new \Exception(lang('error_msg.100003'));
+        }
+
+        if ($subject !== '') {
+            $content = $subject . "\n\n" . $content;
+        }
+
+        try {
+            $accessToken = $this->getAccessToken();
+
+            $body = [
+                'touser' => '@all', // 可直接通过此地址获取 userId,指定接收用户,多个用户用“|”分割 https://qyapi.weixin.qq.com/cgi-bin/user/simplelist?access_token=ACCESS_TOKEN&fetch_child=FETCH_CHILD&department_id=1
+                'msgtype' => 'text', // 消息类型,text 类型支持 a 标签以及 \n 换行,基本满足需求。由于腾讯要求 markdown 语法必须使用 企业微信APP 才能查看,不想安装,故弃之
+                'agentid' => $this->agentId, // 企业应用的 ID,整型,可在应用的设置页面查看
+                'text' => [
+                    'content' => $content, // 消息内容,最长不超过2048个字节,超过将截断
+                ],
+                'enable_duplicate_check' => 1,
+                'duplicate_check_interval' => 60,
+            ];
+
+            return $this->doSend($accessToken, $body);
+        } catch (\Exception $e) {
+            system_log('企业微信送信失败:<red>' . $e->getMessage() . '</red>');
+
+            return false;
+        }
+    }
+
+    /**
+     * 执行送信
+     *
+     * @param string $accessToken
+     * @param array $body
+     * @param int $numOfRetries
+     *
+     * @return bool
+     * @throws \Exception
+     */
+    private function doSend(string $accessToken, array $body, int &$numOfRetries = 0)
+    {
+        $resp = $this->client->post('https://qyapi.weixin.qq.com/cgi-bin/message/send', [
+            'query' => [
+                'access_token' => $accessToken
+            ],
+            'headers' => [
+                'Content-Type' => 'application/json',
+            ],
+            'body' => json_encode($body),
+        ]);
+
+        $resp = (string)$resp->getBody();
+        $resp = (array)json_decode($resp, true);
+
+        if (!isset($resp['errcode']) || !isset($resp['errmsg'])) {
+            throw new \Exception('企业微信接口未返回预期的数据响应,本次响应数据为:' . json_encode($resp, JSON_UNESCAPED_UNICODE));
+        }
+
+        if ($resp['errcode'] === 0) {
+            return true;
+        } else if ($resp['errcode'] === 40014) { // invalid access_token
+            $accessToken = $this->getAccessToken(true);
+
+            if ($numOfRetries > 2) {
+                throw new \Exception('检测到多次提示 access_token 失效,可能是未能正确获取 access_token,请介入调查:' . $resp['errmsg']);
+            }
+
+            $numOfRetries++;
+
+            return $this->doSend($accessToken, $body, $numOfRetries);
+        }
+
+        throw new \Exception($resp['errmsg']);
+    }
+}

+ 1 - 0
run

@@ -18,6 +18,7 @@ define('DS', DIRECTORY_SEPARATOR);
 define('ROOT_PATH', realpath(__DIR__));
 define('VENDOR_PATH', realpath(ROOT_PATH . '/vendor'));
 define('APP_PATH', realpath(ROOT_PATH . '/app'));
+define('DATA_PATH', realpath(ROOT_PATH . '/app/Data'));
 define('RESOURCES_PATH', realpath(ROOT_PATH . '/resources'));
 
 date_default_timezone_set('Asia/Shanghai');