fix(httpclient): 信封 message 默认不进错误,渠道上游自由文本一并收口
信封路径(非 2xx + 信封体、2xx + code!=0)此前把上游 message 经 RedactText 拼进 error,而上游会在 message 里回显被拒的请求正文 —— 她的原话因此进了错误 与调用方日志(M49 探针实测:code 1: invalid content: 她刚才说的那句话 ...)。 - envelope message 改为**默认不带**(新 opt-in WithEnvelopeMessage(),与 trace.UpstreamError 的 message 同一口径);code 与上游 status 保留, errors.Is 的 ErrAPI / trace.ErrUpstream 链不变。 - opt-in 下统一走单一清洗 upstreamText:压平空白 + trace.RedactTextLimited, 上限 40 rune(比 trace 的 128 短:错误里那句话是提示,不是摘要)。 - plugins/channels 同族出口一并收口(扫过全部,命中 4 处): telegram description、qq_obv11 msg(错误 + logEchoFailure 日志)、 weixin_ilink CDN x-error-message;qq 的 status 字段改走 trace.ErrorToken 令牌守卫。被剥掉的一律由 status / error_code / retcode 承担分类。 每条新增/改写用例都做过变异验证(把字段拼回去即变红)。
This commit is contained in:
1 parent
3a0cc64160
commit
996201c4ed
11 files changed
+432
-127
No files matched your search
@@ -21,6 +21,8 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -155,12 +157,27 @@ func (c *obHTTPClient) post(ctx context.Context, payload []byte) (*obAPIResponse
|
||||
return nil, fmt.Errorf("qq-obv11: decode http %s response: %w", action, err)
|
||||
}
|
||||
if env.RetCode != 0 || (env.Status != "" && !strings.EqualFold(env.Status, "ok")) {
|
||||
return nil, fmt.Errorf("qq-obv11: http %s failed: retcode=%d status=%q msg=%q",
|
||||
action, env.RetCode, env.Status, env.Msg)
|
||||
return nil, fmt.Errorf("qq-obv11: http %s failed: retcode=%d%s",
|
||||
action, env.RetCode, upstreamStatusSuffix(env.Status))
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// upstreamStatusSuffix 渲染 OneBot 信封的 status(协议字段,取值 `ok` / `failed`):它
|
||||
// 过 trace.ErrorToken 的令牌守卫后才出镜,因此上游若在 status 里塞散文,那一段整体
|
||||
// 消失(与 trace.UpstreamError 对 code/type 的守卫同一口径)。
|
||||
//
|
||||
// 🔴 上游的 `msg` / `wording` 是**自由文本面**,一律不进错误也不进日志:NapCat /
|
||||
// go-cqhttp 会把失败的请求内容回显在 msg 里,而请求里就有她要发的话(M50 口径 ——
|
||||
// 默认安全,要带必须显式 opt-in,本适配器不开)。排障真正用得到的是 retcode 与
|
||||
// status,两者恒在。
|
||||
func upstreamStatusSuffix(status string) string {
|
||||
if tok := trace.ErrorToken(status); tok != "" {
|
||||
return fmt.Sprintf(" status=%q", tok)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// actionOf 从已编码的 action 请求里取出 action 名,仅用于日志与错误上下文。
|
||||
func actionOf(payload []byte) string {
|
||||
var head struct {
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -155,7 +156,9 @@ func (a *Adapter) logSendReceipt(ctx context.Context, receipt adapter.SendReceip
|
||||
//
|
||||
// 回执报错**不**改成返回 error:帧已经写出去了,把「已投递」报成失败会让调用方重发。
|
||||
// 但**绝不静默** —— 观察到了一个「发送失败」却一声不吭,正是「数据看到了又丢掉」的
|
||||
// 老毛病(本切片要修的就是它)。只记 retcode / status / msg,不记正文。
|
||||
// 老毛病(本切片要修的就是它)。只记 retcode 与 status(status 过 trace.ErrorToken 的
|
||||
// 令牌守卫):上游的 msg / wording 是自由文本面,会把失败的请求内容回显出来,所以
|
||||
// **不记**(M50 口径,与 http_client.post 的错误文本同一道边界)。
|
||||
func (a *Adapter) logEchoFailure(resp *obAPIResponse) {
|
||||
if resp == nil {
|
||||
return
|
||||
@@ -164,5 +167,5 @@ func (a *Adapter) logEchoFailure(resp *obAPIResponse) {
|
||||
return
|
||||
}
|
||||
a.log.Warn("send_msg echo reports failure",
|
||||
"retcode", resp.RetCode, "status", resp.Status, "msg", resp.Msg)
|
||||
"retcode", resp.RetCode, "status", trace.ErrorToken(resp.Status))
|
||||
}
|
||||
@@ -181,8 +181,8 @@ func parseStatusResponse(resp *obAPIResponse) (obStatusData, error) {
|
||||
return obStatusData{}, errors.New("qq-obv11: get_status response: empty envelope")
|
||||
}
|
||||
if resp.RetCode != 0 || (resp.Status != "" && !strings.EqualFold(resp.Status, "ok")) {
|
||||
return obStatusData{}, fmt.Errorf("qq-obv11: get_status response: retcode=%d status=%q",
|
||||
resp.RetCode, resp.Status)
|
||||
return obStatusData{}, fmt.Errorf("qq-obv11: get_status response: retcode=%d%s",
|
||||
resp.RetCode, upstreamStatusSuffix(resp.Status))
|
||||
}
|
||||
var data *obStatusData
|
||||
if err := json.Unmarshal(resp.Data, &data); err != nil {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package qq_obv11
|
||||
|
||||
// 本文件钉住 OneBot 信封里**自由文本面**的出镜边界(M50,与 httpclient 的信封
|
||||
// message、Telegram 的 description、iLink 的 x-error-message 同一族):
|
||||
//
|
||||
// - 失败时上游的 `msg` / `wording` **不进错误文本**:NapCat / go-cqhttp 会把失败的
|
||||
// 请求内容回显在 msg 里,而那些内容里就有她要发的话。留下的是 retcode 与 status。
|
||||
// - status 是**协议字段**,但同样只按令牌出镜(trace.ErrorToken):上游若在 status
|
||||
// 里塞散文,那一段整体消失。
|
||||
// - 回执失败的 WARN 日志(logEchoFailure)走同一道边界 —— 否则同一个洞只是换个出口。
|
||||
//
|
||||
// 每条都是「把字段拼回去就变红」的形状,断言的是**错误文本与日志行**。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newFailingOneBotAPI 起一个总是回「失败信封」的假 OneBot,body 由调用方给。
|
||||
func newFailingOneBotAPI(t *testing.T, body map[string]any) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// TestHTTPClientDropsUpstreamMsg 失败信封里的 msg / wording 带她的原话 ⇒ 错误与日志里
|
||||
// 都搜不到,而 retcode 与 status 仍在(变异的两个方向各钉一条)。
|
||||
func TestHTTPClientDropsUpstreamMsg(t *testing.T) {
|
||||
t.Parallel()
|
||||
const sentinel = "她刚才说的那句话"
|
||||
srv := newFailingOneBotAPI(t, map[string]any{
|
||||
"status": "failed",
|
||||
"retcode": 1404,
|
||||
"msg": "SEND_MSG_API_ERROR: invalid content: " + sentinel,
|
||||
"wording": "参数错误:" + sentinel,
|
||||
"data": nil,
|
||||
})
|
||||
|
||||
c, err := newOBHTTPClient(srv.URL, "tok")
|
||||
if err != nil {
|
||||
t.Fatalf("newOBHTTPClient: %v", err)
|
||||
}
|
||||
payload, err := buildActionReq("send_msg", map[string]any{"message": sentinel}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildActionReq: %v", err)
|
||||
}
|
||||
_, err = c.post(context.Background(), payload)
|
||||
if err == nil {
|
||||
t.Fatal("retcode=1404 却报成功")
|
||||
}
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("onebot action failed", "err", err)
|
||||
for _, surface := range []string{err.Error(), logBuf.String()} {
|
||||
if strings.Contains(surface, sentinel) {
|
||||
t.Fatalf("上游 msg / wording 进了错误或日志:%s", surface)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(err.Error(), "retcode=1404") {
|
||||
t.Fatalf("retcode 丢了(唯一可靠的分类信息):%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), `status="failed"`) {
|
||||
t.Fatalf("status 丢了(它是协议字段,应当仍出镜):%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPClientDropsProseStatus 上游若把散文塞进 status,令牌守卫让它整体消失 ——
|
||||
// status 不是第二条正文通道。
|
||||
func TestHTTPClientDropsProseStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := newFailingOneBotAPI(t, map[string]any{
|
||||
"status": "failed: 她刚才说的那句话",
|
||||
"retcode": 1404,
|
||||
"data": nil,
|
||||
})
|
||||
|
||||
c, err := newOBHTTPClient(srv.URL, "tok")
|
||||
if err != nil {
|
||||
t.Fatalf("newOBHTTPClient: %v", err)
|
||||
}
|
||||
payload, err := buildActionReq("send_msg", map[string]any{"message": "hi"}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildActionReq: %v", err)
|
||||
}
|
||||
_, err = c.post(context.Background(), payload)
|
||||
if err == nil {
|
||||
t.Fatal("retcode=1404 却报成功")
|
||||
}
|
||||
if strings.Contains(err.Error(), "她刚才说的那句话") || strings.Contains(err.Error(), "status=") {
|
||||
t.Fatalf("散文 status 出镜了(应被令牌守卫丢弃):%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "retcode=1404") {
|
||||
t.Fatalf("retcode 丢了:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogEchoFailureDropsUpstreamMsg 回执失败的 WARN 是**另一个出口**:它同样只记
|
||||
// retcode 与 status,不记上游 msg(同一个洞换个出口的形态)。
|
||||
func TestLogEchoFailureDropsUpstreamMsg(t *testing.T) {
|
||||
t.Parallel()
|
||||
const sentinel = "她刚才说的那句话"
|
||||
var buf bytes.Buffer
|
||||
a := NewAdapter(Config{}, WithLogger(slog.New(slog.NewJSONHandler(&buf, nil))))
|
||||
|
||||
a.logEchoFailure(&obAPIResponse{
|
||||
Status: "failed",
|
||||
RetCode: 1404,
|
||||
Msg: "SEND_MSG_API_ERROR: invalid content: " + sentinel,
|
||||
Wording: "参数错误:" + sentinel,
|
||||
})
|
||||
line := buf.String()
|
||||
if line == "" {
|
||||
t.Fatal("回执失败一条日志都没有(静默即缺陷)")
|
||||
}
|
||||
if strings.Contains(line, sentinel) {
|
||||
t.Fatalf("上游 msg 进了日志:%s", line)
|
||||
}
|
||||
if !strings.Contains(line, `"retcode":1404`) || !strings.Contains(line, `"status":"failed"`) {
|
||||
t.Fatalf("日志里没有 retcode/status(诊断面):%s", line)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user