Files
2026-06-02 23:14:41 +08:00

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
}