253 lines
8.1 KiB
Go
253 lines
8.1 KiB
Go
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"
|
||
} |