204 lines
7.5 KiB
Go
204 lines
7.5 KiB
Go
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)
|
||
}
|