Files
Cyrene_v2/plugins/channels/weixin_ilink/send.go
T
AskaEth e792baa316
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(channels): 修正 6 处指向已不存在 design/08 节号的注释(只改注释)
2026-10-05 11:29:54 +08:00

186 lines
6.1 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/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)
}