package weixin_ilink import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "strings" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) const ( // msgTypeUser marks an inbound user message (bot messages are type 2). msgTypeUser = 1 // item type codes from the iLink reference (design 03 §1). itemText = 1 itemImage = 2 itemVoice = 3 itemFile = 4 itemVideo = 5 // retryFastCount is how many consecutive failures use the fast retry. retryFastCount = 3 // retryFastDelay is the delay before the first failures. retryFastDelay = 2 * time.Second // retrySlowDelay is the backoff after retryFastCount consecutive failures. retrySlowDelay = 30 * time.Second ) // getupdatesResp is the getupdates (long-poll) response envelope. type getupdatesResp struct { // Ret is the top-level business result; non-zero is a transient error. Ret int `json:"ret"` // Errcode is the platform error code; -14 means the session expired. Errcode int `json:"errcode"` // Msgs holds the raw inbound messages delivered in this poll. Msgs []json.RawMessage `json:"msgs"` // GetUpdatesBuf is the next Cursor to echo back. GetUpdatesBuf string `json:"get_updates_buf"` // LongPollTimeoutMs is the server's suggested hold time. LongPollTimeoutMs int `json:"longpolling_timeout_ms"` } // rawMsg is the parsed common fields of an inbound WeixinMessage. iLink media // field names are only partially public, so item_list entries are decoded // defensively in parseItem. type rawMsg struct { ClientID string `json:"client_id"` FromUserID string `json:"from_user_id"` ToUserID string `json:"to_user_id"` MessageType int `json:"message_type"` MessageState int `json:"message_state"` ContextToken string `json:"context_token"` GroupID string `json:"group_id"` RoomID string `json:"room_id"` ItemList []json.RawMessage `json:"item_list"` } // ReadMessages runs the long-poll read loop until ctx is canceled. Each // converted inbound message is delivered to the handler installed via // SetMessageHandler when one is present, otherwise to emit. The loop is // cooperative with ctx: a canceled context returns immediately and no timers // outlive it. func (a *Adapter) ReadMessages(ctx context.Context, emit func(adapter.UnifiedMessage)) { a.mu.RLock() connected := a.connected a.mu.RUnlock() if !connected { a.log.Warn("read loop started while disconnected") return } failures := 0 for { resp, err := a.fetchUpdates(ctx) if err != nil { if ctx.Err() != nil { return } // A long-poll timeout is the normal "no messages yet" path. if errors.Is(err, context.DeadlineExceeded) { continue } failures++ a.log.Warn("getupdates failed", "err", err, "failures", failures) if !a.wait(ctx, a.retryDelay(failures)) { return } continue } failures = 0 if resp.Errcode == errSessionExpired { a.log.Warn("session expired (errcode=-14); stop polling, re-scan required") a.mu.Lock() a.connected = false a.mu.Unlock() return } if resp.Ret != 0 { failures++ a.log.Warn("getupdates business error", "ret", resp.Ret) if !a.wait(ctx, a.retryDelay(failures)) { return } continue } a.setCursor(resp.GetUpdatesBuf) for _, raw := range resp.Msgs { token, m := a.convertMessage(raw) if m == nil { continue } a.recordInbound(token, *m) a.deliver(ctx, *m, emit) } if !a.wait(ctx, a.cfg.PollInterval) { return } } } // deliver routes a normalized message to the handler when installed, else to // the emit callback (design/08 §1: push-style vs pull-style delivery). func (a *Adapter) deliver(ctx context.Context, m adapter.UnifiedMessage, emit func(adapter.UnifiedMessage)) { if h := a.handlerLocked(); h != nil { h(ctx, m) return } if emit != nil { emit(m) } } // fetchUpdates performs one long poll. The request context is bounded by // LongPollTimeout if the caller has not already set a tighter deadline. func (a *Adapter) fetchUpdates(ctx context.Context) (*getupdatesResp, error) { reqCtx, cancel := context.WithTimeout(ctx, a.cfg.LongPollTimeout) defer cancel() body, err := json.Marshal(map[string]any{ "get_updates_buf": a.cursor(), "base_info": map[string]string{"channel_version": channelVersion}, }) if err != nil { return nil, fmt.Errorf("weixin_ilink: encode getupdates: %w", err) } var out getupdatesResp if err := a.doJSON(reqCtx, http.MethodPost, a.apiURL("/ilink/bot/getupdates"), bytes.NewReader(body), &out); err != nil { return nil, fmt.Errorf("weixin_ilink: getupdates: %w", err) } return &out, nil } // wait sleeps for d or returns false when ctx is canceled. A non-positive // delay returns immediately. func (a *Adapter) wait(ctx context.Context, d time.Duration) bool { if d <= 0 { return true } t := time.NewTimer(d) defer t.Stop() select { case <-ctx.Done(): return false case <-t.C: return true } } // retryDelay grows from the fast retry to the slow backoff after enough // consecutive failures (design/08 §5.4). func (a *Adapter) retryDelay(failures int) time.Duration { if failures <= retryFastCount { return retryFastDelay } return retrySlowDelay } // convertMessage maps a raw iLink message to a UnifiedMessage and returns the // raw context_token so the caller can track it per peer. It returns nil when // the message is not an inbound user message or cannot be parsed. func (a *Adapter) convertMessage(raw json.RawMessage) (string, *adapter.UnifiedMessage) { var rm rawMsg if err := json.Unmarshal(raw, &rm); err != nil { a.log.Warn("decode inbound message", "err", err) return "", nil } if rm.MessageType != msgTypeUser { return "", nil } group := resolveGroup(&rm) m := &adapter.UnifiedMessage{ Platform: platformName, SenderID: rm.FromUserID, SenderName: rm.FromUserID, MessageID: rm.ClientID, Timestamp: a.clock(), RawData: raw, } if group != "" { m.ChannelType = adapter.ChannelGroup m.GroupID = group m.ChannelID = group } else { m.ChannelType = adapter.ChannelDirect m.ChannelID = rm.FromUserID } m.Sender = adapter.Actor{ Role: adapter.RoleUnknown, UserID: rm.FromUserID, DisplayName: rm.FromUserID, IsGroup: group != "", GroupID: group, } m.Content, m.ContentType, m.Media = a.convertItems(rm.ItemList) return rm.ContextToken, m } // convertItems drains the item list into text, a primary content type and a // media slice. Text from text items is concatenated; voice items contribute // their ASR transcript; image/file/video items are attached lazily as CDN // references (never downloaded during the poll). func (a *Adapter) convertItems(rawItems []json.RawMessage) (string, adapter.ContentType, []adapter.Media) { var text strings.Builder var media []adapter.Media firstMedia := adapter.ContentType("") for _, raw := range rawItems { item := parseItem(raw) switch item.kind { case itemText: text.WriteString(item.text) case itemVoice: text.WriteString(item.text) media = append(media, item.media) firstMedia = adapter.ContentVoice case itemImage: media = append(media, item.media) firstMedia = adapter.ContentImage case itemFile, itemVideo: media = append(media, item.media) firstMedia = adapter.ContentFile } } content := strings.TrimRight(text.String(), "\n") if firstMedia != "" { return content, firstMedia, media } return content, adapter.ContentText, nil } // resolveGroup returns the group identifier when the message carries one, // otherwise "" (direct chat). WeChat group signalling is ⚠️ per design/08 §4, // so several candidate fields are checked defensively. func resolveGroup(rm *rawMsg) string { if rm.GroupID != "" { return rm.GroupID } return rm.RoomID } // parsedItem is a normalized view of a single native item_list entry. type parsedItem struct { kind int text string media adapter.Media } // parseItem decodes one item_list entry defensively. iLink media field names // are not fully public, so a generic map is scanned for the CDN reference and // metadata with several fallback keys. func parseItem(raw json.RawMessage) parsedItem { var m map[string]any if err := json.Unmarshal(raw, &m); err != nil { return parsedItem{} } kind := intOf(m["type"]) switch kind { case itemText: return parsedItem{kind: itemText, text: textOf(m, "text_item")} case itemVoice: return parsedItem{kind: itemVoice, text: textOf(m, "voice_item"), media: mediaOf(m, adapter.MediaVoice)} case itemImage: return parsedItem{kind: itemImage, media: mediaOf(m, adapter.MediaImage)} case itemFile: return parsedItem{kind: itemFile, media: mediaOf(m, adapter.MediaFile)} case itemVideo: return parsedItem{kind: itemVideo, media: mediaOf(m, adapter.MediaVideo)} default: return parsedItem{kind: kind} } } // textOf reads the text payload from the named nested sub-object, falling back // to a flat "text" key. func textOf(m map[string]any, sub string) string { if inner, ok := m[sub].(map[string]any); ok { if s, ok := inner["text"].(string); ok { return s } } if s, ok := m["text"].(string); ok { return s } return "" } // mediaOf builds an adapter.Media from a media item, resolving the CDN URL and // metadata from the flat item or a nested sub-object. func mediaOf(m map[string]any, kind adapter.MediaKind) adapter.Media { res := adapter.Media{Kind: kind} res.URL = firstString(m, "url", "cdn_url", "download_url", "file_url", "cdn_ref") res.Name = firstString(m, "file_name", "name") res.Size = firstInt64(m, "size", "file_size") res.MIMEType = firstString(m, "mime", "mime_type", "content_type") if res.URL != "" { return res } // Bounded scan of the common nested sub-objects for the CDN reference. for _, sub := range []string{"image_item", "file_item", "video_item", "media_item", "cdn_item"} { inner, ok := m[sub].(map[string]any) if !ok { continue } res.URL = firstString(inner, "url", "cdn_url", "download_url", "file_url", "cdn_ref") if res.Name == "" { res.Name = firstString(inner, "file_name", "name") } if res.Size == 0 { res.Size = firstInt64(inner, "size", "file_size") } if res.MIMEType == "" { res.MIMEType = firstString(inner, "mime", "mime_type", "content_type") } if res.URL != "" { return res } } return res } // intOf coerces a JSON number to int, returning 0 for missing or non-numeric. func intOf(v any) int { f, ok := v.(float64) if !ok { return 0 } return int(f) } // firstString returns the first non-empty string among keys. func firstString(m map[string]any, keys ...string) string { for _, k := range keys { if s, ok := m[k].(string); ok && s != "" { return s } } return "" } // firstInt64 returns the first non-zero integer among keys. func firstInt64(m map[string]any, keys ...string) int64 { for _, k := range keys { if f, ok := m[k].(float64); ok && f != 0 { return int64(f) } } return 0 }