package backend import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" ) var ErrNotFound = errors.New("not found") type Config struct { BaseURL string `envconfig:"BACKEND__BASE_URL" default:"http_v1://localhost:8000"` LoginURL string `envconfig:"LOGIN_URL" required:"true"` } type Client struct { http *http.Client baseURL string loginURL string } func New(cfg Config) *Client { return &Client{ http: &http.Client{ Timeout: 5 * time.Second, }, baseURL: cfg.BaseURL, loginURL: cfg.LoginURL, } } func withBearer(token string) func(*http.Request) { return func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+token) } } func (c *Client) do(ctx context.Context, method string, path string, in any, out any, opts ...func(*http.Request)) error { var body io.Reader if in != nil { b, err := json.Marshal(in) if err != nil { return fmt.Errorf("json.Marshal: %w", err) } body = bytes.NewReader(b) } req, err := http.NewRequestWithContext(ctx, method, c.baseURL+"/"+path, body) if err != nil { return fmt.Errorf("http_v1.NewRequest: %w", err) } if in != nil { req.Header.Set("Content-Type", "application/json") } for _, opt := range opts { opt(req) } resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("client.Do: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return ErrNotFound } if resp.StatusCode < 200 || resp.StatusCode >= 300 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) return fmt.Errorf("request failed: %s: %s", resp.Status, b) } if out != nil { if err := json.NewDecoder(resp.Body).Decode(out); err != nil { return fmt.Errorf("json.Decode: %w", err) } } return nil } func (c *Client) LoginURL(token string) string { return c.loginURL + token } func (c *Client) CreateLoginToken(ctx context.Context, telegramID int64) (string, error) { req := struct { TelegramID int64 `json:"telegram_id"` }{ TelegramID: telegramID, } var token string err := c.do(ctx, http.MethodPost, "api/v1/internal/auth/login-token", req, &token) return token, err } func (c *Client) AttachLoginTokenMessage(ctx context.Context, token string, messageID int) error { req := struct { Token string `json:"token"` MessageID int `json:"message_id"` }{ Token: token, MessageID: messageID, } return c.do(ctx, http.MethodPost, "api/v1/internal/auth/login-token/message", req, nil) } func (c *Client) GetJWTByTelegramID( ctx context.Context, telegramID int64, ) (string, error) { q := url.Values{} q.Set("telegram_id", fmt.Sprint(telegramID)) path := "api/v1/internal/auth/jwt?" + q.Encode() var resp struct { AccessToken string `json:"access_token"` } err := c.do(ctx, http.MethodGet, path, nil, &resp) return resp.AccessToken, err } func (c *Client) GetJWTByTelegramUser( ctx context.Context, telegramID int64, username *string, firstName *string, lastName *string, ) (string, error) { q := url.Values{} q.Set("telegram_id", fmt.Sprint(telegramID)) if username != nil && *username != "" { q.Set("username", *username) } if firstName != nil && *firstName != "" { q.Set("first_name", *firstName) } if lastName != nil && *lastName != "" { q.Set("last_name", *lastName) } path := "api/v1/internal/auth/jwt?" + q.Encode() var resp struct { AccessToken string `json:"access_token"` } err := c.do(ctx, http.MethodGet, path, nil, &resp) return resp.AccessToken, err } type Workspace struct { ID string `json:"id"` Name string `json:"name"` } type Project struct { ID string `json:"id"` TelegramID int64 `json:"telegram_id"` Title string `json:"title"` Username *string `json:"username"` Status string `json:"status"` PurchaseInviteTypeDefault string `json:"purchase_invite_type_default"` } type Page struct { Items []Project `json:"items"` Total int `json:"total"` Page int `json:"page"` Size int `json:"size"` Pages int `json:"pages"` } func (c *Client) GetWorkspaces( ctx context.Context, jwt string, ) ([]Workspace, error) { var resp struct { Items []Workspace `json:"items"` } err := c.do( ctx, http.MethodGet, "api/v1/workspaces", nil, &resp, withBearer(jwt), ) return resp.Items, err } func (c *Client) CreateWorkspace(ctx context.Context, jwt string, name string) (Workspace, error) { req := struct { Name string `json:"name"` }{ Name: name, } var workspace Workspace err := c.do(ctx, http.MethodPost, "api/v1/workspaces", req, &workspace, withBearer(jwt)) return workspace, err } func (c *Client) GetProjects( ctx context.Context, jwt string, workspaceID string, page, size int, ) (*Page, error) { q := url.Values{} q.Set("page", fmt.Sprint(page)) q.Set("size", fmt.Sprint(size)) path := fmt.Sprintf("api/v1/workspaces/%s/projects?%s", workspaceID, q.Encode()) var resp Page err := c.do( ctx, http.MethodGet, path, nil, &resp, withBearer(jwt), ) return &resp, err } func (c *Client) GetProject( ctx context.Context, jwt string, workspaceID string, projectID string, ) (*Project, error) { path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s", workspaceID, projectID) var project Project err := c.do( ctx, http.MethodGet, path, nil, &project, withBearer(jwt), ) return &project, err } func (c *Client) UpdateProjectInviteLinkType( ctx context.Context, jwt string, workspaceID string, projectID string, inviteLinkType string, // "public" или "approval" ) (*Project, error) { path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/invite-link-type", workspaceID, projectID) payload := map[string]string{ "purchase_invite_type_default": inviteLinkType, } var project Project err := c.do( ctx, http.MethodPatch, path, payload, &project, withBearer(jwt), ) return &project, err } func (c *Client) SendEvent(ctx context.Context, payload any) error { return c.do( ctx, http.MethodPost, "api/v1/internal/events", payload, nil, ) } func (c *Client) SearchChannels( ctx context.Context, jwt string, username string, ) ([]Channel, error) { path := fmt.Sprintf("api/v1/channels?username=%s", username) var response struct { Items []Channel `json:"items"` } err := c.do( ctx, http.MethodGet, path, nil, &response, withBearer(jwt), ) return response.Items, err } func (c *Client) AttachChannelToWorkspace( ctx context.Context, channelID string, workspaceID string, userTelegramID int64, ) (*Project, error) { input := map[string]any{ "channel_id": channelID, "workspace_id": workspaceID, "user_telegram_id": userTelegramID, } var project Project err := c.do( ctx, http.MethodPost, "api/v1/internal/projects", input, &project, ) return &project, err } func (c *Client) AcceptWorkspaceInvite( ctx context.Context, jwt string, inviteID string, ) error { path := fmt.Sprintf("api/v1/invites/%s/accept", inviteID) return c.do( ctx, http.MethodPost, path, nil, nil, withBearer(jwt), ) } // ============================================================================ // Creatives // ============================================================================ type Creative struct { ID string `json:"id"` Name string `json:"name"` Text string `json:"text"` MediaType string `json:"media_type"` MediaFileID string `json:"media_file_id"` Buttons []CreativeButton `json:"buttons"` ProjectID string `json:"project_id"` ProjectChannelTitle string `json:"project_channel_title"` CreatedAt string `json:"created_at"` Status string `json:"status"` PlacementsCount int `json:"placements_count"` } type CreativeButton struct { Text string `json:"text"` URL string `json:"url"` } type CreativesPage struct { Items []Creative `json:"items"` Total int `json:"total"` Page int `json:"page"` Size int `json:"size"` Pages int `json:"pages"` } func (c *Client) GetCreatives( ctx context.Context, jwt string, workspaceID string, projectID *string, includeArchived bool, page, size int, ) (*CreativesPage, error) { q := url.Values{} if projectID != nil { q.Set("project_id", *projectID) } q.Set("include_archived", fmt.Sprint(includeArchived)) q.Set("page", fmt.Sprint(page)) q.Set("size", fmt.Sprint(size)) path := fmt.Sprintf("api/v1/workspaces/%s/creatives?%s", workspaceID, q.Encode()) var resp CreativesPage err := c.do( ctx, http.MethodGet, path, nil, &resp, withBearer(jwt), ) return &resp, err } func (c *Client) GetCreative( ctx context.Context, jwt string, workspaceID string, creativeID string, ) (*Creative, error) { path := fmt.Sprintf("api/v1/workspaces/%s/creatives/%s", workspaceID, creativeID) var creative Creative err := c.do( ctx, http.MethodGet, path, nil, &creative, withBearer(jwt), ) return &creative, err } type CreateCreativeInput struct { Name string `json:"name"` Text string `json:"text"` MediaType string `json:"media_type,omitempty"` MediaFileID string `json:"media_file_id,omitempty"` MediaData []byte `json:"media_data,omitempty"` Buttons []CreativeButton `json:"buttons,omitempty"` } func (c *Client) CreateCreative( ctx context.Context, jwt string, workspaceID string, projectID string, input CreateCreativeInput, ) (*Creative, error) { q := url.Values{} q.Set("project_id", projectID) path := fmt.Sprintf("api/v1/workspaces/%s/creatives?%s", workspaceID, q.Encode()) var creative Creative err := c.do( ctx, http.MethodPost, path, input, &creative, withBearer(jwt), ) return &creative, err } type UpdateCreativeInput struct { Name *string `json:"name,omitempty"` Text *string `json:"text,omitempty"` MediaType *string `json:"media_type,omitempty"` MediaFileID *string `json:"media_file_id,omitempty"` MediaData *[]byte `json:"media_data,omitempty"` Buttons *[]CreativeButton `json:"buttons,omitempty"` Status *string `json:"status,omitempty"` } func (c *Client) UpdateCreative( ctx context.Context, jwt string, workspaceID string, creativeID string, input UpdateCreativeInput, ) (*Creative, error) { path := fmt.Sprintf("api/v1/workspaces/%s/creatives/%s", workspaceID, creativeID) var creative Creative err := c.do( ctx, http.MethodPatch, path, input, &creative, withBearer(jwt), ) return &creative, err } func (c *Client) DeleteCreative( ctx context.Context, jwt string, workspaceID string, creativeID string, ) error { path := fmt.Sprintf("api/v1/workspaces/%s/creatives/%s", workspaceID, creativeID) return c.do( ctx, http.MethodDelete, path, nil, nil, withBearer(jwt), ) } // ============================================================================ // Placements (Размещения) // ============================================================================ type Channel struct { ID string `json:"id"` TelegramID *int64 `json:"telegram_id"` Title *string `json:"title"` Username *string `json:"username"` } type CostInfo struct { Type string `json:"type"` Value float64 `json:"value"` } type PlacementDetails struct { PlacementAt *string `json:"placement_at,omitempty"` PaymentAt *string `json:"payment_at,omitempty"` Cost *CostInfo `json:"cost,omitempty"` CostBeforeBargain *float64 `json:"cost_before_bargain,omitempty"` PlacementType *string `json:"placement_type,omitempty"` Format *string `json:"format,omitempty"` Comment *string `json:"comment,omitempty"` } type PlacementOutput struct { ID string `json:"id"` Status string `json:"status"` Comment *string `json:"comment,omitempty"` InviteLink string `json:"invite_link"` InviteLinkType string `json:"invite_link_type"` Channel Channel `json:"channel"` Details *PlacementDetails `json:"details,omitempty"` PlacementPosts []PlacementPostOutput `json:"placement_posts,omitempty"` } type PlacementPostOutput struct { ID string `json:"id"` PlacementID string `json:"placement_id"` Placement PlacementOutput `json:"placement"` AdPostURL *string `json:"ad_post_url,omitempty"` Status string `json:"status"` SubscriptionsCount int `json:"subscriptions_count"` CreatedAt string `json:"created_at"` } type CreatePlacementChannelInput struct { Username string `json:"username"` Status *string `json:"status,omitempty"` Comment *string `json:"comment,omitempty"` Details *PlacementDetails `json:"details,omitempty"` } type CreatePlacementsInput struct { CreativeID string `json:"creative_id"` Channels []CreatePlacementChannelInput `json:"channels"` Details *PlacementDetails `json:"details,omitempty"` } type GetPlacementsOutput struct { Placements []PlacementOutput `json:"placements"` } func (c *Client) CreatePlacements( ctx context.Context, jwt string, workspaceID string, projectID string, input CreatePlacementsInput, ) (*GetPlacementsOutput, error) { path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/placements", workspaceID, projectID) var placements GetPlacementsOutput err := c.do( ctx, http.MethodPost, path, input, &placements, withBearer(jwt), ) return &placements, err } func (c *Client) GetPlacements( ctx context.Context, jwt string, workspaceID string, projectID string, ) (*GetPlacementsOutput, error) { path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/placements", workspaceID, projectID) var resp GetPlacementsOutput err := c.do( ctx, http.MethodGet, path, nil, &resp, withBearer(jwt), ) return &resp, err } func (c *Client) GetPlacement( ctx context.Context, jwt string, workspaceID string, projectID string, placementID string, ) (*PlacementOutput, error) { path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/placements/%s", workspaceID, projectID, placementID) var placement PlacementOutput err := c.do( ctx, http.MethodGet, path, nil, &placement, withBearer(jwt), ) return &placement, err } // ============================================================================ // Workspace Members // ============================================================================ type WorkspaceMember struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` UserID string `json:"user_id"` Username string `json:"username"` } func (c *Client) GetWorkspaceMembers( ctx context.Context, jwt string, workspaceID string, ) ([]WorkspaceMember, error) { path := fmt.Sprintf("api/v1/workspaces/%s/members", workspaceID) var resp struct { Items []WorkspaceMember `json:"items"` } err := c.do( ctx, http.MethodGet, path, nil, &resp, withBearer(jwt), ) return resp.Items, err }