Files
cc-connect/platform/weixin/weixin.go
T
2026-06-02 23:14:41 +08:00

750 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package weixin
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/chenhg5/cc-connect/core"
)
func init() {
core.RegisterPlatform("weixin", New)
}
const (
sessionKeyPrefix = "weixin:dm:"
maxWeixinChunk = 3800 // stay under typical IM limits
// weixinSendMaxRetries is the maximum number of retries for sendMessage when API returns ret=-2.
weixinSendMaxRetries = 3
// weixinSendRetryDelay is the delay between retries when sendMessage fails.
weixinSendRetryDelay = 500 * time.Millisecond
// weixinChunkSendDelay is the delay between sending message chunks to avoid rate limiting.
weixinChunkSendDelay = 100 * time.Millisecond
// typingTicketTTL is how long a cached typing ticket remains valid.
typingTicketTTL = 10 * time.Minute
// typingRepeatInterval is how often to resend the typing status to keep it alive.
typingRepeatInterval = 5 * time.Second
)
type replyContext struct {
peerUserID string
contextToken string
}
// Platform implements core.Platform for Weixin personal chat via the ilink bot HTTP API
// (same backend as the OpenClaw openclaw-weixin plugin: long-poll getUpdates + sendMessage).
type Platform struct {
token string
baseURL string
cdnBaseURL string
allowFrom string
routeTag string
stateDir string
longPollMS int
accountLabel string
httpClient *http.Client
cdnHttpClient *http.Client // 专用于 CDN 上传/下载,不走代理
api *apiClient
mu sync.RWMutex
handler core.MessageHandler
cancel context.CancelFunc
stopping bool
syncBufMu sync.Mutex
syncBuf string
syncBufPath string
dedupMu sync.Mutex
dedup map[string]time.Time
pauseMu sync.Mutex
pauseUntil time.Time
tokensMu sync.RWMutex
tokens map[string]string
tokensPath string
typingMu sync.RWMutex
typingTickets map[string]typingTicketEntry // peerUserID → cached ticket
}
type typingTicketEntry struct {
ticket string
fetchedAt time.Time
}
func sanitizePathSegment(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return "default"
}
var b strings.Builder
for _, r := range s {
switch r {
case '/', '\\', ':', '\x00':
b.WriteByte('_')
default:
b.WriteRune(r)
}
}
return b.String()
}
// New constructs a Weixin platform. Required options: token.
// Optional: base_url, cdn_base_url (default https://novac2c.cdn.weixin.qq.com/c2c), allow_from, route_tag, account_id, long_poll_timeout_ms,
// state_dir (override persistence dir), proxy, cc_data_dir + cc_project (injected by main).
func New(opts map[string]any) (core.Platform, error) {
token, _ := opts["token"].(string)
if strings.TrimSpace(token) == "" {
return nil, fmt.Errorf("weixin: token is required (ilink bot Bearer token)")
}
allowFrom, _ := opts["allow_from"].(string)
core.CheckAllowFrom("weixin", allowFrom)
baseURL, _ := opts["base_url"].(string)
cdnBaseURL, _ := opts["cdn_base_url"].(string)
if strings.TrimSpace(cdnBaseURL) == "" {
cdnBaseURL = defaultCDNBaseURL
}
cdnBaseURL = strings.TrimRight(strings.TrimSpace(cdnBaseURL), "/")
routeTag, _ := opts["route_tag"].(string)
accountLabel, _ := opts["account_id"].(string)
if accountLabel == "" {
accountLabel = "default"
}
lp := pickInt(opts["long_poll_timeout_ms"])
dataDir, _ := opts["cc_data_dir"].(string)
project, _ := opts["cc_project"].(string)
stateDir := ""
if dataDir != "" && project != "" {
safeProj := sanitizePathSegment(project)
stateDir = filepath.Join(dataDir, "weixin", safeProj, sanitizePathSegment(accountLabel))
}
if override, _ := opts["state_dir"].(string); strings.TrimSpace(override) != "" {
stateDir = strings.TrimSpace(override)
}
httpClient := &http.Client{Timeout: defaultAPITimeout}
if proxyURL, _ := opts["proxy"].(string); proxyURL != "" {
u, err := url.Parse(proxyURL)
if err != nil {
return nil, fmt.Errorf("weixin: 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)
}
httpClient.Transport = &http.Transport{Proxy: http.ProxyURL(u)}
slog.Info("weixin: using proxy", "proxy", u.Redacted())
}
// CDN 客户端:微信国内 CDN 必须直连,绕过环境变量中的代理(如 HTTPS_PROXY
cdnHttpClient := &http.Client{
Timeout: 60 * time.Second,
Transport: &http.Transport{Proxy: nil},
}
p := &Platform{
token: token,
baseURL: baseURL,
cdnBaseURL: cdnBaseURL,
allowFrom: allowFrom,
routeTag: routeTag,
stateDir: stateDir,
longPollMS: lp,
accountLabel: accountLabel,
httpClient: httpClient,
cdnHttpClient: cdnHttpClient,
tokens: make(map[string]string),
dedup: make(map[string]time.Time),
typingTickets: make(map[string]typingTicketEntry),
}
p.api = newAPIClient(baseURL, token, routeTag, httpClient)
if stateDir != "" {
if err := os.MkdirAll(stateDir, 0o755); err != nil {
return nil, fmt.Errorf("weixin: create state dir: %w", err)
}
p.syncBufPath = filepath.Join(stateDir, "get_updates.buf")
p.tokensPath = filepath.Join(stateDir, "context_tokens.json")
p.loadSyncBuf()
p.loadTokens()
}
return p, nil
}
func pickInt(v any) int {
switch x := v.(type) {
case int:
return x
case int64:
return int(x)
case float64:
return int(x)
default:
return 0
}
}
func (p *Platform) Name() string { return "weixin" }
func (p *Platform) loadSyncBuf() {
if p.syncBufPath == "" {
return
}
b, err := os.ReadFile(p.syncBufPath)
if err != nil {
return
}
p.syncBuf = string(b)
}
// persistSyncBuf writes buf as the next get_updates cursor (caller must hold syncBufMu).
func (p *Platform) persistSyncBuf(buf string) {
p.syncBuf = buf
if p.syncBufPath == "" {
return
}
if err := os.WriteFile(p.syncBufPath, []byte(buf), 0o600); err != nil {
slog.Warn("weixin: save sync buf failed", "path", p.syncBufPath, "error", err)
}
}
func (p *Platform) loadTokens() {
if p.tokensPath == "" {
return
}
b, err := os.ReadFile(p.tokensPath)
if err != nil {
return
}
var m map[string]string
if json.Unmarshal(b, &m) != nil {
return
}
p.tokensMu.Lock()
p.tokens = m
p.tokensMu.Unlock()
}
func (p *Platform) persistTokens() {
if p.tokensPath == "" {
return
}
p.tokensMu.RLock()
out, err := json.MarshalIndent(p.tokens, "", " ")
p.tokensMu.RUnlock()
if err != nil {
return
}
if err := os.WriteFile(p.tokensPath, out, 0o600); err != nil {
slog.Warn("weixin: save context tokens failed", "path", p.tokensPath, "error", err)
}
}
func (p *Platform) setContextToken(peer, tok string) {
if peer == "" || tok == "" {
return
}
p.tokensMu.Lock()
if p.tokens == nil {
p.tokens = make(map[string]string)
}
p.tokens[peer] = tok
p.tokensMu.Unlock()
p.persistTokens()
}
func (p *Platform) getContextToken(peer string) string {
p.tokensMu.RLock()
defer p.tokensMu.RUnlock()
return p.tokens[peer]
}
func (p *Platform) isPaused() bool {
p.pauseMu.Lock()
defer p.pauseMu.Unlock()
if p.pauseUntil.IsZero() || time.Now().After(p.pauseUntil) {
p.pauseUntil = time.Time{}
return false
}
return true
}
func (p *Platform) pauseSession(d time.Duration) {
if d <= 0 {
d = time.Hour
}
p.pauseMu.Lock()
p.pauseUntil = time.Now().Add(d)
p.pauseMu.Unlock()
slog.Warn("weixin: session paused after gateway error", "duration", d, "account", p.accountLabel)
}
func (p *Platform) Start(handler core.MessageHandler) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.stopping {
return fmt.Errorf("weixin: platform stopped")
}
p.handler = handler
ctx, cancel := context.WithCancel(context.Background())
p.cancel = cancel
go p.pollLoop(ctx)
return nil
}
func (p *Platform) Stop() error {
p.mu.Lock()
if p.cancel != nil {
p.cancel()
p.cancel = nil
}
p.stopping = true
p.mu.Unlock()
return nil
}
func (p *Platform) pollLoop(ctx context.Context) {
backoff := time.Second
const maxBackoff = 30 * time.Second
for {
if ctx.Err() != nil {
return
}
if p.isPaused() {
select {
case <-ctx.Done():
return
case <-time.After(5 * time.Second):
continue
}
}
p.syncBufMu.Lock()
buf := p.syncBuf
p.syncBufMu.Unlock()
timeoutMs := p.longPollMS
resp, err := p.api.getUpdates(ctx, buf, timeoutMs)
if err != nil {
if ctx.Err() != nil {
return
}
slog.Warn("weixin: getUpdates failed", "error", err, "backoff", backoff)
time.Sleep(backoff)
if backoff < maxBackoff {
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
continue
}
backoff = time.Second
if resp.Errcode == sessionExpiredErrcode {
p.pauseSession(time.Hour)
continue
}
if resp.Ret != 0 && resp.Errmsg != "" {
slog.Warn("weixin: getUpdates ret", "ret", resp.Ret, "errcode", resp.Errcode, "errmsg", resp.Errmsg)
}
p.mu.RLock()
h := p.handler
p.mu.RUnlock()
if h == nil {
continue
}
var wg sync.WaitGroup
for i := range resp.Msgs {
i := i
wg.Add(1)
go func() {
defer wg.Done()
p.dispatchInbound(ctx, &resp.Msgs[i], h)
}()
}
wg.Wait()
if ctx.Err() == nil && resp.GetUpdatesBuf != "" {
p.syncBufMu.Lock()
p.persistSyncBuf(resp.GetUpdatesBuf)
p.syncBufMu.Unlock()
}
}
}
func (p *Platform) dispatchInbound(ctx context.Context, m *weixinMessage, h core.MessageHandler) {
if m == nil {
return
}
if m.MessageType == messageTypeBot {
return
}
if m.MessageType != 0 && m.MessageType != messageTypeUser {
return
}
from := strings.TrimSpace(m.FromUserID)
if from == "" {
return
}
if !core.AllowList(p.allowFrom, from) {
slog.Debug("weixin: sender not in allow_from", "from", from)
return
}
if m.CreateTimeMs > 0 {
t := time.UnixMilli(m.CreateTimeMs)
if core.IsOldMessage(t) {
slog.Debug("weixin: skip old message", "time", t)
return
}
}
// Include create_time_ms and client_id so (seq,message_id)=(0,0) or duplicates are less likely to collide.
dedupKey := fmt.Sprintf("%s|%d|%d|%d|%s", from, m.MessageID, m.Seq, m.CreateTimeMs, strings.TrimSpace(m.ClientID))
p.dedupMu.Lock()
if p.dedup == nil {
p.dedup = make(map[string]time.Time)
}
now := time.Now()
for k, ts := range p.dedup {
if now.Sub(ts) > 5*time.Minute {
delete(p.dedup, k)
}
}
if _, ok := p.dedup[dedupKey]; ok {
p.dedupMu.Unlock()
return
}
p.dedup[dedupKey] = now
p.dedupMu.Unlock()
if tok := strings.TrimSpace(m.ContextToken); tok != "" {
p.setContextToken(from, tok)
p.refreshTypingTicket(ctx, from, tok)
}
body := bodyFromItemList(m.ItemList)
images, files, audio := p.collectInboundMedia(ctx, m.ItemList)
if strings.TrimSpace(body) == "" && len(images) == 0 && len(files) == 0 && audio == nil && mediaOnlyItems(m.ItemList) {
body = "[收到媒体消息:CDN 下载或解密失败,或未配置 cdn_base_url;请改用文字说明。]"
}
if strings.TrimSpace(body) == "" && len(images) == 0 && len(files) == 0 && audio == nil {
return
}
rc := &replyContext{peerUserID: from, contextToken: strings.TrimSpace(m.ContextToken)}
msgID := fmt.Sprintf("%d", m.MessageID)
if m.MessageID == 0 {
msgID = randomHex(8)
}
h(p, &core.Message{
SessionKey: sessionKeyPrefix + from,
Platform: p.Name(),
MessageID: msgID,
UserID: from,
UserName: shortWeixinUser(from),
Content: body,
Images: images,
Files: files,
Audio: audio,
ReplyCtx: rc,
})
}
func mediaOnlyItems(items []messageItem) bool {
for _, it := range items {
switch it.Type {
case messageItemImage, messageItemVideo, messageItemFile:
return true
case messageItemVoice:
if it.VoiceItem == nil || strings.TrimSpace(it.VoiceItem.Text) == "" {
return true
}
}
}
return false
}
func shortWeixinUser(id string) string {
if len(id) > 32 {
return id[:32] + "…"
}
return id
}
func randomHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
func (p *Platform) Reply(ctx context.Context, replyCtx any, content string) error {
return p.sendChunks(ctx, replyCtx, content)
}
func (p *Platform) Send(ctx context.Context, replyCtx any, content string) error {
return p.sendChunks(ctx, replyCtx, content)
}
// StartTyping sends a typing indicator to the peer and repeats every few seconds
// until the returned stop function is called. Implements core.TypingIndicator.
func (p *Platform) StartTyping(ctx context.Context, rctx any) (stop func()) {
rc, ok := rctx.(*replyContext)
if !ok || rc == nil {
return func() {}
}
peerID := rc.peerUserID
contextToken := rc.contextToken
if strings.TrimSpace(contextToken) == "" {
contextToken = p.getContextToken(peerID)
}
ticket := p.getTypingTicket(ctx, peerID, contextToken)
if ticket == "" {
return func() {}
}
if err := p.api.sendTyping(ctx, peerID, ticket, typingStatusStart); err != nil {
slog.Debug("weixin: initial typing start failed", "peer", peerID, "error", err)
return func() {}
}
done := make(chan struct{})
go func() {
ticker := time.NewTicker(typingRepeatInterval)
defer ticker.Stop()
for {
select {
case <-done:
// Best-effort stop; use background context since ctx may already be cancelled.
stopCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
if err := p.api.sendTyping(stopCtx, peerID, ticket, typingStatusStop); err != nil {
slog.Debug("weixin: typing stop failed", "peer", peerID, "error", err)
}
cancel()
return
case <-ctx.Done():
stopCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
if err := p.api.sendTyping(stopCtx, peerID, ticket, typingStatusStop); err != nil {
slog.Debug("weixin: typing stop failed (ctx cancelled)", "peer", peerID, "error", err)
}
cancel()
return
case <-ticker.C:
if err := p.api.sendTyping(ctx, peerID, ticket, typingStatusStart); err != nil {
slog.Debug("weixin: typing repeat failed", "peer", peerID, "error", err)
bestEffortCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
_ = p.api.sendTyping(bestEffortCtx, peerID, ticket, typingStatusStop)
cancel()
return
}
}
}
}()
return func() { close(done) }
}
// getTypingTicket returns a cached typing ticket for the peer, fetching one
// from the getconfig API if the cache is empty or expired.
func (p *Platform) getTypingTicket(ctx context.Context, peerID, contextToken string) string {
p.typingMu.RLock()
entry, ok := p.typingTickets[peerID]
p.typingMu.RUnlock()
if ok && time.Since(entry.fetchedAt) < typingTicketTTL {
return entry.ticket
}
resp, err := p.api.getConfig(ctx, peerID, contextToken)
if err != nil {
slog.Debug("weixin: getConfig for typing ticket failed", "peer", peerID, "error", err)
return ""
}
ticket := strings.TrimSpace(resp.TypingTicket)
if ticket == "" {
return ""
}
p.typingMu.Lock()
p.typingTickets[peerID] = typingTicketEntry{ticket: ticket, fetchedAt: time.Now()}
p.typingMu.Unlock()
return ticket
}
// refreshTypingTicket proactively fetches and caches a typing ticket when a
// message is received, so that StartTyping can use it without an extra round-trip.
func (p *Platform) refreshTypingTicket(ctx context.Context, peerID, contextToken string) {
go func() {
fetchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
p.getTypingTicket(fetchCtx, peerID, contextToken)
}()
}
func (p *Platform) sendChunks(ctx context.Context, replyCtx any, content string) error {
rc, ok := replyCtx.(*replyContext)
if !ok || rc == nil {
return fmt.Errorf("weixin: invalid reply context")
}
if strings.TrimSpace(rc.contextToken) == "" {
rc.contextToken = p.getContextToken(rc.peerUserID)
}
if strings.TrimSpace(rc.contextToken) == "" {
slog.Error("weixin: cannot send message - missing context_token",
"peer", rc.peerUserID,
"content_preview", truncatePreview(content, 100),
"hint", "user needs to send a new message to refresh context_token")
return fmt.Errorf("weixin: missing context_token for peer %q - user must send a new message first", rc.peerUserID)
}
if strings.TrimSpace(content) == "" {
return nil
}
chunks := splitUTF8(content, maxWeixinChunk)
total := len(chunks)
for i, chunk := range chunks {
// Add delay between chunks to avoid rate limiting (except for first chunk)
if i > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(weixinChunkSendDelay):
}
}
// Retry sendText with context_token refresh on failure
err := p.sendChunkWithRetry(ctx, rc, chunk, i+1, total)
if err != nil {
slog.Error("weixin: chunk send failed, message incomplete",
"peer", rc.peerUserID,
"failed_chunk", fmt.Sprintf("%d/%d", i+1, total),
"error", err)
// Notify user that message delivery was incomplete.
// Use a short message that is unlikely to fail itself.
notice := "⚠️ 消息发送不完整,请在终端查看完整结果。"
noticeID := "cc-" + randomHex(6)
if nerr := p.api.sendText(ctx, rc.peerUserID, notice, rc.contextToken, noticeID); nerr != nil {
slog.Warn("weixin: failed to send incomplete-delivery notice", "peer", rc.peerUserID, "error", nerr)
}
return fmt.Errorf("weixin: send chunk %d/%d: %w", i+1, total, err)
}
}
return nil
}
// sendChunkWithRetry sends a single chunk with retry mechanism.
// When sendMessage returns ret=-2, it retries with a fresh context_token.
// chunkIdx and totalChunks are 1-based indices used for logging context.
func (p *Platform) sendChunkWithRetry(ctx context.Context, rc *replyContext, chunk string, chunkIdx, totalChunks int) error {
var lastErr error
for attempt := 0; attempt < weixinSendMaxRetries; attempt++ {
clientID := "cc-" + randomHex(6)
err := p.api.sendText(ctx, rc.peerUserID, chunk, rc.contextToken, clientID)
if err == nil {
return nil
}
lastErr = err
// Check if error is ret=-2 (API declined) - retry with fresh token
if strings.Contains(err.Error(), "ret=-2") {
preview := []rune(chunk)
if len(preview) > 50 {
preview = preview[:50]
}
slog.Warn("weixin: sendMessage ret=-2, retrying with fresh context_token",
"attempt", attempt+1, "peer", rc.peerUserID,
"chunk", fmt.Sprintf("%d/%d", chunkIdx, totalChunks),
"chunk_runes", utf8.RuneCountInString(chunk),
"preview", string(preview))
// Add delay before retry
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(weixinSendRetryDelay):
}
// Refresh context_token from stored tokens (may have been updated by new incoming message)
freshToken := p.getContextToken(rc.peerUserID)
if freshToken != "" && freshToken != rc.contextToken {
rc.contextToken = freshToken
slog.Debug("weixin: using refreshed context_token for retry", "peer", rc.peerUserID)
}
continue
}
// For other errors, don't retry
return err
}
return lastErr
}
func truncatePreview(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}
func splitUTF8(s string, maxRunes int) []string {
if maxRunes <= 0 || utf8.RuneCountInString(s) <= maxRunes {
return []string{s}
}
var out []string
runes := []rune(s)
for len(runes) > 0 {
n := maxRunes
if len(runes) < n {
n = len(runes)
}
out = append(out, string(runes[:n]))
runes = runes[n:]
}
return out
}
// ReconstructReplyCtx implements core.ReplyContextReconstructor for cron / proactive sends.
func (p *Platform) ReconstructReplyCtx(sessionKey string) (any, error) {
if !strings.HasPrefix(sessionKey, sessionKeyPrefix) {
return nil, fmt.Errorf("weixin: not a weixin session key")
}
peer := strings.TrimPrefix(sessionKey, sessionKeyPrefix)
tok := p.getContextToken(peer)
if tok == "" {
return nil, fmt.Errorf("weixin: no stored context_token for %q (user must message the bot first)", peer)
}
return &replyContext{peerUserID: peer, contextToken: tok}, nil
}
// FormattingInstructions implements core.FormattingInstructionProvider.
func (p *Platform) FormattingInstructions() string {
return "Replies are delivered as plain text to Weixin. Avoid markdown tables; use short paragraphs."
}
var (
_ core.Platform = (*Platform)(nil)
_ core.ReplyContextReconstructor = (*Platform)(nil)
_ core.FormattingInstructionProvider = (*Platform)(nil)
_ core.ImageSender = (*Platform)(nil)
_ core.FileSender = (*Platform)(nil)
_ core.TypingIndicator = (*Platform)(nil)
)