Files
Cyrene_v2/plugins/channels/weixin_ilink/poll.go
T

379 lines
11 KiB
Go

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
}