初始化仓库
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user