Files
Cyrene_v2/plugins/channels/weixin_ilink/send_media_failure_test.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

351 lines
15 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
// 本文件锁定出站媒体的两条真机纪律(2026-10-04 实机缺陷):
//
// B1 单条媒体项构建/上传失败**不得**把整条回复(含文本)判失败 ——
// 真机现场是她的中文文本本身完好,却因为一个媒体项构建失败而整条没发出去;
// B3 getuploadurl 的请求必须带上官方字段、响应必须认官方键名,且「键缺失」
// 与「会话被拒」必须分开报(后者已有 markRejected 路径)。
import (
"bytes"
"context"
"encoding/hex"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// mediaSendServer 是假 iLink:getuploadurl 按 upload 作答,**CDN 上传按官方口径回
// 200 + `x-encrypted-param` 头**(POST),sendmessage 回 ret:0。
func mediaSendServer(t *testing.T, rec *reqRecorder, upload handlerResult) *httptest.Server {
t.Helper()
return newJSONServer(t, rec, func(req recordedReq) handlerResult {
switch {
case req.method == http.MethodPost && req.path == "/ilink/bot/getuploadurl":
return upload
case req.method == http.MethodPost && req.path == "/cdn/up":
return handlerResult{
status: http.StatusOK,
body: []byte("ok"),
headers: map[string]string{uploadParamHeader: downloadParamFixture},
}
case req.method == http.MethodPost && req.path == "/ilink/bot/sendmessage":
return handlerResult{body: jsonBody(map[string]any{"ret": 0})}
default:
return handlerResult{status: http.StatusNotFound}
}
})
}
// downloadParamFixture 是假 CDN 回填的下载凭据(真实值由服务端生成,测试只关心它被
// 原样带进 item 的 media.encrypt_query_param)。
const downloadParamFixture = "dl+param/1="
// newLoggingSendAdapter 装配一个把日志写进 buf 的已连接适配器,用来断言失败**不静默**。
func newLoggingSendAdapter(t *testing.T, srv *httptest.Server, buf *bytes.Buffer) *Adapter {
t.Helper()
log := logger.WithSource(
slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), source)
ad, err := New(Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv.Client(), log)
if err != nil {
t.Fatalf("New: %v", err)
}
if err := ad.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
return ad
}
// outboundItems 返回第一个 sendmessage 请求的 item_list。
func outboundItems(t *testing.T, rec *reqRecorder) []any {
t.Helper()
items, ok := getSendBody(t, rec)["msg"].(map[string]any)["item_list"].([]any)
if !ok {
t.Fatal("sendmessage 没有 item_list")
}
return items
}
// TestSendMessage_MediaFailureStillSendsText 是 **Bug B1 的回归用例**:媒体项构建失败时,
// 文本必须照常发出,且失败要留下带来源的 WARN。
func TestSendMessage_MediaFailureStillSendsText(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
// getuploadurl 回一个「什么都没有」的空壳:媒体项必然构建失败。
srv := mediaSendServer(t, rec, handlerResult{body: jsonBody(map[string]any{})})
var buf bytes.Buffer
ad := newLoggingSendAdapter(t, srv, &buf)
ad.recordInbound("tok-1", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()})
const reply = "这是一条正常的中文回复"
err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{
SenderID: "u-1",
Content: reply,
Media: []adapter.Media{{Kind: adapter.MediaFile, Data: []byte("x"), Name: "1.txt"}},
}},
})
if err != nil {
t.Fatalf("媒体失败连累了整条回复:%v", err)
}
items := outboundItems(t, rec)
if len(items) != 1 {
t.Fatalf("item_list = %d 条, want 1(失败的媒体被跳过、文本照发)", len(items))
}
item, _ := items[0].(map[string]any)
if intOf(item["type"]) != itemText {
t.Fatalf("item type = %v, want %d", item["type"], itemText)
}
if txt := item["text_item"].(map[string]any)["text"]; txt != reply {
t.Fatalf("发出的文本 = %q, want %q", txt, reply)
}
// 失败必须可诊断:一条带来源、带原因的 WARN。
logs := buf.String()
if !strings.Contains(logs, "level=WARN") || !strings.Contains(logs, "outbound media item skipped") {
t.Fatalf("媒体失败没有留下 WARN:\n%s", logs)
}
if !strings.Contains(logs, "source="+source) {
t.Fatalf("告警没有标注来源:\n%s", logs)
}
if !strings.Contains(logs, "kind=file") || !strings.Contains(logs, "name=1.txt") {
t.Fatalf("告警缺少 kind/name:\n%s", logs)
}
}
// TestSendMessage_AllMediaFailWithoutTextIsReported 钉住反向边界:一条文本都没有、媒体
// 又全挂 ⇒ **什么都没发出去**,不能报成功(诚实失败,而不是静默丢弃)。
func TestSendMessage_AllMediaFailWithoutTextIsReported(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
srv := mediaSendServer(t, rec, handlerResult{body: jsonBody(map[string]any{})})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv)
ad.recordInbound("tok-1", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()})
err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{
SenderID: "u-1",
Media: []adapter.Media{{Kind: adapter.MediaFile, Data: []byte("x"), Name: "1.txt"}},
}},
})
if !errors.Is(err, ErrNoUploadURL) {
t.Fatalf("err = %v, want ErrNoUploadURL", err)
}
if reqs := rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/sendmessage" }); len(reqs) != 0 {
t.Fatalf("没有任何可投递内容却发出了 %d 次 sendmessage", len(reqs))
}
}
// TestGetUploadURL_RequestCarriesProtocolFields 钉住「returned no url」的真根因:旧实现
// 发的是**空 body**,而官方 GetUploadUrlReq 要求
// filekey/media_type/to_user_id/rawsize/rawfilemd5/filesize/no_need_thumb/aeskey。
// 没有这些字段,服务端只会回一个空壳。
func TestGetUploadURL_RequestCarriesProtocolFields(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
srv := mediaSendServer(t, rec, handlerResult{body: jsonBody(map[string]any{})})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv)
ad.recordInbound("tok-1", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()})
payload := []byte("fake-jpeg-bytes")
// 媒体仍会按 B1 被跳过(这里不关心结果),我们只看它发出的 getuploadurl 请求。
_ = ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{
SenderID: "u-1",
Media: []adapter.Media{{Kind: adapter.MediaImage, Data: payload, Name: "a.jpg"}},
}},
})
reqs := rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/getuploadurl" })
if len(reqs) != 1 {
t.Fatalf("getuploadurl 请求 = %d 次, want 1", len(reqs))
}
body := reqs[0].bodyJSON()
if body == nil {
t.Fatal("getuploadurl 请求体不是 JSON")
}
fileKey, _ := body["filekey"].(string)
if len(fileKey) != 2*fileKeyBytes || !isHexASCII([]byte(fileKey)) {
t.Fatalf("filekey = %q, want %d 位十六进制", fileKey, 2*fileKeyBytes)
}
// ⚠️ 这里必须是 getuploadurl 的 media_type 码(图片=1),而不是 item 的 type 码(图片=2)。
if got := intOf(body["media_type"]); got != uploadMediaImage {
t.Fatalf("media_type = %v, want %d(与 item 码不是一套)", body["media_type"], uploadMediaImage)
}
if body["to_user_id"] != "u-1" {
t.Fatalf("to_user_id = %v, want u-1", body["to_user_id"])
}
if got := int64(intOf(body["rawsize"])); got != int64(len(payload)) {
t.Fatalf("rawsize = %d, want %d", got, len(payload))
}
if body["rawfilemd5"] != md5Hex(payload) {
t.Fatalf("rawfilemd5 = %v, want %s", body["rawfilemd5"], md5Hex(payload))
}
cipherSize := int64(intOf(body["filesize"]))
if cipherSize <= int64(len(payload)) || cipherSize%16 != 0 {
t.Fatalf("filesize = %d, want PKCS#7 填充后的密文长度(> %d 且为 16 的倍数)", cipherSize, len(payload))
}
if body["no_need_thumb"] != true {
t.Fatalf("no_need_thumb = %v, want true(我们不上传缩略图)", body["no_need_thumb"])
}
// aeskey 必须是真正用于加密的那把:拿它去解上传的密文能还原原文。
rawKey, err := hex.DecodeString(body["aeskey"].(string))
if err != nil {
t.Fatalf("aeskey 不是十六进制:%v", err)
}
// 上传地址缺失时**一次上传请求都不该发出**(只应有那一次 getuploadurl)。
if reqs := rec.find(func(q recordedReq) bool { return q.method == http.MethodPost }); len(reqs) != 1 {
t.Fatalf("POST 请求 = %d 次, want 1(只有 getuploadurl;地址缺失就不该上传)", len(reqs))
}
if len(rawKey) != 16 {
t.Fatalf("aeskey = %d 字节, want 16", len(rawKey))
}
}
// TestOutboundMedia_UsesUploadFullURL 覆盖官方 GetUploadUrlResp 的主字段
// `upload_full_url`:旧实现只认 upload_url/url/... 这几个猜测名,拿到它也会判「没地址」。
func TestOutboundMedia_UsesUploadFullURL(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
var srv *httptest.Server
srv = newJSONServer(t, rec, func(req recordedReq) handlerResult {
switch {
case req.method == http.MethodPost && req.path == "/ilink/bot/getuploadurl":
return handlerResult{body: jsonBody(map[string]any{"upload_full_url": srv.URL + "/cdn/up"})}
case req.method == http.MethodPost && req.path == "/cdn/up":
return handlerResult{
status: http.StatusOK,
body: []byte("ok"),
headers: map[string]string{uploadParamHeader: downloadParamFixture},
}
case req.method == http.MethodPost && req.path == "/ilink/bot/sendmessage":
return handlerResult{body: jsonBody(map[string]any{"ret": 0})}
default:
return handlerResult{status: http.StatusNotFound}
}
})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv)
ad.recordInbound("tok-m", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()})
if err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{
SenderID: "u-1",
Media: []adapter.Media{{Kind: adapter.MediaImage, Data: []byte("bytes"), Name: "a.jpg"}},
}},
}); err != nil {
t.Fatalf("SendMessage(media, upload_full_url): %v", err)
}
items := outboundItems(t, rec)
if len(items) != 1 || intOf(items[0].(map[string]any)["type"]) != itemImage {
t.Fatalf("item_list = %+v, want 一条图片 item", items)
}
}
// TestOutboundMedia_BuildsUploadURLFromUploadParam 覆盖 `upload_param`:官方口径是把它
// 拼到 CDN 根下的 `/upload?encrypted_query_param=…&filekey=…`,且 filekey 必须与
// getuploadurl 请求里发出的那一个一致。
func TestOutboundMedia_BuildsUploadURLFromUploadParam(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
var srv *httptest.Server
srv = newJSONServer(t, rec, func(req recordedReq) handlerResult {
switch {
case req.method == http.MethodPost && req.path == "/ilink/bot/getuploadurl":
return handlerResult{body: jsonBody(map[string]any{"upload_param": "ab+cd="})}
case req.method == http.MethodPost && req.path == "/upload":
return handlerResult{
status: http.StatusOK,
body: []byte("ok"),
headers: map[string]string{uploadParamHeader: downloadParamFixture},
}
case req.method == http.MethodPost && req.path == "/ilink/bot/sendmessage":
return handlerResult{body: jsonBody(map[string]any{"ret": 0})}
default:
return handlerResult{status: http.StatusNotFound}
}
})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv)
ad.recordInbound("tok-m", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()})
if err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{
SenderID: "u-1",
Media: []adapter.Media{{Kind: adapter.MediaImage, Data: []byte("bytes"), Name: "a.jpg"}},
}},
}); err != nil {
t.Fatalf("SendMessage(media, upload_param): %v", err)
}
puts := rec.find(func(q recordedReq) bool { return q.path == "/upload" })
if len(puts) != 1 {
t.Fatalf("上传请求 = %d 次, want 1", len(puts))
}
if puts[0].method != http.MethodPost {
t.Fatalf("上传方法 = %s, want POST(官方口径)", puts[0].method)
}
if !strings.Contains(puts[0].query, "encrypted_query_param=ab%2Bcd%3D") {
t.Fatalf("上传查询串 = %q, want 转义后的 encrypted_query_param", puts[0].query)
}
param, err := url.ParseQuery(puts[0].query)
if err != nil {
t.Fatalf("上传查询串不合法:%v", err)
}
if got := param.Get("encrypted_query_param"); got != "ab+cd=" {
t.Fatalf("服务端解出的 upload_param = %q, want ab+cd=", got)
}
upReqs := rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/getuploadurl" })
body := upReqs[0].bodyJSON()
if got, want := param.Get("filekey"), body["filekey"]; got != want {
t.Fatalf("上传 URL 里的 filekey = %q, 与 getuploadurl 请求里的 %v 不一致", got, want)
}
}
// TestGetUploadURL_DistinguishesMissingAddressFromSessionRejected 钉住两类失败的分野:
// 键缺失是协议字段对不上(ErrNoUploadURL,不改变凭据状态),errcode=-14 是凭据被拒
// (ErrSessionRejected,必须记进被拒状态、要求重新扫码)。
func TestGetUploadURL_DistinguishesMissingAddressFromSessionRejected(t *testing.T) {
t.Parallel()
req := uploadRequest{peer: "u-1", mediaType: uploadMediaFile, aesKey: make([]byte, 16)}
t.Run("address missing", func(t *testing.T) {
t.Parallel()
srv := mediaSendServer(t, &reqRecorder{}, handlerResult{body: jsonBody(map[string]any{})})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv)
_, err := ad.getUploadURL(context.Background(), req)
if !errors.Is(err, ErrNoUploadURL) {
t.Fatalf("err = %v, want ErrNoUploadURL", err)
}
if errors.Is(err, ErrSessionRejected) {
t.Fatal("键缺失被误判成凭据被拒")
}
if required, _ := ad.RescanRequired(); required {
t.Fatal("键缺失不该要求重新扫码")
}
})
t.Run("session rejected", func(t *testing.T) {
t.Parallel()
srv := mediaSendServer(t, &reqRecorder{}, handlerResult{body: jsonBody(map[string]any{"errcode": errcodeSessionRejected})})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv)
_, err := ad.getUploadURL(context.Background(), req)
if !errors.Is(err, ErrSessionRejected) {
t.Fatalf("err = %v, want ErrSessionRejected", err)
}
if errors.Is(err, ErrNoUploadURL) {
t.Fatal("凭据被拒被降级成「没返回地址」")
}
if required, _ := ad.RescanRequired(); !required {
t.Fatal("errcode=-14 必须记进被拒状态")
}
})
}