ActivityFetcher/internal/clash/client.go
2025-10-14 10:45:43 +02:00

216 lines
5.3 KiB
Go

package clash
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const defaultBaseURL = "https://api.clashofclans.com/v1"
// Client wraps the Clash of Clans REST API.
type Client struct {
token string
httpClient *http.Client
baseURL string
}
// MemberDetail combines clan and player information for reporting.
type MemberDetail struct {
Tag string
Name string
TownHallLevel int
League string
Trophies int
Donations int
DonationsReceived int
LastSeen time.Time
}
// NewClient constructs a Clash API client with the provided token.
func NewClient(token string, opts ...Option) *Client {
c := &Client{
token: token,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
baseURL: defaultBaseURL,
}
for _, opt := range opts {
opt(c)
}
return c
}
// Option customises the API client.
type Option func(*Client)
// WithHTTPClient injects a custom HTTP client.
func WithHTTPClient(httpClient *http.Client) Option {
return func(c *Client) {
if httpClient != nil {
c.httpClient = httpClient
}
}
}
// WithBaseURL overrides the default API base URL. Useful for testing.
func WithBaseURL(baseURL string) Option {
return func(c *Client) {
if baseURL != "" {
c.baseURL = strings.TrimRight(baseURL, "/")
}
}
}
// ClanMembers returns detailed information for current clan members.
func (c *Client) ClanMembers(ctx context.Context, clanTag string) ([]MemberDetail, error) {
clan, err := c.fetchClan(ctx, clanTag)
if err != nil {
return nil, err
}
if len(clan.MemberList) == 0 {
return nil, nil
}
members := make([]MemberDetail, 0, len(clan.MemberList))
for _, member := range clan.MemberList {
player, err := c.fetchPlayer(ctx, member.Tag)
if err != nil {
return nil, fmt.Errorf("fetch player %s: %w", member.Tag, err)
}
lastSeen, err := parseLastSeen(player.LastSeen)
if err != nil {
lastSeen = time.Time{}
}
members = append(members, MemberDetail{
Tag: member.Tag,
Name: member.Name,
TownHallLevel: player.TownHallLevel,
League: member.League.Name,
Trophies: member.Trophies,
Donations: member.Donations,
DonationsReceived: member.DonationsReceived,
LastSeen: lastSeen,
})
}
return members, nil
}
func (c *Client) fetchClan(ctx context.Context, clanTag string) (*clanResponse, error) {
escapedTag := url.PathEscape(strings.TrimSpace(clanTag))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/clans/%s", c.baseURL, escapedTag), nil)
if err != nil {
return nil, fmt.Errorf("build clan request: %w", err)
}
c.applyHeaders(req)
res, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("execute clan request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(res.Body, 2048))
return nil, fmt.Errorf("clan request failed: status=%d body=%s", res.StatusCode, string(body))
}
var payload clanResponse
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
return nil, fmt.Errorf("decode clan response: %w", err)
}
return &payload, nil
}
func (c *Client) fetchPlayer(ctx context.Context, playerTag string) (*playerResponse, error) {
escapedTag := url.PathEscape(strings.TrimSpace(playerTag))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/players/%s", c.baseURL, escapedTag), nil)
if err != nil {
return nil, fmt.Errorf("build player request: %w", err)
}
c.applyHeaders(req)
res, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("execute player request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(res.Body, 2048))
return nil, fmt.Errorf("player request failed: status=%d body=%s", res.StatusCode, string(body))
}
var payload playerResponse
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
return nil, fmt.Errorf("decode player response: %w", err)
}
return &payload, nil
}
func (c *Client) applyHeaders(req *http.Request) {
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
}
func parseLastSeen(value string) (time.Time, error) {
if value == "" {
return time.Time{}, fmt.Errorf("empty lastSeen")
}
layouts := []string{
"20060102T150405.000Z",
time.RFC3339,
}
for _, layout := range layouts {
if t, err := time.Parse(layout, value); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("unsupported lastSeen format: %s", value)
}
type clanResponse struct {
MemberList []clanMember `json:"memberList"`
}
type clanMember struct {
Tag string `json:"tag"`
Name string `json:"name"`
Donations int `json:"donations"`
DonationsReceived int `json:"donationsReceived"`
Trophies int `json:"trophies"`
League league `json:"league"`
Role string `json:"role"`
ExpLevel int `json:"expLevel"`
TownHallLevel int `json:"townHallLevel"`
}
type league struct {
Name string `json:"name"`
}
type playerResponse struct {
Tag string `json:"tag"`
Name string `json:"name"`
LastSeen string `json:"lastSeen"`
TownHallLevel int `json:"townHallLevel"`
}