package main import ( "bytes" "context" "crypto/tls" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "sync/atomic" "time" ) type apiClient struct { baseURL string apiKey string tenant string client *http.Client seq atomic.Uint64 } type apiEnvelope struct { Code int `json:"code"` Data json.RawMessage `json:"data"` Reason string `json:"reason"` Message string `json:"message"` RequestID string `json:"request_id"` } type apiError struct { Status int Code int Reason string Message string } func (e *apiError) Error() string { return fmt.Sprintf("HTTP %d code=%d reason=%s message=%s", e.Status, e.Code, e.Reason, e.Message) } func newAPIClient(cfg *Config, apiKey string) *apiClient { transport := http.DefaultTransport.(*http.Transport).Clone() if cfg.InsecureTLS { transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec G402: explicit load-test option. } return &apiClient{ baseURL: strings.TrimRight(cfg.HTTPBase, "/"), apiKey: apiKey, tenant: cfg.Tenant, client: &http.Client{Timeout: 2 * time.Minute, Transport: transport}, } } func (a *apiClient) do(ctx context.Context, method, path, token, idempotencyKey string, body any, output any) error { var reader io.Reader if body != nil { raw, err := json.Marshal(body) if err != nil { return err } reader = bytes.NewReader(raw) } req, err := http.NewRequestWithContext(ctx, method, a.baseURL+path, reader) if err != nil { return err } req.Header.Set("X-MeetingIM-APIKey", a.apiKey) req.Header.Set("X-Tenant-Code", a.tenant) req.Header.Set("X-IM-Schema", "1") req.Header.Set("X-Request-ID", fmt.Sprintf("loadtest-%d-%d", time.Now().UnixNano(), a.seq.Add(1))) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } if idempotencyKey != "" { req.Header.Set("Idempotency-Key", idempotencyKey) } if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := a.client.Do(req) if err != nil { return err } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) if err != nil { return err } var envelope apiEnvelope if err = json.Unmarshal(raw, &envelope); err != nil { return fmt.Errorf("decode HTTP %d response: %w", resp.StatusCode, err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 || envelope.Code != 0 { return &apiError{Status: resp.StatusCode, Code: envelope.Code, Reason: envelope.Reason, Message: envelope.Message} } if output != nil && len(envelope.Data) > 0 { if err = json.Unmarshal(envelope.Data, output); err != nil { return fmt.Errorf("decode response data: %w", err) } } return nil } type privacyResponse struct { FriendRequestVerification bool `json:"friend_request_verification"` GroupInviteVerification bool `json:"group_invite_verification"` Version int `json:"version"` } type registrationCredential struct { ChallengeID string VerificationToken string } func (a *apiClient) createRegistrationCredential(ctx context.Context, account AccountRecord, code string) (*registrationCredential, error) { if account.Phone == "" { return nil, nil } var challenge struct { ChallengeID string `json:"challenge_id"` } key := fmt.Sprintf("loadtest-verification-%s-%d", account.Username, time.Now().UnixNano()) body := map[string]any{"scene": "register", "registration_type": 0, "tenant_code": a.tenant, "phone": account.Phone} if err := a.do(ctx, http.MethodPost, "/v1/im/auth/verification-codes", "", key, body, &challenge); err != nil { return nil, fmt.Errorf("create verification for %s: %w", account.Username, err) } if challenge.ChallengeID == "" { return nil, fmt.Errorf("create verification for %s: missing challenge_id", account.Username) } var verified struct { VerificationToken string `json:"verification_token"` } key = fmt.Sprintf("loadtest-verify-%s-%d", account.Username, time.Now().UnixNano()) path := "/v1/im/auth/verification-codes/" + url.PathEscape(challenge.ChallengeID) + "/verify" if err := a.do(ctx, http.MethodPost, path, "", key, map[string]any{"code": code}, &verified); err != nil { return nil, fmt.Errorf("verify code for %s: %w", account.Username, err) } if verified.VerificationToken == "" { return nil, fmt.Errorf("verify code for %s: missing verification_token", account.Username) } return ®istrationCredential{ChallengeID: challenge.ChallengeID, VerificationToken: verified.VerificationToken}, nil } func (a *apiClient) disableGroupInviteVerification(ctx context.Context, account AccountRecord) error { var privacy privacyResponse if err := a.do(ctx, http.MethodGet, "/v1/im/me/privacy", account.Token, "", nil, &privacy); err != nil { return err } if !privacy.GroupInviteVerification { return nil } body := map[string]any{"group_invite_verification": false, "version": privacy.Version} return a.do(ctx, http.MethodPatch, "/v1/im/me/privacy", account.Token, "loadtest-privacy-"+account.Username, body, &privacy) } type friendRequestResponse struct { RequestID string `json:"request_id"` Status string `json:"status"` Topic string `json:"topic"` } type friendRequestListResponse struct { Items []struct { RequestID string `json:"request_id"` ApplicantID string `json:"applicant_id"` Status string `json:"status"` } `json:"items"` NextCursor string `json:"next_cursor"` } func (a *apiClient) findPendingFriendRequest(ctx context.Context, target AccountRecord, applicantID string) (string, error) { cursor := "" for { path := "/v1/im/friend-requests?direction=inbound&status=pending&limit=100" if cursor != "" { path += "&cursor=" + url.QueryEscape(cursor) } var page friendRequestListResponse if err := a.do(ctx, http.MethodGet, path, target.Token, "", nil, &page); err != nil { return "", err } for _, item := range page.Items { if item.ApplicantID == applicantID && item.Status == "pending" { return item.RequestID, nil } } if page.NextCursor == "" || page.NextCursor == cursor { return "", nil } cursor = page.NextCursor } } func (a *apiClient) createFriendRequest(ctx context.Context, source, target AccountRecord) (*friendRequestResponse, error) { var result friendRequestResponse body := map[string]any{"target_user_id": target.UserID, "message": "loadtest", "source": "search", "source_topic": ""} idempotencyKey := fmt.Sprintf("loadtest-friend-%d-%s-%04d", time.Now().UnixNano(), source.Machine, source.Index) err := a.do(ctx, http.MethodPost, "/v1/im/friend-requests", source.Token, idempotencyKey, body, &result) return &result, err } func (a *apiClient) acceptFriendRequest(ctx context.Context, target AccountRecord, requestID string) (*friendRequestResponse, error) { var result friendRequestResponse idempotencyKey := fmt.Sprintf("loadtest-friend-accept-%d", time.Now().UnixNano()) err := a.do(ctx, http.MethodPost, "/v1/im/friend-requests/"+url.PathEscape(requestID)+"/accept", target.Token, idempotencyKey, map[string]any{}, &result) return &result, err } type p2pResponse struct { Topic string `json:"topic"` Relationship string `json:"relationship"` } func (a *apiClient) ensureP2P(ctx context.Context, source, target AccountRecord) (*p2pResponse, error) { var result p2pResponse // EnsureP2P must execute on every preflight so it can repair subscriptions // and refresh a loaded topic. Use a fresh key instead of replaying an old // successful response from a previous load-test run. idempotencyKey := fmt.Sprintf("loadtest-p2p-%d-%s-%04d", time.Now().UnixNano(), source.Machine, source.Index) err := a.do(ctx, http.MethodPost, "/v1/im/conversations/p2p", source.Token, idempotencyKey, map[string]any{"peer_user_id": target.UserID}, &result) return &result, err } type groupCreateResponse struct { Topic string `json:"topic"` MemberCount int `json:"member_count"` } func (a *apiClient) createGroup(ctx context.Context, owner AccountRecord) (*groupCreateResponse, error) { var result groupCreateResponse body := map[string]any{"name": "IM 006 Load Test", "description": "im-pressure", "member_user_ids": []string{}} err := a.do(ctx, http.MethodPost, "/v1/im/groups", owner.Token, "loadtest-group-create", body, &result) return &result, err } type groupInvitationItem struct { UserID string `json:"user_id"` Status string `json:"status"` Reason string `json:"reason"` } type groupInvitationResponse struct { Topic string `json:"topic"` Items []groupInvitationItem `json:"items"` } func (a *apiClient) inviteGroup(ctx context.Context, owner AccountRecord, topic string, batch int, userIDs []string) (*groupInvitationResponse, error) { var result groupInvitationResponse membersHash := topicHash(strings.Join(userIDs, ",")) err := a.do(ctx, http.MethodPost, "/v1/im/groups/"+url.PathEscape(topic)+"/invitations", owner.Token, fmt.Sprintf("loadtest-group-%s-invite-%03d-%s", topicHash(topic), batch, membersHash), map[string]any{"user_ids": userIDs, "message": "loadtest"}, &result) return &result, err } type groupMembersResponse struct { Items []struct { UserID string `json:"user_id"` } `json:"items"` NextCursor string `json:"next_cursor"` HasMore bool `json:"has_more"` } func (a *apiClient) countGroupMembers(ctx context.Context, account AccountRecord, topic string) (int, error) { members, err := a.groupMemberIDs(ctx, account, topic) return len(members), err } func (a *apiClient) groupMemberIDs(ctx context.Context, account AccountRecord, topic string) (map[string]bool, error) { cursor := "" seen := make(map[string]bool) for { path := "/v1/im/groups/" + url.PathEscape(topic) + "/members?limit=100" if cursor != "" { path += "&cursor=" + url.QueryEscape(cursor) } var page groupMembersResponse if err := a.do(ctx, http.MethodGet, path, account.Token, "", nil, &page); err != nil { return nil, err } for _, item := range page.Items { seen[item.UserID] = true } if page.NextCursor == "" || (!page.HasMore && len(page.Items) < 100) { break } if page.NextCursor == cursor { return nil, fmt.Errorf("group member cursor did not advance: %s", cursor) } cursor = page.NextCursor } return seen, nil } func queryInt(value string, fallback int) int { n, err := strconv.Atoi(value) if err != nil { return fallback } return n }