250 lines
6.6 KiB
Go
250 lines
6.6 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"git.pcux.cn/xuans/Smail/dto/parsemail"
|
||
"git.pcux.cn/xuans/Smail/hooks/framework"
|
||
"git.pcux.cn/xuans/Smail/models"
|
||
"git.pcux.cn/xuans/Smail/utils/context"
|
||
"git.pcux.cn/xuans/Smail/utils/credential"
|
||
log "github.com/sirupsen/logrus"
|
||
"io"
|
||
"net/http"
|
||
urlpkg "net/url"
|
||
"os"
|
||
"strconv"
|
||
"sync"
|
||
"time"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
const (
|
||
maxWeChatResponseBytes = 1 << 20
|
||
maxWeChatMessageBytes = 4 << 10
|
||
)
|
||
|
||
var weChatHTTPClient = &http.Client{Timeout: 10 * time.Second}
|
||
|
||
type accessTokenRes struct {
|
||
AccessToken string `db:"access_token" json:"access_token"`
|
||
ExpiresIn int `db:"expires_in" json:"expires_in"`
|
||
}
|
||
|
||
type WeChatPushHook struct {
|
||
appId string
|
||
secret string
|
||
tokenMu sync.Mutex
|
||
token string
|
||
tokenExpires int64
|
||
templateId string
|
||
pushUser string
|
||
webDomain string
|
||
httpsEnabled int
|
||
}
|
||
|
||
func (w *WeChatPushHook) GetName(ctx *context.Context) string {
|
||
return "WeChatPushHook"
|
||
}
|
||
|
||
// SettingsHtml 插件页面
|
||
func (w *WeChatPushHook) SettingsHtml(ctx *context.Context, url string, requestData string) string {
|
||
return fmt.Sprintf(`
|
||
<div>
|
||
Wechat push No Settings Page
|
||
</div>
|
||
`)
|
||
}
|
||
|
||
func (w *WeChatPushHook) ReceiveSaveAfter(ctx *context.Context, email *parsemail.Email, ue []*models.UserEmail) {
|
||
if email == nil || w.appId == "" || w.secret == "" || w.pushUser == "" {
|
||
return
|
||
}
|
||
|
||
for _, u := range ue {
|
||
if u == nil {
|
||
continue
|
||
}
|
||
// 管理员(Uid=1)收到邮件且非已读、非已删除 触发通知
|
||
if u.UserID == 1 && u.IsRead == 0 && u.Status == 0 && email.MessageId > 0 {
|
||
w.sendUserMsg(ctx, w.pushUser, buildWeChatMessage(email))
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
func (w *WeChatPushHook) SendBefore(ctx *context.Context, email *parsemail.Email) {
|
||
|
||
}
|
||
|
||
func (w *WeChatPushHook) SendAfter(ctx *context.Context, email *parsemail.Email, err map[string]error) {
|
||
|
||
}
|
||
|
||
func (w *WeChatPushHook) ReceiveParseBefore(ctx *context.Context, email *[]byte) {
|
||
|
||
}
|
||
|
||
func (w *WeChatPushHook) ReceiveParseAfter(ctx *context.Context, email *parsemail.Email) {}
|
||
|
||
func (w *WeChatPushHook) getWxAccessToken() string {
|
||
w.tokenMu.Lock()
|
||
defer w.tokenMu.Unlock()
|
||
|
||
if w.token != "" && w.tokenExpires > time.Now().Unix() {
|
||
return w.token
|
||
}
|
||
appID, err := credential.ValidateSecret(w.appId, 256)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
secret, err := credential.ValidateSecret(w.secret, 4096)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
|
||
endpoint, _ := urlpkg.Parse("https://api.weixin.qq.com/cgi-bin/token")
|
||
query := endpoint.Query()
|
||
query.Set("grant_type", "client_credential")
|
||
query.Set("appid", appID)
|
||
query.Set("secret", secret)
|
||
endpoint.RawQuery = query.Encode()
|
||
request, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
resp, err := weChatHTTPClient.Do(request)
|
||
if err != nil || resp == nil {
|
||
return ""
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||
return ""
|
||
}
|
||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxWeChatResponseBytes+1))
|
||
if err != nil || len(body) > maxWeChatResponseBytes {
|
||
return ""
|
||
}
|
||
var ret accessTokenRes
|
||
if json.Unmarshal(body, &ret) != nil || ret.ExpiresIn <= 0 {
|
||
return ""
|
||
}
|
||
if token, err := credential.ValidateSecret(ret.AccessToken, 4096); err == nil {
|
||
w.token = token
|
||
w.tokenExpires = time.Now().Unix() + int64(ret.ExpiresIn)
|
||
}
|
||
return w.token
|
||
}
|
||
|
||
type sendMsgRequest struct {
|
||
Touser string `db:"touser" json:"touser"`
|
||
Template_id string `db:"template_id" json:"template_id"`
|
||
Url string `db:"url" json:"url"`
|
||
Data SendData `db:"data" json:"data"`
|
||
}
|
||
type SendData struct {
|
||
Content DataItem `json:"Content"`
|
||
}
|
||
type DataItem struct {
|
||
Value string `json:"value"`
|
||
Color string `json:"color"`
|
||
}
|
||
|
||
func (w *WeChatPushHook) sendUserMsg(ctx *context.Context, userId string, content string) {
|
||
content = truncateWeChatText(content, maxWeChatMessageBytes)
|
||
url := w.webDomain
|
||
if w.httpsEnabled > 1 {
|
||
url = "http://" + url
|
||
} else {
|
||
url = "https://" + url
|
||
}
|
||
|
||
sendMsgReq, err := json.Marshal(sendMsgRequest{
|
||
Touser: userId,
|
||
Template_id: w.templateId,
|
||
Url: url,
|
||
Data: SendData{Content: DataItem{Value: content, Color: "#000000"}},
|
||
})
|
||
if err != nil {
|
||
log.WithContext(ctx).Error("wechat push request build failed")
|
||
return
|
||
}
|
||
token := w.getWxAccessToken()
|
||
if token == "" {
|
||
log.WithContext(ctx).Error("wechat push authentication failed")
|
||
return
|
||
}
|
||
endpoint, _ := urlpkg.Parse("https://api.weixin.qq.com/cgi-bin/message/template/send")
|
||
query := endpoint.Query()
|
||
query.Set("access_token", token)
|
||
endpoint.RawQuery = query.Encode()
|
||
request, err := http.NewRequest(http.MethodPost, endpoint.String(), bytes.NewReader(sendMsgReq))
|
||
if err != nil {
|
||
log.WithContext(ctx).Error("wechat push request build failed")
|
||
return
|
||
}
|
||
request.Header.Set("Content-Type", "application/json")
|
||
resp, err := weChatHTTPClient.Do(request)
|
||
if err != nil || resp == nil {
|
||
log.WithContext(ctx).Error("wechat push request failed")
|
||
return
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||
log.WithContext(ctx).Error("wechat push request was rejected")
|
||
return
|
||
}
|
||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxWeChatResponseBytes))
|
||
}
|
||
|
||
func buildWeChatMessage(email *parsemail.Email) string {
|
||
if email == nil {
|
||
return ""
|
||
}
|
||
const prefix = "<<"
|
||
const separator = ">> "
|
||
subjectLimit := maxWeChatMessageBytes - len(prefix) - len(separator)
|
||
subject := truncateWeChatText(email.Subject, subjectLimit)
|
||
remaining := maxWeChatMessageBytes - len(prefix) - len(subject) - len(separator)
|
||
text := truncateWeChatText(string(email.Text), remaining)
|
||
return prefix + subject + separator + text
|
||
}
|
||
|
||
func truncateWeChatText(value string, maxBytes int) string {
|
||
if maxBytes <= 0 {
|
||
return ""
|
||
}
|
||
if len(value) <= maxBytes {
|
||
return value
|
||
}
|
||
cut := maxBytes
|
||
for cut > 0 && !utf8.RuneStart(value[cut]) {
|
||
cut--
|
||
}
|
||
return value[:cut]
|
||
}
|
||
|
||
func NewWechatPushHook() *WeChatPushHook {
|
||
httpsEnabled := 0
|
||
if value, err := strconv.Atoi(os.Getenv("SMAIL_HTTPS_ENABLED")); err == nil {
|
||
httpsEnabled = value
|
||
}
|
||
return &WeChatPushHook{
|
||
appId: os.Getenv("SMAIL_WECHAT_APP_ID"),
|
||
secret: os.Getenv("SMAIL_WECHAT_SECRET"),
|
||
templateId: os.Getenv("SMAIL_WECHAT_TEMPLATE_ID"),
|
||
pushUser: os.Getenv("SMAIL_WECHAT_USER_ID"),
|
||
webDomain: os.Getenv("SMAIL_WEB_DOMAIN"),
|
||
httpsEnabled: httpsEnabled,
|
||
}
|
||
}
|
||
|
||
// 插件将以独立进程运行,因此需要主函数。
|
||
func main() {
|
||
if err := framework.CreatePlugin("wechat_push", NewWechatPushHook()).Run(); err != nil {
|
||
log.WithError(err).Error("wechat push plugin stopped")
|
||
os.Exit(1)
|
||
}
|
||
}
|