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

204 lines
7.5 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 §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/size/序号/原因),
// 文本项照常发出;全部媒体都失败也仍然发文本。失败必须可诊断,但绝不等于整条回复
// 失败。
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 i, md := range m.Media {
item, err := a.buildMediaItem(ctx, peer, md)
if err != nil {
if mediaErr == nil {
mediaErr = err
}
// 🔴 `md.Name` **整字段不进日志**(M56)。它是**用户侧自由文本**:出站媒体名
// 来自调用方给的 adapter.Media.Name,入站侧就是用户自己起的文件名,可以是
// 「身份证-张三.jpg」这类内容 —— 与同一行的 peer/kind/size 不是一类东西。
//
// 为什么删字段、而不是打码或截断:包内可复用的打码(trace.RedactTextLimited)
// 是**按形状**认凭据的,对「长得像人话的文件名」完全无效;截断则必然留下前 N
// 个字符,而中文文件名的头几个字往往就是全部有意义的部分 —— 两种修法都只是
// 把泄露面缩小一点,并没有关上。而这个字段对排障**零贡献**:失败原因在 err
// 里,出问题的是哪一条由 kind + size + 下标(index)唯一确定。删字段因此是
// 严格更强、又不损失诊断力的选择。包内已有同一口径的先例:入站取证行
// (inbound_debug.go 的 inboundMediaShape)同样明确「正文、文件名、URL 原串
// 与一切密钥都不取」—— 日志面从不承载用户侧的正文/文件名,两个方向一致。
a.log.Warn("outbound media item skipped",
"peer", peer, "kind", md.Kind, "size", md.Size, "index", i, "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)
}