298 lines
9.6 KiB
Go
298 lines
9.6 KiB
Go
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": ""}
|
|
err := a.do(ctx, http.MethodPost, "/v1/im/friend-requests", source.Token,
|
|
fmt.Sprintf("loadtest-friend-%04d", source.Index), body, &result)
|
|
return &result, err
|
|
}
|
|
|
|
func (a *apiClient) acceptFriendRequest(ctx context.Context, target AccountRecord, requestID string) (*friendRequestResponse, error) {
|
|
var result friendRequestResponse
|
|
err := a.do(ctx, http.MethodPost, "/v1/im/friend-requests/"+url.PathEscape(requestID)+"/accept", target.Token,
|
|
"loadtest-friend-accept-"+requestID, 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
|
|
err := a.do(ctx, http.MethodPost, "/v1/im/conversations/p2p", source.Token,
|
|
fmt.Sprintf("loadtest-p2p-%s-%04d", source.Machine, source.Index),
|
|
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) {
|
|
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 0, 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 0, fmt.Errorf("group member cursor did not advance: %s", cursor)
|
|
}
|
|
cursor = page.NextCursor
|
|
}
|
|
return len(seen), nil
|
|
}
|
|
|
|
func queryInt(value string, fallback int) int {
|
|
n, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return n
|
|
}
|