mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 03:57:02 +08:00
- 将 WechatExplorer 产品身份升级为 TraceMemo - 更新 appId、Runtime Identity、Reader Skill 和 API 环境变量 - 增加旧用户数据、Knowledge、Token 与 AI Provider 安全迁移 - 保留 Windows WeFlow 和旧版配置兼容 - 完善首次启动迁移测试及 v2.2.0 发布文档 - 移除 macOS Intel x64 构建与发布支持
182 lines
4.6 KiB
Go
182 lines
4.6 KiB
Go
package ilink
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
maxConsecutiveFailures = 5
|
|
initialBackoff = 3 * time.Second
|
|
maxBackoff = 60 * time.Second
|
|
sessionExpiredBackoff = 5 * time.Second
|
|
errCodeSessionExpired = -14
|
|
)
|
|
|
|
// MessageHandler is called for each received message.
|
|
type MessageHandler func(ctx context.Context, client *Client, msg WeixinMessage)
|
|
|
|
// Monitor manages the long-poll loop for receiving messages.
|
|
type Monitor struct {
|
|
client *Client
|
|
handler MessageHandler
|
|
getUpdatesBuf string
|
|
bufPath string
|
|
failures int
|
|
lastActivity time.Time
|
|
}
|
|
|
|
// NewMonitor creates a new long-poll monitor.
|
|
func NewMonitor(client *Client, handler MessageHandler) (*Monitor, error) {
|
|
accountID := NormalizeAccountID(client.BotID())
|
|
accountsRoot, err := accountDirectoryForID(accountID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
bufPath := filepath.Join(accountsRoot, accountID+".sync.json")
|
|
|
|
m := &Monitor{
|
|
client: client,
|
|
handler: handler,
|
|
bufPath: bufPath,
|
|
lastActivity: time.Now(),
|
|
}
|
|
m.loadBuf()
|
|
return m, nil
|
|
}
|
|
|
|
// Run starts the long-poll loop. It blocks until ctx is cancelled.
|
|
// Automatically recovers from errors with exponential backoff.
|
|
func (m *Monitor) Run(ctx context.Context) error {
|
|
log.Println("[monitor] starting long-poll loop")
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
log.Println("[monitor] shutting down")
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
resp, err := m.client.GetUpdates(ctx, m.getUpdatesBuf)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
m.failures++
|
|
backoff := m.calcBackoff()
|
|
log.Printf("[monitor] GetUpdates error (%d/%d, backoff=%s): %v",
|
|
m.failures, maxConsecutiveFailures, backoff, err)
|
|
if m.failures == maxConsecutiveFailures {
|
|
log.Printf("[monitor] WARNING: %d consecutive failures; reconnect from TraceMemo if this persists.", maxConsecutiveFailures)
|
|
}
|
|
select {
|
|
case <-time.After(backoff):
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Reset failure counter on any successful response
|
|
m.failures = 0
|
|
m.lastActivity = time.Now()
|
|
|
|
// Session expired — reset sync buf and reconnect silently
|
|
if resp.ErrCode == errCodeSessionExpired {
|
|
if m.getUpdatesBuf != "" {
|
|
log.Printf("[monitor] session expired, resetting sync buf")
|
|
m.getUpdatesBuf = ""
|
|
m.saveBuf()
|
|
} else {
|
|
// Sync buf already empty but still getting session expired:
|
|
// the bot token itself has expired. The user needs to re-login.
|
|
log.Printf("[monitor] WARNING: WeChat session expired and cannot be auto-recovered; reconnect from TraceMemo.")
|
|
}
|
|
select {
|
|
case <-time.After(sessionExpiredBackoff):
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Other server errors
|
|
if resp.Ret != 0 && resp.ErrCode != 0 {
|
|
log.Printf("[monitor] server error: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.ErrCode, resp.ErrMsg)
|
|
continue
|
|
}
|
|
|
|
// Update buf for next poll
|
|
if resp.GetUpdatesBuf != "" {
|
|
m.getUpdatesBuf = resp.GetUpdatesBuf
|
|
m.saveBuf()
|
|
}
|
|
|
|
// Process messages concurrently — don't block the poll loop
|
|
for _, msg := range resp.Msgs {
|
|
go m.handler(ctx, m.client, msg)
|
|
}
|
|
}
|
|
}
|
|
|
|
// calcBackoff returns an exponential backoff duration capped at maxBackoff.
|
|
func (m *Monitor) calcBackoff() time.Duration {
|
|
d := initialBackoff
|
|
for i := 1; i < m.failures; i++ {
|
|
d *= 2
|
|
if d > maxBackoff {
|
|
return maxBackoff
|
|
}
|
|
}
|
|
return d
|
|
}
|
|
|
|
type syncData struct {
|
|
GetUpdatesBuf string `json:"get_updates_buf"`
|
|
}
|
|
|
|
func (m *Monitor) loadBuf() {
|
|
data, err := os.ReadFile(m.bufPath)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var s syncData
|
|
if json.Unmarshal(data, &s) == nil && s.GetUpdatesBuf != "" {
|
|
m.getUpdatesBuf = s.GetUpdatesBuf
|
|
log.Printf("[monitor] loaded sync buf from %s", m.bufPath)
|
|
}
|
|
}
|
|
|
|
func (m *Monitor) saveBuf() {
|
|
dir := filepath.Dir(m.bufPath)
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
log.Printf("[monitor] failed to create buf dir: %v", err)
|
|
return
|
|
}
|
|
data, _ := json.Marshal(syncData{GetUpdatesBuf: m.getUpdatesBuf})
|
|
if err := os.WriteFile(m.bufPath, data, 0o600); err != nil {
|
|
log.Printf("[monitor] failed to save buf: %v", err)
|
|
}
|
|
}
|
|
|
|
// FormatMessageSummary returns a short description of a message for logging.
|
|
func FormatMessageSummary(msg WeixinMessage) string {
|
|
text := ""
|
|
for _, item := range msg.ItemList {
|
|
if item.Type == ItemTypeText && item.TextItem != nil {
|
|
text = item.TextItem.Text
|
|
break
|
|
}
|
|
}
|
|
if len(text) > 50 {
|
|
text = text[:50] + "..."
|
|
}
|
|
return fmt.Sprintf("from=%s type=%d state=%d text=%q", msg.FromUserID, msg.MessageType, msg.MessageState, text)
|
|
}
|