返回 DeepSeek-Reasonix
approval_delivery_test.go
根目录 / internal / bot / qq / approval_delivery_test.go
1 package qq
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "log/slog"
10 "net/http"
11 "slices"
12 "strings"
13 "testing"
14 "time"
15
16 "reasonix/internal/bot"
17 "reasonix/internal/config"
18 )
19
20 type keyboardSendRequest struct {
21 MsgType int `json:"msg_type"`
22 Content string `json:"content"`
23 MsgID string `json:"msg_id"`
24 MsgSeq int `json:"msg_seq"`
25 Markdown *struct {
26 Content string `json:"content"`
27 } `json:"markdown"`
28 Keyboard *struct {
29 Content struct {
30 Rows []struct {
31 Buttons []struct {
32 ID string `json:"id"`
33 RenderData struct {
34 Label string `json:"label"`
35 Style int `json:"style"`
36 } `json:"render_data"`
37 Action struct {
38 Type int `json:"type"`
39 Data string `json:"data"`
40 Enter bool `json:"enter"`
41 Permission *struct {
42 Type int `json:"type"`
43 } `json:"permission"`
44 } `json:"action"`
45 } `json:"buttons"`
46 } `json:"rows"`
47 } `json:"content"`
48 } `json:"keyboard"`
49 }
50
51 func keyboardTestAdapter(t *testing.T, send func(*http.Request) (*http.Response, error)) *adapter {
52 t.Helper()
53 original := qqHTTPClient
54 qqHTTPClient = &http.Client{Transport: roundTripFunc(send)}
55 t.Cleanup(func() { qqHTTPClient = original })
56 return &adapter{
57 cfg: config.QQBotConfig{AppID: "app-id"},
58 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
59 token: "test-token",
60 tokenExpiry: time.Now().Add(time.Hour),
61 }
62 }
63
64 func keyboardTestMessage(chatType bot.ChatType) bot.OutboundMessage {
65 return bot.OutboundMessage{
66 ChatType: chatType, ChatID: "chat-1", ReplyToMsgID: "incoming-1",
67 Text: "需要批准操作:echo hello\n回复 1 批准,2 拒绝;/approve approval-1 或 /deny approval-1。",
68 Keyboard: &bot.InlineKeyboard{Rows: []bot.InlineKeyboardRow{
69 {Buttons: []bot.InlineKeyboardButton{{ID: " allow_once ", Label: "允许一次", Style: 1, CallbackID: "/approve approval-1"}}},
70 {Buttons: []bot.InlineKeyboardButton{{ID: "deny", Label: "拒绝", Style: 2, CallbackID: "/deny approval-1"}}},
71 }},
72 }
73 }
74
75 func TestSendApprovalKeyboardMatchesQQProtocol(t *testing.T) {
76 for _, chatType := range []bot.ChatType{bot.ChatDM, bot.ChatGroup} {
77 t.Run(string(chatType), func(t *testing.T) {
78 msg := keyboardTestMessage(chatType)
79 var body keyboardSendRequest
80 a := keyboardTestAdapter(t, func(req *http.Request) (*http.Response, error) {
81 if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
82 t.Fatalf("decode QQ request: %v", err)
83 }
84 return jsonResponse(http.StatusOK, map[string]string{"id": "approval-sent"}), nil
85 })
86 result, err := a.Send(context.Background(), msg)
87 if err != nil || result.MessageID != "approval-sent" {
88 t.Fatalf("Send() = %#v, %v", result, err)
89 }
90 if body.MsgType != 2 || body.Content != "" || body.Markdown == nil || body.Markdown.Content != msg.Text {
91 t.Fatalf("expected Markdown body without top-level content: %#v", body)
92 }
93 if body.MsgID != msg.ReplyToMsgID || body.MsgSeq != 1 {
94 t.Fatalf("reply identity = %q/%d", body.MsgID, body.MsgSeq)
95 }
96 if body.Keyboard == nil || len(body.Keyboard.Content.Rows) != 2 {
97 t.Fatalf("expected keyboard.content.rows: %#v", body.Keyboard)
98 }
99 for i, want := range []struct {
100 id, label, command string
101 style int
102 }{{"allow_once", "允许一次", "/approve approval-1", 1}, {"deny", "拒绝", "/deny approval-1", 3}} {
103 buttons := body.Keyboard.Content.Rows[i].Buttons
104 if len(buttons) != 1 {
105 t.Fatalf("row %d has %d buttons", i, len(buttons))
106 }
107 button := buttons[0]
108 if button.ID != want.id || button.RenderData.Label != want.label || button.RenderData.Style != want.style {
109 t.Fatalf("button %d rendering = %#v", i, button)
110 }
111 if button.Action.Type != 2 || button.Action.Data != want.command || button.Action.Permission == nil || button.Action.Permission.Type != 2 {
112 t.Fatalf("button %d command action = %#v", i, button.Action)
113 }
114 if button.Action.Enter != (chatType == bot.ChatDM) {
115 t.Fatalf("button %d enter = %v for %s", i, button.Action.Enter, chatType)
116 }
117 }
118 })
119 }
120 }
121
122 func TestSendApprovalKeyboardFailureFallsBackWithoutDisablingMarkdown(t *testing.T) {
123 var bodies []keyboardSendRequest
124 a := keyboardTestAdapter(t, func(req *http.Request) (*http.Response, error) {
125 var body keyboardSendRequest
126 if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
127 t.Fatal(err)
128 }
129 bodies = append(bodies, body)
130 if len(bodies) == 1 {
131 return jsonResponse(http.StatusInternalServerError, map[string]any{"message": "系统繁忙,请稍后重试", "code": 50015001}), nil
132 }
133 return jsonResponse(http.StatusOK, map[string]string{"id": fmt.Sprintf("sent-%d", len(bodies))}), nil
134 })
135 msg := keyboardTestMessage(bot.ChatDM)
136 result, err := a.Send(context.Background(), msg)
137 if err != nil || result.MessageID != "sent-2" {
138 t.Fatalf("approval Send() = %#v, %v", result, err)
139 }
140 msg.Keyboard = nil
141 if _, err := a.Send(context.Background(), msg); err != nil {
142 t.Fatalf("ordinary Send(): %v", err)
143 }
144 if len(bodies) != 3 {
145 t.Fatalf("requests = %d, want rich approval, text fallback, ordinary Markdown", len(bodies))
146 }
147 plain := bodies[1]
148 if plain.MsgType != 0 || plain.Content != msg.Text || plain.Keyboard != nil || plain.Markdown != nil {
149 t.Fatalf("fallback lost approval instructions or retained rich fields: %#v", plain)
150 }
151 if plain.MsgID != msg.ReplyToMsgID || plain.MsgSeq != 2 || bodies[2].MsgSeq != 3 || bodies[2].Markdown == nil {
152 t.Fatalf("fallback reply identity or later Markdown delivery = %#v", bodies)
153 }
154 }
155
156 func TestSendApprovalKeyboardFallbackPreservesDeliveredChunks(t *testing.T) {
157 var sequences []int
158 a := keyboardTestAdapter(t, func(req *http.Request) (*http.Response, error) {
159 var body keyboardSendRequest
160 if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
161 t.Fatal(err)
162 }
163 sequences = append(sequences, body.MsgSeq)
164 if len(sequences) == 2 {
165 return jsonResponse(http.StatusInternalServerError, map[string]int{"code": 50015001}), nil
166 }
167 return jsonResponse(http.StatusOK, map[string]string{"id": fmt.Sprintf("sent-%d", len(sequences))}), nil
168 })
169 msg := keyboardTestMessage(bot.ChatDM)
170 msg.Text = strings.Repeat("a", qqMaxChunkBytes) + "\n/approve approval-1 or /deny approval-1"
171 result, err := a.Send(context.Background(), msg)
172 if err != nil {
173 t.Fatalf("Send(): %v", err)
174 }
175 if !slices.Equal(sequences, []int{1, 2, 3}) || !slices.Equal(result.DeliveredMessageIDs(), []string{"sent-1", "sent-3"}) {
176 t.Fatalf("sequences = %v, delivered = %v", sequences, result.DeliveredMessageIDs())
177 }
178 }
179
180 func TestSendApprovalKeyboardFallbackFailureReturnsError(t *testing.T) {
181 calls := 0
182 a := keyboardTestAdapter(t, func(req *http.Request) (*http.Response, error) {
183 calls++
184 return jsonResponse(http.StatusInternalServerError, map[string]int{"code": 50015001}), nil
185 })
186 result, err := a.Send(context.Background(), keyboardTestMessage(bot.ChatDM))
187 if err == nil || calls != 2 || len(result.DeliveredMessageIDs()) != 0 {
188 t.Fatalf("Send() = %#v, %v after %d requests", result, err, calls)
189 }
190 }
191
192 func TestSendApprovalKeyboardCancellationDoesNotSendFallback(t *testing.T) {
193 ctx, cancel := context.WithCancel(context.Background())
194 defer cancel()
195 calls := 0
196 a := keyboardTestAdapter(t, func(req *http.Request) (*http.Response, error) {
197 calls++
198 cancel()
199 return nil, context.Canceled
200 })
201 _, err := a.Send(ctx, keyboardTestMessage(bot.ChatDM))
202 if !errors.Is(err, context.Canceled) || calls != 1 {
203 t.Fatalf("Send() error = %v after %d requests", err, calls)
204 }
205 }
206
206 lines GO