TTW_Bot_GO/internal/twitchapi/twitchapi.go

253 lines
8.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
}