package twitchapi import ( "encoding/json" "fmt" "net/http" "stream-bot/internal/db" "sync" "time" ) type TwitchAPI struct { client *http.Client userIDCache sync.Map // map[string]string (login -> userID) createdAtCache sync.Map // map[string]time.Time (userID -> createdAt) followCache sync.Map // map[string]time.Time (key: "broadcasterID:userID" -> followedAt) broadcasterID string mu sync.RWMutex } func New() *TwitchAPI { return &TwitchAPI{ client: &http.Client{Timeout: 10 * time.Second}, } } // getValidToken возвращает токен бота (или стримера) для API запросов. // Для получения данных о пользователе и подписках достаточно бота, но для надёжности используем user_token. func (t *TwitchAPI) getToken() (string, error) { tokens, err := db.GetPlatformTokens("twitch") if err != nil || tokens == nil { return "", fmt.Errorf("no twitch tokens") } if tokens.UserToken != "" { return tokens.UserToken, nil } if tokens.BotToken != "" { return tokens.BotToken, nil } return "", fmt.Errorf("no valid token") } // getBroadcasterID получает ID канала стримера из его логина (UserLogin) и кэширует. func (t *TwitchAPI) GetBroadcasterID() (string, error) { if t.broadcasterID != "" { return t.broadcasterID, nil } tokens, err := db.GetPlatformTokens("twitch") if err != nil || tokens == nil { return "", fmt.Errorf("no twitch tokens") } if tokens.UserLogin == "" { return "", fmt.Errorf("broadcaster login not set") } id, err := t.GetUserID(tokens.UserLogin) if err != nil { return "", err } t.broadcasterID = id return id, nil } // GetUserID получает ID пользователя по логину (с кэшированием). func (t *TwitchAPI) GetUserID(login string) (string, error) { if cached, ok := t.userIDCache.Load(login); ok { return cached.(string), nil } token, err := t.getToken() if err != nil { return "", err } url := fmt.Sprintf("https://api.twitch.tv/helix/users?login=%s", login) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Client-Id", getClientID()) resp, err := t.client.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("twitch api error: %d", resp.StatusCode) } var data struct { Data []struct { ID string `json:"id"` } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return "", err } if len(data.Data) == 0 { return "", fmt.Errorf("user not found") } t.userIDCache.Store(login, data.Data[0].ID) return data.Data[0].ID, nil } // GetUserCreatedAt возвращает дату регистрации пользователя. func (t *TwitchAPI) GetUserCreatedAt(userID string) (time.Time, error) { if cached, ok := t.createdAtCache.Load(userID); ok { return cached.(time.Time), nil } token, err := t.getToken() if err != nil { return time.Time{}, err } url := fmt.Sprintf("https://api.twitch.tv/helix/users?id=%s", userID) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Client-Id", getClientID()) resp, err := t.client.Do(req) if err != nil { return time.Time{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return time.Time{}, fmt.Errorf("twitch api error: %d", resp.StatusCode) } var data struct { Data []struct { CreatedAt string `json:"created_at"` } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return time.Time{}, err } if len(data.Data) == 0 { return time.Time{}, fmt.Errorf("user not found") } createdAt, err := time.Parse(time.RFC3339, data.Data[0].CreatedAt) if err != nil { return time.Time{}, err } t.createdAtCache.Store(userID, createdAt) return createdAt, nil } // GetFollowCreatedAt возвращает дату начала подписки пользователя на канал стримера. // Если не подписан, возвращает нулевое время и ошибку (можно обработать как "не подписан"). func (t *TwitchAPI) GetFollowCreatedAt(broadcasterID, userID string) (time.Time, error) { key := broadcasterID + ":" + userID if cached, ok := t.followCache.Load(key); ok { return cached.(time.Time), nil } token, err := t.getToken() if err != nil { return time.Time{}, err } url := fmt.Sprintf("https://api.twitch.tv/helix/channels/followers?broadcaster_id=%s&user_id=%s", broadcasterID, userID) req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Client-Id", getClientID()) resp, err := t.client.Do(req) if err != nil { return time.Time{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { // Если статус не 200, считаем, что пользователь не подписан return time.Time{}, fmt.Errorf("not following or API error") } var data struct { Data []struct { FollowedAt string `json:"followed_at"` } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return time.Time{}, err } if len(data.Data) == 0 { return time.Time{}, fmt.Errorf("not following") } followedAt, err := time.Parse(time.RFC3339, data.Data[0].FollowedAt) if err != nil { return time.Time{}, err } t.followCache.Store(key, followedAt) return followedAt, nil } func getClientID() string { tokens, _ := db.GetPlatformTokens("twitch") if tokens != nil && tokens.ClientID != "" { return tokens.ClientID } // fallback (можно захардкодить, но лучше читать из БД) return "0xc6mhgsuxpkvze0gi70ywjno6l2jw" } // FormatDuration возвращает человекочитаемую строку "X лет Y месяцев Z дней" func FormatDuration(t time.Time) string { now := time.Now() diff := now.Sub(t) years := int(diff.Hours() / 24 / 365) months := int(diff.Hours()/24/30) % 12 days := int(diff.Hours()/24) % 30 if years == 0 && months == 0 && days == 0 { return "менее дня" } parts := []string{} if years > 0 { parts = append(parts, fmt.Sprintf("%d %s", years, plural(years, "год", "года", "лет"))) } if months > 0 { parts = append(parts, fmt.Sprintf("%d %s", months, plural(months, "месяц", "месяца", "месяцев"))) } if days > 0 { parts = append(parts, fmt.Sprintf("%d %s", days, plural(days, "день", "дня", "дней"))) } return joinRussian(parts) } func plural(n int, one, few, many string) string { if n%10 == 1 && n%100 != 11 { return one } else if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) { return few } else { return many } } func joinRussian(parts []string) string { if len(parts) == 0 { return "" } if len(parts) == 1 { return parts[0] } if len(parts) == 2 { return parts[0] + " и " + parts[1] } return parts[0] + ", " + parts[1] + " и " + parts[2] } // GetToken возвращает действующий токен для API (бот или стример) func (t *TwitchAPI) GetToken() (string, error) { return t.getToken() } func GetClientID() string { tokens, _ := db.GetPlatformTokens("twitch") if tokens != nil && tokens.ClientID != "" { return tokens.ClientID } return "0xc6mhgsuxpkvze0gi70ywjno6l2jw" }