package twitchapi import ( "bytes" "encoding/json" "fmt" "io" "net/http" "stream-bot/internal/db" "sync" "time" ) type TwitchAPI struct { client *http.Client clientID string clientSecret string 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(clientID, clientSecret string) *TwitchAPI { return &TwitchAPI{ client: &http.Client{Timeout: 10 * time.Second}, clientID: clientID, clientSecret: clientSecret, } } // 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", t.clientID) resp, err := t.client.Do(req) if err != nil { return "", err } defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) 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", t.clientID) resp, err := t.client.Do(req) if err != nil { return time.Time{}, err } defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) 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", t.clientID) resp, err := t.client.Do(req) if err != nil { return time.Time{}, err } defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) 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 (t *TwitchAPI) GetClientID() string { return t.clientID } // 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 "менее дня" } var 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() } // GetUserToken возвращает токен стримера (user_token) для API запросов, требующих прав модератора func (t *TwitchAPI) GetUserToken() (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 } return "", fmt.Errorf("no user token available") } // TimeoutUser отправляет пользователя в таймаут на указанное количество секунд func (t *TwitchAPI) TimeoutUser(broadcasterID, moderatorID, userID string, durationSeconds int) error { token, err := t.getToken() if err != nil { return err } url := "https://api.twitch.tv/helix/moderation/bans" body := map[string]interface{}{ "broadcaster_id": broadcasterID, "moderator_id": moderatorID, "data": map[string]interface{}{ "user_id": userID, "duration": durationSeconds, "reason": "Timeout from bot command", }, } jsonBody, _ := json.Marshal(body) req, _ := http.NewRequest("POST", url, bytes.NewReader(jsonBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Client-Id", t.clientID) req.Header.Set("Content-Type", "application/json") resp, err := t.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { bodyBytes, _ := io.ReadAll(resp.Body) return fmt.Errorf("timeout failed: %d %s", resp.StatusCode, string(bodyBytes)) } return nil } // BanUser банит пользователя func (t *TwitchAPI) BanUser(broadcasterID, moderatorID, userID string) error { return t.TimeoutUser(broadcasterID, moderatorID, userID, 0) // 0 = перманентный бан } // UnbanUser разбанивает пользователя func (t *TwitchAPI) UnbanUser(broadcasterID, moderatorID, userID string) error { token, err := t.getToken() if err != nil { return err } url := fmt.Sprintf("https://api.twitch.tv/helix/moderation/bans?broadcaster_id=%s&moderator_id=%s&user_id=%s", broadcasterID, moderatorID, userID) req, _ := http.NewRequest("DELETE", url, nil) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Client-Id", t.clientID) resp, err := t.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return fmt.Errorf("unban failed: %d %s", resp.StatusCode, string(bodyBytes)) } return nil } // AddVip добавляет VIP статус func (t *TwitchAPI) AddVip(broadcasterID, userID string) error { token, err := t.getToken() if err != nil { return err } url := "https://api.twitch.tv/helix/channels/vips" body := map[string]interface{}{ "broadcaster_id": broadcasterID, "user_id": userID, } jsonBody, _ := json.Marshal(body) req, _ := http.NewRequest("POST", url, bytes.NewReader(jsonBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Client-Id", t.clientID) req.Header.Set("Content-Type", "application/json") resp, err := t.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { bodyBytes, _ := io.ReadAll(resp.Body) return fmt.Errorf("add vip failed: %d %s", resp.StatusCode, string(bodyBytes)) } return nil } // RemoveVip удаляет VIP статус func (t *TwitchAPI) RemoveVip(broadcasterID, userID string) error { token, err := t.getToken() if err != nil { return err } url := fmt.Sprintf("https://api.twitch.tv/helix/channels/vips?broadcaster_id=%s&user_id=%s", broadcasterID, userID) req, _ := http.NewRequest("DELETE", url, nil) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Client-Id", t.clientID) resp, err := t.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return fmt.Errorf("remove vip failed: %d %s", resp.StatusCode, string(bodyBytes)) } return nil } // AddMod добавляет модератора func (t *TwitchAPI) AddMod(broadcasterID, userID string) error { token, err := t.getToken() if err != nil { return err } url := "https://api.twitch.tv/helix/moderation/moderators" body := map[string]interface{}{ "broadcaster_id": broadcasterID, "user_id": userID, } jsonBody, _ := json.Marshal(body) req, _ := http.NewRequest("POST", url, bytes.NewReader(jsonBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Client-Id", t.clientID) req.Header.Set("Content-Type", "application/json") resp, err := t.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { bodyBytes, _ := io.ReadAll(resp.Body) return fmt.Errorf("add mod failed: %d %s", resp.StatusCode, string(bodyBytes)) } return nil } // RemoveMod удаляет модератора func (t *TwitchAPI) RemoveMod(broadcasterID, userID string) error { token, err := t.getToken() if err != nil { return err } url := fmt.Sprintf("https://api.twitch.tv/helix/moderation/moderators?broadcaster_id=%s&user_id=%s", broadcasterID, userID) req, _ := http.NewRequest("DELETE", url, nil) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Client-Id", t.clientID) resp, err := t.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return fmt.Errorf("remove mod failed: %d %s", resp.StatusCode, string(bodyBytes)) } return nil }