919 lines
26 KiB
Go
919 lines
26 KiB
Go
package wecom
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/sha1"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/chenhg5/cc-connect/core"
|
|
)
|
|
|
|
func init() {
|
|
core.RegisterPlatform("wecom", New)
|
|
}
|
|
|
|
// Incoming XML envelope from WeChat Work callback.
|
|
type xmlEncryptedMsg struct {
|
|
XMLName xml.Name `xml:"xml"`
|
|
ToUserName string `xml:"ToUserName"`
|
|
AgentID string `xml:"AgentID"`
|
|
Encrypt string `xml:"Encrypt"`
|
|
}
|
|
|
|
// Decrypted message body.
|
|
type xmlMessage struct {
|
|
XMLName xml.Name `xml:"xml"`
|
|
ToUserName string `xml:"ToUserName"`
|
|
FromUserName string `xml:"FromUserName"`
|
|
CreateTime int64 `xml:"CreateTime"`
|
|
MsgType string `xml:"MsgType"`
|
|
Content string `xml:"Content"`
|
|
PicUrl string `xml:"PicUrl"`
|
|
MediaId string `xml:"MediaId"`
|
|
FileName string `xml:"FileName"` // inbound file messages (MsgType=file)
|
|
Format string `xml:"Format"` // voice format: amr, speex, etc.
|
|
MsgId int64 `xml:"MsgId"`
|
|
AgentID int64 `xml:"AgentID"`
|
|
}
|
|
|
|
type replyContext struct {
|
|
userID string
|
|
}
|
|
|
|
type tokenCache struct {
|
|
mu sync.Mutex
|
|
token string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
type Platform struct {
|
|
corpID string
|
|
corpSecret string
|
|
agentID string
|
|
apiBaseURL string
|
|
allowFrom string
|
|
token string // callback verification token
|
|
aesKey []byte // decoded EncodingAESKey (32 bytes)
|
|
port string
|
|
callbackPath string
|
|
enableMarkdown bool
|
|
server *http.Server
|
|
handler core.MessageHandler
|
|
apiClient *http.Client // HTTP client for outbound API calls (may use proxy)
|
|
tokenCache tokenCache
|
|
dedup msgDedup
|
|
userNameCache sync.Map // userID -> display name
|
|
}
|
|
|
|
const defaultAPIBaseURL = "https://qyapi.weixin.qq.com"
|
|
|
|
// msgDedup tracks recently processed MsgIds to avoid WeChat Work retry duplicates.
|
|
type msgDedup struct {
|
|
mu sync.Mutex
|
|
seen map[int64]time.Time
|
|
}
|
|
|
|
func (d *msgDedup) isDuplicate(msgID int64) bool {
|
|
if msgID == 0 {
|
|
return false
|
|
}
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if d.seen == nil {
|
|
d.seen = make(map[int64]time.Time)
|
|
}
|
|
// Evict old entries (older than 60s)
|
|
now := time.Now()
|
|
for k, t := range d.seen {
|
|
if now.Sub(t) > 60*time.Second {
|
|
delete(d.seen, k)
|
|
}
|
|
}
|
|
if _, exists := d.seen[msgID]; exists {
|
|
return true
|
|
}
|
|
d.seen[msgID] = now
|
|
return false
|
|
}
|
|
|
|
func New(opts map[string]any) (core.Platform, error) {
|
|
mode, _ := opts["mode"].(string)
|
|
if mode == "websocket" {
|
|
return newWebSocket(opts)
|
|
}
|
|
|
|
corpID, _ := opts["corp_id"].(string)
|
|
corpSecret, _ := opts["corp_secret"].(string)
|
|
agentID, _ := opts["agent_id"].(string)
|
|
callbackToken, _ := opts["callback_token"].(string)
|
|
callbackAESKey, _ := opts["callback_aes_key"].(string)
|
|
|
|
if corpID == "" || corpSecret == "" || agentID == "" {
|
|
return nil, fmt.Errorf("wecom: corp_id, corp_secret, and agent_id are required")
|
|
}
|
|
if callbackToken == "" || callbackAESKey == "" {
|
|
return nil, fmt.Errorf("wecom: callback_token and callback_aes_key are required")
|
|
}
|
|
|
|
aesKey, err := decodeAESKey(callbackAESKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("wecom: invalid callback_aes_key: %w", err)
|
|
}
|
|
|
|
port, _ := opts["port"].(string)
|
|
if port == "" {
|
|
port = "8081"
|
|
}
|
|
path, _ := opts["callback_path"].(string)
|
|
if path == "" {
|
|
path = "/wecom/callback"
|
|
}
|
|
apiBaseURL, _ := opts["api_base_url"].(string)
|
|
apiBaseURL = strings.TrimRight(strings.TrimSpace(apiBaseURL), "/")
|
|
if apiBaseURL == "" {
|
|
apiBaseURL = defaultAPIBaseURL
|
|
} else {
|
|
parsed, err := url.Parse(apiBaseURL)
|
|
if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" {
|
|
return nil, fmt.Errorf("wecom: invalid api_base_url %q: must be a valid http(s) URL", apiBaseURL)
|
|
}
|
|
}
|
|
|
|
transport := &http.Transport{
|
|
MaxIdleConns: 2,
|
|
MaxIdleConnsPerHost: 1,
|
|
IdleConnTimeout: 10 * time.Second,
|
|
}
|
|
if proxyURL, _ := opts["proxy"].(string); proxyURL != "" {
|
|
u, err := url.Parse(proxyURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("wecom: invalid proxy URL %q: %w", proxyURL, err)
|
|
}
|
|
proxyUser, _ := opts["proxy_username"].(string)
|
|
proxyPass, _ := opts["proxy_password"].(string)
|
|
if proxyUser != "" {
|
|
u.User = url.UserPassword(proxyUser, proxyPass)
|
|
}
|
|
transport.Proxy = http.ProxyURL(u)
|
|
transport.DisableKeepAlives = true // prevent CONNECT tunnel accumulation on proxy
|
|
slog.Info("wecom: outbound API requests will use proxy (keep-alive disabled)", "proxy", u.Host, "auth", proxyUser != "")
|
|
}
|
|
apiClient := &http.Client{Timeout: 30 * time.Second, Transport: transport}
|
|
|
|
enableMarkdown, _ := opts["enable_markdown"].(bool)
|
|
allowFrom, _ := opts["allow_from"].(string)
|
|
core.CheckAllowFrom("wecom", allowFrom)
|
|
|
|
return &Platform{
|
|
corpID: corpID,
|
|
corpSecret: corpSecret,
|
|
agentID: agentID,
|
|
apiBaseURL: apiBaseURL,
|
|
allowFrom: allowFrom,
|
|
token: callbackToken,
|
|
aesKey: aesKey,
|
|
port: port,
|
|
callbackPath: path,
|
|
enableMarkdown: enableMarkdown,
|
|
apiClient: apiClient,
|
|
}, nil
|
|
}
|
|
|
|
func (p *Platform) Name() string { return "wecom" }
|
|
|
|
func (p *Platform) wecomAPIURL(path string, query url.Values) string {
|
|
base := strings.TrimRight(strings.TrimSpace(p.apiBaseURL), "/")
|
|
if base == "" {
|
|
base = defaultAPIBaseURL
|
|
}
|
|
u := base + path
|
|
if len(query) == 0 {
|
|
return u
|
|
}
|
|
return u + "?" + query.Encode()
|
|
}
|
|
|
|
func (p *Platform) Start(handler core.MessageHandler) error {
|
|
p.handler = handler
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc(p.callbackPath, p.callbackHandler)
|
|
|
|
p.server = &http.Server{
|
|
Addr: ":" + p.port,
|
|
Handler: mux,
|
|
}
|
|
|
|
go func() {
|
|
slog.Info("wecom: webhook server listening", "port", p.port, "path", p.callbackPath)
|
|
if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
slog.Error("wecom: server error", "error", err)
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *Platform) callbackHandler(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
msgSignature := q.Get("msg_signature")
|
|
timestamp := q.Get("timestamp")
|
|
nonce := q.Get("nonce")
|
|
|
|
if r.Method == http.MethodGet {
|
|
p.handleVerify(w, msgSignature, timestamp, nonce, q.Get("echostr"))
|
|
return
|
|
}
|
|
|
|
if r.Method == http.MethodPost {
|
|
p.handleMessage(w, r, msgSignature, timestamp, nonce)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
}
|
|
|
|
// handleVerify handles the one-time URL verification from WeChat Work.
|
|
func (p *Platform) handleVerify(w http.ResponseWriter, msgSig, timestamp, nonce, echostr string) {
|
|
if !p.verifySignature(msgSig, timestamp, nonce, echostr) {
|
|
slog.Warn("wecom: verify signature failed")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
plain, err := p.decrypt(echostr)
|
|
if err != nil {
|
|
slog.Error("wecom: decrypt echostr failed", "error", err)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
slog.Info("wecom: URL verification succeeded")
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprint(w, plain)
|
|
}
|
|
|
|
// wecomLogXMLPreview returns a short prefix of XML for debug only (may contain user content).
|
|
func wecomLogXMLPreview(s string, max int) string {
|
|
if max <= 0 || len(s) <= max {
|
|
return s
|
|
}
|
|
return s[:max] + "…"
|
|
}
|
|
|
|
// handleMessage processes incoming encrypted message POSTs.
|
|
func (p *Platform) handleMessage(w http.ResponseWriter, r *http.Request, msgSig, timestamp, nonce string) {
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
if err != nil {
|
|
slog.Warn("wecom: read callback body failed", "error", err)
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
slog.Info("wecom: callback POST received",
|
|
"body_bytes", len(body),
|
|
"content_length", r.ContentLength,
|
|
"has_msg_signature", msgSig != "",
|
|
"has_timestamp", timestamp != "",
|
|
"has_nonce", nonce != "")
|
|
if len(body) == 0 {
|
|
slog.Warn("wecom: empty callback POST body")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var encMsg xmlEncryptedMsg
|
|
if err := xml.Unmarshal(body, &encMsg); err != nil {
|
|
slog.Error("wecom: parse outer xml failed", "error", err, "body_bytes", len(body))
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if !p.verifySignature(msgSig, timestamp, nonce, encMsg.Encrypt) {
|
|
slog.Warn("wecom: message signature verification failed")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
plainXML, err := p.decrypt(encMsg.Encrypt)
|
|
if err != nil {
|
|
slog.Error("wecom: decrypt message failed", "error", err)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
slog.Debug("wecom: decrypted xml preview", "preview", wecomLogXMLPreview(plainXML, 512))
|
|
|
|
// Return 200 immediately (WeChat Work requires response within 5 seconds)
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
var msg xmlMessage
|
|
if err := xml.Unmarshal([]byte(plainXML), &msg); err != nil {
|
|
slog.Error("wecom: parse decrypted xml failed", "error", err,
|
|
"plain_len", len(plainXML),
|
|
"preview", wecomLogXMLPreview(plainXML, 256))
|
|
return
|
|
}
|
|
|
|
slog.Info("wecom: inbound parsed",
|
|
"msg_type", msg.MsgType,
|
|
"msg_id", msg.MsgId,
|
|
"from_user", msg.FromUserName,
|
|
"create_time", msg.CreateTime,
|
|
"has_media_id", msg.MediaId != "",
|
|
"file_name", msg.FileName)
|
|
|
|
if p.dedup.isDuplicate(msg.MsgId) {
|
|
slog.Info("wecom: dropping duplicate message", "msg_id", msg.MsgId, "msg_type", msg.MsgType)
|
|
return
|
|
}
|
|
|
|
if msg.CreateTime > 0 {
|
|
if core.IsOldMessage(time.Unix(msg.CreateTime, 0)) {
|
|
slog.Info("wecom: ignoring old message after restart", "create_time", msg.CreateTime, "msg_type", msg.MsgType)
|
|
return
|
|
}
|
|
}
|
|
|
|
if !core.AllowList(p.allowFrom, msg.FromUserName) {
|
|
slog.Warn("wecom: message rejected by allow_from", "user", msg.FromUserName, "msg_type", msg.MsgType)
|
|
return
|
|
}
|
|
|
|
sessionKey := fmt.Sprintf("wecom:%s", msg.FromUserName)
|
|
rctx := replyContext{userID: msg.FromUserName}
|
|
|
|
switch msg.MsgType {
|
|
case "text":
|
|
text := stripWeComAtMentions(msg.Content, p.agentID)
|
|
slog.Debug("wecom: message received", "user", msg.FromUserName, "text_len", len(text))
|
|
go p.handler(p, &core.Message{
|
|
SessionKey: sessionKey, Platform: "wecom",
|
|
MessageID: strconv.FormatInt(msg.MsgId, 10),
|
|
UserID: msg.FromUserName, UserName: p.resolveUserName(msg.FromUserName),
|
|
Content: text, ReplyCtx: rctx,
|
|
})
|
|
|
|
case "image":
|
|
slog.Debug("wecom: image received", "user", msg.FromUserName)
|
|
go func() {
|
|
imgData, err := p.downloadMedia(msg.MediaId)
|
|
if err != nil {
|
|
slog.Error("wecom: download image failed", "error", err)
|
|
return
|
|
}
|
|
p.handler(p, &core.Message{
|
|
SessionKey: sessionKey, Platform: "wecom",
|
|
MessageID: strconv.FormatInt(msg.MsgId, 10),
|
|
UserID: msg.FromUserName, UserName: p.resolveUserName(msg.FromUserName),
|
|
Images: []core.ImageAttachment{{MimeType: "image/jpeg", Data: imgData}},
|
|
ReplyCtx: rctx,
|
|
})
|
|
}()
|
|
|
|
case "voice":
|
|
slog.Debug("wecom: voice received", "user", msg.FromUserName, "format", msg.Format)
|
|
go func() {
|
|
audioData, err := p.downloadMedia(msg.MediaId)
|
|
if err != nil {
|
|
slog.Error("wecom: download voice failed", "error", err)
|
|
return
|
|
}
|
|
format := strings.ToLower(msg.Format)
|
|
if format == "" {
|
|
format = "amr"
|
|
}
|
|
p.handler(p, &core.Message{
|
|
SessionKey: sessionKey, Platform: "wecom",
|
|
MessageID: strconv.FormatInt(msg.MsgId, 10),
|
|
UserID: msg.FromUserName, UserName: p.resolveUserName(msg.FromUserName),
|
|
Audio: &core.AudioAttachment{MimeType: "audio/" + format, Data: audioData, Format: format},
|
|
ReplyCtx: rctx,
|
|
})
|
|
}()
|
|
|
|
case "file":
|
|
slog.Info("wecom: file message accepted", "user", msg.FromUserName, "file_name", msg.FileName, "media_id_len", len(msg.MediaId))
|
|
if msg.MediaId == "" {
|
|
slog.Warn("wecom: file message missing MediaId")
|
|
return
|
|
}
|
|
go func() {
|
|
fileData, err := p.downloadMedia(msg.MediaId)
|
|
if err != nil {
|
|
slog.Error("wecom: download file failed", "error", err)
|
|
return
|
|
}
|
|
baseName := filepath.Base(strings.TrimSpace(msg.FileName))
|
|
if baseName == "" || baseName == "." {
|
|
baseName = "attachment"
|
|
}
|
|
mt := wecomInboundFileMime(baseName, fileData)
|
|
p.handler(p, &core.Message{
|
|
SessionKey: sessionKey, Platform: "wecom",
|
|
MessageID: strconv.FormatInt(msg.MsgId, 10),
|
|
UserID: msg.FromUserName, UserName: p.resolveUserName(msg.FromUserName),
|
|
Files: []core.FileAttachment{{
|
|
MimeType: mt,
|
|
Data: fileData,
|
|
FileName: baseName,
|
|
}},
|
|
ReplyCtx: rctx,
|
|
})
|
|
}()
|
|
|
|
default:
|
|
slog.Warn("wecom: unsupported inbound message type (no handler)",
|
|
"msg_type", msg.MsgType,
|
|
"msg_id", msg.MsgId,
|
|
"from_user", msg.FromUserName)
|
|
}
|
|
}
|
|
|
|
func (p *Platform) Reply(ctx context.Context, rctx any, content string) error {
|
|
rc, ok := rctx.(replyContext)
|
|
if !ok {
|
|
return fmt.Errorf("wecom: invalid reply context type %T", rctx)
|
|
}
|
|
if content == "" {
|
|
return nil
|
|
}
|
|
|
|
accessToken, err := p.getAccessToken()
|
|
if err != nil {
|
|
slog.Error("wecom: get access_token failed", "error", err)
|
|
return fmt.Errorf("wecom: get access_token: %w", err)
|
|
}
|
|
|
|
if !p.enableMarkdown {
|
|
content = core.StripMarkdown(content)
|
|
}
|
|
|
|
chunks := splitByBytes(content, 2000)
|
|
for i, chunk := range chunks {
|
|
var sendErr error
|
|
if p.enableMarkdown {
|
|
sendErr = p.sendMarkdown(accessToken, rc.userID, chunk)
|
|
} else {
|
|
sendErr = p.sendText(accessToken, rc.userID, chunk)
|
|
}
|
|
if sendErr != nil {
|
|
slog.Error("wecom: send failed", "user", rc.userID, "chunk", i, "error", sendErr)
|
|
return sendErr
|
|
}
|
|
}
|
|
slog.Debug("wecom: message sent", "user", rc.userID, "chunks", len(chunks), "total_len", len(content))
|
|
return nil
|
|
}
|
|
|
|
// Send sends a new message (same as Reply for WeChat Work)
|
|
func (p *Platform) Send(ctx context.Context, rctx any, content string) error {
|
|
return p.Reply(ctx, rctx, content)
|
|
}
|
|
|
|
// SendImage uploads and sends an image to the user.
|
|
// Implements core.ImageSender.
|
|
func (p *Platform) SendImage(ctx context.Context, rctx any, img core.ImageAttachment) error {
|
|
rc, ok := rctx.(replyContext)
|
|
if !ok {
|
|
return fmt.Errorf("wecom: SendImage: invalid reply context type %T", rctx)
|
|
}
|
|
|
|
accessToken, err := p.getAccessToken()
|
|
if err != nil {
|
|
return fmt.Errorf("wecom: send image: %w", err)
|
|
}
|
|
|
|
mediaID, err := p.uploadImageMedia(accessToken, img)
|
|
if err != nil {
|
|
return fmt.Errorf("wecom: send image: %w", err)
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"touser": rc.userID,
|
|
"msgtype": "image",
|
|
"agentid": p.agentID,
|
|
"image": map[string]string{"media_id": mediaID},
|
|
}
|
|
|
|
body, _ := json.Marshal(payload)
|
|
apiURL := p.wecomAPIURL("/cgi-bin/message/send", url.Values{
|
|
"access_token": []string{accessToken},
|
|
})
|
|
|
|
resp, err := p.apiClient.Post(apiURL, "application/json", strings.NewReader(string(body)))
|
|
if err != nil {
|
|
return fmt.Errorf("wecom: send image: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var result struct {
|
|
ErrCode int `json:"errcode"`
|
|
ErrMsg string `json:"errmsg"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return fmt.Errorf("wecom: decode send image response: %w", err)
|
|
}
|
|
if result.ErrCode != 0 {
|
|
return fmt.Errorf("wecom: send image failed: %d %s", result.ErrCode, result.ErrMsg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// uploadImageMedia uploads an image to WeChat Work media API and returns the media_id.
|
|
func (p *Platform) uploadImageMedia(accessToken string, img core.ImageAttachment) (string, error) {
|
|
name := img.FileName
|
|
if name == "" {
|
|
name = "image.png"
|
|
}
|
|
|
|
body := &bytes.Buffer{}
|
|
writer := multipart.NewWriter(body)
|
|
|
|
part, err := writer.CreateFormFile("media", name)
|
|
if err != nil {
|
|
return "", fmt.Errorf("wecom: create form file: %w", err)
|
|
}
|
|
if _, err := part.Write(img.Data); err != nil {
|
|
return "", fmt.Errorf("wecom: write image data: %w", err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
return "", fmt.Errorf("wecom: close multipart writer: %w", err)
|
|
}
|
|
|
|
apiURL := p.wecomAPIURL("/cgi-bin/media/upload", url.Values{
|
|
"access_token": []string{accessToken},
|
|
"type": []string{"image"},
|
|
})
|
|
resp, err := p.apiClient.Post(apiURL, writer.FormDataContentType(), body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("wecom: upload image: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var result struct {
|
|
ErrCode int `json:"errcode"`
|
|
ErrMsg string `json:"errmsg"`
|
|
MediaID string `json:"media_id"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", fmt.Errorf("wecom: decode upload response: %w", err)
|
|
}
|
|
if result.ErrCode != 0 {
|
|
return "", fmt.Errorf("wecom: upload image failed: %d %s", result.ErrCode, result.ErrMsg)
|
|
}
|
|
if result.MediaID == "" {
|
|
return "", fmt.Errorf("wecom: upload image: empty media_id")
|
|
}
|
|
return result.MediaID, nil
|
|
}
|
|
|
|
var _ core.ImageSender = (*Platform)(nil)
|
|
|
|
func (p *Platform) sendMarkdown(accessToken, toUser, content string) error {
|
|
payload := map[string]any{
|
|
"touser": toUser,
|
|
"msgtype": "markdown",
|
|
"agentid": p.agentID,
|
|
"markdown": map[string]string{"content": content},
|
|
}
|
|
|
|
body, _ := json.Marshal(payload)
|
|
apiURL := p.wecomAPIURL("/cgi-bin/message/send", url.Values{
|
|
"access_token": []string{accessToken},
|
|
})
|
|
|
|
resp, err := p.apiClient.Post(apiURL, "application/json", strings.NewReader(string(body)))
|
|
if err != nil {
|
|
return fmt.Errorf("wecom: send markdown: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var result struct {
|
|
ErrCode int `json:"errcode"`
|
|
ErrMsg string `json:"errmsg"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return fmt.Errorf("wecom: decode send response: %w", err)
|
|
}
|
|
if result.ErrCode != 0 {
|
|
return fmt.Errorf("wecom: send markdown failed: %d %s", result.ErrCode, result.ErrMsg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Platform) sendText(accessToken, toUser, text string) error {
|
|
payload := map[string]any{
|
|
"touser": toUser,
|
|
"msgtype": "text",
|
|
"agentid": p.agentID,
|
|
"text": map[string]string{"content": text},
|
|
"safe": 0,
|
|
}
|
|
|
|
body, _ := json.Marshal(payload)
|
|
apiURL := p.wecomAPIURL("/cgi-bin/message/send", url.Values{
|
|
"access_token": []string{accessToken},
|
|
})
|
|
|
|
resp, err := p.apiClient.Post(apiURL, "application/json", strings.NewReader(string(body)))
|
|
if err != nil {
|
|
return fmt.Errorf("wecom: send message: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var result struct {
|
|
ErrCode int `json:"errcode"`
|
|
ErrMsg string `json:"errmsg"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return fmt.Errorf("wecom: decode send response: %w", err)
|
|
}
|
|
if result.ErrCode != 0 {
|
|
return fmt.Errorf("wecom: send failed: %d %s", result.ErrCode, result.ErrMsg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Platform) getAccessToken() (string, error) {
|
|
p.tokenCache.mu.Lock()
|
|
defer p.tokenCache.mu.Unlock()
|
|
|
|
if p.tokenCache.token != "" && time.Now().Before(p.tokenCache.expiresAt) {
|
|
return p.tokenCache.token, nil
|
|
}
|
|
|
|
apiURL := p.wecomAPIURL("/cgi-bin/gettoken", url.Values{
|
|
"corpid": []string{p.corpID},
|
|
"corpsecret": []string{p.corpSecret},
|
|
})
|
|
|
|
resp, err := p.apiClient.Get(apiURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("wecom: request access_token: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var result struct {
|
|
ErrCode int `json:"errcode"`
|
|
ErrMsg string `json:"errmsg"`
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", fmt.Errorf("wecom: decode token response: %w", err)
|
|
}
|
|
if result.ErrCode != 0 {
|
|
return "", fmt.Errorf("wecom: get token failed: %d %s", result.ErrCode, result.ErrMsg)
|
|
}
|
|
|
|
// Compute the cache window from expires_in with a 60-second safety
|
|
// margin. When the server omits or zeroes the field, fall back to
|
|
// WeCom's documented 7200s default; without this, the raw value would
|
|
// land at -60 and the cache would be stale on the very next call,
|
|
// turning every outbound API request into a fresh /gettoken round-trip.
|
|
expires := result.ExpiresIn
|
|
if expires <= 0 {
|
|
slog.Warn("wecom: missing/invalid expires_in in token response, defaulting to 7200s", "got", result.ExpiresIn)
|
|
expires = 7200
|
|
}
|
|
if expires > 60 {
|
|
expires -= 60
|
|
}
|
|
p.tokenCache.token = result.AccessToken
|
|
p.tokenCache.expiresAt = time.Now().Add(time.Duration(expires) * time.Second)
|
|
|
|
slog.Debug("wecom: access_token refreshed", "expires_in", result.ExpiresIn)
|
|
return result.AccessToken, nil
|
|
}
|
|
|
|
func (p *Platform) ReconstructReplyCtx(sessionKey string) (any, error) {
|
|
// wecom:{userID}
|
|
parts := strings.SplitN(sessionKey, ":", 2)
|
|
if len(parts) < 2 || parts[0] != "wecom" {
|
|
return nil, fmt.Errorf("wecom: invalid session key %q", sessionKey)
|
|
}
|
|
return replyContext{userID: parts[1]}, nil
|
|
}
|
|
|
|
func (p *Platform) Stop() error {
|
|
if p.server != nil {
|
|
return p.server.Shutdown(context.Background())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- Crypto helpers ---
|
|
|
|
// verifySignature checks SHA1(sort(token, timestamp, nonce, encrypt)).
|
|
func (p *Platform) verifySignature(expected, timestamp, nonce, encrypt string) bool {
|
|
parts := []string{p.token, timestamp, nonce, encrypt}
|
|
sort.Strings(parts)
|
|
h := sha1.New()
|
|
h.Write([]byte(strings.Join(parts, "")))
|
|
got := fmt.Sprintf("%x", h.Sum(nil))
|
|
return got == expected
|
|
}
|
|
|
|
// decodeAESKey converts the 43-char Base64 EncodingAESKey to 32 bytes.
|
|
func decodeAESKey(encodingAESKey string) ([]byte, error) {
|
|
if len(encodingAESKey) != 43 {
|
|
return nil, fmt.Errorf("EncodingAESKey must be 43 characters, got %d", len(encodingAESKey))
|
|
}
|
|
return base64.StdEncoding.DecodeString(encodingAESKey + "=")
|
|
}
|
|
|
|
// decrypt decodes and decrypts a Base64-encoded AES-256-CBC ciphertext.
|
|
// Layout after decryption + PKCS#7 unpad:
|
|
//
|
|
// [16 bytes random] [4 bytes msg_len (big-endian)] [msg_len bytes message] [corp_id]
|
|
func (p *Platform) decrypt(cipherBase64 string) (string, error) {
|
|
cipherData, err := base64.StdEncoding.DecodeString(cipherBase64)
|
|
if err != nil {
|
|
return "", fmt.Errorf("base64 decode: %w", err)
|
|
}
|
|
|
|
block, err := aes.NewCipher(p.aesKey)
|
|
if err != nil {
|
|
return "", fmt.Errorf("aes new cipher: %w", err)
|
|
}
|
|
|
|
if len(cipherData) < aes.BlockSize || len(cipherData)%aes.BlockSize != 0 {
|
|
return "", fmt.Errorf("invalid ciphertext length %d", len(cipherData))
|
|
}
|
|
|
|
iv := p.aesKey[:16]
|
|
mode := cipher.NewCBCDecrypter(block, iv)
|
|
plain := make([]byte, len(cipherData))
|
|
mode.CryptBlocks(plain, cipherData)
|
|
|
|
plain = pkcs7Unpad(plain)
|
|
|
|
if len(plain) < 20 {
|
|
return "", fmt.Errorf("decrypted data too short")
|
|
}
|
|
|
|
msgLen := int(binary.BigEndian.Uint32(plain[16:20]))
|
|
if 20+msgLen > len(plain) {
|
|
return "", fmt.Errorf("invalid message length %d in decrypted data (total %d)", msgLen, len(plain))
|
|
}
|
|
|
|
msg := string(plain[20 : 20+msgLen])
|
|
corpID := string(plain[20+msgLen:])
|
|
|
|
if corpID != p.corpID {
|
|
return "", fmt.Errorf("corp_id mismatch: expected %s, got %s", p.corpID, corpID)
|
|
}
|
|
|
|
return msg, nil
|
|
}
|
|
|
|
func pkcs7Unpad(data []byte) []byte {
|
|
if len(data) == 0 {
|
|
return data
|
|
}
|
|
pad := int(data[len(data)-1])
|
|
if pad < 1 || pad > 32 || pad > len(data) {
|
|
return data
|
|
}
|
|
return data[:len(data)-pad]
|
|
}
|
|
|
|
// downloadMedia fetches a temporary media file from WeChat Work by media_id.
|
|
func (p *Platform) resolveUserName(userID string) string {
|
|
if cached, ok := p.userNameCache.Load(userID); ok {
|
|
return cached.(string)
|
|
}
|
|
accessToken, err := p.getAccessToken()
|
|
if err != nil {
|
|
slog.Debug("wecom: resolve user name: get token failed", "error", err)
|
|
return userID
|
|
}
|
|
apiURL := p.wecomAPIURL("/cgi-bin/user/get", url.Values{
|
|
"access_token": []string{accessToken},
|
|
"userid": []string{userID},
|
|
})
|
|
resp, err := p.apiClient.Get(apiURL)
|
|
if err != nil {
|
|
slog.Debug("wecom: resolve user name failed", "user", userID, "error", err)
|
|
return userID
|
|
}
|
|
defer resp.Body.Close()
|
|
var result struct {
|
|
ErrCode int `json:"errcode"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || result.ErrCode != 0 {
|
|
slog.Debug("wecom: resolve user name: api error", "user", userID, "errcode", result.ErrCode)
|
|
return userID
|
|
}
|
|
if result.Name != "" {
|
|
p.userNameCache.Store(userID, result.Name)
|
|
return result.Name
|
|
}
|
|
return userID
|
|
}
|
|
|
|
// wecomInboundFileMime infers MIME type from filename extension, then from content sniffing.
|
|
func wecomInboundFileMime(fileName string, data []byte) string {
|
|
ext := strings.ToLower(filepath.Ext(fileName))
|
|
if ext != "" {
|
|
if mt := mime.TypeByExtension(ext); mt != "" {
|
|
if mt != "application/octet-stream" {
|
|
return mt
|
|
}
|
|
}
|
|
}
|
|
if len(data) > 0 {
|
|
if mt := wecomInboundFileMagicMime(data); mt != "" {
|
|
return mt
|
|
}
|
|
if sniff := http.DetectContentType(data); sniff != "" {
|
|
return sniff
|
|
}
|
|
}
|
|
return "application/octet-stream"
|
|
}
|
|
|
|
func wecomInboundFileMagicMime(data []byte) string {
|
|
if len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n" {
|
|
return "image/png"
|
|
}
|
|
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
|
return "image/jpeg"
|
|
}
|
|
if len(data) >= 6 {
|
|
head := string(data[:6])
|
|
if head == "GIF87a" || head == "GIF89a" {
|
|
return "image/gif"
|
|
}
|
|
}
|
|
if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
|
|
return "image/webp"
|
|
}
|
|
if len(data) >= 4 && string(data[:4]) == "%PDF" {
|
|
return "application/pdf"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (p *Platform) downloadMedia(mediaID string) ([]byte, error) {
|
|
accessToken, err := p.getAccessToken()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get token: %w", err)
|
|
}
|
|
u := p.wecomAPIURL("/cgi-bin/media/get", url.Values{
|
|
"access_token": []string{accessToken},
|
|
"media_id": []string{mediaID},
|
|
})
|
|
resp, err := p.apiClient.Get(u)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("download: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
// splitByBytes splits text by UTF-8 byte length (WeChat Work limit is 2048 bytes).
|
|
func splitByBytes(s string, maxBytes int) []string {
|
|
if len(s) <= maxBytes {
|
|
return []string{s}
|
|
}
|
|
var parts []string
|
|
for len(s) > 0 {
|
|
end := maxBytes
|
|
if end > len(s) {
|
|
end = len(s)
|
|
}
|
|
// Avoid splitting in the middle of a UTF-8 character
|
|
for end > 0 && end < len(s) && s[end]>>6 == 0b10 {
|
|
end--
|
|
}
|
|
if end == 0 {
|
|
end = maxBytes
|
|
}
|
|
parts = append(parts, s[:end])
|
|
s = s[end:]
|
|
}
|
|
return parts
|
|
}
|