750 lines
20 KiB
Go
750 lines
20 KiB
Go
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)
|
||
)
|