| 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 |