Files
Cyrene_v2/plugins/channels/weixin_ilink/send.go
T
AskaEth f8f2e9c54c
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(weixin_ilink): 出站媒体走通(主人已批的三项)
按官方包 @tencent-weixin/openclaw-weixin 2.4.9 源码对齐:
① 上传由 PUT 改 POST;② 上传凭据读响应头 x-encrypted-param;
③ nativeMediaItem 改为官方 *_item.media.encrypt_query_param 嵌套形状(含 aes_key,
图片优先 image_item.aeskey)。
配合前一版已修的两点(入站按条密钥、媒体失败不阻断文本),机器人现在可主动发出文件/图片。
(逐项测试、变异红行与诚实边界见该切片报告)
2026-10-05 08:04:50 +08:00

186 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 §8)。
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):用户沉默更久、或从未
// 发过消息时,推送以 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
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)
}
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)
}