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