初始化仓库

This commit is contained in:
2026-06-02 23:14:41 +08:00
commit 0bc3f02670
520 changed files with 191097 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
package wecom
import (
"encoding/xml"
"testing"
)
func TestWecomInboundFileMime(t *testing.T) {
t.Parallel()
if got := wecomInboundFileMime("report.pdf", []byte("%PDF-1.4")); got != "application/pdf" {
t.Fatalf("pdf by extension: got %q", got)
}
if got := wecomInboundFileMime("unknown.bin", []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}); got != "image/png" {
t.Fatalf("png by magic: got %q", got)
}
if got := wecomInboundFileMime("weird", []byte{0x00, 0x01, 0x02}); got != "application/octet-stream" {
t.Fatalf("fallback: got %q", got)
}
}
func TestXMLMessageFile(t *testing.T) {
t.Parallel()
raw := `<xml>
<ToUserName><![CDATA[to]]></ToUserName>
<FromUserName><![CDATA[from]]></FromUserName>
<CreateTime>123</CreateTime>
<MsgType><![CDATA[file]]></MsgType>
<MediaId><![CDATA[mid]]></MediaId>
<FileName><![CDATA[../dir/doc.pdf]]></FileName>
<MsgId>999</MsgId>
<AgentID>1</AgentID>
</xml>`
var msg xmlMessage
if err := xml.Unmarshal([]byte(raw), &msg); err != nil {
t.Fatal(err)
}
if msg.MsgType != "file" || msg.MediaId != "mid" || msg.FileName != "../dir/doc.pdf" {
t.Fatalf("parsed: %+v", msg)
}
if msg.MsgId != 999 {
t.Fatalf("MsgId = %d", msg.MsgId)
}
}
+79
View File
@@ -0,0 +1,79 @@
package wecom
import "strings"
// stripWeComAtMentions removes @<botId> / <botId> segments so group replies like
// "允许 @机器人" still match engine permission keywords (#98). Only affects wecom.
func stripWeComAtMentions(s string, botIDs ...string) string {
s = strings.TrimSpace(s)
for _, id := range botIDs {
id = strings.TrimSpace(id)
if id == "" {
continue
}
s = stripOneWeComAtMention(s, id)
s = strings.TrimSpace(s)
}
for strings.Contains(s, " ") {
s = strings.ReplaceAll(s, " ", " ")
}
return stripLeadingDisplayMentionCommand(strings.TrimSpace(s))
}
func stripOneWeComAtMention(s, botID string) string {
if s == "" || botID == "" {
return s
}
// Fullwidth commercial at (common on mobile keyboards)
s = removeAllEqualFold(s, ""+botID)
// ASCII @
needleLower := "@" + strings.ToLower(botID)
for {
lower := strings.ToLower(s)
idx := strings.Index(lower, needleLower)
if idx < 0 {
return s
}
end := idx + len(needleLower)
if end > len(s) {
return s
}
s = s[:idx] + s[end:]
}
}
// removeAllEqualFold removes every case-insensitive occurrence of literal sub from s.
// sub must be UTF-8; indices align because case folding does not change byte length
// for ASCII letters in sub.
func removeAllEqualFold(s, sub string) string {
if sub == "" {
return s
}
subLower := strings.ToLower(sub)
for {
lower := strings.ToLower(s)
idx := strings.Index(lower, subLower)
if idx < 0 {
return s
}
s = s[:idx] + s[idx+len(sub):]
}
}
func stripLeadingDisplayMentionCommand(s string) string {
if s == "" {
return s
}
if !strings.HasPrefix(s, "@") && !strings.HasPrefix(s, "") {
return s
}
fields := strings.Fields(s)
if len(fields) < 2 {
return s
}
rest := strings.TrimSpace(strings.TrimPrefix(s, fields[0]))
if strings.HasPrefix(rest, "/") || strings.HasPrefix(rest, "!") {
return rest
}
return s
}
+32
View File
@@ -0,0 +1,32 @@
package wecom
import "testing"
func TestStripWeComAtMentions(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in string
ids []string
want string
}{
{"empty", "", []string{"x"}, ""},
{"no ids", "允许", nil, "允许"},
{"suffix mention", "允许 @mybot", []string{"mybot"}, "允许"},
{"prefix mention", "@MyBot 允许", []string{"mybot"}, "允许"},
{"fullwidth at", "允许 mybot", []string{"mybot"}, "允许"},
{"two ids second", "ok @a @b", []string{"a", "b"}, "ok"},
{"unrelated at", "email x@y.com", []string{"mybot"}, "email x@y.com"},
{"display mention before slash command", "@小口不休息 /whoami", []string{"robot01"}, "/whoami"},
{"display mention before bang command", "@小口不休息 !pwd", []string{"robot01"}, "!pwd"},
{"display mention before normal text preserved", "@小口不休息 你好", []string{"robot01"}, "@小口不休息 你好"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := stripWeComAtMentions(tt.in, tt.ids...)
if got != tt.want {
t.Fatalf("got %q want %q", got, tt.want)
}
})
}
}
+631
View File
@@ -0,0 +1,631 @@
package wecom
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/chenhg5/cc-connect/core"
"github.com/gorilla/websocket"
)
const (
wsEndpoint = "wss://openws.work.weixin.qq.com"
wsPingInterval = 30 * time.Second
wsMaxBackoff = 30 * time.Second
wsMaxMissedPong = 2
)
// WSPlatform implements core.Platform using the WeChat Work WebSocket long-connection
// mode (智能机器人长连接). No public URL, no message encryption, no IP allowlist required.
type WSPlatform struct {
botID string
secret string
allowFrom string
conn *websocket.Conn
handler core.MessageHandler
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex // protects conn writes
dedup core.MessageDedup
reqSeq atomic.Int64 // monotonic counter for generating unique req_id
missedPong atomic.Int32 // consecutive heartbeat acks not received
pendingAcks sync.Map // req_id -> chan wsAckResult, for sequential send with ack waiting
}
const (
wsAckTimeout = 5 * time.Second
wsMediaAckTimeout = 30 * time.Second
)
var errWSAckTimeout = errors.New("wecom-ws: ack timeout")
// wsReplyContext holds the context needed to reply to a specific message.
type wsReplyContext struct {
reqID string // req_id from headers of aibot_msg_callback
chatID string // chatid for aibot_send_msg
chatType string // chattype: "single" or "group"
userID string // from.userid
}
// --- WebSocket protocol frame types (matching official SDK) ---
// wsFrame is the unified frame structure used for all WebSocket communication.
// Format: { cmd, headers: { req_id }, body: {...} }
// Response frames may omit cmd and include errcode/errmsg instead.
type wsFrame struct {
Cmd string `json:"cmd,omitempty"`
Headers wsFrameHeaders `json:"headers"`
Body json.RawMessage `json:"body,omitempty"`
ErrCode *int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
}
type wsFrameHeaders struct {
ReqID string `json:"req_id"`
}
type wsAckResult struct {
frame wsFrame
err error
}
// wsMsgCallbackBody is the body of an aibot_msg_callback frame.
type wsMsgCallbackBody struct {
MsgID string `json:"msgid"`
AibotID string `json:"aibotid"`
ChatID string `json:"chatid"`
ChatType string `json:"chattype"` // "single" or "group"
From struct {
UserID string `json:"userid"`
} `json:"from"`
MsgType string `json:"msgtype"`
Text struct {
Content string `json:"content"`
} `json:"text"`
// Voice: official field is content; some payloads used text — accept both.
Voice struct {
Text string `json:"text,omitempty"`
Content string `json:"content,omitempty"`
} `json:"voice"`
Image *struct {
URL string `json:"url"`
Aeskey string `json:"aeskey"`
} `json:"image,omitempty"`
File *struct {
URL string `json:"url"`
Aeskey string `json:"aeskey"`
} `json:"file,omitempty"`
Mixed *wsMixedBlock `json:"mixed,omitempty"`
Quote *wsQuoteBlock `json:"quote,omitempty"`
CreateTime int64 `json:"create_time"`
}
func wsVoiceText(v struct {
Text string `json:"text,omitempty"`
Content string `json:"content,omitempty"`
}) string {
if s := strings.TrimSpace(v.Content); s != "" {
return s
}
return strings.TrimSpace(v.Text)
}
func newWebSocket(opts map[string]any) (core.Platform, error) {
botID, _ := opts["bot_id"].(string)
secret, _ := opts["bot_secret"].(string)
if botID == "" || secret == "" {
return nil, fmt.Errorf("wecom-ws: bot_id and bot_secret are required for websocket mode")
}
allowFrom, _ := opts["allow_from"].(string)
return &WSPlatform{
botID: botID,
secret: secret,
allowFrom: allowFrom,
}, nil
}
// generateReqID creates a unique req_id with the given prefix (e.g. "ping_1", "aibot_subscribe_2").
func (p *WSPlatform) generateReqID(prefix string) string {
seq := p.reqSeq.Add(1)
return fmt.Sprintf("%s_%d", prefix, seq)
}
func (p *WSPlatform) Name() string { return "wecom" }
func (p *WSPlatform) Start(handler core.MessageHandler) error {
p.handler = handler
p.ctx, p.cancel = context.WithCancel(context.Background())
go p.connectLoop()
return nil
}
// connectLoop establishes the WebSocket connection and reconnects on failure with
// exponential backoff (1s → 2s → 4s → ... → 30s max).
func (p *WSPlatform) connectLoop() {
backoff := time.Second
for {
select {
case <-p.ctx.Done():
return
default:
}
start := time.Now()
err := p.runConnection()
if p.ctx.Err() != nil {
return // shutting down
}
// If the connection was alive for a meaningful period, reset backoff
if time.Since(start) > 2*wsPingInterval {
backoff = time.Second
}
slog.Warn("wecom-ws: connection lost, reconnecting", "error", err, "backoff", backoff)
select {
case <-time.After(backoff):
case <-p.ctx.Done():
return
}
backoff *= 2
if backoff > wsMaxBackoff {
backoff = wsMaxBackoff
}
}
}
// runConnection dials, subscribes, and processes messages until disconnection.
func (p *WSPlatform) runConnection() error {
slog.Info("wecom-ws: connecting", "endpoint", wsEndpoint)
conn, _, err := websocket.DefaultDialer.DialContext(p.ctx, wsEndpoint, nil)
if err != nil {
return fmt.Errorf("dial: %w", err)
}
p.mu.Lock()
p.conn = conn
p.mu.Unlock()
defer func() {
p.mu.Lock()
p.conn = nil
p.mu.Unlock()
conn.Close()
// Drain pending ACK channels so waiting goroutines are unblocked
// and stale entries do not accumulate across reconnections.
// Collect keys first, then delete — Range+Delete in callback is
// not guaranteed safe by the sync.Map contract.
var staleKeys []any
p.pendingAcks.Range(func(key, value any) bool {
if ch, ok := value.(chan wsAckResult); ok {
select {
case ch <- wsAckResult{err: fmt.Errorf("wecom-ws: connection closed")}:
default:
}
}
staleKeys = append(staleKeys, key)
return true
})
for _, k := range staleKeys {
p.pendingAcks.Delete(k)
}
}()
// Send subscribe (auth) frame
// Format: { cmd: "aibot_subscribe", headers: { req_id }, body: { bot_id, secret } }
subReqID := p.generateReqID("aibot_subscribe")
subFrame := map[string]any{
"cmd": "aibot_subscribe",
"headers": map[string]string{"req_id": subReqID},
"body": map[string]string{
"bot_id": p.botID,
"secret": p.secret,
},
}
if err := p.writeJSON(subFrame); err != nil {
return fmt.Errorf("subscribe: %w", err)
}
// Read subscribe response: { headers: { req_id }, errcode: 0, errmsg: "ok" }
var subResp wsFrame
if err := conn.ReadJSON(&subResp); err != nil {
return fmt.Errorf("subscribe response: %w", err)
}
if subResp.ErrCode == nil || *subResp.ErrCode != 0 {
errCode := 0
if subResp.ErrCode != nil {
errCode = *subResp.ErrCode
}
return fmt.Errorf("subscribe failed: errcode=%d errmsg=%s", errCode, subResp.ErrMsg)
}
slog.Info("wecom-ws: subscribed successfully", "bot_id", p.botID)
p.missedPong.Store(0)
// Start heartbeat goroutine
heartCtx, heartCancel := context.WithCancel(p.ctx)
defer heartCancel()
go p.heartbeat(heartCtx, conn)
// Read loop
for {
select {
case <-p.ctx.Done():
return p.ctx.Err()
default:
}
_, raw, err := conn.ReadMessage()
if err != nil {
return fmt.Errorf("read: %w", err)
}
var frame wsFrame
if err := json.Unmarshal(raw, &frame); err != nil {
slog.Warn("wecom-ws: invalid json", "error", err)
continue
}
p.handleFrame(frame)
}
}
// handleFrame dispatches incoming frames by cmd or req_id prefix.
func (p *WSPlatform) handleFrame(frame wsFrame) {
switch frame.Cmd {
case "aibot_msg_callback":
p.handleMsgCallback(frame)
case "aibot_event_callback":
slog.Debug("wecom-ws: event callback received (ignored)", "req_id", frame.Headers.ReqID)
case "":
// Response frame (no cmd): identify by req_id prefix
reqID := frame.Headers.ReqID
switch {
case strings.HasPrefix(reqID, "ping"):
p.missedPong.Store(0)
slog.Debug("wecom-ws: heartbeat ack received")
case strings.HasPrefix(reqID, "aibot_subscribe"):
// Late subscribe ack (should have been consumed in runConnection)
slog.Debug("wecom-ws: late subscribe ack")
default:
var ackErr error
if frame.ErrCode != nil && *frame.ErrCode != 0 {
ackErr = fmt.Errorf("wecom-ws: ack error: errcode=%d errmsg=%s", *frame.ErrCode, frame.ErrMsg)
slog.Warn("wecom-ws: reply/send ack error", "req_id", reqID, "errcode", *frame.ErrCode, "errmsg", frame.ErrMsg)
} else {
slog.Debug("wecom-ws: reply/send ack ok", "req_id", reqID)
}
p.dispatchAck(reqID, wsAckResult{frame: frame, err: ackErr})
}
default:
slog.Debug("wecom-ws: unhandled cmd", "cmd", frame.Cmd)
}
}
func (p *WSPlatform) dispatchAck(reqID string, result wsAckResult) {
ch, ok := p.pendingAcks.LoadAndDelete(reqID)
if !ok {
return
}
resultCh, ok := ch.(chan wsAckResult)
if !ok {
slog.Warn("wecom-ws: unexpected ack channel type", "req_id", reqID)
return
}
resultCh <- result
}
func (p *WSPlatform) heartbeat(ctx context.Context, conn *websocket.Conn) {
ticker := time.NewTicker(wsPingInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
missed := int(p.missedPong.Load())
if missed >= wsMaxMissedPong {
slog.Warn("wecom-ws: no heartbeat ack for consecutive pings, connection considered dead",
"missed", missed)
conn.Close()
return
}
p.missedPong.Add(1)
pingFrame := map[string]any{
"cmd": "ping",
"headers": map[string]string{"req_id": p.generateReqID("ping")},
}
if err := p.writeJSON(pingFrame); err != nil {
slog.Warn("wecom-ws: ping failed", "error", err)
return
}
slog.Debug("wecom-ws: ping sent", "missed_pong", p.missedPong.Load())
}
}
}
func (p *WSPlatform) handleMsgCallback(frame wsFrame) {
var body wsMsgCallbackBody
if err := json.Unmarshal(frame.Body, &body); err != nil {
slog.Warn("wecom-ws: parse msg_callback body failed", "error", err)
return
}
reqID := frame.Headers.ReqID
if p.dedup.IsDuplicate(body.MsgID) {
slog.Debug("wecom-ws: skipping duplicate message", "msg_id", body.MsgID)
return
}
if body.CreateTime > 0 {
if core.IsOldMessage(time.Unix(body.CreateTime, 0)) {
slog.Debug("wecom-ws: ignoring old message", "create_time", body.CreateTime)
return
}
}
if !core.AllowList(p.allowFrom, body.From.UserID) {
slog.Debug("wecom-ws: message from unauthorized user", "user", body.From.UserID)
return
}
chatID := body.ChatID
if chatID == "" {
chatID = body.From.UserID
}
sessionKey := fmt.Sprintf("wecom:%s:%s", chatID, body.From.UserID)
rctx := wsReplyContext{
reqID: reqID,
chatID: chatID,
chatType: body.ChatType,
userID: body.From.UserID,
}
// WS mode does not provide display names; the protocol only carries userID.
// Name resolution would require a separate HTTP API call with corpSecret,
// which is unavailable in WebSocket-only mode.
chatName := ""
if body.ChatType == "group" {
chatName = body.ChatID
}
texts, imgRefs, fileRefs := wsCollectInboundParts(&body)
switch body.MsgType {
case "voice":
vt := stripWeComAtMentions(wsVoiceText(body.Voice), p.botID, body.AibotID)
if vt == "" && len(imgRefs) == 0 && len(fileRefs) == 0 {
slog.Debug("wecom-ws: voice message with empty transcription, ignoring")
return
}
if len(imgRefs) > 0 || len(fileRefs) > 0 {
out := []string{}
if vt != "" {
out = append(out, vt)
}
out = append(out, texts...)
slog.Info("wecom-ws: voice + media", "user", body.From.UserID, "images", len(imgRefs), "files", len(fileRefs))
go p.deliverWSMediaInbound(&body, sessionKey, chatName, rctx, out, imgRefs, fileRefs)
return
}
slog.Debug("wecom-ws: voice received (transcribed)", "user", body.From.UserID, "len", len(vt))
go p.handler(p, &core.Message{
SessionKey: sessionKey, Platform: "wecom",
MessageID: body.MsgID,
UserID: body.From.UserID, UserName: body.From.UserID,
ChatName: chatName,
Content: vt, ReplyCtx: rctx, FromVoice: true,
})
return
}
if len(imgRefs) == 0 && len(fileRefs) == 0 {
if len(texts) == 0 {
slog.Warn("wecom-ws: no text or media in message", "msg_type", body.MsgType, "msg_id", body.MsgID)
return
}
content := stripWeComAtMentions(strings.Join(texts, "\n"), p.botID, body.AibotID)
slog.Debug("wecom-ws: text received", "user", body.From.UserID, "len", len(content))
go p.handler(p, &core.Message{
SessionKey: sessionKey, Platform: "wecom",
MessageID: body.MsgID,
UserID: body.From.UserID, UserName: body.From.UserID,
ChatName: chatName,
Content: content, ReplyCtx: rctx,
})
return
}
slog.Info("wecom-ws: media message", "msg_type", body.MsgType, "user", body.From.UserID,
"images", len(imgRefs), "files", len(fileRefs), "text_parts", len(texts))
go p.deliverWSMediaInbound(&body, sessionKey, chatName, rctx, texts, imgRefs, fileRefs)
}
// Reply sends a response message via aibot_respond_msg using the stream format.
// Uses the req_id from the original callback.
// The stream content field is a full-replacement (not incremental append), so we
// send the complete content in one frame with finish=true.
// Markdown is natively supported by the stream reply format.
func (p *WSPlatform) Reply(ctx context.Context, rctx any, content string) error {
rc, ok := rctx.(wsReplyContext)
if !ok {
return fmt.Errorf("wecom-ws: invalid reply context type %T", rctx)
}
if content == "" {
return nil
}
streamID := p.generateReqID("stream")
frame := map[string]any{
"cmd": "aibot_respond_msg",
"headers": map[string]string{"req_id": rc.reqID},
"body": map[string]any{
"msgtype": "stream",
"stream": map[string]any{
"id": streamID,
"finish": true,
"content": content,
},
},
}
if err := p.writeJSON(frame); err != nil {
slog.Error("wecom-ws: reply failed", "user", rc.userID, "error", err)
return err
}
slog.Debug("wecom-ws: reply sent", "user", rc.userID, "len", len(content))
return nil
}
// Send sends a proactive message via aibot_send_msg (markdown format).
// Used for follow-up messages and cron-triggered messages where no req_id is available.
// Markdown is natively supported.
func (p *WSPlatform) Send(ctx context.Context, rctx any, content string) error {
rc, ok := rctx.(wsReplyContext)
if !ok {
return fmt.Errorf("wecom-ws: invalid reply context type %T", rctx)
}
if content == "" {
return nil
}
if rc.chatID == "" {
return fmt.Errorf("wecom-ws: chatID is empty, cannot send proactive message")
}
chunks := splitByBytes(content, 2000)
for i, chunk := range chunks {
reqID := p.generateReqID("aibot_send_msg")
frame := map[string]any{
"cmd": "aibot_send_msg",
"headers": map[string]string{"req_id": reqID},
"body": map[string]any{
"chatid": rc.chatID,
"msgtype": "markdown",
"markdown": map[string]string{
"content": chunk,
},
},
}
if err := p.writeAndWaitAck(ctx, frame, reqID); err != nil {
slog.Error("wecom-ws: send failed", "user", rc.userID, "chunk", i, "error", err)
return err
}
}
slog.Debug("wecom-ws: message sent", "user", rc.userID, "chunks", len(chunks), "total_len", len(content))
return nil
}
// ReconstructReplyCtx rebuilds a reply context from a session key.
// Session key format: "wecom:{chatID}:{userID}".
// The reconstructed context has no req_id, so Reply() (which needs req_id for
// aibot_respond_msg) won't work — the engine should use Send() (aibot_send_msg)
// for cron/relay scenarios.
func (p *WSPlatform) ReconstructReplyCtx(sessionKey string) (any, error) {
// wecom:{chatID}:{userID}
parts := strings.SplitN(sessionKey, ":", 3)
if len(parts) < 3 || parts[0] != "wecom" {
return nil, fmt.Errorf("wecom-ws: invalid session key %q", sessionKey)
}
return wsReplyContext{chatID: parts[1], userID: parts[2]}, nil
}
func (p *WSPlatform) Stop() error {
if p.cancel != nil {
p.cancel()
}
p.mu.Lock()
conn := p.conn
p.mu.Unlock()
if conn != nil {
return conn.Close()
}
return nil
}
// writeJSON sends a JSON message over the WebSocket connection with mutex protection.
func (p *WSPlatform) writeJSON(v any) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.conn == nil {
return fmt.Errorf("wecom-ws: not connected")
}
return p.conn.WriteJSON(v)
}
// writeAndWaitAck sends a frame and waits for the server ack before returning.
// Falls back to non-blocking on timeout to avoid deadlocks.
func (p *WSPlatform) writeAndWaitAck(ctx context.Context, frame map[string]any, reqID string) error {
return p.writeAndWaitAckWithTimeout(ctx, frame, reqID, wsAckTimeout)
}
func (p *WSPlatform) writeAndWaitAckWithTimeout(ctx context.Context, frame map[string]any, reqID string, timeout time.Duration) error {
result, err := p.writeAndWaitResult(ctx, frame, reqID, timeout)
if errors.Is(err, errWSAckTimeout) {
slog.Debug("wecom-ws: ack timeout, proceeding", "req_id", reqID)
return nil
}
if err != nil {
return err
}
return result.err
}
func (p *WSPlatform) writeAndWaitAckStrict(ctx context.Context, frame map[string]any, reqID string, timeout time.Duration) error {
result, err := p.writeAndWaitResult(ctx, frame, reqID, timeout)
if errors.Is(err, errWSAckTimeout) {
return fmt.Errorf("wecom-ws: ack timeout waiting for %s", reqID)
}
if err != nil {
return err
}
return result.err
}
func (p *WSPlatform) writeAndWaitFrameWithTimeout(ctx context.Context, frame map[string]any, reqID string, timeout time.Duration) (wsFrame, error) {
result, err := p.writeAndWaitResult(ctx, frame, reqID, timeout)
if errors.Is(err, errWSAckTimeout) {
return wsFrame{}, fmt.Errorf("wecom-ws: ack timeout waiting for %s", reqID)
}
if err != nil {
return wsFrame{}, err
}
if result.err != nil {
return wsFrame{}, result.err
}
return result.frame, nil
}
func (p *WSPlatform) writeAndWaitResult(ctx context.Context, frame map[string]any, reqID string, timeout time.Duration) (wsAckResult, error) {
ch := make(chan wsAckResult, 1)
p.pendingAcks.Store(reqID, ch)
if err := p.writeJSON(frame); err != nil {
p.pendingAcks.Delete(reqID)
return wsAckResult{}, err
}
select {
case result := <-ch:
return result, nil
case <-ctx.Done():
p.pendingAcks.Delete(reqID)
return wsAckResult{}, ctx.Err()
case <-time.After(timeout):
p.pendingAcks.Delete(reqID)
return wsAckResult{}, errWSAckTimeout
}
}
+394
View File
@@ -0,0 +1,394 @@
package wecom
import (
"context"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"path/filepath"
"strings"
"time"
"github.com/chenhg5/cc-connect/core"
)
// Max download size for WeCom WS image/file payloads (matches OpenClaw default).
const wecomWSMediaMaxBytes = 20 << 20
// wsMediaRef is an encrypted download URL plus optional AES key (base64) from the WS protocol.
type wsMediaRef struct {
URL string
Aeskey string
}
// wsMsgCallbackBodyWS is the full callback body for media-capable parsing (embedded in main struct).
// We keep flat fields on wsMsgCallbackBody for backward compatibility; this mirrors the official JSON.
type wsMixedItem struct {
MsgType string `json:"msgtype"`
Text *struct {
Content string `json:"content"`
} `json:"text,omitempty"`
Image *struct {
URL string `json:"url"`
Aeskey string `json:"aeskey"`
} `json:"image,omitempty"`
File *struct {
URL string `json:"url"`
Aeskey string `json:"aeskey"`
} `json:"file,omitempty"`
}
type wsMixedBlock struct {
MsgItem []wsMixedItem `json:"msg_item"`
}
type wsQuoteBlock struct {
MsgType string `json:"msgtype"`
Text *struct {
Content string `json:"content"`
} `json:"text,omitempty"`
Voice *struct {
Content string `json:"content"`
} `json:"voice,omitempty"`
Image *struct {
URL string `json:"url"`
Aeskey string `json:"aeskey"`
} `json:"image,omitempty"`
File *struct {
URL string `json:"url"`
Aeskey string `json:"aeskey"`
} `json:"file,omitempty"`
Mixed *wsMixedBlock `json:"mixed,omitempty"`
}
// wsCollectInboundParts extracts text lines and media refs (main message + quote + mixed),
// matching @wecom/aibot-node-sdk message parsing. Does not include the top-level voice
// transcription (handled separately via wsVoiceText).
func wsCollectInboundParts(body *wsMsgCallbackBody) (texts []string, imgs, files []wsMediaRef) {
appendText := func(s string) {
s = strings.TrimSpace(s)
if s != "" {
texts = append(texts, s)
}
}
appendImage := func(url, aeskey string) {
if url != "" {
imgs = append(imgs, wsMediaRef{URL: url, Aeskey: aeskey})
}
}
appendFile := func(url, aeskey string) {
if url != "" {
files = append(files, wsMediaRef{URL: url, Aeskey: aeskey})
}
}
walkMixed := func(m *wsMixedBlock) {
if m == nil {
return
}
for _, item := range m.MsgItem {
switch item.MsgType {
case "text":
if item.Text != nil {
appendText(item.Text.Content)
}
case "image":
if item.Image != nil {
appendImage(item.Image.URL, item.Image.Aeskey)
}
case "file":
if item.File != nil {
appendFile(item.File.URL, item.File.Aeskey)
}
}
}
}
walkQuote := func(q *wsQuoteBlock) {
if q == nil {
return
}
switch q.MsgType {
case "text":
if q.Text != nil {
appendText(q.Text.Content)
}
case "voice":
if q.Voice != nil {
appendText(q.Voice.Content)
}
case "image":
if q.Image != nil {
appendImage(q.Image.URL, q.Image.Aeskey)
}
case "file":
if q.File != nil {
appendFile(q.File.URL, q.File.Aeskey)
}
case "mixed":
walkMixed(q.Mixed)
}
}
if body.Mixed != nil && len(body.Mixed.MsgItem) > 0 {
walkMixed(body.Mixed)
} else {
appendText(body.Text.Content)
if body.Image != nil {
appendImage(body.Image.URL, body.Image.Aeskey)
}
if body.MsgType == "file" && body.File != nil {
appendFile(body.File.URL, body.File.Aeskey)
}
}
// WeCom may send msgtype=file (or image) together with a non-empty mixed block; the real
// download url is then only on the top-level file/image object. Merge those here.
if body.Mixed != nil && len(body.Mixed.MsgItem) > 0 {
if body.MsgType == "file" && body.File != nil {
appendFile(body.File.URL, body.File.Aeskey)
}
if body.MsgType == "image" && body.Image != nil {
appendImage(body.Image.URL, body.Image.Aeskey)
}
}
walkQuote(body.Quote)
return texts, imgs, files
}
// decodeWeComAESKey normalizes and decodes the aeskey from WeCom WS callbacks.
// The server may send standard Base64, URL-safe Base64 (- _), omit padding, insert
// whitespace, or (rarely) a 64-char hex string. Node's Buffer.from(s, 'base64') is more
// permissive than Go's StdEncoding; we mirror common cases so decryption matches the SDK.
func decodeWeComAESKey(aesKey string) ([]byte, error) {
s := strings.TrimSpace(aesKey)
if s == "" {
return nil, fmt.Errorf("wecom-ws: empty aeskey")
}
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
switch s[i] {
case '\n', '\r', ' ', '\t':
continue
default:
b.WriteByte(s[i])
}
}
s = b.String()
if len(s) == 64 && isHexString(s) {
key, err := hex.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("wecom-ws: decode aeskey hex: %w", err)
}
if len(key) != 32 {
return nil, fmt.Errorf("wecom-ws: aeskey hex length %d, want 32 bytes", len(key))
}
return key, nil
}
// URL-safe alphabet → standard (RFC 4648 §5)
s = strings.ReplaceAll(s, "-", "+")
s = strings.ReplaceAll(s, "_", "/")
switch len(s) % 4 {
case 0:
case 2:
s += "=="
case 3:
s += "="
default:
return nil, fmt.Errorf("wecom-ws: invalid aeskey base64 length")
}
key, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("wecom-ws: decode aeskey: %w", err)
}
if len(key) < 32 {
return nil, fmt.Errorf("wecom-ws: aeskey decoded length %d, need >= 32", len(key))
}
return key, nil
}
func isHexString(s string) bool {
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F':
default:
return false
}
}
return true
}
// wecomDecryptFile decrypts payload from WeCom WS media URLs (AES-256-CBC, IV = first 16 key bytes).
// Same algorithm as @wecom/aibot-node-sdk decryptFile.
func wecomDecryptFile(ciphertext []byte, aesKeyB64 string) ([]byte, error) {
if len(ciphertext) == 0 {
return nil, fmt.Errorf("wecom-ws: empty ciphertext")
}
key, err := decodeWeComAESKey(aesKeyB64)
if err != nil {
return nil, err
}
key32 := key[:32]
iv := key32[:16]
block, err := aes.NewCipher(key32)
if err != nil {
return nil, err
}
if len(ciphertext)%aes.BlockSize != 0 {
return nil, fmt.Errorf("wecom-ws: ciphertext not multiple of block size")
}
plain := make([]byte, len(ciphertext))
cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, ciphertext)
return pkcs7UnpadWeCom(plain)
}
func pkcs7UnpadWeCom(data []byte) ([]byte, error) {
if len(data) == 0 {
return nil, fmt.Errorf("wecom-ws: empty padded data")
}
padLen := int(data[len(data)-1])
if padLen < 1 || padLen > 32 || padLen > len(data) {
return nil, fmt.Errorf("wecom-ws: invalid pkcs7 pad length %d", padLen)
}
for i := len(data) - padLen; i < len(data); i++ {
if int(data[i]) != padLen {
return nil, fmt.Errorf("wecom-ws: invalid pkcs7 padding")
}
}
return data[:len(data)-padLen], nil
}
func parseContentDispositionFilename(h string) string {
h = strings.TrimSpace(h)
if h == "" {
return ""
}
lower := strings.ToLower(h)
// RFC 5987: filename*=UTF-8''percent-encoded
if idx := strings.Index(lower, "filename*="); idx >= 0 {
val := strings.TrimSpace(h[idx+len("filename*="):])
val = strings.TrimSuffix(strings.TrimSpace(val), ";")
if after, ok := strings.CutPrefix(val, "UTF-8''"); ok {
if dec, err := url.QueryUnescape(after); err == nil {
return filepath.Base(dec)
}
return filepath.Base(after)
}
}
if idx := strings.Index(lower, "filename="); idx >= 0 {
val := strings.TrimSpace(h[idx+len("filename="):])
val = strings.TrimSuffix(val, ";")
val = strings.Trim(val, `"`)
if dec, err := url.QueryUnescape(val); err == nil {
return filepath.Base(dec)
}
return filepath.Base(val)
}
return ""
}
func downloadWeComWSMedia(ctx context.Context, urlStr, aesKey string) (data []byte, fileName string, err error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
if err != nil {
return nil, "", err
}
client := &http.Client{Timeout: 90 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, "", fmt.Errorf("wecom-ws: download HTTP %s", resp.Status)
}
fileName = parseContentDispositionFilename(resp.Header.Get("Content-Disposition"))
lim := io.LimitReader(resp.Body, wecomWSMediaMaxBytes+1)
raw, err := io.ReadAll(lim)
if err != nil {
return nil, "", err
}
if len(raw) > wecomWSMediaMaxBytes {
return nil, "", fmt.Errorf("wecom-ws: media larger than %d bytes", wecomWSMediaMaxBytes)
}
if aesKey != "" {
raw, err = wecomDecryptFile(raw, aesKey)
if err != nil {
return nil, "", err
}
}
return raw, fileName, nil
}
// deliverWSMediaInbound downloads image/file refs and forwards one core.Message.
func (p *WSPlatform) deliverWSMediaInbound(body *wsMsgCallbackBody, sessionKey, chatName string, rctx wsReplyContext, texts []string, imgs, files []wsMediaRef) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
var images []core.ImageAttachment
var fileAtts []core.FileAttachment
for _, im := range imgs {
buf, fn, err := downloadWeComWSMedia(ctx, im.URL, im.Aeskey)
if err != nil {
slog.Error("wecom-ws: download image failed", "error", err)
continue
}
base := filepath.Base(strings.TrimSpace(fn))
if base == "" || base == "." {
base = "image.bin"
}
mt := wecomInboundFileMime(base, buf)
if !strings.HasPrefix(mt, "image/") {
mt = http.DetectContentType(buf)
if !strings.HasPrefix(mt, "image/") {
mt = "image/jpeg"
}
}
images = append(images, core.ImageAttachment{MimeType: mt, Data: buf, FileName: base})
slog.Info("wecom-ws: image downloaded", "bytes", len(buf), "mime", mt, "name", base)
}
for _, f := range files {
buf, fn, err := downloadWeComWSMedia(ctx, f.URL, f.Aeskey)
if err != nil {
slog.Error("wecom-ws: download file failed", "error", err)
continue
}
base := filepath.Base(strings.TrimSpace(fn))
if base == "" || base == "." {
base = "attachment"
}
mt := wecomInboundFileMime(base, buf)
fileAtts = append(fileAtts, core.FileAttachment{MimeType: mt, Data: buf, FileName: base})
slog.Info("wecom-ws: file downloaded", "bytes", len(buf), "mime", mt, "name", base)
}
content := strings.Join(texts, "\n")
content = stripWeComAtMentions(content, p.botID, body.AibotID)
if content == "" && len(images) == 0 && len(fileAtts) == 0 {
slog.Warn("wecom-ws: media inbound empty after downloads", "msg_id", body.MsgID)
return
}
p.handler(p, &core.Message{
SessionKey: sessionKey, Platform: "wecom",
MessageID: body.MsgID,
UserID: body.From.UserID, UserName: body.From.UserID,
ChatName: chatName,
Content: content,
Images: images,
Files: fileAtts,
ReplyCtx: rctx,
})
}
+196
View File
@@ -0,0 +1,196 @@
package wecom
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/hex"
"encoding/json"
"strings"
"testing"
)
func TestParseContentDispositionFilename(t *testing.T) {
t.Parallel()
got := parseContentDispositionFilename(`attachment; filename="doc.pdf"`)
if got != "doc.pdf" {
t.Fatalf("got %q", got)
}
got = parseContentDispositionFilename(`attachment; filename*=UTF-8''%E4%B8%AD.txt`)
if got != "中.txt" {
t.Fatalf("got %q", got)
}
}
func TestWsCollectInboundParts_fileAndQuote(t *testing.T) {
t.Parallel()
raw := `{
"msgid": "1",
"aibotid": "bot",
"chatid": "c1",
"chattype": "single",
"from": {"userid": "u1"},
"msgtype": "file",
"file": {"url": "https://example.com/f", "aeskey": "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo="}
}`
var body wsMsgCallbackBody
if err := json.Unmarshal([]byte(raw), &body); err != nil {
t.Fatal(err)
}
texts, imgs, files := wsCollectInboundParts(&body)
if len(texts) != 0 || len(imgs) != 0 || len(files) != 1 || files[0].URL != "https://example.com/f" {
t.Fatalf("files=%v texts=%v imgs=%v", files, texts, imgs)
}
}
func TestWsCollectInboundParts_mixed(t *testing.T) {
t.Parallel()
raw := `{
"msgid": "2",
"aibotid": "bot",
"chattype": "group",
"chatid": "g1",
"from": {"userid": "u1"},
"msgtype": "mixed",
"mixed": {
"msg_item": [
{"msgtype": "text", "text": {"content": "see"}},
{"msgtype": "image", "image": {"url": "https://i", "aeskey": "k"}}
]
}
}`
var body wsMsgCallbackBody
if err := json.Unmarshal([]byte(raw), &body); err != nil {
t.Fatal(err)
}
texts, imgs, files := wsCollectInboundParts(&body)
if len(texts) != 1 || texts[0] != "see" || len(imgs) != 1 || imgs[0].URL != "https://i" || len(files) != 0 {
t.Fatalf("texts=%v imgs=%v files=%v", texts, imgs, files)
}
}
func TestWsCollectInboundParts_fileWithNonEmptyMixedUsesTopLevelFile(t *testing.T) {
t.Parallel()
raw := `{
"msgid": "3",
"aibotid": "bot",
"chattype": "single",
"from": {"userid": "u1"},
"msgtype": "file",
"mixed": {
"msg_item": [
{"msgtype": "text", "text": {"content": " "}}
]
},
"file": {"url": "https://example.com/doc.pdf", "aeskey": "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo="}
}`
var body wsMsgCallbackBody
if err := json.Unmarshal([]byte(raw), &body); err != nil {
t.Fatal(err)
}
texts, imgs, files := wsCollectInboundParts(&body)
if len(files) != 1 || files[0].URL != "https://example.com/doc.pdf" || len(imgs) != 0 {
t.Fatalf("texts=%v imgs=%v files=%v", texts, imgs, files)
}
}
func TestWsCollectInboundParts_mixedContainsFile(t *testing.T) {
t.Parallel()
raw := `{
"msgid": "4",
"aibotid": "bot",
"chattype": "group",
"chatid": "g1",
"from": {"userid": "u1"},
"msgtype": "mixed",
"mixed": {
"msg_item": [
{"msgtype": "text", "text": {"content": "see file"}},
{"msgtype": "file", "file": {"url": "https://f", "aeskey": "k"}}
]
}
}`
var body wsMsgCallbackBody
if err := json.Unmarshal([]byte(raw), &body); err != nil {
t.Fatal(err)
}
texts, imgs, files := wsCollectInboundParts(&body)
if len(texts) != 1 || len(imgs) != 0 || len(files) != 1 || files[0].URL != "https://f" {
t.Fatalf("texts=%v imgs=%v files=%v", texts, imgs, files)
}
}
func TestDecodeWeComAESKey_URLSafeUnpadded(t *testing.T) {
t.Parallel()
want := make([]byte, 32)
for i := range want {
want[i] = byte(i + 1)
}
std := base64.StdEncoding.EncodeToString(want)
us := strings.ReplaceAll(std, "+", "-")
us = strings.ReplaceAll(us, "/", "_")
us = strings.TrimRight(us, "=")
got, err := decodeWeComAESKey(us)
if err != nil {
t.Fatal(err)
}
if len(got) < 32 {
t.Fatalf("len=%d", len(got))
}
got = got[:32]
for i := range want {
if got[i] != want[i] {
t.Fatalf("byte %d: got %d want %d", i, got[i], want[i])
}
}
}
func TestDecodeWeComAESKey_hex64(t *testing.T) {
t.Parallel()
want := bytes.Repeat([]byte{0xab}, 32)
hexStr := hex.EncodeToString(want)
got, err := decodeWeComAESKey(hexStr)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, want) {
t.Fatalf("got %x want %x", got, want)
}
}
func TestWecomDecryptFile_AES256CBC(t *testing.T) {
t.Parallel()
// 32-byte key; IV = first 16 bytes (WeCom scheme)
key32 := []byte("0123456789abcdef0123456789abcdef")
aesKeyB64 := base64.StdEncoding.EncodeToString(key32)
plain := []byte("hello-wecom")
padded := pkcs7PadBlock(plain, aes.BlockSize)
block, _ := aes.NewCipher(key32)
iv := key32[:aes.BlockSize]
ct := make([]byte, len(padded))
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ct, padded)
out, err := wecomDecryptFile(ct, aesKeyB64)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(out, plain) {
t.Fatalf("got %q want %q", out, plain)
}
}
func pkcs7PadBlock(data []byte, blockSize int) []byte {
pad := blockSize - len(data)%blockSize
if pad == 0 {
pad = blockSize
}
out := make([]byte, len(data)+pad)
copy(out, data)
for i := len(data); i < len(out); i++ {
out[i] = byte(pad)
}
return out
}
+158
View File
@@ -0,0 +1,158 @@
package wecom
import (
"context"
"crypto/md5"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"github.com/chenhg5/cc-connect/core"
)
const (
wecomWSUploadChunkSize = 512 * 1024
wecomWSUploadMaxChunks = 100
)
// SendImage uploads and sends an image through the WeCom AI Bot WebSocket API.
func (p *WSPlatform) SendImage(ctx context.Context, rctx any, img core.ImageAttachment) error {
rc, ok := rctx.(wsReplyContext)
if !ok {
return fmt.Errorf("wecom-ws: SendImage: invalid reply context type %T", rctx)
}
if rc.chatID == "" {
return fmt.Errorf("wecom-ws: chatID is empty, cannot send image")
}
if len(img.Data) == 0 {
return fmt.Errorf("wecom-ws: image data is empty")
}
mediaID, err := p.uploadWSMedia(ctx, "image", wsImageFileName(img), img.Data)
if err != nil {
return fmt.Errorf("wecom-ws: send image: %w", err)
}
if err := p.sendWSMediaMessage(ctx, rc.chatID, "image", mediaID); err != nil {
return fmt.Errorf("wecom-ws: send image: %w", err)
}
return nil
}
func (p *WSPlatform) uploadWSMedia(ctx context.Context, mediaType, filename string, data []byte) (string, error) {
totalChunks := (len(data) + wecomWSUploadChunkSize - 1) / wecomWSUploadChunkSize
if totalChunks == 0 {
return "", fmt.Errorf("empty media data")
}
if totalChunks > wecomWSUploadMaxChunks {
return "", fmt.Errorf("media too large: %d chunks exceeds maximum %d", totalChunks, wecomWSUploadMaxChunks)
}
sum := md5.Sum(data)
initReqID := p.generateReqID("aibot_upload_media_init")
initFrame := map[string]any{
"cmd": "aibot_upload_media_init",
"headers": map[string]string{"req_id": initReqID},
"body": map[string]any{
"type": mediaType,
"filename": filename,
"total_size": len(data),
"total_chunks": totalChunks,
"md5": hex.EncodeToString(sum[:]),
},
}
initResp, err := p.writeAndWaitFrameWithTimeout(ctx, initFrame, initReqID, wsMediaAckTimeout)
if err != nil {
return "", fmt.Errorf("upload init: %w", err)
}
var initBody struct {
UploadID string `json:"upload_id"`
}
if err := json.Unmarshal(initResp.Body, &initBody); err != nil {
return "", fmt.Errorf("decode upload init response: %w", err)
}
if initBody.UploadID == "" {
return "", fmt.Errorf("upload init: empty upload_id")
}
for i := 0; i < totalChunks; i++ {
start := i * wecomWSUploadChunkSize
end := start + wecomWSUploadChunkSize
if end > len(data) {
end = len(data)
}
reqID := p.generateReqID("aibot_upload_media_chunk")
chunkFrame := map[string]any{
"cmd": "aibot_upload_media_chunk",
"headers": map[string]string{"req_id": reqID},
"body": map[string]any{
"upload_id": initBody.UploadID,
"chunk_index": i,
"base64_data": base64.StdEncoding.EncodeToString(data[start:end]),
},
}
if _, err := p.writeAndWaitFrameWithTimeout(ctx, chunkFrame, reqID, wsMediaAckTimeout); err != nil {
return "", fmt.Errorf("upload chunk %d: %w", i, err)
}
}
finishReqID := p.generateReqID("aibot_upload_media_finish")
finishFrame := map[string]any{
"cmd": "aibot_upload_media_finish",
"headers": map[string]string{"req_id": finishReqID},
"body": map[string]any{
"upload_id": initBody.UploadID,
},
}
finishResp, err := p.writeAndWaitFrameWithTimeout(ctx, finishFrame, finishReqID, wsMediaAckTimeout)
if err != nil {
return "", fmt.Errorf("upload finish: %w", err)
}
var finishBody struct {
MediaID string `json:"media_id"`
}
if err := json.Unmarshal(finishResp.Body, &finishBody); err != nil {
return "", fmt.Errorf("decode upload finish response: %w", err)
}
if finishBody.MediaID == "" {
return "", fmt.Errorf("upload finish: empty media_id")
}
return finishBody.MediaID, nil
}
func (p *WSPlatform) sendWSMediaMessage(ctx context.Context, chatID, mediaType, mediaID string) error {
reqID := p.generateReqID("aibot_send_msg")
frame := map[string]any{
"cmd": "aibot_send_msg",
"headers": map[string]string{"req_id": reqID},
"body": map[string]any{
"chatid": chatID,
"msgtype": mediaType,
mediaType: map[string]string{
"media_id": mediaID,
},
},
}
return p.writeAndWaitAckStrict(ctx, frame, reqID, wsMediaAckTimeout)
}
func wsImageFileName(img core.ImageAttachment) string {
name := filepath.Base(strings.TrimSpace(img.FileName))
if name != "" && name != "." {
return name
}
switch strings.ToLower(img.MimeType) {
case "image/jpeg", "image/jpg":
return "image.jpg"
case "image/gif":
return "image.gif"
case "image/webp":
return "image.webp"
default:
return "image.png"
}
}
var _ core.ImageSender = (*WSPlatform)(nil)
+673
View File
@@ -0,0 +1,673 @@
package wecom
import (
"context"
"crypto/md5"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/chenhg5/cc-connect/core"
"github.com/gorilla/websocket"
)
// ---------------------------------------------------------------------------
// splitByBytes
// ---------------------------------------------------------------------------
func TestSplitByBytes_ShortString(t *testing.T) {
parts := splitByBytes("hello", 100)
if len(parts) != 1 || parts[0] != "hello" {
t.Fatalf("expected single chunk, got %v", parts)
}
}
func TestSplitByBytes_ExactBoundary(t *testing.T) {
s := "abcdef"
parts := splitByBytes(s, 6)
if len(parts) != 1 || parts[0] != s {
t.Fatalf("expected single chunk at exact boundary, got %v", parts)
}
}
func TestSplitByBytes_SplitASCII(t *testing.T) {
s := "abcdef"
parts := splitByBytes(s, 4)
if len(parts) != 2 {
t.Fatalf("expected 2 chunks, got %d: %v", len(parts), parts)
}
if parts[0] != "abcd" || parts[1] != "ef" {
t.Fatalf("unexpected chunks: %v", parts)
}
}
func TestSplitByBytes_UTF8NeverSplitsMidRune(t *testing.T) {
// "你好世界" = 4 runes × 3 bytes = 12 bytes
s := "你好世界"
parts := splitByBytes(s, 5) // 5 < 6, so only one 3-byte rune fits? Actually 3 fits, 4 doesn't → first chunk = "你" (3 bytes)
// With maxBytes=5: first iteration end=5, s[5] is a continuation byte → back off to 3 → "你", next end=5 but only 9 left, s[5] continuation → 6 → "好世" wait...
// Let's just verify no chunk contains a partial rune.
reassembled := ""
for _, p := range parts {
reassembled += p
// Each chunk must be valid UTF-8 (no partial rune)
for i := 0; i < len(p); i++ {
if p[i]>>6 == 0b10 && (i == 0 || p[i-1] < 0x80) {
t.Fatalf("chunk contains orphaned continuation byte: %q", p)
}
}
}
if reassembled != s {
t.Fatalf("reassembled %q != original %q", reassembled, s)
}
}
func TestSplitByBytes_EmptyString(t *testing.T) {
parts := splitByBytes("", 100)
if len(parts) != 1 || parts[0] != "" {
t.Fatalf("expected single empty chunk, got %v", parts)
}
}
func TestSplitByBytes_ReassemblesLargeContent(t *testing.T) {
var s string
for i := 0; i < 500; i++ {
s += fmt.Sprintf("line %d: 这是一段中文\n", i)
}
parts := splitByBytes(s, 2000)
reassembled := ""
for _, p := range parts {
if len(p) > 2000 {
t.Fatalf("chunk exceeds maxBytes: %d", len(p))
}
reassembled += p
}
if reassembled != s {
t.Fatalf("reassembled content does not match original (len %d vs %d)", len(reassembled), len(s))
}
}
// ---------------------------------------------------------------------------
// handleMsgCallback — chatID fallback to userID for single chats
// ---------------------------------------------------------------------------
func newCapturedWSPlatform() (*WSPlatform, <-chan *core.Message) {
p := &WSPlatform{allowFrom: "*"}
captured := make(chan *core.Message, 1)
p.handler = func(_ core.Platform, msg *core.Message) {
captured <- msg
}
return p, captured
}
func wsCallbackFrame(t *testing.T, reqID string, body wsMsgCallbackBody) wsFrame {
t.Helper()
bodyBytes, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal callback body: %v", err)
}
return wsFrame{
Cmd: "aibot_msg_callback",
Headers: wsFrameHeaders{ReqID: reqID},
Body: bodyBytes,
}
}
func TestHandleMsgCallback_SingleChat_ChatIDFallback(t *testing.T) {
p, captured := newCapturedWSPlatform()
body := wsMsgCallbackBody{
MsgID: "msg_001",
ChatID: "", // single chat: no chatID from server
ChatType: "single",
MsgType: "text",
}
body.From.UserID = "zhangsan"
body.Text.Content = "hello"
body.CreateTime = time.Now().Unix()
p.handleMsgCallback(wsCallbackFrame(t, "req_123", body))
select {
case msg := <-captured:
if msg.SessionKey != "wecom:zhangsan:zhangsan" {
t.Fatalf("expected sessionKey 'wecom:zhangsan:zhangsan', got %q", msg.SessionKey)
}
rc := msg.ReplyCtx.(wsReplyContext)
if rc.chatID != "zhangsan" {
t.Fatalf("expected chatID to fall back to userID 'zhangsan', got %q", rc.chatID)
}
case <-time.After(1 * time.Second):
t.Fatal("handler not called")
}
}
func TestHandleMsgCallback_GroupChat_ChatIDPreserved(t *testing.T) {
p, captured := newCapturedWSPlatform()
body := wsMsgCallbackBody{
MsgID: "msg_002",
ChatID: "group_chat_id_123",
ChatType: "group",
MsgType: "text",
}
body.From.UserID = "zhangsan"
body.Text.Content = "hi group"
body.CreateTime = time.Now().Unix()
p.handleMsgCallback(wsCallbackFrame(t, "req_456", body))
select {
case msg := <-captured:
if msg.SessionKey != "wecom:group_chat_id_123:zhangsan" {
t.Fatalf("expected sessionKey 'wecom:group_chat_id_123:zhangsan', got %q", msg.SessionKey)
}
rc := msg.ReplyCtx.(wsReplyContext)
if rc.chatID != "group_chat_id_123" {
t.Fatalf("expected chatID 'group_chat_id_123', got %q", rc.chatID)
}
case <-time.After(1 * time.Second):
t.Fatal("handler not called")
}
}
func TestHandleMsgCallback_StripsBotMention(t *testing.T) {
p, captured := newCapturedWSPlatform()
p.botID = "robot01"
body := wsMsgCallbackBody{
MsgID: "msg_mention",
ChatID: "grp1",
ChatType: "group",
MsgType: "text",
AibotID: "robot01",
}
body.From.UserID = "u1"
body.Text.Content = "允许 @Robot01"
body.CreateTime = time.Now().Unix()
p.handleMsgCallback(wsCallbackFrame(t, "req_m", body))
select {
case msg := <-captured:
if msg.Content != "允许" {
t.Fatalf("expected stripped content %q, got %q", "允许", msg.Content)
}
case <-time.After(1 * time.Second):
t.Fatal("handler not called")
}
}
// ---------------------------------------------------------------------------
// ReconstructReplyCtx
// ---------------------------------------------------------------------------
func TestReconstructReplyCtx_Valid(t *testing.T) {
p := &WSPlatform{}
rctx, err := p.ReconstructReplyCtx("wecom:chatid123:user456")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
rc := rctx.(wsReplyContext)
if rc.chatID != "chatid123" || rc.userID != "user456" {
t.Fatalf("unexpected context: %+v", rc)
}
}
func TestReconstructReplyCtx_InvalidPrefix(t *testing.T) {
p := &WSPlatform{}
_, err := p.ReconstructReplyCtx("slack:chatid123:user456")
if err == nil {
t.Fatal("expected error for invalid prefix")
}
}
func TestReconstructReplyCtx_TooFewParts(t *testing.T) {
p := &WSPlatform{}
_, err := p.ReconstructReplyCtx("wecom:only")
if err == nil {
t.Fatal("expected error for too few parts")
}
}
// ---------------------------------------------------------------------------
// writeAndWaitAck
// ---------------------------------------------------------------------------
func TestWriteAndWaitAck_SuccessfulAck(t *testing.T) {
p := &WSPlatform{}
reqID := "send_1"
ch := make(chan wsAckResult, 1)
p.pendingAcks.Store(reqID, ch)
// Simulate receiving ack in another goroutine
go func() {
time.Sleep(10 * time.Millisecond)
p.dispatchAck(reqID, wsAckResult{})
}()
assertAckResult(t, ch, func(result wsAckResult) {
if result.err != nil {
t.Fatalf("expected nil ack error, got %v", result.err)
}
})
}
func TestWriteAndWaitAck_AckWithError(t *testing.T) {
p := &WSPlatform{}
reqID := "send_2"
ch := make(chan wsAckResult, 1)
p.pendingAcks.Store(reqID, ch)
ackErr := fmt.Errorf("wecom-ws: ack error: errcode=40001 errmsg=invalid token")
go func() {
time.Sleep(10 * time.Millisecond)
p.dispatchAck(reqID, wsAckResult{err: ackErr})
}()
assertAckResult(t, ch, func(result wsAckResult) {
if result.err == nil {
t.Fatal("expected ack error, got nil")
}
if result.err.Error() != ackErr.Error() {
t.Fatalf("unexpected error: %v", result.err)
}
})
}
func TestWriteAndWaitAck_Timeout(t *testing.T) {
p := &WSPlatform{}
reqID := "send_timeout"
ch := make(chan wsAckResult, 1)
p.pendingAcks.Store(reqID, ch)
// Nobody sends ack → should timeout
start := time.Now()
select {
case <-ch:
t.Fatal("should not receive from channel without ack")
case <-time.After(100 * time.Millisecond):
// Expected: timed out without blocking forever
}
elapsed := time.Since(start)
if elapsed > 1*time.Second {
t.Fatalf("timeout took too long: %v", elapsed)
}
// Clean up
p.pendingAcks.Delete(reqID)
}
func TestWriteAndWaitAck_ContextCancelled(t *testing.T) {
p := &WSPlatform{}
reqID := "send_cancel"
ch := make(chan wsAckResult, 1)
p.pendingAcks.Store(reqID, ch)
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(20 * time.Millisecond)
cancel()
}()
select {
case <-ch:
t.Fatal("should not receive ack")
case <-ctx.Done():
// Expected: context cancelled
case <-time.After(1 * time.Second):
t.Fatal("timed out")
}
p.pendingAcks.Delete(reqID)
}
// ---------------------------------------------------------------------------
// handleFrame — ACK dispatch
// ---------------------------------------------------------------------------
func TestHandleFrame_AckDispatch(t *testing.T) {
p := &WSPlatform{}
reqID := "aibot_send_msg_1"
ch := make(chan wsAckResult, 1)
p.pendingAcks.Store(reqID, ch)
errCode := 0
frame := wsFrame{
Cmd: "",
Headers: wsFrameHeaders{ReqID: reqID},
ErrCode: &errCode,
ErrMsg: "ok",
}
p.handleFrame(frame)
assertAckResult(t, ch, func(result wsAckResult) {
if result.err != nil {
t.Fatalf("expected nil error for successful ack, got %v", result.err)
}
})
}
func TestHandleFrame_AckDispatch_WithError(t *testing.T) {
p := &WSPlatform{}
reqID := "aibot_send_msg_2"
ch := make(chan wsAckResult, 1)
p.pendingAcks.Store(reqID, ch)
errCode := 40001
frame := wsFrame{
Cmd: "",
Headers: wsFrameHeaders{ReqID: reqID},
ErrCode: &errCode,
ErrMsg: "invalid token",
}
p.handleFrame(frame)
assertAckResult(t, ch, func(result wsAckResult) {
if result.err == nil {
t.Fatal("expected error for failed ack, got nil")
}
})
}
func assertAckResult(t *testing.T, ch <-chan wsAckResult, check func(wsAckResult)) {
t.Helper()
select {
case result := <-ch:
check(result)
case <-time.After(100 * time.Millisecond):
t.Fatal("ack not dispatched")
}
}
func TestHandleFrame_PingAck_ResetsMissedPong(t *testing.T) {
p := &WSPlatform{}
p.missedPong.Store(2)
frame := wsFrame{
Cmd: "",
Headers: wsFrameHeaders{ReqID: "ping_1"},
}
p.handleFrame(frame)
if p.missedPong.Load() != 0 {
t.Fatalf("expected missedPong to be reset to 0, got %d", p.missedPong.Load())
}
}
// ---------------------------------------------------------------------------
// generateReqID
// ---------------------------------------------------------------------------
func TestGenerateReqID_Monotonic(t *testing.T) {
p := &WSPlatform{}
ids := make(map[string]bool)
for i := 0; i < 100; i++ {
id := p.generateReqID("test")
if ids[id] {
t.Fatalf("duplicate req_id: %s", id)
}
ids[id] = true
}
}
func TestGenerateReqID_Format(t *testing.T) {
p := &WSPlatform{}
id := p.generateReqID("ping")
if id != "ping_1" {
t.Fatalf("expected ping_1, got %s", id)
}
id2 := p.generateReqID("aibot_send_msg")
if id2 != "aibot_send_msg_2" {
t.Fatalf("expected aibot_send_msg_2, got %s", id2)
}
}
// ---------------------------------------------------------------------------
// SendImage
// ---------------------------------------------------------------------------
func TestWSPlatformSendImage_UploadsAndSendsMedia(t *testing.T) {
imageData := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 1, 2, 3}
serverDone := make(chan error, 1)
upgrader := websocket.Upgrader{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
serverDone <- err
return
}
defer conn.Close()
serverDone <- assertWeComWSSendImageFrames(conn, imageData)
}))
defer server.Close()
wsURL := "ws" + server.URL[len("http"):]
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial test websocket: %v", err)
}
defer conn.Close()
p := &WSPlatform{conn: conn}
go func() {
for {
var frame wsFrame
if err := conn.ReadJSON(&frame); err != nil {
return
}
p.handleFrame(frame)
}
}()
err = p.SendImage(context.Background(), wsReplyContext{chatID: "chat1", userID: "u1"}, core.ImageAttachment{
MimeType: "image/png",
Data: imageData,
FileName: "chart.png",
})
if err != nil {
t.Fatalf("SendImage returned error: %v", err)
}
select {
case err := <-serverDone:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("server did not observe all expected frames")
}
}
func assertWeComWSSendImageFrames(conn *websocket.Conn, imageData []byte) error {
var initFrame struct {
Cmd string `json:"cmd"`
Headers wsFrameHeaders `json:"headers"`
Body struct {
Type string `json:"type"`
Filename string `json:"filename"`
TotalSize int `json:"total_size"`
TotalChunks int `json:"total_chunks"`
MD5 string `json:"md5"`
} `json:"body"`
}
if err := conn.ReadJSON(&initFrame); err != nil {
return fmt.Errorf("read init frame: %w", err)
}
sum := md5.Sum(imageData)
if initFrame.Cmd != "aibot_upload_media_init" ||
initFrame.Body.Type != "image" ||
initFrame.Body.Filename != "chart.png" ||
initFrame.Body.TotalSize != len(imageData) ||
initFrame.Body.TotalChunks != 1 ||
initFrame.Body.MD5 != hex.EncodeToString(sum[:]) {
return fmt.Errorf("unexpected init frame: %#v", initFrame)
}
if err := conn.WriteJSON(map[string]any{
"headers": initFrame.Headers,
"errcode": 0,
"errmsg": "ok",
"body": map[string]string{"upload_id": "upload-1"},
}); err != nil {
return fmt.Errorf("write init ack: %w", err)
}
var chunkFrame struct {
Cmd string `json:"cmd"`
Headers wsFrameHeaders `json:"headers"`
Body struct {
UploadID string `json:"upload_id"`
ChunkIndex int `json:"chunk_index"`
Base64Data string `json:"base64_data"`
} `json:"body"`
}
if err := conn.ReadJSON(&chunkFrame); err != nil {
return fmt.Errorf("read chunk frame: %w", err)
}
if chunkFrame.Cmd != "aibot_upload_media_chunk" ||
chunkFrame.Body.UploadID != "upload-1" ||
chunkFrame.Body.ChunkIndex != 0 ||
chunkFrame.Body.Base64Data != base64.StdEncoding.EncodeToString(imageData) {
return fmt.Errorf("unexpected chunk frame: %#v", chunkFrame)
}
if err := conn.WriteJSON(map[string]any{
"headers": chunkFrame.Headers,
"errcode": 0,
"errmsg": "ok",
}); err != nil {
return fmt.Errorf("write chunk ack: %w", err)
}
var finishFrame struct {
Cmd string `json:"cmd"`
Headers wsFrameHeaders `json:"headers"`
Body struct {
UploadID string `json:"upload_id"`
} `json:"body"`
}
if err := conn.ReadJSON(&finishFrame); err != nil {
return fmt.Errorf("read finish frame: %w", err)
}
if finishFrame.Cmd != "aibot_upload_media_finish" || finishFrame.Body.UploadID != "upload-1" {
return fmt.Errorf("unexpected finish frame: %#v", finishFrame)
}
if err := conn.WriteJSON(map[string]any{
"headers": finishFrame.Headers,
"errcode": 0,
"errmsg": "ok",
"body": map[string]string{"media_id": "media-1"},
}); err != nil {
return fmt.Errorf("write finish ack: %w", err)
}
var sendFrame struct {
Cmd string `json:"cmd"`
Headers wsFrameHeaders `json:"headers"`
Body struct {
ChatID string `json:"chatid"`
MsgType string `json:"msgtype"`
Image struct {
MediaID string `json:"media_id"`
} `json:"image"`
} `json:"body"`
}
if err := conn.ReadJSON(&sendFrame); err != nil {
return fmt.Errorf("read send frame: %w", err)
}
if sendFrame.Cmd != "aibot_send_msg" ||
sendFrame.Body.ChatID != "chat1" ||
sendFrame.Body.MsgType != "image" ||
sendFrame.Body.Image.MediaID != "media-1" {
return fmt.Errorf("unexpected send frame: %#v", sendFrame)
}
if err := conn.WriteJSON(map[string]any{
"headers": sendFrame.Headers,
"errcode": 0,
"errmsg": "ok",
}); err != nil {
return fmt.Errorf("write send ack: %w", err)
}
return nil
}
// ---------------------------------------------------------------------------
// generateReqID — concurrency safety
// ---------------------------------------------------------------------------
func TestGenerateReqID_ConcurrentSafety(t *testing.T) {
p := &WSPlatform{}
var wg sync.WaitGroup
ids := sync.Map{}
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
id := p.generateReqID("concurrent")
if _, loaded := ids.LoadOrStore(id, true); loaded {
t.Errorf("duplicate req_id: %s", id)
}
}()
}
wg.Wait()
}
// ---------------------------------------------------------------------------
// newWebSocket
// ---------------------------------------------------------------------------
func TestNewWebSocket_MissingCredentials(t *testing.T) {
tests := []struct {
name string
opts map[string]any
}{
{"empty opts", map[string]any{}},
{"missing bot_secret", map[string]any{"bot_id": "aib123"}},
{"missing bot_id", map[string]any{"bot_secret": "secret"}},
{"both empty strings", map[string]any{"bot_id": "", "bot_secret": ""}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := newWebSocket(tt.opts)
if err == nil {
t.Fatal("expected error for missing credentials")
}
})
}
}
func TestNewWebSocket_ValidConfig(t *testing.T) {
p, err := newWebSocket(map[string]any{
"bot_id": "aibTest",
"bot_secret": "secretXYZ",
"allow_from": "user1,user2",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ws := p.(*WSPlatform)
if ws.botID != "aibTest" || ws.secret != "secretXYZ" || ws.allowFrom != "user1,user2" {
t.Fatalf("unexpected config: botID=%s secret=%s allowFrom=%s", ws.botID, ws.secret, ws.allowFrom)
}
}
+918
View File
@@ -0,0 +1,918 @@
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
}
+140
View File
@@ -0,0 +1,140 @@
package wecom
import (
"io"
"net/http"
"net/url"
"strings"
"testing"
"time"
)
func TestWeComAPIURL_DefaultBase(t *testing.T) {
p := &Platform{}
got := p.wecomAPIURL("/cgi-bin/gettoken", url.Values{
"corpid": []string{"ww-test"},
"corpsecret": []string{"sec-test"},
})
want := "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=ww-test&corpsecret=sec-test"
if got != want {
t.Fatalf("url = %q, want %q", got, want)
}
}
func TestWeComAPIURL_CustomBase(t *testing.T) {
p := &Platform{apiBaseURL: "https://wecom.internal.example.com/"}
got := p.wecomAPIURL("/cgi-bin/message/send", url.Values{
"access_token": []string{"tok"},
})
want := "https://wecom.internal.example.com/cgi-bin/message/send?access_token=tok"
if got != want {
t.Fatalf("url = %q, want %q", got, want)
}
}
func TestNew_DefaultAPIBaseURL(t *testing.T) {
pf, err := New(map[string]any{
"corp_id": "ww_test",
"corp_secret": "sec_test",
"agent_id": "1000002",
"callback_token": "cb_token",
"callback_aes_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
p, ok := pf.(*Platform)
if !ok {
t.Fatalf("platform type = %T, want *wecom.Platform", pf)
}
if p.apiBaseURL != defaultAPIBaseURL {
t.Fatalf("apiBaseURL = %q, want %q", p.apiBaseURL, defaultAPIBaseURL)
}
}
func TestNew_CustomAPIBaseURL_TrimTrailingSlash(t *testing.T) {
pf, err := New(map[string]any{
"corp_id": "ww_test",
"corp_secret": "sec_test",
"agent_id": "1000002",
"callback_token": "cb_token",
"callback_aes_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"api_base_url": "https://wecom.internal.example.com/",
})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
p, ok := pf.(*Platform)
if !ok {
t.Fatalf("platform type = %T, want *wecom.Platform", pf)
}
if p.apiBaseURL != "https://wecom.internal.example.com" {
t.Fatalf("apiBaseURL = %q, want %q", p.apiBaseURL, "https://wecom.internal.example.com")
}
}
// fakeWeComTokenRT serves a single canned /cgi-bin/gettoken response so
// getAccessToken's cache arithmetic can be exercised without network.
type fakeWeComTokenRT struct {
body string
}
func (f *fakeWeComTokenRT) RoundTrip(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(f.body)),
Header: make(http.Header),
Request: req,
}, nil
}
func TestGetAccessToken_ZeroExpiresIn_FallsBackToDefault(t *testing.T) {
p := &Platform{
corpID: "ww_test",
corpSecret: "sec_test",
apiBaseURL: defaultAPIBaseURL,
apiClient: &http.Client{
Transport: &fakeWeComTokenRT{body: `{"errcode":0,"errmsg":"ok","access_token":"tok-zero","expires_in":0}`},
},
}
before := time.Now()
tok, err := p.getAccessToken()
if err != nil {
t.Fatalf("getAccessToken() error = %v", err)
}
if tok != "tok-zero" {
t.Fatalf("token = %q, want %q", tok, "tok-zero")
}
// Without the fallback, expiresAt = time.Now() - 60s, so the cached
// token would be stale on the very next call and every getAccessToken
// invocation would re-fetch from /cgi-bin/gettoken.
window := p.tokenCache.expiresAt.Sub(before)
if window < time.Hour {
t.Errorf("tokenCache window for expires_in=0 = %v, want >= 1h (zero should fall back, not cache for -60s)", window)
}
}
func TestGetAccessToken_NormalExpiresIn_AppliesBuffer(t *testing.T) {
p := &Platform{
corpID: "ww_test",
corpSecret: "sec_test",
apiBaseURL: defaultAPIBaseURL,
apiClient: &http.Client{
Transport: &fakeWeComTokenRT{body: `{"errcode":0,"errmsg":"ok","access_token":"tok-7200","expires_in":7200}`},
},
}
before := time.Now()
if _, err := p.getAccessToken(); err != nil {
t.Fatalf("getAccessToken() error = %v", err)
}
// 7200 - 60 buffer = 7140s = 119 min. Allow a wide tolerance for elapsed time.
window := p.tokenCache.expiresAt.Sub(before)
if window < 110*time.Minute || window > 120*time.Minute {
t.Errorf("tokenCache window for expires_in=7200 = %v, want ~7140s (110-120min)", window)
}
}