初始化仓库

This commit is contained in:
2026-06-02 23:14:41 +08:00
commit 0bc3f02670
520 changed files with 191097 additions and 0 deletions
+749
View File
@@ -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)
)