package weixin_ilink import ( "bytes" "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "net/http" "strconv" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) // 本文件承载**发送编排**:把一条归一化回复切成 item_list 并投递。媒体上传与 item 组装 // 在 send_media.go(本文件保持在项目 ≤500 行的单文件上限之内)。 const ( // clientIDBytes 是发送去重 ID(client_id)的随机字节数:它对应协议里的 client_id, // 不是随手取的数字。 clientIDBytes = 12 ) // sendResult 是 sendmessage 的响应外壳。 type sendResult struct { Ret int `json:"ret"` Errcode int `json:"errcode"` } // SendMessage 向渠道投递一条回复。每条消息都发给从该消息(或 resp.ReplyTo) // 解析出的对端,并使用该对端已存的 context_token,以便平台把回复关联到正确的 // 会话(见 design/08 §4 的 weixin-ilink 条目:`context_token`)。 func (a *Adapter) SendMessage(ctx context.Context, resp adapter.UnifiedResponse) error { if err := a.sendGuard(); err != nil { return err } for _, m := range resp.Messages { peer, _ := recipient(m) if peer == "" { peer = resp.ReplyTo } if err := a.sendToOne(ctx, peer, m); err != nil { return fmt.Errorf("weixin_ilink: send message: %w", err) } } return nil } // SendProactive 推送一条并非由近期入站消息触发的消息。微信 iLink 只允许在用户 // 最后一条消息之后的 24 小时窗口内这么做(见 design/08 §3 的主动推送表与 §4 的 // weixin-ilink 条目):用户沉默更久、或从未发过消息时,推送以 ErrProactiveWindow // 被拒,而 Capabilities.Proactive 仅为 true,好让调用方知道该能力存在。 func (a *Adapter) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) error { if err := a.sendGuard(); err != nil { return err } peer, _ := recipient(msg) if peer == "" { return ErrNoContextToken } a.mu.RLock() last, seen := a.lastMsgAt[peer] window := a.cfg.ProactiveWindow a.mu.RUnlock() if !seen || a.clock().Sub(last) > window { return ErrProactiveWindow } if a.contextFor(peer) == "" { return ErrNoContextToken } return a.sendToOne(ctx, peer, msg) } // sendGuard 报告发送在触网之前必须暴露的错误:iLink 已拒绝的会话(errcode=-14) // 表现为 ErrSessionRejected——主人必须重新扫码——而其余任何断开状态仍是 // ErrNotConnected。 func (a *Adapter) sendGuard() error { if rejected, reason := a.rejectedState(); rejected { return fmt.Errorf("weixin_ilink: send: %w: %s", ErrSessionRejected, reason) } if !a.IsConnected() { return adapter.ErrNotConnected } return nil } // sendToOne 把一条归一化消息作为原生 iLink 消息发给某个对端,用文本加上已上传的 // 媒体组装其 item_list。 // // **单条媒体的失败绝不连累文本**:媒体只是附件,回复的主体是文字。真机实测 // (2026-10-04)就是「媒体项构建失败 ⇒ 整条回复被判失败 ⇒ 她的中文文本一起没了」。 // 因此这里逐条构建,失败的那条跳过并留一条带来源的 Warn(kind/name/原因),文本项 // 照常发出;全部媒体都失败也仍然发文本。失败必须可诊断,但绝不等于整条回复失败。 func (a *Adapter) sendToOne(ctx context.Context, peer string, m adapter.UnifiedMessage) error { if peer == "" { return ErrNoContextToken } ctxToken := a.contextFor(peer) if ctxToken == "" { return ErrNoContextToken } items := make([]any, 0, 1+len(m.Media)) if m.Content != "" { items = append(items, nativeTextItem(m.Content)) } var mediaErr error for _, md := range m.Media { item, err := a.buildMediaItem(ctx, peer, md) if err != nil { if mediaErr == nil { mediaErr = err } a.log.Warn("outbound media item skipped", "peer", peer, "kind", md.Kind, "name", md.Name, "size", md.Size, "err", err) continue } items = append(items, item) } if len(items) == 0 { if mediaErr != nil { // 一条文本都没有、媒体又全挂:**什么都没发出去**,不能报成功。 return fmt.Errorf("weixin_ilink: send: no deliverable item: %w", mediaErr) } return nil } return a.sendNative(ctx, peer, ctxToken, items) } // sendNative 提交一份组装好的 sendmessage 载荷。 func (a *Adapter) sendNative(ctx context.Context, peer, ctxToken string, items []any) error { body, err := json.Marshal(map[string]any{ "msg": map[string]any{ "to_user_id": peer, "client_id": newClientID(), "message_type": 2, // 机器人消息 "message_state": 2, // FINISH(结束态) "context_token": ctxToken, "item_list": items, }, }) if err != nil { return fmt.Errorf("weixin_ilink: encode sendmessage: %w", err) } var out sendResult // 与读循环同一口径:先拍下本次调用所用的凭据快照,成功时才能把「已证实」记到 // 对的那份凭据上(session_status.go)。 token := a.botToken() if err := a.doJSON(ctx, http.MethodPost, a.apiURL("/ilink/bot/sendmessage"), bytes.NewReader(body), &out); err != nil { return fmt.Errorf("weixin_ilink: sendmessage: %w", err) } if out.Errcode == errcodeSessionRejected { // 发送路径与轮询路径看到的是同一份被拒凭据:记录该拒绝状态,让适配器不再 // 宣称会话存活,面板也就可以要求重新扫码。 a.markRejected(rescanReasonSendMessage) return ErrSessionRejected } if out.Ret != 0 { return fmt.Errorf("weixin_ilink: sendmessage ret=%d", out.Ret) } // 一次成功的 sendmessage 同样是「iLink 接受了这份凭据」的证据。 a.markVerified(token) return nil } // nativeTextItem 构造一个文本 item_list 条目。 func nativeTextItem(text string) map[string]any { return map[string]any{ "type": itemText, "text_item": map[string]any{"text": text}, } } // newClientID 返回随机的十六进制 client id,用于发送去重。 func newClientID() string { return randomHex(clientIDBytes) } // randomHex 返回 n 字节随机值的十六进制串(2n 个字符)。crypto/rand 失败是极小概率 // 事件,此时退回到基于时间的值 —— 发送路径不该因为随机源故障整条失败。 func randomHex(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err == nil { return hex.EncodeToString(b) } return strconv.FormatInt(time.Now().UnixNano(), 36) }