залил

This commit is contained in:
2026-04-15 08:00:15 +03:00
commit 5549b3545e
51 changed files with 8073 additions and 0 deletions
+430
View File
@@ -0,0 +1,430 @@
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
}