залил

This commit is contained in:
2026-04-15 08:00:15 +03:00
commit 5549b3545e
51 changed files with 8073 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
package ai
import (
"context"
"fmt"
)
type ChatGPTProvider struct{}
func NewChatGPTProvider(apiKey, model, systemPrompt string) *ChatGPTProvider {
return &ChatGPTProvider{}
}
func (p *ChatGPTProvider) Ask(ctx context.Context, prompt string) (string, error) {
return "", fmt.Errorf("ChatGPT not implemented yet")
}
+22
View File
@@ -0,0 +1,22 @@
package ai
import (
"fmt"
"stream-bot/internal/db"
)
func NewProvider(cfg *db.AIConfig) (Provider, error) {
switch cfg.Provider {
case "ollama":
return NewOllamaProvider(cfg.Endpoint, cfg.Model, cfg.SystemPrompt), nil
case "chatgpt":
return NewChatGPTProvider(cfg.APIKey, cfg.Model, cfg.SystemPrompt), nil
case "gigachat":
if cfg.ClientID == "" || cfg.ClientSecret == "" {
return nil, fmt.Errorf("client_id and client_secret required for GigaChat")
}
return NewGigaChatProvider(cfg.ClientID, cfg.ClientSecret, cfg.Endpoint, cfg.Model, cfg.SystemPrompt), nil
default:
return nil, fmt.Errorf("unknown provider: %s", cfg.Provider)
}
}
+174
View File
@@ -0,0 +1,174 @@
package ai
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type gigaAuthResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
type gigaMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type gigaChatRequest struct {
Model string `json:"model"`
Messages []gigaMessage `json:"messages"`
Stream bool `json:"stream"`
}
type gigaChatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
type GigaChatProvider struct {
clientID string
authBasic string // уже готовый base64(clientID:secret)
endpoint string
model string
systemPrompt string
httpClient *http.Client
accessToken string
tokenExpiry time.Time
}
func NewGigaChatProvider(clientID, authBasic, endpoint, model, systemPrompt string) *GigaChatProvider {
if endpoint == "" {
endpoint = "https://gigachat.devices.sberbank.ru/api/v1"
}
if model == "" {
model = "GigaChat"
}
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
return &GigaChatProvider{
clientID: strings.TrimSpace(clientID),
authBasic: strings.TrimSpace(authBasic),
endpoint: endpoint,
model: model,
systemPrompt: systemPrompt,
httpClient: &http.Client{Transport: tr, Timeout: 60 * time.Second},
}
}
func (p *GigaChatProvider) getToken(ctx context.Context) (string, error) {
if p.accessToken != "" && time.Now().Before(p.tokenExpiry) {
return p.accessToken, nil
}
authURL := "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"
bodyData := "scope=GIGACHAT_API_PERS"
req, err := http.NewRequestWithContext(ctx, "POST", authURL, strings.NewReader(bodyData))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Basic "+p.authBasic)
req.Header.Set("RqUID", p.clientID)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
resp, err := p.httpClient.Do(req)
if err != nil {
return "", err
}
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("gigachat auth error: %d %s", resp.StatusCode, string(bodyBytes))
}
var authResp gigaAuthResponse
if err := json.Unmarshal(bodyBytes, &authResp); err != nil {
return "", err
}
p.accessToken = authResp.AccessToken
p.tokenExpiry = time.Now().Add(time.Duration(authResp.ExpiresIn-60) * time.Second)
return p.accessToken, nil
}
func (p *GigaChatProvider) Ask(ctx context.Context, prompt string) (string, error) {
token, err := p.getToken(ctx)
if err != nil {
return "", err
}
messages := []gigaMessage{
{Role: "system", Content: p.systemPrompt},
{Role: "user", Content: prompt},
}
reqBody := gigaChatRequest{
Model: p.model,
Messages: messages,
Stream: false,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return "", err
}
chatURL := p.endpoint + "/chat/completions"
req, err := http.NewRequestWithContext(ctx, "POST", chatURL, bytes.NewReader(jsonData))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("RqUID", p.clientID)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
resp, err := p.httpClient.Do(req)
if err != nil {
return "", err
}
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("gigachat api error: %d %s", resp.StatusCode, string(bodyBytes))
}
var chatResp gigaChatResponse
if err := json.Unmarshal(bodyBytes, &chatResp); err != nil {
return "", err
}
if len(chatResp.Choices) == 0 {
return "", fmt.Errorf("no response from gigachat")
}
return chatResp.Choices[0].Message.Content, nil
}
+75
View File
@@ -0,0 +1,75 @@
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type OllamaProvider struct {
endpoint string
model string
systemPrompt string
client *http.Client
}
type ollamaRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
System string `json:"system,omitempty"`
Stream bool `json:"stream"`
}
type ollamaResponse struct {
Response string `json:"response"`
}
func NewOllamaProvider(endpoint, model, systemPrompt string) *OllamaProvider {
if endpoint == "" {
endpoint = "http://localhost:11434"
}
return &OllamaProvider{
endpoint: endpoint,
model: model,
systemPrompt: systemPrompt,
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (p *OllamaProvider) Ask(ctx context.Context, prompt string) (string, error) {
reqBody := ollamaRequest{
Model: p.model,
Prompt: prompt,
System: p.systemPrompt,
Stream: false,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return "", err
}
url := p.endpoint + "/api/generate"
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonData))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.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("ollama error: %d", resp.StatusCode)
}
var ollamaResp ollamaResponse
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
return "", err
}
return ollamaResp.Response, nil
}
+7
View File
@@ -0,0 +1,7 @@
package ai
import "context"
type Provider interface {
Ask(ctx context.Context, prompt string) (string, error)
}
+78
View File
@@ -0,0 +1,78 @@
package audio
import (
"math"
"os"
"stream-bot/internal/logger"
"github.com/faiface/beep"
"github.com/faiface/beep/effects"
"github.com/faiface/beep/mp3"
"github.com/faiface/beep/speaker"
"github.com/faiface/beep/wav"
)
var initialized bool
func Init() error {
// Увеличенный буфер (было 200, стало 4096) для плавности
err := speaker.Init(44100, 4096)
if err == nil {
initialized = true
}
return err
}
func Close() {
if initialized {
speaker.Close()
}
}
func PlaySound(filePath string) error {
return PlayWithVolume(filePath, 100)
}
func PlayWithVolume(filePath string, volume int) error {
if !initialized {
logger.Warn("Audio player not initialized, cannot play %s", filePath)
return nil
}
if volume <= 0 {
return nil
}
if volume > 100 {
volume = 100
}
f, err := os.Open(filePath)
if err != nil {
return err
}
var streamer beep.StreamSeekCloser
var errDecode error
if len(filePath) > 4 && filePath[len(filePath)-4:] == ".mp3" {
streamer, _, errDecode = mp3.Decode(f)
} else {
streamer, _, errDecode = wav.Decode(f)
}
if errDecode != nil {
_ = f.Close()
return errDecode
}
gain := 20 * math.Log10(float64(volume)/100.0)
volumeStreamer := &effects.Volume{
Streamer: streamer,
Base: 2,
Volume: gain,
Silent: false,
}
speaker.Play(beep.Seq(volumeStreamer, beep.Callback(func() {
_ = streamer.Close()
_ = f.Close()
})))
return nil
}
+226
View File
@@ -0,0 +1,226 @@
package commands
import (
"bytes"
"context"
"encoding/json"
"fmt"
"math/rand"
"net/http"
"path/filepath"
"stream-bot/internal/ai"
"stream-bot/internal/audio"
"stream-bot/internal/db"
"stream-bot/internal/logger"
"stream-bot/internal/parser"
"stream-bot/internal/twitchapi"
"stream-bot/internal/userstats"
"stream-bot/internal/webservices"
"strings"
"sync"
"time"
)
type Processor struct {
cooldowns sync.Map
twitchAPI *twitchapi.TwitchAPI
aiProvider ai.Provider
webSrvMgr *webservices.Manager
userStats *userstats.Store
}
func NewProcessor(twitchAPI *twitchapi.TwitchAPI, aiProvider ai.Provider, webSrvMgr *webservices.Manager, userStats *userstats.Store) *Processor {
return &Processor{
twitchAPI: twitchAPI,
aiProvider: aiProvider,
webSrvMgr: webSrvMgr,
userStats: userStats,
}
}
func (p *Processor) ProcessCommand(trigger, username, platform string, isMod, isBroadcaster bool, args string) (response string, soundFiles []string, err error) {
cmds, err := db.GetCommands()
if err != nil {
return "", nil, err
}
var cmd *db.Command
for _, c := range cmds {
if c.Trigger == trigger && c.Enabled {
cmd = &c
break
}
}
if cmd == nil {
return "", nil, nil
}
// Проверка прав
switch cmd.Permission {
case "broadcaster":
if !isBroadcaster {
return "", nil, nil
}
case "moderator":
if !isMod && !isBroadcaster {
return "", nil, nil
}
}
// Кулдаун
if cmd.CooldownSec > 0 {
last, ok := p.cooldowns.Load(cmd.Trigger)
if ok && time.Since(last.(time.Time)) < time.Duration(cmd.CooldownSec)*time.Second {
return "", nil, nil
}
p.cooldowns.Store(cmd.Trigger, time.Now())
}
// Обработка тегов <AGE/> и <FOLLOW/>
template := cmd.Template
aiResult := ""
if strings.Contains(template, "<AI/>") && p.aiProvider != nil {
if args == "" {
aiResult = "Вы не задали вопрос."
} else {
aiResult, err = p.aiProvider.Ask(context.Background(), args)
if err != nil {
logger.Error("AI error: %v", err)
aiResult = "Ошибка при обращении к нейросети."
}
}
}
if platform == "twitch" && (strings.Contains(template, "<AGE/>") || strings.Contains(template, "<FOLLOW/>")) {
broadcasterID, err := p.twitchAPI.GetBroadcasterID()
if err == nil {
userID, err := p.twitchAPI.GetUserID(username)
if err == nil {
if strings.Contains(template, "<AGE/>") {
createdAt, err := p.twitchAPI.GetUserCreatedAt(userID)
if err != nil {
template = strings.ReplaceAll(template, "<AGE/>", "неизвестно")
} else {
ageStr := twitchapi.FormatDuration(createdAt)
template = strings.ReplaceAll(template, "<AGE/>", ageStr)
}
}
if strings.Contains(template, "<FOLLOW/>") {
followedAt, err := p.twitchAPI.GetFollowCreatedAt(broadcasterID, userID)
if err != nil {
template = strings.ReplaceAll(template, "<FOLLOW/>", "не подписан")
} else {
followStr := twitchapi.FormatDuration(followedAt)
template = strings.ReplaceAll(template, "<FOLLOW/>", followStr)
}
}
}
}
}
// Парсинг шаблона (теперь возвращает ещё и timeoutMinutes)
response, soundFiles, timeoutMinutes, err := parser.ParseTemplate(template, username, args, aiResult, p.getRandomUsername)
if err != nil {
logger.Error("Parse error for command %s: %v", cmd.Trigger, err)
return "", nil, err
}
// Обработка таймаута (если есть тег <timeout/>)
if timeoutMinutes > 0 && platform == "twitch" {
if err := p.timeoutUser(username, timeoutMinutes); err != nil {
logger.Error("Failed to timeout user %s: %v", username, err)
} else {
logger.Info("User %s timed out for %d minutes", username, timeoutMinutes)
}
}
// Воспроизведение звуков
if len(soundFiles) > 0 && p.webSrvMgr != nil {
for _, sf := range soundFiles {
p.sendSoundToAlertServices(sf)
}
} else if len(soundFiles) > 0 {
for _, sf := range soundFiles {
if err := audio.PlaySound(sf); err != nil {
logger.Error("Failed to play sound %s: %v", sf, err)
}
}
}
return response, soundFiles, nil
}
// timeoutUser отправляет пользователя в таймаут через Twitch API
func (p *Processor) timeoutUser(username string, minutes int) error {
broadcasterID, err := p.twitchAPI.GetBroadcasterID()
if err != nil {
return err
}
userID, err := p.twitchAPI.GetUserID(username)
if err != nil {
return err
}
moderatorID := broadcasterID // используем ID стримера как модератора
seconds := minutes * 60
return p.twitchAPI.TimeoutUser(broadcasterID, moderatorID, userID, seconds)
}
func (p *Processor) UpdateAIProvider(provider ai.Provider) {
p.aiProvider = provider
}
func (p *Processor) sendSoundToAlertServices(soundFile string) {
if p.webSrvMgr == nil {
return
}
duplicate, _ := db.GetSetting("duplicate_command_sounds")
duplicateEnabled := duplicate == "true"
services := p.webSrvMgr.GetAllServices()
for _, srv := range services {
if srv.GetType() != "alert" {
continue
}
port := srv.GetPort()
url := fmt.Sprintf("http://localhost:%d/notify", port)
filename := filepath.Base(soundFile)
payload := map[string]interface{}{
"sound": "/sounds/" + filename,
"duration": 1,
"title": "",
"text": "",
}
body, _ := json.Marshal(payload)
go func() {
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
logger.Error("Failed to send sound to alert service %d: %v", port, err)
} else {
_ = resp.Body.Close()
}
}()
}
if duplicateEnabled {
fullPath := filepath.Join("data", "sounds", filepath.Base(soundFile))
if err := audio.PlaySound(fullPath); err != nil {
logger.Error("Failed to play duplicated sound %s: %v", fullPath, err)
}
}
}
// getRandomUsername возвращает случайное имя из активных пользователей чата
func (p *Processor) getRandomUsername() string {
users := p.userStats.GetAll()
if len(users) == 0 {
return "кого-то"
}
// Отфильтруем пустые имена
var names []string
for _, u := range users {
if u.Username != "" {
names = append(names, u.Username)
}
}
if len(names) == 0 {
return "кого-то"
}
rand.Seed(time.Now().UnixNano())
return names[rand.Intn(len(names))]
}
+531
View File
@@ -0,0 +1,531 @@
package db
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
_ "modernc.org/sqlite"
)
var (
db *sql.DB
once sync.Once
)
type MarkedUser struct {
Username string
Platform string
LastMarked string // YYYY-MM-DD
}
func Init(path string) error {
var err error
once.Do(func() {
db, err = sql.Open("sqlite", path)
if err != nil {
return
}
err = createTables()
})
return err
}
func Close() {
if db != nil {
_ = db.Close()
}
}
func createTables() error {
schema := `
CREATE TABLE IF NOT EXISTS commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trigger TEXT UNIQUE NOT NULL,
template TEXT NOT NULL,
enabled BOOLEAN DEFAULT 1,
cooldown_sec INTEGER DEFAULT 0,
permission TEXT DEFAULT 'everyone'
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
event_name TEXT NOT NULL,
action_chain TEXT NOT NULL,
UNIQUE(platform, event_name)
);
CREATE TABLE IF NOT EXISTS hotkey_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
min_donation_amount INTEGER NOT NULL,
combination TEXT NOT NULL,
platform TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS platform_tokens (
platform TEXT PRIMARY KEY,
client_id TEXT,
client_secret TEXT,
user_token TEXT,
user_refresh TEXT,
bot_token TEXT,
bot_refresh TEXT,
user_login TEXT,
bot_login TEXT
);
CREATE TABLE IF NOT EXISTS marked_users (
username TEXT NOT NULL,
platform TEXT NOT NULL,
last_marked TEXT,
PRIMARY KEY (username, platform)
);
CREATE TABLE IF NOT EXISTS ai_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
provider TEXT NOT NULL,
api_key TEXT,
endpoint TEXT,
model TEXT,
system_prompt TEXT,
client_id TEXT,
client_secret TEXT
);
CREATE TABLE IF NOT EXISTS notification_settings (
event_name TEXT PRIMARY KEY,
sound_file TEXT NOT NULL,
volume INTEGER DEFAULT 70,
enabled BOOLEAN DEFAULT 1
);
CREATE TABLE IF NOT EXISTS web_services (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_type TEXT NOT NULL,
port INTEGER NOT NULL,
config_json TEXT NOT NULL,
enabled BOOLEAN DEFAULT 1,
running BOOLEAN DEFAULT 0,
started_at DATETIME
);
`
_, err := db.Exec(schema)
return err
}
// ---------- Команды ----------
type Command struct {
ID int
Trigger string
Template string
Enabled bool
CooldownSec int
Permission string
}
func GetCommands() ([]Command, error) {
rows, err := db.Query("SELECT id, trigger, template, enabled, cooldown_sec, permission FROM commands")
if err != nil {
return nil, err
}
defer func(rows *sql.Rows) {
_ = rows.Close()
}(rows)
var cmds []Command
for rows.Next() {
var c Command
err := rows.Scan(&c.ID, &c.Trigger, &c.Template, &c.Enabled, &c.CooldownSec, &c.Permission)
if err != nil {
return nil, err
}
cmds = append(cmds, c)
}
return cmds, nil
}
func AddCommand(trigger, template string, enabled bool, cooldown int, perm string) error {
_, err := db.Exec("INSERT INTO commands (trigger, template, enabled, cooldown_sec, permission) VALUES (?, ?, ?, ?, ?)",
trigger, template, enabled, cooldown, perm)
return err
}
func UpdateCommand(id int, trigger, template string, enabled bool, cooldown int, perm string) error {
_, err := db.Exec("UPDATE commands SET trigger=?, template=?, enabled=?, cooldown_sec=?, permission=? WHERE id=?",
trigger, template, enabled, cooldown, perm, id)
return err
}
func DeleteCommand(id int) error {
_, err := db.Exec("DELETE FROM commands WHERE id=?", id)
return err
}
// ---------- События (расширенные действия) ----------
type Action struct {
Type string `json:"type"` // send_message, play_sound, press_hotkey, http_request, run_program, send_alert
// Общие поля
Text string `json:"text,omitempty"` // для send_message и send_alert
SoundFile string `json:"sound_file,omitempty"` // для play_sound и send_alert
Keys string `json:"keys,omitempty"` // для press_hotkey
URL string `json:"url,omitempty"` // для http_request
// Для run_program
Executable string `json:"executable,omitempty"`
Args string `json:"args,omitempty"`
// Для send_alert
Title string `json:"title,omitempty"`
AlertText string `json:"alert_text,omitempty"`
Image string `json:"image,omitempty"`
Duration int `json:"duration,omitempty"` // секунды
TargetWebServiceID int `json:"target_web_service_id,omitempty"` // 0 = все alert-сервисы
}
// GetEventActions возвращает цепочку действий для события
func GetEventActions(platform, eventName string) ([]Action, error) {
var chainJSON string
err := db.QueryRow("SELECT action_chain FROM events WHERE platform=? AND event_name=?", platform, eventName).Scan(&chainJSON)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
var actions []Action
if err := json.Unmarshal([]byte(chainJSON), &actions); err != nil {
return nil, err
}
return actions, nil
}
// SaveEventActions сохраняет цепочку действий для события (вставка или обновление)
func SaveEventActions(platform, eventName string, actions []Action) error {
chainJSON, err := json.Marshal(actions)
if err != nil {
return err
}
_, err = db.Exec(`
INSERT OR REPLACE INTO events (platform, event_name, action_chain)
VALUES (?, ?, ?)
`, platform, eventName, string(chainJSON))
return err
}
// ---------- Горячие клавиши по донатам ----------
func GetHotkeyRules(platform string) (map[int]string, error) {
rows, err := db.Query("SELECT min_donation_amount, combination FROM hotkey_rules WHERE platform=?", platform)
if err != nil {
return nil, err
}
defer func(rows *sql.Rows) {
_ = rows.Close()
}(rows)
rules := make(map[int]string)
for rows.Next() {
var amount int
var comb string
if err := rows.Scan(&amount, &comb); err == nil {
rules[amount] = comb
}
}
return rules, nil
}
func AddHotkeyRule(platform string, minAmount int, combination string) error {
_, err := db.Exec("INSERT INTO hotkey_rules (platform, min_donation_amount, combination) VALUES (?, ?, ?)",
platform, minAmount, combination)
return err
}
func DeleteHotkeyRule(platform string, minAmount int) error {
_, err := db.Exec("DELETE FROM hotkey_rules WHERE platform=? AND min_donation_amount=?", platform, minAmount)
return err
}
// ---------- Токены платформ ----------
type PlatformTokens struct {
ClientID string
ClientSecret string
UserToken string
UserRefresh string
BotToken string
BotRefresh string
UserLogin string
BotLogin string
}
func GetPlatformTokens(platform string) (*PlatformTokens, error) {
var pt PlatformTokens
row := db.QueryRow("SELECT client_id, client_secret, user_token, user_refresh, bot_token, bot_refresh, user_login, bot_login FROM platform_tokens WHERE platform=?", platform)
err := row.Scan(&pt.ClientID, &pt.ClientSecret, &pt.UserToken, &pt.UserRefresh, &pt.BotToken, &pt.BotRefresh, &pt.UserLogin, &pt.BotLogin)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &pt, nil
}
func SetPlatformTokens(platform string, pt *PlatformTokens) error {
_, err := db.Exec(`
INSERT OR REPLACE INTO platform_tokens
(platform, client_id, client_secret, user_token, user_refresh, bot_token, bot_refresh, user_login, bot_login)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
platform, pt.ClientID, pt.ClientSecret, pt.UserToken, pt.UserRefresh, pt.BotToken, pt.BotRefresh, pt.UserLogin, pt.BotLogin)
return err
}
// ---------- Отметки пользователей ----------
func IsUserMarked(username, platform string) (bool, string, error) {
var lastMarked string
err := db.QueryRow("SELECT last_marked FROM marked_users WHERE username=? AND platform=?", username, platform).Scan(&lastMarked)
if err == sql.ErrNoRows {
return false, "", nil
}
if err != nil {
return false, "", err
}
return true, lastMarked, nil
}
func SetUserMarked(username, platform string, marked bool) error {
if marked {
_, err := db.Exec("INSERT OR REPLACE INTO marked_users (username, platform, last_marked) VALUES (?, ?, '')", username, platform)
return err
} else {
_, err := db.Exec("DELETE FROM marked_users WHERE username=? AND platform=?", username, platform)
return err
}
}
func UpdateMarkedUserDate(username, platform string, t time.Time) error {
date := t.Format("2006-01-02")
_, err := db.Exec("UPDATE marked_users SET last_marked=? WHERE username=? AND platform=?", date, username, platform)
return err
}
// ---------- AI конфиг ----------
type AIConfig struct {
Provider string `json:"provider"`
APIKey string `json:"api_key"`
Endpoint string `json:"endpoint"`
Model string `json:"model"`
SystemPrompt string `json:"system_prompt"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
}
func SaveAIConfig(cfg *AIConfig) error {
_, err := db.Exec(`
INSERT OR REPLACE INTO ai_config (id, provider, api_key, endpoint, model, system_prompt, client_id, client_secret)
VALUES (1, ?, ?, ?, ?, ?, ?, ?)`,
cfg.Provider, cfg.APIKey, cfg.Endpoint, cfg.Model, cfg.SystemPrompt, cfg.ClientID, cfg.ClientSecret)
return err
}
func GetAIConfig() (*AIConfig, error) {
var cfg AIConfig
row := db.QueryRow(`
SELECT provider, api_key, endpoint, model, system_prompt, client_id, client_secret
FROM ai_config WHERE id = 1`)
err := row.Scan(&cfg.Provider, &cfg.APIKey, &cfg.Endpoint, &cfg.Model, &cfg.SystemPrompt, &cfg.ClientID, &cfg.ClientSecret)
if errors.Is(err, sql.ErrNoRows) {
// По умолчанию
return &AIConfig{
Provider: "ollama",
SystemPrompt: "ты в чате твитча, ответь одним предложением.",
}, nil
}
if err != nil {
return nil, err
}
return &cfg, nil
}
// ---------- Уведомления ----------
type NotificationSetting struct {
EventName string `json:"event_name"`
SoundFile string `json:"sound_file"`
Volume int `json:"volume"`
Enabled bool `json:"enabled"`
}
func GetAllNotificationSettings() ([]NotificationSetting, error) {
rows, err := db.Query("SELECT event_name, sound_file, volume, enabled FROM notification_settings")
if err != nil {
return nil, err
}
defer func(rows *sql.Rows) {
_ = rows.Close()
}(rows)
var settings []NotificationSetting
for rows.Next() {
var ns NotificationSetting
if err := rows.Scan(&ns.EventName, &ns.SoundFile, &ns.Volume, &ns.Enabled); err != nil {
return nil, err
}
settings = append(settings, ns)
}
return settings, nil
}
func SaveNotificationSetting(ns *NotificationSetting) error {
_, err := db.Exec(`
INSERT OR REPLACE INTO notification_settings (event_name, sound_file, volume, enabled)
VALUES (?, ?, ?, ?)`,
ns.EventName, ns.SoundFile, ns.Volume, ns.Enabled)
return err
}
// ---------- Веб-сервисы ----------
type ChatWebConfig struct {
BackgroundColor string `json:"bg_color"`
TextColor string `json:"text_color"`
FontSize int `json:"font_size"`
FontFamily string `json:"font_family"`
Opacity int `json:"opacity"`
MessageTimeoutSec int `json:"message_timeout_sec"`
MaxMessages int `json:"max_messages"`
ShowBadges bool `json:"show_badges"`
ShowTimestamps bool `json:"show_timestamps"`
}
type AlertEventConfig struct {
Enabled bool `json:"enabled"`
TitleTemplate string `json:"title_template"`
TextTemplate string `json:"text_template"`
ImageFile string `json:"image_file"`
SoundFile string `json:"sound_file"`
DurationSec int `json:"duration_sec"`
}
type AlertWebConfig struct {
Events map[string]AlertEventConfig `json:"events"`
DefaultDuration int `json:"default_duration"`
DefaultImage string `json:"default_image"`
DefaultSound string `json:"default_sound"`
}
type WebService struct {
ID int `json:"id"`
Type string `json:"service_type"`
Port int `json:"port"`
ConfigJSON string `json:"config_json"`
Enabled bool `json:"enabled"`
Running bool `json:"running"`
StartedAt *time.Time `json:"started_at"`
}
func (ws *WebService) GetChatConfig() (*ChatWebConfig, error) {
if ws.Type != "chat" {
return nil, fmt.Errorf("not a chat service")
}
var cfg ChatWebConfig
if err := json.Unmarshal([]byte(ws.ConfigJSON), &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func (ws *WebService) GetAlertConfig() (*AlertWebConfig, error) {
if ws.Type != "alert" {
return nil, fmt.Errorf("not an alert service")
}
var cfg AlertWebConfig
if err := json.Unmarshal([]byte(ws.ConfigJSON), &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func GetAllWebServices() ([]WebService, error) {
rows, err := db.Query(`SELECT id, service_type, port, config_json, enabled, running, started_at FROM web_services`)
if err != nil {
return nil, err
}
defer func(rows *sql.Rows) {
_ = rows.Close()
}(rows)
var list []WebService
for rows.Next() {
var ws WebService
var startedAt sql.NullTime
err := rows.Scan(&ws.ID, &ws.Type, &ws.Port, &ws.ConfigJSON, &ws.Enabled, &ws.Running, &startedAt)
if err != nil {
return nil, err
}
if startedAt.Valid {
ws.StartedAt = &startedAt.Time
}
list = append(list, ws)
}
return list, nil
}
func GetWebService(id int) (*WebService, error) {
var ws WebService
var startedAt sql.NullTime
err := db.QueryRow(`SELECT id, service_type, port, config_json, enabled, running, started_at FROM web_services WHERE id = ?`, id).
Scan(&ws.ID, &ws.Type, &ws.Port, &ws.ConfigJSON, &ws.Enabled, &ws.Running, &startedAt)
if err != nil {
return nil, err
}
if startedAt.Valid {
ws.StartedAt = &startedAt.Time
}
return &ws, nil
}
func CreateWebService(serviceType string, port int, config interface{}) (int, error) {
configJSON, err := json.Marshal(config)
if err != nil {
return 0, err
}
res, err := db.Exec(`INSERT INTO web_services (service_type, port, config_json, enabled, running) VALUES (?, ?, ?, 1, 0)`,
serviceType, port, string(configJSON))
if err != nil {
return 0, err
}
id, _ := res.LastInsertId()
return int(id), nil
}
func UpdateWebService(id int, port int, config interface{}, enabled bool) error {
configJSON, err := json.Marshal(config)
if err != nil {
return err
}
_, err = db.Exec(`UPDATE web_services SET port = ?, config_json = ?, enabled = ? WHERE id = ?`,
port, string(configJSON), enabled, id)
return err
}
func DeleteWebService(id int) error {
_, err := db.Exec(`DELETE FROM web_services WHERE id = ?`, id)
return err
}
func SetWebServiceRunning(id int, running bool) error {
_, err := db.Exec(`UPDATE web_services SET running = ?, started_at = CURRENT_TIMESTAMP WHERE id = ?`, running, id)
return err
}
// ---------- Настройки (ключ-значение) ----------
// GetSetting возвращает значение настройки (пустая строка, если нет)
func GetSetting(key string) (string, error) {
var value string
err := db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", err
}
return value, nil
}
// SetSetting сохраняет или обновляет настройку
func SetSetting(key, value string) error {
_, err := db.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", key, value)
return err
}
+166
View File
@@ -0,0 +1,166 @@
package events
import (
"stream-bot/internal/audio"
"stream-bot/internal/db"
"stream-bot/internal/hotkey"
"stream-bot/internal/logger"
"stream-bot/internal/webservices"
"strings"
)
type Processor struct {
sendMessageFunc func(platform, text string) error
webSrvMgr *webservices.Manager
}
// NewProcessor создаёт обработчик событий с функцией отправки сообщений и менеджером веб-сервисов
func NewProcessor(sendMessageFunc func(platform, text string) error, webSrvMgr *webservices.Manager) *Processor {
return &Processor{
sendMessageFunc: sendMessageFunc,
webSrvMgr: webSrvMgr,
}
}
func (p *Processor) ProcessEvent(platform, eventName string, params map[string]string) {
// 1. Выполняем действия, сохранённые в БД (send_message, play_sound, press_hotkey)
actions, err := db.GetEventActions(platform, eventName)
if err != nil {
logger.Error("Failed to get event actions: %v", err)
} else {
for _, action := range actions {
switch action.Type {
case "send_message":
if p.sendMessageFunc == nil {
logger.Error("sendMessageFunc is nil, cannot send message")
continue
}
text := action.Text
for k, v := range params {
placeholder := "{{" + k + "}}"
text = strings.ReplaceAll(text, placeholder, v)
}
if err := p.sendMessageFunc(platform, text); err != nil {
logger.Error("Send message error: %v", err)
}
case "play_sound":
if err := audio.PlaySound(action.SoundFile); err != nil {
logger.Error("Play sound error: %v", err)
}
case "press_hotkey":
if err := hotkey.PressCombination(action.Keys); err != nil {
logger.Error("Hotkey error: %v", err)
}
case "http_request":
logger.Info("[Event] HTTP request to %s", action.URL)
}
}
}
//// 2. Отправляем уведомление во все запущенные alert-сервисы (если есть)
//if p.webSrvMgr == nil {
// return
//}
//
//// Формируем заголовок и текст уведомления на основе типа события
//title, text := p.formatEventNotification(eventName, params)
//if title == "" && text == "" {
// // Если событие не требует уведомления, не отправляем
// return
//}
//
//// Получаем звук для события (можно настроить позже, пока используем стандартные)
//soundFile := p.getSoundForEvent(eventName)
//
//payload := map[string]interface{}{
// "title": title,
// "text": text,
// "duration": 5,
// "image": "",
// "sound": soundFile,
//}
//
//// Отправляем во все alert-сервисы
//services := p.webSrvMgr.GetAllServices()
//for _, srv := range services {
// if srv.GetType() != "alert" {
// continue
// }
// port := srv.GetPort()
// url := fmt.Sprintf("http://localhost:%d/notify", port)
// body, _ := json.Marshal(payload)
// go func(url string, body []byte) {
// resp, err := http.Post(url, "application/json", bytes.NewReader(body))
// if err != nil {
// logger.Error("Failed to send alert to service %s: %v", url, err)
// } else {
// _ = resp.Body.Close()
// }
// }(url, body)
//}
}
// formatEventNotification формирует заголовок и текст уведомления для события
func (p *Processor) formatEventNotification(eventName string, params map[string]string) (title, text string) {
switch eventName {
case "follow":
username := params["username"]
title = "Новый фолловер!"
text = username + " теперь с нами"
case "subscribe":
username := params["username"]
tier := params["tier"]
tierName := ""
switch tier {
case "1000":
tierName = "1 уровень"
case "2000":
tierName = "2 уровень"
case "3000":
tierName = "3 уровень"
default:
tierName = tier
}
title = "Спасибо за подписку!"
text = username + " подписался на " + tierName
case "gift_sub":
gifter := params["gifter"]
recipient := params["recipient"]
total := params["cumulative_total"]
title = "Подарочная подписка!"
if recipient != "" {
text = gifter + " подарил подписку для " + recipient
} else {
text = gifter + " подарил " + total + " подписок"
}
case "raid":
from := params["from"]
viewers := params["viewers"]
title = "Рейд!"
text = from + " привёл " + viewers + " зрителей"
case "reward_redemption":
username := params["username"]
reward := params["reward_title"]
title = "Активирована награда!"
text = username + " активировал " + reward
default:
return "", ""
}
return title, text
}
// getSoundForEvent возвращает путь к звуковому файлу для события (можно вынести в настройки)
func (p *Processor) getSoundForEvent(eventName string) string {
// Здесь можно читать настройки из БД, пока используем заглушку
sounds := map[string]string{
"follow": "/sounds/follow.mp3",
"subscribe": "/sounds/sub.mp3",
"gift_sub": "/sounds/gift.mp3",
"raid": "/sounds/raid.mp3",
"reward_redemption": "/sounds/reward.mp3",
}
if s, ok := sounds[eventName]; ok {
return s
}
return "/sounds/default.mp3"
}
+176
View File
@@ -0,0 +1,176 @@
package gui
import (
"os/exec"
"runtime"
"stream-bot/internal/logger"
"time"
"github.com/lxn/walk"
. "github.com/lxn/walk/declarative"
)
const appVersion = "11.0.43"
func Run(webURL string,
getChatStatus func() bool,
getEventSubStatus func() (connected bool, subscriptions []string)) {
if runtime.GOOS != "windows" {
logger.Warn("GUI only supported on Windows, running without window")
return
}
var urlLE *walk.LineEdit
var openBtn *walk.PushButton
var exitBtn *walk.PushButton
var chatStatusLabel, eventSubLabel *walk.Label
var mw *walk.MainWindow
exitWithoutMinimize := false // флаг для полного закрытия
err := MainWindow{
AssignTo: &mw,
Title: "TTW_Bot v" + appVersion,
MinSize: Size{Width: 450, Height: 280},
Size: Size{Width: 500, Height: 300},
Layout: VBox{},
Children: []Widget{
Label{Text: "Веб-интерфейс бота доступен по адресу:"},
LineEdit{
AssignTo: &urlLE,
Text: webURL,
ReadOnly: true,
},
PushButton{
AssignTo: &openBtn,
Text: "🌐 Открыть в браузере",
OnClicked: func() {
openBrowser(webURL)
},
},
Label{Text: "─────────────────────────────────"},
Label{Text: "Статус Twitch чата:"},
Label{AssignTo: &chatStatusLabel, Text: "загрузка..."},
Label{Text: "Статус EventSub:"},
Label{AssignTo: &eventSubLabel, Text: "загрузка..."},
Label{Text: "─────────────────────────────────"},
PushButton{
AssignTo: &exitBtn,
Text: "❌ Завершить работу бота",
OnClicked: func() {
exitWithoutMinimize = true // запоминаем, что хотим выйти
_ = mw.Close()
},
},
},
}.Create()
if err != nil {
msg := "Failed to create GUI window: " + err.Error()
logger.Error(msg)
walk.MsgBox(nil, "Ошибка", msg, walk.MsgBoxIconError)
return
}
// При закрытии окна – либо сворачиваем в трей, либо завершаем программу
mw.Closing().Attach(func(cancel *bool, reason walk.CloseReason) {
if exitWithoutMinimize {
// полное завершение – не отменяем закрытие
return
}
// иначе – сворачиваем в трей
*cancel = true
mw.Hide()
})
// Создаём иконку в трее
ni, err := walk.NewNotifyIcon(mw)
if err != nil {
logger.Error("Failed to create notify icon: %v", err)
} else {
_ = ni.SetToolTip("TTW_Bot")
// Устанавливаем иконку (системная иконка информации)
if err := ni.SetIcon(walk.IconInformation()); err != nil {
logger.Warn("Failed to set icon: %v", err)
}
showAction := walk.NewAction()
_ = showAction.SetText("Показать окно")
showAction.Triggered().Attach(func() {
mw.Show()
mw.SetVisible(true)
})
exitAction := walk.NewAction()
_ = exitAction.SetText("Выход")
exitAction.Triggered().Attach(func() {
exitWithoutMinimize = true
_ = mw.Close()
})
menu := ni.ContextMenu()
_ = menu.Actions().Add(showAction)
_ = menu.Actions().Add(exitAction)
ni.MouseDown().Attach(func(x, y int, button walk.MouseButton) {
if button == walk.LeftButton {
mw.Show()
mw.SetVisible(true)
}
})
if err := ni.SetVisible(true); err != nil {
logger.Error("Failed to set tray icon visible: %v", err)
}
}
// Запускаем периодическое обновление статусов
ticker := time.NewTicker(5 * time.Second)
go func() {
for range ticker.C {
mw.Synchronize(func() {
chatConnected := getChatStatus()
if chatConnected {
_ = chatStatusLabel.SetText("✅ Подключен")
} else {
_ = chatStatusLabel.SetText("❌ Отключен")
}
esConnected, subs := getEventSubStatus()
if esConnected {
_ = eventSubLabel.SetText("✅ Подключен (" + itoa(len(subs)) + " подписок)")
} else {
_ = eventSubLabel.SetText("❌ Отключен")
}
})
}
}()
mw.Run()
ticker.Stop()
}
func openBrowser(url string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
cmd = exec.Command("cmd", "/c", "start", url)
case "darwin":
cmd = exec.Command("open", url)
default:
cmd = exec.Command("xdg-open", url)
}
if err := cmd.Start(); err != nil {
logger.Error("Failed to open browser: %v", err)
walk.MsgBox(nil, "Ошибка", "Не удалось открыть браузер: "+err.Error(), walk.MsgBoxIconError)
}
}
func itoa(i int) string {
if i == 0 {
return "0"
}
digits := ""
for i > 0 {
digits = string(rune('0'+i%10)) + digits
i /= 10
}
return digits
}
+120
View File
@@ -0,0 +1,120 @@
package hotkey
import (
"fmt"
"runtime"
"strings"
"syscall"
"unsafe"
)
var (
user32 = syscall.NewLazyDLL("user32.dll")
procSendInput = user32.NewProc("SendInput")
)
const (
INPUT_KEYBOARD = 1
KEYEVENTF_KEYDOWN = 0x0000
KEYEVENTF_KEYUP = 0x0002
)
type INPUT struct {
Type uint32
Ki KEYBDINPUT
}
type KEYBDINPUT struct {
WVirtKey uint16
WScan uint16
DwFlags uint32
Time uint32
DwExtra uint32
}
// Виртуальные коды клавиш
var keyCodes = map[string]uint16{
"A": 0x41, "B": 0x42, "C": 0x43, "D": 0x44, "E": 0x45, "F": 0x46, "G": 0x47, "H": 0x48,
"I": 0x49, "J": 0x4A, "K": 0x4B, "L": 0x4C, "M": 0x4D, "N": 0x4E, "O": 0x4F, "P": 0x50,
"Q": 0x51, "R": 0x52, "S": 0x53, "T": 0x54, "U": 0x55, "V": 0x56, "W": 0x57, "X": 0x58,
"Y": 0x59, "Z": 0x5A,
"0": 0x30, "1": 0x31, "2": 0x32, "3": 0x33, "4": 0x34, "5": 0x35, "6": 0x36, "7": 0x37, "8": 0x38, "9": 0x39,
"F1": 0x70, "F2": 0x71, "F3": 0x72, "F4": 0x73, "F5": 0x74, "F6": 0x75, "F7": 0x76, "F8": 0x77,
"F9": 0x78, "F10": 0x79, "F11": 0x7A, "F12": 0x7B,
"SPACE": 0x20, "ENTER": 0x0D, "TAB": 0x09, "ESC": 0x1B,
"SHIFT": 0x10, "CTRL": 0x11, "ALT": 0x12, "WIN": 0x5B,
"LEFT": 0x25, "UP": 0x26, "RIGHT": 0x27, "DOWN": 0x28,
}
var modCodes = map[string]uint16{
"ALT": 0x12,
"CTRL": 0x11,
"SHIFT": 0x10,
"WIN": 0x5B,
}
var enabled bool = false
func Init() error {
if runtime.GOOS != "windows" {
return fmt.Errorf("hotkey emulation only supported on Windows")
}
enabled = true
return nil
}
func SetEnabled(e bool) {
enabled = e
}
func PressCombination(combination string) error {
if !enabled {
return fmt.Errorf("hotkey emulation disabled by user")
}
parts := strings.Split(combination, "+")
if len(parts) == 0 {
return fmt.Errorf("invalid combination")
}
var mods []uint16
var mainKey uint16
for _, p := range parts {
upper := strings.ToUpper(p)
if code, ok := modCodes[upper]; ok {
mods = append(mods, code)
} else if code, ok := keyCodes[upper]; ok {
mainKey = code
} else {
return fmt.Errorf("unknown key: %s", p)
}
}
if mainKey == 0 {
return fmt.Errorf("no main key found")
}
for _, mod := range mods {
pressKey(mod, true)
}
pressKey(mainKey, true)
pressKey(mainKey, false)
for i := len(mods) - 1; i >= 0; i-- {
pressKey(mods[i], false)
}
return nil
}
func pressKey(vk uint16, down bool) {
var flags uint32 = KEYEVENTF_KEYDOWN
if !down {
flags = KEYEVENTF_KEYUP
}
input := INPUT{
Type: INPUT_KEYBOARD,
Ki: KEYBDINPUT{
WVirtKey: vk,
DwFlags: flags,
},
}
_, _, _ = procSendInput.Call(1, uintptr(unsafe.Pointer(&input)), unsafe.Sizeof(input))
}
+165
View File
@@ -0,0 +1,165 @@
package logger
import (
"fmt"
"log"
"os"
"sync"
"time"
)
type LogLevel string
const (
LevelInfo LogLevel = "INFO"
LevelWarn LogLevel = "WARN"
LevelError LogLevel = "ERROR"
LevelFatal LogLevel = "FATAL"
LevelDebug LogLevel = "DEBUG"
)
type LogEntry struct {
Time time.Time `json:"time"`
Level LogLevel `json:"level"`
Message string `json:"message"`
}
var (
mu sync.Mutex
// Кольцевой буфер последних записей (максимум 1000)
buffer []LogEntry
bufferIdx int
bufferCap = 1000
// Канал для подписчиков (один на всех, но можно расширить)
subscribers []chan LogEntry
subMu sync.RWMutex
)
func Init(filename string) error {
buffer = make([]LogEntry, bufferCap)
bufferIdx = 0
return nil
}
// Добавляет запись в буфер и рассылает подписчикам
func addEntry(level LogLevel, format string, v ...interface{}) {
msg := fmt.Sprintf(format, v...)
entry := LogEntry{
Time: time.Now(),
Level: level,
Message: msg,
}
// Сохраняем в буфер
mu.Lock()
buffer[bufferIdx] = entry
bufferIdx = (bufferIdx + 1) % bufferCap
mu.Unlock()
// Отправляем подписчикам (асинхронно, чтобы не блокировать)
subMu.RLock()
for _, ch := range subscribers {
select {
case ch <- entry:
default:
// Если канал заполнен, пропускаем (чтобы не тормозить)
}
}
subMu.RUnlock()
// Вывод в консоль
log.Printf("[%s] %s", level, msg)
if level == LevelFatal {
os.Exit(1)
}
}
func Info(format string, v ...interface{}) {
addEntry(LevelInfo, format, v...)
}
func Warn(format string, v ...interface{}) {
addEntry(LevelWarn, format, v...)
}
func Error(format string, v ...interface{}) {
addEntry(LevelError, format, v...)
}
func Fatal(format string, v ...interface{}) {
addEntry(LevelFatal, format, v...)
}
// GetRecent возвращает последние N записей (в хронологическом порядке)
func GetRecent(limit int) []LogEntry {
if limit > bufferCap {
limit = bufferCap
}
mu.Lock()
defer mu.Unlock()
result := make([]LogEntry, 0, limit)
// Буфер заполнен циклически, начинаем с bufferIdx-1 и идём назад
start := bufferIdx - 1
if start < 0 {
start = bufferCap - 1
}
for i := 0; i < limit; i++ {
idx := (start - i + bufferCap) % bufferCap
if buffer[idx].Time.IsZero() {
break
}
result = append([]LogEntry{buffer[idx]}, result...)
}
return result
}
// Subscribe возвращает канал для получения новых записей (буферизированный)
func Subscribe() <-chan LogEntry {
ch := make(chan LogEntry, 100)
subMu.Lock()
subscribers = append(subscribers, ch)
subMu.Unlock()
return ch
}
// Unsubscribe удаляет канал из списка подписчиков
func Unsubscribe(ch <-chan LogEntry) {
subMu.Lock()
defer subMu.Unlock()
for i, c := range subscribers {
if c == ch {
subscribers = append(subscribers[:i], subscribers[i+1:]...)
close(c)
break
}
}
}
func Debug(format string, v ...interface{}) {
addEntry(LevelDebug, format, v...)
}
// GetAll возвращает все имеющиеся записи в порядке от старых к новым.
func GetAll() []LogEntry {
mu.Lock()
defer mu.Unlock()
result := make([]LogEntry, 0, bufferCap)
// Начинаем с самого старого: индекс (bufferIdx) - это место, куда будет записана следующая запись.
// Самый старый элемент находится в bufferIdx, если буфер заполнен, иначе в 0.
start := 0
if buffer[bufferCap-1].Time.IsZero() {
// Буфер не полностью заполнен, начинаем с 0
start = 0
} else {
// Буфер заполнен, начинаем с bufferIdx (следующая позиция записи — это самое старое)
start = bufferIdx
}
for i := 0; i < bufferCap; i++ {
idx := (start + i) % bufferCap
if buffer[idx].Time.IsZero() {
continue
}
result = append(result, buffer[idx])
}
return result
}
+66
View File
@@ -0,0 +1,66 @@
package notifications
import (
"stream-bot/internal/audio" // вместо player
"stream-bot/internal/db"
"sync"
)
type Manager struct {
mu sync.RWMutex
settings map[string]*db.NotificationSetting
}
func NewManager() (*Manager, error) {
m := &Manager{
settings: make(map[string]*db.NotificationSetting),
}
if err := m.load(); err != nil {
return nil, err
}
return m, nil
}
func (m *Manager) load() error {
settings, err := db.GetAllNotificationSettings()
if err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
for _, s := range settings {
m.settings[s.EventName] = &s
}
return nil
}
func (m *Manager) PlayEvent(eventName string) error {
m.mu.RLock()
setting, ok := m.settings[eventName]
m.mu.RUnlock()
if !ok || !setting.Enabled || setting.SoundFile == "" {
return nil
}
// Используем audio.PlayWithVolume с громкостью из настроек
return audio.PlayWithVolume(setting.SoundFile, setting.Volume)
}
func (m *Manager) UpdateSetting(ns *db.NotificationSetting) error {
if err := db.SaveNotificationSetting(ns); err != nil {
return err
}
m.mu.Lock()
m.settings[ns.EventName] = ns
m.mu.Unlock()
return nil
}
func (m *Manager) GetAll() []db.NotificationSetting {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]db.NotificationSetting, 0, len(m.settings))
for _, v := range m.settings {
result = append(result, *v)
}
return result
}
+232
View File
@@ -0,0 +1,232 @@
package parser
import (
"fmt"
"math/rand"
"regexp"
"strconv"
"strings"
"time"
)
func init() {
rand.Seed(time.Now().UnixNano())
}
// ParseTemplate возвращает текст, список звуков и количество минут для таймаута (0 если нет)
func ParseTemplate(template string, username string, args string, aiResult string, getRandomUsername func() string) (result string, soundFiles []string, timeoutMinutes int, err error) {
// Замена простых переменных
result = strings.ReplaceAll(template, "<USERNAME/>", "@"+username)
result = strings.ReplaceAll(result, "<RANDOMUSER/>", getRandomUsername())
result = strings.ReplaceAll(result, "<ARG/>", args)
result = strings.ReplaceAll(result, "<AI/>", aiResult)
// Обработка <random>, <song>, <timeout>
result, soundFiles, timeoutMinutes = processTags(result)
// Рекурсивная обработка всех <group>
result, err = processGroups(result)
if err != nil {
return "", nil, 0, err
}
result = strings.ReplaceAll(result, "\n", " ")
result = strings.ReplaceAll(result, "\r", " ")
result = regexp.MustCompile(`\s+`).ReplaceAllString(result, " ")
result = strings.TrimSpace(result)
return result, soundFiles, timeoutMinutes, nil
}
func getRandomViewer() string {
names := []string{"зрителя", "кого-то", "случайного пользователя", "незнакомца"}
return names[rand.Intn(len(names))]
}
// processTags обрабатывает теги <random>, <song>, <timeout>
func processTags(text string) (string, []string, int) {
// <random>
randomRe := regexp.MustCompile(`<random\s+s=([-0-9]+)\s+e=([-0-9]+)\s*/>`)
text = randomRe.ReplaceAllStringFunc(text, func(m string) string {
sub := randomRe.FindStringSubmatch(m)
s, _ := strconv.Atoi(sub[1])
e, _ := strconv.Atoi(sub[2])
if s > e {
s, e = e, s
}
val := rand.Intn(e-s+1) + s
return strconv.Itoa(val)
})
// <song>
songRe := regexp.MustCompile(`<song\s+([^>]+)\s*/>`)
soundFiles := make([]string, 0)
text = songRe.ReplaceAllStringFunc(text, func(m string) string {
fRe := regexp.MustCompile(`f="([^"]+)"`)
matches := fRe.FindStringSubmatch(m)
if len(matches) > 1 {
soundFiles = append(soundFiles, matches[1])
}
return ""
})
// <timeout minutes="X"/>
timeoutMinutes := 0
timeoutRe := regexp.MustCompile(`<timeout\s+minutes="?([0-9]+)"?\s*/>`)
text = timeoutRe.ReplaceAllStringFunc(text, func(m string) string {
sub := timeoutRe.FindStringSubmatch(m)
if len(sub) > 1 {
minutes, _ := strconv.Atoi(sub[1])
if minutes > 0 {
timeoutMinutes = minutes
}
}
return ""
})
return text, soundFiles, timeoutMinutes
}
func processRandomAndSong(text string) (string, []string) {
// Обработка <random>
randomRe := regexp.MustCompile(`<random\s+s=([-0-9]+)\s+e=([-0-9]+)\s*/>`)
text = randomRe.ReplaceAllStringFunc(text, func(m string) string {
sub := randomRe.FindStringSubmatch(m)
s, _ := strconv.Atoi(sub[1])
e, _ := strconv.Atoi(sub[2])
if s > e {
s, e = e, s
}
val := rand.Intn(e-s+1) + s
return strconv.Itoa(val)
})
// Обработка <song> с любыми атрибутами
songRe := regexp.MustCompile(`<song\s+([^>]+)\s*/>`)
soundFiles := make([]string, 0)
text = songRe.ReplaceAllStringFunc(text, func(m string) string {
// Извлекаем значение атрибута f="..."
fRe := regexp.MustCompile(`f="([^"]+)"`)
matches := fRe.FindStringSubmatch(m)
if len(matches) > 1 {
soundFiles = append(soundFiles, matches[1])
}
return "" // заменяем тег на пустую строку
})
return text, soundFiles
}
// processGroups и остальные функции без изменений ...
func processGroups(text string) (string, error) {
var err error
for {
text, err = processOneGroup(text)
if err != nil {
return "", err
}
if !strings.Contains(text, "<group>") {
break
}
}
return text, nil
}
func processOneGroup(text string) (string, error) {
start := strings.Index(text, "<group>")
if start == -1 {
return text, nil
}
end := findMatchingClosingTag(text, start)
if end == -1 {
return "", fmt.Errorf("unclosed <group> at position %d", start)
}
inner := text[start+7 : end]
sections := extractGSections(inner)
if len(sections) == 0 {
return "", fmt.Errorf("no <g> sections inside <group> at position %d", start)
}
chosen := sections[rand.Intn(len(sections))]
newText := text[:start] + chosen + text[end+8:]
return newText, nil
}
// findMatchingClosingTag ищет позицию закрывающего </group> с учётом вложенности
func findMatchingClosingTag(text string, start int) int {
depth := 1
i := start + 7
for i < len(text) {
if strings.HasPrefix(text[i:], "<group>") {
depth++
i += 7
} else if strings.HasPrefix(text[i:], "</group>") {
depth--
if depth == 0 {
return i
}
i += 8
} else {
i++
}
}
return -1
}
// extractGSections извлекает содержимое всех <g>...</g> на верхнем уровне (не внутри вложенных групп)
func extractGSections(s string) []string {
var result []string
i := 0
for i < len(s) {
if strings.HasPrefix(s[i:], "<g>") {
startContent := i + 3
j := startContent
depth := 0
for j < len(s) {
if strings.HasPrefix(s[j:], "<group>") {
depth++
j += 7
} else if strings.HasPrefix(s[j:], "</group>") {
if depth > 0 {
depth--
}
j += 8
} else if strings.HasPrefix(s[j:], "</g>") && depth == 0 {
content := s[startContent:j]
result = append(result, content)
i = j + 4
break
} else {
j++
}
}
if j >= len(s) {
break
}
} else if strings.HasPrefix(s[i:], "<group>") {
// Пропускаем вложенную группу целиком
groupEnd := findMatchingClosingTag(s, i)
if groupEnd == -1 {
break
}
i = groupEnd + 8
} else {
i++
}
}
return result
}
// ValidateTemplate проверяет баланс тегов
func ValidateTemplate(template string) error {
balance := 0
for i := 0; i < len(template); i++ {
if strings.HasPrefix(template[i:], "<group>") {
balance++
i += 6
} else if strings.HasPrefix(template[i:], "</group>") {
balance--
i += 7
}
}
if balance != 0 {
return fmt.Errorf("unbalanced <group> tags")
}
return nil
}
+213
View File
@@ -0,0 +1,213 @@
package platforms
import (
"fmt"
"stream-bot/internal/commands"
"stream-bot/internal/db"
"stream-bot/internal/events"
"stream-bot/internal/logger"
"stream-bot/internal/notifications"
"stream-bot/internal/userstats"
"stream-bot/internal/webservices"
"strings"
"sync"
"time"
)
type Platform interface {
Connect() error
Disconnect()
SendMessage(text string) error
GetName() string
}
type Manager struct {
platforms map[string]Platform
cmdProc *commands.Processor
eventProc *events.Processor
mu sync.RWMutex
userStats *userstats.Store
notifMgr *notifications.Manager
webServices *webservices.Manager
}
func NewManager(cmdProc *commands.Processor, eventProc *events.Processor, notifMgr *notifications.Manager, webSrv *webservices.Manager, twitchClientID, twitchClientSecret string) *Manager {
m := &Manager{
platforms: make(map[string]Platform),
cmdProc: cmdProc,
eventProc: eventProc,
userStats: userstats.NewStore(),
notifMgr: notifMgr,
webServices: webSrv,
}
m.platforms["twitch"] = NewTwitchPlatform(m, twitchClientID, twitchClientSecret)
return m
}
func (m *Manager) ConnectAll() {
for name, p := range m.platforms {
if err := p.Connect(); err != nil {
logger.Error("Failed to connect %s: %v", name, err)
}
}
}
func (m *Manager) StopAll() {
for _, p := range m.platforms {
p.Disconnect()
}
}
func (m *Manager) GetPlatform(name string) Platform {
m.mu.RLock()
defer m.mu.RUnlock()
return m.platforms[name]
}
func (m *Manager) OnChatMessage(platform, channel, username, message string, isMod, isBroadcaster, isVip, isSubscriber bool) {
// Обновляем статистику пользователя
m.userStats.Update(username, func(u *userstats.UserStats) {
u.MessageCount++
u.LastActive = time.Now()
u.IsMod = isMod
u.IsVip = isVip
u.IsSubscriber = isSubscriber
})
if m.notifMgr != nil {
_ = m.notifMgr.PlayEvent("new_message")
}
m.webServices.SendChatMessage(webservices.ChatMessage{
Username: username,
Message: message,
IsMod: isMod,
IsVip: isVip,
IsSub: isSubscriber,
Timestamp: time.Now().Unix(),
})
// Обработка команды
if len(message) == 0 || message[0] != '!' {
// Проверка на отметку: если пользователь отмечен и это его первое сообщение за стрим (сегодня)
m.checkAndSendMarkNotification(username, platform, channel)
return
}
parts := strings.SplitN(message, " ", 2)
trigger := strings.TrimPrefix(parts[0], "!")
args := ""
if len(parts) > 1 {
args = parts[1]
}
resp, _, err := m.cmdProc.ProcessCommand(trigger, username, platform, isMod, isBroadcaster, args)
if err != nil {
logger.Error("Command error: %v", err)
return
}
if resp != "" {
if p := m.GetPlatform(platform); p != nil {
_ = p.SendMessage(resp)
}
}
// После команды тоже проверяем отметку (можно вынести в общее место)
m.checkAndSendMarkNotification(username, platform, channel)
}
// checkAndSendMarkNotification отправляет сообщение, если пользователь отмечен и сегодня ещё не отмечали
func (m *Manager) checkAndSendMarkNotification(username, platform, channel string) {
marked, lastDate, err := db.IsUserMarked(username, platform)
if err != nil {
logger.Error("Failed to check marked user: %v", err)
return
}
if !marked {
return
}
today := time.Now().Format("2006-01-02")
if lastDate == today {
return
}
// Отправляем сообщение с упоминанием пользователя, а не канала
msg := fmt.Sprintf("Время кое-кого отметить! Отмечен @%s", username)
if p := m.GetPlatform(platform); p != nil {
_ = p.SendMessage(msg)
}
_ = db.UpdateMarkedUserDate(username, platform, time.Now())
}
func (m *Manager) OnEvent(platform, eventName string, params map[string]string) {
m.eventProc.ProcessEvent(platform, eventName, params)
if m.webServices != nil {
// Преобразуем map[string]string в map[string]interface{}
data := make(map[string]interface{})
for k, v := range params {
data[k] = v
}
// Исправленный вызов:
m.webServices.SendAlertEvent(webservices.AlertEvent{
Type: eventName,
Data: data,
})
}
}
func (m *Manager) IsConnected(platform string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
if p, ok := m.platforms[platform]; ok {
if tw, ok := p.(*TwitchPlatform); ok {
return tw.IsConnected()
}
// для других платформ можно добавить аналогично
}
return false
}
func (m *Manager) GetAllUsers() []*userstats.UserStats {
return m.userStats.GetAll()
}
func (m *Manager) UpdateUserFlags(username string, isVip, isMod, isSubscriber bool) {
m.userStats.Update(username, func(u *userstats.UserStats) {
if isVip {
u.IsVip = isVip
}
if isMod {
u.IsMod = isMod
}
if isSubscriber {
u.IsSubscriber = isSubscriber
}
})
}
func (m *Manager) UpdateUserMarked(username string, marked bool) {
m.userStats.Update(username, func(u *userstats.UserStats) {
u.IsMarked = marked
})
}
func (m *Manager) SetVip(username string, isVip bool) {
m.userStats.Update(username, func(u *userstats.UserStats) {
u.IsVip = isVip
})
}
func (m *Manager) SetMod(username string, isMod bool) {
m.userStats.Update(username, func(u *userstats.UserStats) {
u.IsMod = isMod
})
}
func (m *Manager) SetMarked(username string, isMarked bool) {
m.userStats.Update(username, func(u *userstats.UserStats) {
u.IsMarked = isMarked
})
}
func (m *Manager) GetTwitchEventSubStatus() (connected bool, subscriptions []string, err error) {
tw, ok := m.platforms["twitch"].(*TwitchPlatform)
if !ok {
return false, nil, fmt.Errorf("twitch platform not available")
}
connected, subscriptions = tw.EventSubStatus()
return connected, subscriptions, nil
}
+234
View File
@@ -0,0 +1,234 @@
package platforms
import (
"fmt"
"stream-bot/internal/db"
"stream-bot/internal/logger"
"stream-bot/internal/twitchapi"
"strings"
"sync"
"github.com/gempir/go-twitch-irc/v4"
)
type TwitchPlatform struct {
client *twitch.Client
manager *Manager
channel string
botLogin string
connected bool
mu sync.RWMutex
eventSub *TwitchEventSub
twitchAPI *twitchapi.TwitchAPI
}
func NewTwitchPlatform(mgr *Manager, clientID, clientSecret string) *TwitchPlatform {
twitchAPI := twitchapi.New(clientID, clientSecret)
return &TwitchPlatform{
manager: mgr,
twitchAPI: twitchAPI,
}
}
func (t *TwitchPlatform) GetName() string {
return "twitch"
}
func (t *TwitchPlatform) Connect() error {
tokens, err := db.GetPlatformTokens("twitch")
if err != nil || tokens == nil || tokens.BotToken == "" {
logger.Warn("Twitch bot token not set, skipping connection")
return fmt.Errorf("no bot token")
}
t.botLogin = tokens.BotLogin
if t.botLogin == "" {
t.botLogin = "justinfan123"
}
t.channel = tokens.UserLogin
if t.channel == "" {
logger.Warn("Twitch user login not set, cannot join channel")
return fmt.Errorf("no channel name")
}
t.client = twitch.NewClient(t.botLogin, "oauth:"+tokens.BotToken)
t.client.OnPrivateMessage(func(msg twitch.PrivateMessage) {
badges := msg.Tags["badges"]
isMod := strings.Contains(badges, "moderator/1")
isVip := strings.Contains(badges, "vip/1")
isSubscriber := strings.Contains(badges, "subscriber/")
isBroadcaster := strings.Contains(badges, "broadcaster/1")
t.manager.OnChatMessage("twitch", msg.Channel, msg.User.Name, msg.Message, isMod, isBroadcaster, isVip, isSubscriber)
})
t.client.Join(t.channel)
go func() {
_ = t.client.Connect()
}()
t.mu.Lock()
t.connected = true
t.mu.Unlock()
// EventSub использует уже существующий twitchAPI
t.eventSub = NewTwitchEventSub(t.manager, t.twitchAPI)
if err := t.eventSub.Start(); err != nil {
logger.Warn("Failed to start EventSub: %v", err)
}
return nil
}
func (t *TwitchPlatform) Disconnect() {
t.mu.Lock()
defer t.mu.Unlock()
if t.eventSub != nil {
t.eventSub.Stop()
}
if t.client != nil {
t.client.Disconnect()
}
t.connected = false
}
func (t *TwitchPlatform) IsConnected() bool {
t.mu.RLock()
defer t.mu.RUnlock()
return t.connected
}
func (t *TwitchPlatform) SendMessage(text string) error {
if t.client == nil {
return fmt.Errorf("not connected")
}
t.client.Say(t.channel, text)
return nil
}
func (t *TwitchPlatform) TimeoutUser(username string, seconds int) {
t.client.Say(t.channel, fmt.Sprintf("/timeout %s %d", username, seconds))
}
func (t *TwitchPlatform) BanUser(username string) {
t.client.Say(t.channel, fmt.Sprintf("/ban %s", username))
}
func (t *TwitchPlatform) UnbanUser(username string) {
t.client.Say(t.channel, fmt.Sprintf("/unban %s", username))
}
func (t *TwitchPlatform) AddVip(username string) {
t.client.Say(t.channel, fmt.Sprintf("/vip %s", username))
}
func (t *TwitchPlatform) RemoveVip(username string) {
t.client.Say(t.channel, fmt.Sprintf("/unvip %s", username))
}
func (t *TwitchPlatform) AddMod(username string) {
t.client.Say(t.channel, fmt.Sprintf("/mod %s", username))
}
func (t *TwitchPlatform) RemoveMod(username string) {
t.client.Say(t.channel, fmt.Sprintf("/unmod %s", username))
}
// GetClientID удалён используем t.twitchAPI.GetClientID() при необходимости
func (t *TwitchPlatform) EventSubStatus() (connected bool, subscriptions []string) {
if t.eventSub == nil {
return false, nil
}
return t.eventSub.IsConnected(), t.eventSub.GetSubscriptions()
}
// TimeoutUserViaAPI таймаут через API
func (t *TwitchPlatform) TimeoutUserViaAPI(username string, seconds int) error {
broadcasterID, err := t.twitchAPI.GetBroadcasterID()
if err != nil {
return err
}
moderatorID := broadcasterID
userID, err := t.twitchAPI.GetUserID(username)
if err != nil {
return err
}
return t.twitchAPI.TimeoutUser(broadcasterID, moderatorID, userID, seconds)
}
// BanUserViaAPI бан через API
func (t *TwitchPlatform) BanUserViaAPI(username string) error {
broadcasterID, err := t.twitchAPI.GetBroadcasterID()
if err != nil {
return err
}
userID, err := t.twitchAPI.GetUserID(username)
if err != nil {
return err
}
return t.twitchAPI.BanUser(broadcasterID, broadcasterID, userID)
}
// UnbanUserViaAPI разбан через API
func (t *TwitchPlatform) UnbanUserViaAPI(username string) error {
broadcasterID, err := t.twitchAPI.GetBroadcasterID()
if err != nil {
return err
}
userID, err := t.twitchAPI.GetUserID(username)
if err != nil {
return err
}
return t.twitchAPI.UnbanUser(broadcasterID, broadcasterID, userID)
}
// AddVipViaAPI добавить VIP
func (t *TwitchPlatform) AddVipViaAPI(username string) error {
broadcasterID, err := t.twitchAPI.GetBroadcasterID()
if err != nil {
return err
}
userID, err := t.twitchAPI.GetUserID(username)
if err != nil {
return err
}
return t.twitchAPI.AddVip(broadcasterID, userID)
}
// RemoveVipViaAPI удалить VIP
func (t *TwitchPlatform) RemoveVipViaAPI(username string) error {
broadcasterID, err := t.twitchAPI.GetBroadcasterID()
if err != nil {
return err
}
userID, err := t.twitchAPI.GetUserID(username)
if err != nil {
return err
}
return t.twitchAPI.RemoveVip(broadcasterID, userID)
}
// AddModViaAPI добавить модератора
func (t *TwitchPlatform) AddModViaAPI(username string) error {
broadcasterID, err := t.twitchAPI.GetBroadcasterID()
if err != nil {
return err
}
userID, err := t.twitchAPI.GetUserID(username)
if err != nil {
return err
}
return t.twitchAPI.AddMod(broadcasterID, userID)
}
// RemoveModViaAPI удалить модератора
func (t *TwitchPlatform) RemoveModViaAPI(username string) error {
broadcasterID, err := t.twitchAPI.GetBroadcasterID()
if err != nil {
return err
}
userID, err := t.twitchAPI.GetUserID(username)
if err != nil {
return err
}
return t.twitchAPI.RemoveMod(broadcasterID, userID)
}
+134
View File
@@ -0,0 +1,134 @@
package platforms
import (
"context"
"fmt"
"net/http"
"stream-bot/internal/logger"
"time"
)
type TwitchAuth struct {
clientID string
clientSecret string
redirectURI string
server *http.Server
waitCh chan string
}
func NewTwitchAuth(clientID, clientSecret string) *TwitchAuth {
return &TwitchAuth{
clientID: clientID,
clientSecret: clientSecret,
redirectURI: "http://localhost:8089",
waitCh: make(chan string, 1),
}
}
func (ta *TwitchAuth) GenerateAuthURL(scope []string, state string) string {
url := "https://id.twitch.tv/oauth2/authorize?" +
"client_id=" + ta.clientID +
"&redirect_uri=" + ta.redirectURI +
"&response_type=token" +
"&scope=" + scopeString(scope) +
"&state=" + state
return url
}
func scopeString(scopes []string) string {
s := ""
for i, sc := range scopes {
if i > 0 {
s += "+"
}
s += sc
}
return s
}
func (ta *TwitchAuth) StartTempServer() error {
if ta.server != nil {
return nil
}
mux := http.NewServeMux()
mux.HandleFunc("/", ta.handleCallback)
ta.server = &http.Server{
Addr: ":8089",
Handler: mux,
}
go func() {
if err := ta.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("OAuth server error: %v", err)
}
}()
return nil
}
func (ta *TwitchAuth) StopTempServer() {
if ta.server != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = ta.server.Shutdown(ctx)
ta.server = nil
}
}
func (ta *TwitchAuth) handleCallback(w http.ResponseWriter, r *http.Request) {
html := `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Twitch Auth</title>
<style>
body { font-family: sans-serif; text-align: center; margin-top: 50px; }
.success { color: green; }
.error { color: red; }
</style>
</head>
<body>
<h3>Авторизация Twitch</h3>
<p>Обработка токена...</p>
<script>
const hash = window.location.hash.substring(1);
const params = new URLSearchParams(hash);
const accessToken = params.get('access_token');
const state = params.get('state');
if (accessToken && state) {
fetch('http://localhost:8080/api/platforms/twitch/auth/callback?token=' + encodeURIComponent(accessToken) + '&state=' + encodeURIComponent(state))
.then(res => {
if (res.ok) return res.text();
throw new Error('Server error: ' + res.status);
})
.then(data => {
document.body.innerHTML = '<h3 class="success">✅ Токен успешно сохранён! Теперь можно закрыть эту вкладку.</h3>';
})
.catch(err => {
document.body.innerHTML = '<h3 class="error">❌ Ошибка сохранения токена: ' + err.message + '</h3><p>Пожалуйста, закройте это окно и проверьте настройки в боте.</p>';
});
} else {
document.body.innerHTML = '<h3 class="error">❌ Токен не получен или отсутствует state</h3>';
}
</script>
</body>
</html>
`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(html))
}
func (ta *TwitchAuth) WaitForToken(timeout time.Duration) (string, error) {
select {
case token := <-ta.waitCh:
return token, nil
case <-time.After(timeout):
return "", fmt.Errorf("timeout waiting for token")
}
}
func (ta *TwitchAuth) SetTokenCallback(token string) {
select {
case ta.waitCh <- token:
default:
}
}
+416
View File
@@ -0,0 +1,416 @@
package platforms
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"stream-bot/internal/logger"
"stream-bot/internal/twitchapi"
"sync"
"time"
"github.com/gorilla/websocket"
)
// TwitchEventSub реализует клиент EventSub через WebSocket
type TwitchEventSub struct {
manager *Manager
twitchAPI *twitchapi.TwitchAPI
conn *websocket.Conn
sessionID string
subscriptions map[string]bool // eventType -> подписана ли
mu sync.Mutex
stopCh chan struct{}
doneCh chan struct{}
ctx context.Context
cancel context.CancelFunc
}
// Структуры сообщений EventSub
type eventSubMessage struct {
Metadata struct {
MessageID string `json:"message_id"`
MessageType string `json:"message_type"` // session_welcome, session_keepalive, notification, session_revoke
MessageTimestamp string `json:"message_timestamp"`
SessionID string `json:"session_id,omitempty"`
SubscriptionType string `json:"subscription_type,omitempty"`
SubscriptionVersion string `json:"subscription_version,omitempty"`
} `json:"metadata"`
Payload struct {
Session struct {
ID string `json:"id"`
Status string `json:"status"`
ConnectedAt string `json:"connected_at"`
KeepaliveTimeoutSeconds int `json:"keepalive_timeout_seconds"`
ReconnectURL string `json:"reconnect_url"`
} `json:"session,omitempty"`
Subscription struct {
ID string `json:"id"`
Status string `json:"status"`
Type string `json:"type"`
Version string `json:"version"`
Condition map[string]string `json:"condition"`
Transport struct {
Method string `json:"method"`
SessionID string `json:"session_id"`
} `json:"transport"`
CreatedAt string `json:"created_at"`
} `json:"subscription,omitempty"`
Event json.RawMessage `json:"event,omitempty"`
} `json:"payload"`
}
func NewTwitchEventSub(manager *Manager, twitchAPI *twitchapi.TwitchAPI) *TwitchEventSub {
return &TwitchEventSub{
manager: manager,
twitchAPI: twitchAPI,
subscriptions: make(map[string]bool),
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
}
func (es *TwitchEventSub) Start() error {
es.ctx, es.cancel = context.WithCancel(context.Background())
logger.Info("Starting Twitch EventSub WebSocket client...")
go es.connect()
return nil
}
func (es *TwitchEventSub) Stop() {
if es.cancel != nil {
es.cancel()
}
if es.conn != nil {
_ = es.conn.Close()
}
select {
case <-es.doneCh:
logger.Info("Twitch EventSub stopped gracefully")
case <-time.After(3 * time.Second):
logger.Warn("Twitch EventSub stop timeout")
}
}
func (es *TwitchEventSub) connect() {
defer close(es.doneCh)
for {
select {
case <-es.ctx.Done():
return
default:
}
conn, _, err := websocket.DefaultDialer.Dial("wss://eventsub.wss.twitch.tv/ws", nil)
if err != nil {
logger.Error("EventSub WebSocket dial error: %v, reconnecting...", err)
select {
case <-es.ctx.Done():
return
case <-time.After(5 * time.Second):
continue
}
}
es.mu.Lock()
es.conn = conn
es.mu.Unlock()
err = es.readLoop(conn)
if err != nil {
logger.Error("EventSub read loop error: %v, reconnecting...", err)
_ = conn.Close()
select {
case <-es.ctx.Done():
return
case <-time.After(5 * time.Second):
continue
}
}
select {
case <-es.ctx.Done():
return
default:
}
}
}
func (es *TwitchEventSub) readLoop(conn *websocket.Conn) error {
for {
select {
case <-es.ctx.Done():
return es.ctx.Err()
default:
}
_, msg, err := conn.ReadMessage()
if err != nil {
return err
}
var envelope eventSubMessage
if err := json.Unmarshal(msg, &envelope); err != nil {
logger.Error("Failed to parse EventSub message: %v", err)
continue
}
switch envelope.Metadata.MessageType {
case "session_welcome":
es.handleWelcome(envelope)
case "session_keepalive":
// Ничего не логируем, чтобы не засорять логи
case "notification":
es.handleNotification(envelope)
case "session_revoke":
logger.Warn("EventSub session revoked, will reconnect")
return fmt.Errorf("session revoked")
default:
logger.Warn("Unknown EventSub message type: %s", envelope.Metadata.MessageType)
}
}
}
func (es *TwitchEventSub) handleWelcome(msg eventSubMessage) {
es.sessionID = msg.Payload.Session.ID
logger.Info("EventSub connected, session ID: %s", es.sessionID)
// После получения welcome подписываемся на события
broadcasterID, err := es.twitchAPI.GetBroadcasterID()
if err != nil {
logger.Error("Cannot get broadcaster ID for subscriptions: %v", err)
return
}
// Список событий для подписки
subscriptions := []struct {
Type string
Version string
Condition map[string]string
}{
{
Type: "channel.follow",
Version: "2",
Condition: map[string]string{
"broadcaster_user_id": broadcasterID,
"moderator_user_id": broadcasterID, // используем ID стримера как модератора
},
},
{
Type: "channel.subscribe",
Version: "1",
Condition: map[string]string{
"broadcaster_user_id": broadcasterID,
},
},
{
Type: "channel.subscription.gift",
Version: "1",
Condition: map[string]string{
"broadcaster_user_id": broadcasterID,
},
},
{
Type: "channel.raid",
Version: "1",
Condition: map[string]string{
"to_broadcaster_user_id": broadcasterID,
},
},
{
Type: "channel.channel_points_custom_reward_redemption.add",
Version: "1",
Condition: map[string]string{
"broadcaster_user_id": broadcasterID,
},
},
}
for _, sub := range subscriptions {
if err := es.subscribe(sub.Type, sub.Version, sub.Condition); err != nil {
logger.Error("Failed to subscribe to %s: %v", sub.Type, err)
} else {
es.mu.Lock()
es.subscriptions[sub.Type] = true
es.mu.Unlock()
logger.Info("Subscribed to %s", sub.Type)
}
}
}
// subscribe создаёт подписку через API Twitch, используя токен стримера (user_token)
func (es *TwitchEventSub) subscribe(eventType, version string, condition map[string]string) error {
// Берём токен стримера (user_token), т.к. для подписки на follow нужны права модератора
token, err := es.twitchAPI.GetUserToken() // добавим этот метод в twitchapi
if err != nil {
return err
}
clientID := es.twitchAPI.GetClientID()
subReq := struct {
Type string `json:"type"`
Version string `json:"version"`
Condition map[string]string `json:"condition"`
Transport struct {
Method string `json:"method"`
SessionID string `json:"session_id"`
} `json:"transport"`
}{
Type: eventType,
Version: version,
Condition: condition,
Transport: struct {
Method string `json:"method"`
SessionID string `json:"session_id"`
}{
Method: "websocket",
SessionID: es.sessionID,
},
}
body, _ := json.Marshal(subReq)
url := "https://api.twitch.tv/helix/eventsub/subscriptions"
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Client-Id", clientID)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
if resp.StatusCode != 202 {
errBody, _ := io.ReadAll(resp.Body)
logger.Error("Twitch API error when subscribing to %s: status %d, body: %s", eventType, resp.StatusCode, string(errBody))
return fmt.Errorf("subscription failed: %d %s", resp.StatusCode, string(errBody))
}
logger.Info("Subscription to %s created successfully", eventType)
return nil
}
func (es *TwitchEventSub) handleNotification(msg eventSubMessage) {
eventType := msg.Metadata.SubscriptionType
logger.Info("EventSub notification received: %s", eventType)
var params map[string]string
switch eventType {
case "channel.follow":
var data struct {
UserName string `json:"user_name"`
UserLogin string `json:"user_login"`
FollowedAt string `json:"followed_at"`
}
if err := json.Unmarshal(msg.Payload.Event, &data); err != nil {
logger.Error("Failed to parse follow event: %v", err)
return
}
logger.Info("Follow event: %s followed the channel at %s", data.UserName, data.FollowedAt)
params = map[string]string{
"username": data.UserName,
"user_login": data.UserLogin,
"followed_at": data.FollowedAt,
}
es.manager.OnEvent("twitch", "follow", params)
case "channel.subscribe":
var data struct {
UserName string `json:"user_name"`
Tier string `json:"tier"` // "1000", "2000", "3000"
IsGift bool `json:"is_gift"`
}
if err := json.Unmarshal(msg.Payload.Event, &data); err != nil {
logger.Error("Failed to parse subscribe event: %v", err)
return
}
logger.Info("Subscribe event: %s subscribed with tier %s (is_gift=%v)", data.UserName, data.Tier, data.IsGift)
params = map[string]string{
"username": data.UserName,
"tier": data.Tier,
"is_gift": fmt.Sprintf("%t", data.IsGift),
}
es.manager.OnEvent("twitch", "subscribe", params)
case "channel.subscription.gift":
var data struct {
UserName string `json:"user_name"` // даритель
RecipientName string `json:"recipient_user_name"`
Tier string `json:"tier"`
CumulativeTotal int `json:"cumulative_total"`
}
if err := json.Unmarshal(msg.Payload.Event, &data); err != nil {
logger.Error("Failed to parse gift sub event: %v", err)
return
}
logger.Info("Gift sub event: %s gifted %d subscription(s) (tier %s), recipient: %s", data.UserName, data.CumulativeTotal, data.Tier, data.RecipientName)
params = map[string]string{
"gifter": data.UserName,
"recipient": data.RecipientName,
"tier": data.Tier,
"cumulative_total": fmt.Sprintf("%d", data.CumulativeTotal),
}
es.manager.OnEvent("twitch", "gift_sub", params)
case "channel.raid":
var data struct {
FromBroadcasterName string `json:"from_broadcaster_user_name"`
Viewers int `json:"viewers"`
}
if err := json.Unmarshal(msg.Payload.Event, &data); err != nil {
logger.Error("Failed to parse raid event: %v", err)
return
}
logger.Info("Raid event: %s raided with %d viewers", data.FromBroadcasterName, data.Viewers)
params = map[string]string{
"from": data.FromBroadcasterName,
"viewers": fmt.Sprintf("%d", data.Viewers),
}
es.manager.OnEvent("twitch", "raid", params)
case "channel.channel_points_custom_reward_redemption.add":
var data struct {
UserName string `json:"user_name"`
Reward struct {
Title string `json:"title"`
Cost int `json:"cost"`
} `json:"reward"`
UserInput string `json:"user_input"`
}
if err := json.Unmarshal(msg.Payload.Event, &data); err != nil {
logger.Error("Failed to parse reward redemption event: %v", err)
return
}
logger.Info("Reward redemption: %s redeemed '%s' (cost %d) with input: %s", data.UserName, data.Reward.Title, data.Reward.Cost, data.UserInput)
params = map[string]string{
"username": data.UserName,
"reward_title": data.Reward.Title,
"reward_cost": fmt.Sprintf("%d", data.Reward.Cost),
"user_input": data.UserInput,
}
es.manager.OnEvent("twitch", "reward_redemption", params)
default:
logger.Warn("Unhandled event type: %s", eventType)
}
}
func (es *TwitchEventSub) IsConnected() bool {
es.mu.Lock()
defer es.mu.Unlock()
return es.conn != nil
}
func (es *TwitchEventSub) GetSubscriptions() []string {
es.mu.Lock()
defer es.mu.Unlock()
subs := make([]string, 0, len(es.subscriptions))
for s := range es.subscriptions {
subs = append(subs, s)
}
return subs
}
+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
}
+62
View File
@@ -0,0 +1,62 @@
package userstats
import (
"sync"
"time"
)
type UserStats struct {
Username string `json:"username"`
MessageCount int `json:"message_count"`
LastActive time.Time `json:"last_active"`
IsMod bool `json:"is_mod"`
IsVip bool `json:"is_vip"`
IsSubscriber bool `json:"is_subscriber"`
IsMarked bool `json:"is_marked"`
LastMarkedDate time.Time `json:"-"`
}
type Store struct {
mu sync.RWMutex
users map[string]*UserStats // key = username (lowercase)
}
func NewStore() *Store {
return &Store{
users: make(map[string]*UserStats),
}
}
func (s *Store) GetOrCreate(username string) *UserStats {
s.mu.Lock()
defer s.mu.Unlock()
lower := username
if u, ok := s.users[lower]; ok {
return u
}
u := &UserStats{Username: username}
s.users[lower] = u
return u
}
func (s *Store) Update(username string, fn func(*UserStats)) {
s.mu.Lock()
defer s.mu.Unlock()
lower := username
u, ok := s.users[lower]
if !ok {
u = &UserStats{Username: username}
s.users[lower] = u
}
fn(u)
}
func (s *Store) GetAll() []*UserStats {
s.mu.RLock()
defer s.mu.RUnlock()
res := make([]*UserStats, 0, len(s.users))
for _, u := range s.users {
res = append(res, u)
}
return res
}
+208
View File
@@ -0,0 +1,208 @@
package webservices
import (
"encoding/json"
"fmt"
"html/template"
"net/http"
"stream-bot/internal/db"
"stream-bot/internal/logger"
"strings"
)
type AlertService struct {
*baseService
config *db.AlertWebConfig
server *http.Server
}
func NewAlertService(port int, config *db.AlertWebConfig) *AlertService {
return &AlertService{
baseService: newBaseService(port),
config: config,
}
}
func (s *AlertService) Start() error {
mux := http.NewServeMux()
mux.HandleFunc("/", s.handleIndex)
mux.HandleFunc("/events", s.handleEvents)
mux.HandleFunc("/notify", s.handleNotify) // новый эндпоинт
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("data/media"))))
mux.Handle("/sounds/", http.StripPrefix("/sounds/", http.FileServer(http.Dir("data/sounds")))) // для звуков
s.server = &http.Server{Addr: fmt.Sprintf(":%d", s.port), Handler: mux}
s.running = true
go func() {
if err := s.server.ListenAndServe(); nil != err && err != http.ErrServerClosed {
logger.Error("Alert service error on port %d: %v", s.port, err)
}
}()
logger.Info("Alert service started on port %d", s.port)
return nil
}
// handleNotify принимает POST-запросы с уведомлениями
func (s *AlertService) handleNotify(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var data map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Отправляем всем подключённым клиентам
s.broadcast(data)
w.WriteHeader(http.StatusOK)
}
func (s *AlertService) Stop() error {
s.running = false
if s.server != nil {
return s.server.Close()
}
return nil
}
func (s *AlertService) ReloadConfig(config interface{}) error {
newCfg, ok := config.(*db.AlertWebConfig)
if !ok {
return fmt.Errorf("invalid config type, expected *db.AlertWebConfig")
}
s.config = newCfg
return nil
}
func (s *AlertService) GetPort() int { return s.port }
func (s *AlertService) GetType() string { return "alert" }
func (s *AlertService) IsRunning() bool { return s.running }
// SendToClients обрабатывает событие и рассылает его клиентам
func (s *AlertService) SendToClients(event AlertEvent) {
s.mu.RLock()
cfg := s.config
s.mu.RUnlock()
if cfg == nil {
return
}
// Обработка команды со звуком (без визуального уведомления)
if event.Type == "command_sound" && event.Sound != "" {
out := map[string]interface{}{
"title": "",
"text": "",
"duration": 1,
"image": "",
"sound": event.Sound,
}
s.broadcast(out)
return
}
// Обычные события (follow, subscribe и т.д.)
eventCfg, ok := cfg.Events[event.Type]
if !ok || !eventCfg.Enabled {
return
}
title := replacePlaceholders(eventCfg.TitleTemplate, event.Data)
text := replacePlaceholders(eventCfg.TextTemplate, event.Data)
duration := eventCfg.DurationSec
if duration == 0 {
duration = cfg.DefaultDuration
}
image := eventCfg.ImageFile
if image == "" {
image = cfg.DefaultImage
}
sound := eventCfg.SoundFile
if sound == "" {
sound = cfg.DefaultSound
}
out := map[string]interface{}{
"title": title,
"text": text,
"duration": duration,
"image": image,
"sound": sound,
}
s.broadcast(out)
}
func (s *AlertService) handleIndex(w http.ResponseWriter, _ *http.Request) {
tmpl := `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Alerts Overlay</title>
<style>
body { margin: 0; overflow: hidden; font-family: 'Arial', sans-serif; }
.alert-container { position: fixed; bottom: 20px; right: 20px; width: 300px; z-index: 1000; }
.alert { background: rgba(0,0,0,0.8); color: white; border-radius: 8px; padding: 10px; margin-bottom: 10px; animation: slideIn 0.5s ease; display: flex; align-items: center; gap: 10px; }
.alert img { max-width: 60px; max-height: 60px; border-radius: 50%; }
.alert .content { flex: 1; }
.alert .title { font-weight: bold; font-size: 1.2em; }
.alert .text { font-size: 0.9em; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
</style>
</head>
<body>
<div id="alerts" class="alert-container"></div>
<script>
const eventSource = new EventSource('/events');
eventSource.onmessage = function(e) { showAlert(JSON.parse(e.data)); };
function showAlert(ev) {
const container = document.getElementById('alerts');
const div = document.createElement('div');
div.className = 'alert';
let imgHtml = ev.image ? '<img src="' + ev.image + '">' : '';
div.innerHTML = imgHtml + '<div class="content"><div class="title">' + escapeHtml(ev.title) + '</div><div class="text">' + escapeHtml(ev.text) + '</div></div>';
container.appendChild(div);
setTimeout(() => div.remove(), ev.duration * 1000);
if (ev.sound) new Audio(ev.sound).play().catch(e => console.log(e));
}
function escapeHtml(str) { if (!str) return ''; return str.replace(/[&<>]/g, function(m) { if (m === '&') return '&amp;'; if (m === '<') return '&lt;'; if (m === '>') return '&gt;'; return m; }); }
</script>
</body>
</html>`
t := template.Must(template.New("alert").Parse(tmpl))
_ = t.Execute(w, nil)
}
func (s *AlertService) handleEvents(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}
clientChan := make(chan interface{}, 100)
s.clientsMu.Lock()
s.clients[clientChan] = true
s.clientsMu.Unlock()
defer func() {
s.clientsMu.Lock()
delete(s.clients, clientChan)
s.clientsMu.Unlock()
close(clientChan)
}()
for {
select {
case data := <-clientChan:
jsonData, _ := json.Marshal(data)
_, _ = fmt.Fprintf(w, "data: %s\n\n", jsonData)
flusher.Flush()
case <-r.Context().Done():
return
}
}
}
func replacePlaceholders(tmpl string, data map[string]interface{}) string {
res := tmpl
for k, v := range data {
res = strings.ReplaceAll(res, "{"+k+"}", fmt.Sprintf("%v", v))
}
return res
}
+37
View File
@@ -0,0 +1,37 @@
package webservices
import (
"sync"
)
type baseService struct {
port int
running bool
mu sync.RWMutex
clients map[chan interface{}]bool
clientsMu sync.RWMutex
}
func newBaseService(port int) *baseService {
return &baseService{
port: port,
clients: make(map[chan interface{}]bool),
}
}
// Broadcast отправляет данные всем подключённым SSE-клиентам
func (s *baseService) broadcast(data interface{}) {
s.clientsMu.RLock()
defer s.clientsMu.RUnlock()
for ch := range s.clients {
// Убираем default, чтобы отправка была блокирующей, но тогда один медленный клиент может замедлить всех
// Лучше увеличить буфер и оставить default с предупреждением
select {
case ch <- data:
default:
// Если канал заполнен, это проблема клиента, но мы не должны терять сообщения для других клиентов.
// Однако блокировка нежелательна. Увеличим буфер до 500.
}
}
}
+154
View File
@@ -0,0 +1,154 @@
package webservices
import (
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"stream-bot/internal/db"
"stream-bot/internal/logger"
)
type ChatService struct {
*baseService
config *db.ChatWebConfig
server *http.Server
}
func NewChatService(port int, config *db.ChatWebConfig) *ChatService {
return &ChatService{
baseService: newBaseService(port),
config: config,
}
}
func (s *ChatService) Start() error {
mux := http.NewServeMux()
mux.HandleFunc("/", s.handleIndex)
mux.HandleFunc("/events", s.handleEvents)
s.server = &http.Server{Addr: fmt.Sprintf(":%d", s.port), Handler: mux}
s.running = true
go func() {
if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("Chat service error on port %d: %v", s.port, err)
}
}()
logger.Info("Chat service started on port %d", s.port)
return nil
}
func (s *ChatService) Stop() error {
s.running = false
if s.server != nil {
return s.server.Close()
}
return nil
}
func (s *ChatService) ReloadConfig(config interface{}) error {
newCfg, ok := config.(*db.ChatWebConfig)
if !ok {
return fmt.Errorf("invalid config type, expected *db.ChatWebConfig")
}
s.config = newCfg
return nil
}
func (s *ChatService) GetPort() int { return s.port }
func (s *ChatService) GetType() string { return "chat" }
func (s *ChatService) IsRunning() bool { return s.running }
// SendToClients отправляет сообщение всем подключённым SSE-клиентам
func (s *ChatService) SendToClients(msg ChatMessage) {
s.broadcast(msg)
}
func (s *ChatService) handleIndex(w http.ResponseWriter, _ *http.Request) {
tmpl := `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Chat Overlay</title>
<style>
body { background-color: {{.BackgroundColor}}; color: {{.TextColor}}; font-family: {{.FontFamily}}; font-size: {{.FontSize}}px; opacity: {{.Opacity}}%; margin: 0; padding: 10px; overflow: hidden; }
.message { margin-bottom: 8px; padding: 4px; border-bottom: 1px solid rgba(255,255,255,0.2); animation: fadeIn 0.3s; }
.badge { display: inline-block; margin-right: 4px; }
.username { font-weight: bold; margin-right: 8px; }
.mod { color: #34eb5e; }
.vip { color: #e8b92e; }
.sub { color: #9147ff; }
.time { font-size: 0.8em; color: #aaa; margin-right: 8px; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
</style>
</head>
<body>
<div id="messages"></div>
<script>
if (window.eventSource) window.eventSource.close();
const eventSource = new EventSource('/events');
window.eventSource = eventSource;
let messageCounter = 0;
const maxMessages = {{.MaxMessages}};
const messageTimeout = {{.MessageTimeoutSec}} * 1000;
let messageQueue = [];
function addMessage(msg) {
messageCounter++;
console.log('#' + messageCounter, msg.username + ':', msg.message);
const container = document.getElementById('messages');
const div = document.createElement('div');
div.className = 'message';
let badgeHtml = '';
if (msg.is_mod) badgeHtml += '<span class="badge mod">🎭</span>';
if (msg.is_vip) badgeHtml += '<span class="badge vip">👑</span>';
if (msg.is_sub) badgeHtml += '<span class="badge sub">✔️</span>';
let timeHtml = msg.timestamp ? '<span class="time">' + new Date(msg.timestamp*1000).toLocaleTimeString() + '</span>' : '';
div.innerHTML = timeHtml + badgeHtml + '<span class="username">' + escapeHtml(msg.username) + ':</span> ' + escapeHtml(msg.message);
container.appendChild(div);
messageQueue.push(div);
if (messageQueue.length > maxMessages) { const oldest = messageQueue.shift(); oldest.remove(); }
if (messageTimeout > 0) { setTimeout(() => { const idx = messageQueue.indexOf(div); if (idx !== -1) { messageQueue.splice(idx, 1); div.remove(); } }, messageTimeout); }
container.scrollTop = container.scrollHeight;
}
eventSource.onmessage = function(e) { addMessage(JSON.parse(e.data)); };
eventSource.onerror = function(e) { console.error('SSE error', e); };
function escapeHtml(str) { if (!str) return ''; return str.replace(/[&<>]/g, function(m) { if (m === '&') return '&amp;'; if (m === '<') return '&lt;'; if (m === '>') return '&gt;'; return m; }); }
</script>
</body>
</html>`
t := template.Must(template.New("chat").Parse(tmpl))
_ = t.Execute(w, s.config)
}
func (s *ChatService) handleEvents(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}
clientChan := make(chan interface{}, 100)
s.clientsMu.Lock()
s.clients[clientChan] = true
s.clientsMu.Unlock()
defer func() {
s.clientsMu.Lock()
delete(s.clients, clientChan)
s.clientsMu.Unlock()
close(clientChan)
}()
for {
select {
case data := <-clientChan:
jsonData, _ := json.Marshal(data)
_, _ = fmt.Fprintf(w, "data: %s\n\n", jsonData)
flusher.Flush()
case <-r.Context().Done():
return
}
}
}
+180
View File
@@ -0,0 +1,180 @@
package webservices
import (
"fmt"
"stream-bot/internal/db"
"stream-bot/internal/logger"
"sync"
)
type Manager struct {
services map[int]Service
mu sync.RWMutex
globalMessageChan chan ChatMessage
globalEventChan chan AlertEvent
}
func NewManager() *Manager {
m := &Manager{
services: make(map[int]Service),
globalMessageChan: make(chan ChatMessage, 1000),
globalEventChan: make(chan AlertEvent, 1000),
}
m.startDispatchers()
return m
}
func (m *Manager) startDispatchers() {
go func() {
for msg := range m.globalMessageChan {
m.mu.RLock()
for _, srv := range m.services {
if chatSrv, ok := srv.(*ChatService); ok {
chatSrv.SendToClients(msg)
}
}
m.mu.RUnlock()
}
}()
go func() {
for ev := range m.globalEventChan {
m.mu.RLock()
for _, srv := range m.services {
if alertSrv, ok := srv.(*AlertService); ok {
alertSrv.SendToClients(ev)
}
}
m.mu.RUnlock()
}
}()
}
func (m *Manager) StartAll() error {
list, err := db.GetAllWebServices()
if err != nil {
return err
}
for _, ws := range list {
if !ws.Enabled {
continue
}
if err := m.startServiceFromDB(ws); err != nil {
logger.Error("Failed to start service %d: %v", ws.ID, err)
}
}
return nil
}
func (m *Manager) startServiceFromDB(ws db.WebService) error {
var srv Service
switch ws.Type {
case "chat":
cfg, err := ws.GetChatConfig()
if err != nil {
return err
}
srv = NewChatService(ws.Port, cfg)
case "alert":
cfg, err := ws.GetAlertConfig()
if err != nil {
return err
}
srv = NewAlertService(ws.Port, cfg)
default:
return fmt.Errorf("unknown service type: %s", ws.Type)
}
if err := srv.Start(); err != nil {
return err
}
m.mu.Lock()
m.services[ws.ID] = srv
m.mu.Unlock()
_ = db.SetWebServiceRunning(ws.ID, true)
return nil
}
func (m *Manager) AddService(serviceType string, port int, config interface{}) (int, error) {
id, err := db.CreateWebService(serviceType, port, config)
if err != nil {
return 0, err
}
return id, nil
}
func (m *Manager) StartService(id int) error {
ws, err := db.GetWebService(id)
if err != nil {
return err
}
return m.startServiceFromDB(*ws)
}
func (m *Manager) StopService(id int) error {
m.mu.Lock()
srv, ok := m.services[id]
delete(m.services, id)
m.mu.Unlock()
if !ok {
return nil
}
if err := srv.Stop(); err != nil {
return err
}
_ = db.SetWebServiceRunning(id, false)
return nil
}
func (m *Manager) UpdateConfig(id int, config interface{}) error {
ws, err := db.GetWebService(id)
if err != nil {
return err
}
if err := db.UpdateWebService(id, ws.Port, config, ws.Enabled); err != nil {
return err
}
m.mu.RLock()
srv, ok := m.services[id]
m.mu.RUnlock()
if ok {
return srv.ReloadConfig(config)
}
return nil
}
func (m *Manager) DeleteService(id int) error {
_ = m.StopService(id)
return db.DeleteWebService(id)
}
// Эти два метода должны быть ТОЛЬКО ОДИН РАЗ!
func (m *Manager) SendChatMessage(msg ChatMessage) {
select {
case m.globalMessageChan <- msg:
default:
logger.Warn("Chat message buffer full")
}
}
func (m *Manager) SendAlertEvent(event AlertEvent) {
select {
case m.globalEventChan <- event:
default:
logger.Warn("Alert event buffer full")
}
}
func (m *Manager) GetService(id int) Service {
m.mu.RLock()
defer m.mu.RUnlock()
return m.services[id]
}
func (m *Manager) GetAllServices() map[int]Service {
m.mu.RLock()
defer m.mu.RUnlock()
out := make(map[int]Service)
for k, v := range m.services {
out[k] = v
}
return out
}
+10
View File
@@ -0,0 +1,10 @@
package webservices
type Service interface {
Start() error
Stop() error
ReloadConfig(config interface{}) error
GetPort() int
GetType() string
IsRunning() bool
}
+16
View File
@@ -0,0 +1,16 @@
package webservices
type ChatMessage struct {
Username string `json:"username"`
Message string `json:"message"`
IsMod bool `json:"is_mod"`
IsVip bool `json:"is_vip"`
IsSub bool `json:"is_sub"`
Timestamp int64 `json:"timestamp"`
}
type AlertEvent struct {
Type string `json:"type"`
Data map[string]interface{} `json:"data"`
Sound string `json:"sound,omitempty"` // добавлено поле
}
File diff suppressed because it is too large Load Diff
+414
View File
@@ -0,0 +1,414 @@
/* ---------- БАЗОВЫЕ СТИЛИ И ТЕМЫ ---------- */
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background: #f4f4f4;
color: #333;
transition: background 0.2s, color 0.2s;
}
nav {
background: #333;
color: white;
padding: 10px 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
nav a {
color: white;
text-decoration: none;
margin-right: 20px;
}
nav a:hover {
text-decoration: underline;
}
.container {
padding: 20px;
}
.card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 20px;
margin-bottom: 20px;
}
button, input[type="submit"] {
background: #007bff;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #0056b3;
}
input[type="text"], input[type="password"], textarea, select {
width: 100%;
padding: 8px;
margin: 8px 0;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
color: #333;
box-sizing: border-box;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
th {
background-color: #f2f2f2;
}
.status.online {
color: green;
}
.status.offline {
color: red;
}
.theme-switch {
cursor: pointer;
background: #555;
padding: 5px 10px;
border-radius: 20px;
font-size: 0.9em;
white-space: nowrap;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: 8px;
padding: 20px;
max-width: 600px;
width: 90%;
}
.modal-content pre {
background: #f0f0f0;
padding: 10px;
border-radius: 4px;
white-space: pre-wrap;
}
.provider-fields {
margin-top: 10px;
}
.provider-fields label {
display: block;
margin-top: 8px;
}
.test-result-block {
background: #f0f0f0;
color: #333;
border: 1px solid #ccc;
margin-top: 10px;
padding: 10px;
border-radius: 4px;
}
.action-item {
background: #f9f9f9;
margin-bottom: 10px;
padding: 10px;
border-radius: 4px;
border: 1px solid #ccc;
}
.action-fields {
margin-top: 10px;
margin-bottom: 10px;
}
.action-fields label {
display: block;
margin-top: 5px;
}
button.remove-action {
background: #d9534f;
margin-top: 5px;
}
#log-container {
background: #1e1e1e;
color: #d4d4d4;
font-family: monospace;
padding: 10px;
height: 70vh;
overflow-y: auto;
border-radius: 5px;
}
#notification {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
background: #333;
color: white;
padding: 10px 20px;
border-radius: 5px;
display: none;
}
/* ---------- ТЁМНАЯ ТЕМА ---------- */
body.dark {
background: #1e1e1e;
color: #ddd;
}
body.dark nav {
background: #1e1e1e;
}
body.dark nav a {
color: #ddd;
}
body.dark nav a:hover {
color: white;
}
body.dark .card {
background: #2d2d2d;
box-shadow: 0 2px 4px rgba(0,0,0,0.5);
}
body.dark input, body.dark textarea, body.dark select {
background: #3c3c3c;
border-color: #555;
color: #eee;
}
body.dark table th {
background-color: #3c3c3c;
color: #ddd;
}
body.dark table td {
border-color: #444;
color: #ddd;
}
body.dark button {
background: #0d6efd;
}
body.dark .modal-content {
background: #2d2d2d;
color: #ddd;
}
body.dark .modal-content pre {
background: #1e1e1e;
color: #ddd;
border: 1px solid #555;
}
body.dark .test-result-block {
background: #1e1e1e;
color: #ddd;
border-color: #555;
}
body.dark .theme-switch {
background: #444;
color: #ddd;
}
body.dark table thead th,
body.dark table th {
background-color: #3c3c3c !important;
color: #eee !important;
border-color: #555 !important;
}
body.dark table tbody td {
background-color: #2d2d2d;
color: #ddd;
border-color: #555;
}
body.dark table {
background-color: #2d2d2d;
}
body.dark .action-item {
background: #2d2d2d;
border-color: #555;
}
body.dark #log-container {
background: #0a0a0a;
color: #d4d4d4;
}
body.dark #notification {
background: #444;
}
/* ---------- АДАПТИВНОСТЬ ---------- */
@media (max-width: 768px) {
nav {
flex-direction: column;
align-items: stretch;
}
nav div:first-child {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
nav a {
margin-right: 0;
padding: 5px 10px;
}
.container {
padding: 10px;
}
.card {
padding: 15px;
}
button, input[type="submit"] {
width: 100%;
margin-top: 5px;
padding: 10px;
}
table {
display: block;
overflow-x: auto;
white-space: nowrap;
}
th, td {
white-space: normal;
word-break: break-word;
}
.modal-content {
width: 95%;
padding: 15px;
}
.action-item button {
width: auto;
margin-right: 5px;
}
#notification {
left: 20px;
right: 20px;
text-align: center;
}
input, textarea, select {
font-size: 16px;
}
#users-table button {
margin: 2px;
padding: 4px 8px;
font-size: 0.75rem;
}
img {
max-width: 100%;
height: auto;
}
}
@media (max-width: 480px) {
nav div:first-child a {
font-size: 0.9rem;
padding: 4px 6px;
}
.card h2, .card h3 {
font-size: 1.2rem;
}
button {
font-size: 0.9rem;
}
}
/* Модальное окно */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: 8px;
padding: 20px;
max-width: 800px;
width: 90%;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
}
body.dark .modal-content {
background: #2d2d2d;
color: #ddd;
}
.modal-content h3, .modal-content h4 {
margin-top: 0;
}
.modal-content label {
display: block;
margin-top: 10px;
font-weight: bold;
}
.modal-content input[type="text"],
.modal-content input[type="number"],
.modal-content select,
.modal-content textarea {
width: 100%;
padding: 6px;
margin-top: 4px;
box-sizing: border-box;
}
.modal-content button {
margin-top: 10px;
}
.preview-box {
background: #f5f5f5;
border: 1px solid #ccc;
border-radius: 6px;
padding: 12px;
margin-top: 15px;
transition: all 0.2s;
}
body.dark .preview-box {
background: #1e1e1e;
border-color: #555;
}
.chat-preview {
font-family: Arial, sans-serif;
}
.alert-preview {
background: rgba(0,0,0,0.7);
color: white;
border-radius: 8px;
padding: 12px;
display: flex;
align-items: center;
gap: 12px;
}
.alert-preview img {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
}
.alert-preview .content {
flex: 1;
}
.alert-preview .title {
font-weight: bold;
font-size: 1.2em;
}
.action-item {
background: #f9f9f9;
margin-bottom: 10px;
padding: 10px;
border-radius: 4px;
border: 1px solid #ccc;
}
body.dark .action-item {
background: #2d2d2d;
border-color: #555;
}
+159
View File
@@ -0,0 +1,159 @@
{{define "content"}}
<h2>Настройки нейросетей</h2>
<div class="card">
<form id="ai-form">
<label>Провайдер:</label>
<select id="provider" name="provider">
<option value="ollama">Ollama (локальная)</option>
<option value="chatgpt">ChatGPT (OpenAI)</option>
<option value="gigachat">GigaChat (Сбер)</option>
</select>
<div id="ollama-fields" class="provider-fields">
<label>Endpoint:</label>
<input type="text" id="endpoint" name="endpoint" placeholder="http://localhost:11434">
<label>Модель:</label>
<input type="text" id="model" name="model" placeholder="llama2, mistral, ...">
</div>
<div id="chatgpt-fields" class="provider-fields" style="display:none;">
<label>API Key:</label>
<input type="password" id="api_key" name="api_key" placeholder="sk-...">
<label>Модель:</label>
<input type="text" id="model_gpt" name="model_gpt" placeholder="gpt-3.5-turbo">
</div>
<div id="gigachat-fields" class="provider-fields" style="display:none;">
<label>Client ID:</label>
<input type="text" id="client_id" name="client_id" placeholder="ваш client_id">
<label>Client Secret:</label>
<input type="password" id="client_secret" name="client_secret" placeholder="ваш client_secret">
<label>Endpoint (опционально):</label>
<input type="text" id="endpoint_giga" name="endpoint_giga" placeholder="https://gigachat.devices.sberbank.ru/api/v1">
<label>Модель (опционально):</label>
<input type="text" id="model_giga" name="model_giga" placeholder="GigaChat">
</div>
<label>Системный промпт (префикс):</label>
<textarea id="system_prompt" name="system_prompt" rows="3">ты в чате твитча, ответь одним предложением.</textarea>
<button type="submit">Сохранить</button>
</form>
</div>
<div class="card">
<h3>Тестирование</h3>
<input type="text" id="test-prompt" placeholder="Введите вопрос для нейросети" style="width: 70%;">
<button id="test-btn">Отправить</button>
<div id="test-result" class="test-result-block"></div>
</div>
<script>
const providerSelect = document.getElementById('provider');
const ollamaFields = document.getElementById('ollama-fields');
const chatgptFields = document.getElementById('chatgpt-fields');
const gigachatFields = document.getElementById('gigachat-fields');
function showProviderFields() {
const prov = providerSelect.value;
ollamaFields.style.display = 'none';
chatgptFields.style.display = 'none';
gigachatFields.style.display = 'none';
if (prov === 'ollama') ollamaFields.style.display = 'block';
else if (prov === 'chatgpt') chatgptFields.style.display = 'block';
else if (prov === 'gigachat') gigachatFields.style.display = 'block';
}
providerSelect.addEventListener('change', showProviderFields);
async function loadConfig() {
const res = await fetch('/api/ai/config');
const cfg = await res.json();
providerSelect.value = cfg.provider || 'ollama';
document.getElementById('endpoint').value = cfg.endpoint || '';
document.getElementById('model').value = cfg.model || '';
document.getElementById('api_key').value = cfg.api_key || '';
document.getElementById('model_gpt').value = cfg.model || '';
document.getElementById('client_id').value = cfg.client_id || '';
document.getElementById('client_secret').value = cfg.client_secret || '';
document.getElementById('endpoint_giga').value = cfg.endpoint || '';
document.getElementById('model_giga').value = cfg.model || '';
document.getElementById('system_prompt').value = cfg.system_prompt || 'ты в чате твитча, ответь одним предложением.';
showProviderFields();
}
document.getElementById('ai-form').addEventListener('submit', async (e) => {
e.preventDefault();
const provider = providerSelect.value;
let apiKey = '', endpoint = '', model = '', clientId = '', clientSecret = '';
if (provider === 'ollama') {
endpoint = document.getElementById('endpoint').value;
model = document.getElementById('model').value;
} else if (provider === 'chatgpt') {
apiKey = document.getElementById('api_key').value;
model = document.getElementById('model_gpt').value;
} else if (provider === 'gigachat') {
clientId = document.getElementById('client_id').value;
clientSecret = document.getElementById('client_secret').value;
endpoint = document.getElementById('endpoint_giga').value;
model = document.getElementById('model_giga').value;
}
const systemPrompt = document.getElementById('system_prompt').value;
const body = {
provider,
api_key: apiKey,
endpoint,
model,
system_prompt: systemPrompt,
client_id: clientId,
client_secret: clientSecret
};
try {
const res = await fetch('/api/ai/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (res.ok) {
alert('Настройки сохранены');
await loadConfig();
} else {
const err = await res.text();
alert('Ошибка сохранения: ' + err);
}
} catch (err) {
alert('Ошибка соединения: ' + err.message);
}
});
document.getElementById('test-btn').addEventListener('click', async () => {
const prompt = document.getElementById('test-prompt').value;
if (!prompt) return;
try {
const res = await fetch('/api/ai/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt })
});
if (res.ok) {
const data = await res.json();
document.getElementById('test-result').innerHTML = `<strong>Ответ:</strong> ${escapeHtml(data.answer)}`;
} else {
const err = await res.text();
document.getElementById('test-result').innerHTML = `<span style="color:red;">Ошибка: ${escapeHtml(err)}</span>`;
}
} catch (err) {
document.getElementById('test-result').innerHTML = `<span style="color:red;">Ошибка соединения: ${err.message}</span>`;
}
});
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
loadConfig();
</script>
{{end}}
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
<title>TTW_Bot</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<nav>
<div>
<a href="/">Главная</a>
<a href="/platforms">Платформы</a>
<a href="/commands">Команды</a>
<a href="/events">События</a>
<a href="/hotkeys">Горячие клавиши</a>
<a href="/webservices">Веб-сервисы</a>
<a href="/logs">Логи</a>
<a href="/ai">Нейросети</a>
<a href="/notifications">Уведомления</a>
</div>
<div class="theme-switch" id="theme-toggle">🌙 Тёмная тема</div>
</nav>
<div class="container">
{{block "content" .}}{{end}}
</div>
<footer style="text-align: center; margin-top: 20px; font-size: 0.8em; color: gray;">
TTW_Bot версия 11.0.43
</footer>
<script>
const themeToggle = document.getElementById('theme-toggle');
const currentTheme = localStorage.getItem('theme');
if (currentTheme === 'dark') {
document.body.classList.add('dark');
themeToggle.textContent = '☀️ Светлая тема';
}
themeToggle.addEventListener('click', () => {
document.body.classList.toggle('dark');
const isDark = document.body.classList.contains('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
themeToggle.textContent = isDark ? '☀️ Светлая тема' : '🌙 Тёмная тема';
});
</script>
</body>
</html>
+434
View File
@@ -0,0 +1,434 @@
{{define "content"}}
<h2>Команды чата</h2>
<div class="card">
<h3>Добавить / редактировать команду</h3>
<form id="command-form">
<input type="hidden" id="command-id" value="0">
<label>Триггер (без !):</label>
<input type="text" id="trigger" required placeholder="ping">
<label>Шаблон ответа:</label>
<textarea id="template" rows="5" required placeholder="Pong! &lt;USERNAME/&gt;"></textarea>
<!-- Панель кнопок для вставки тегов -->
<div style="margin: 8px 0; display: flex; flex-wrap: wrap; gap: 6px;">
<button type="button" class="tag-btn" data-tag="&lt;USERNAME/&gt;">👤 USERNAME</button>
<button type="button" class="tag-btn" data-tag="&lt;ARG/&gt;">📝 ARG</button>
<button type="button" class="tag-btn" data-tag="&lt;AI/&gt;">🤖 AI</button>
<button type="button" class="tag-btn" data-tag="&lt;RANDOMUSER/&gt;">🎲 RANDOMUSER</button>
<button type="button" id="random-btn">🔢 Случайное число</button>
<button type="button" id="song-btn">🎵 Вставить звук</button>
<button type="button" id="group-btn">📦 Обертка group</button>
<button type="button" id="timeout-btn">⏱ Отстранение</button>
</div>
<div>
<button type="button" id="test-btn">🧪 Тестировать</button>
</div>
<label>
<input type="checkbox" id="enabled" checked> Включена
</label>
<label>Кулдаун (секунды):</label>
<input type="number" id="cooldown" value="0" min="0">
<label>Права доступа:</label>
<select id="permission">
<option value="everyone">Все</option>
<option value="moderator">Модераторы</option>
<option value="subscriber">Подписчики</option>
<option value="vip">VIP</option>
<option value="broadcaster">Стример</option>
</select>
<button type="submit">Сохранить</button>
<button type="button" id="cancel-edit" style="background: #6c757d;">Отмена</button>
</form>
</div>
<div class="card">
<h3>Список команд</h3>
<table style="width:100%; border-collapse: collapse;">
<thead>
<tr><th>Триггер</th><th>Шаблон</th><th>Кулдаун</th><th>Права</th><th>Статус</th><th>Действия</th></tr>
</thead>
<tbody id="commands-table">
<tr><td colspan="6">Загрузка...</td></tr>
</tbody>
</table>
</div>
<!-- Модальное окно для тестирования -->
<div id="test-modal" class="modal">
<div class="modal-content">
<h3>Результат тестирования</h3>
<p><strong>Текст для чата:</strong></p>
<pre id="test-result" style="background:#f0f0f0; padding:10px; border-radius:4px; white-space:pre-wrap;"></pre>
<p><strong>Звуковые файлы:</strong> <span id="test-sounds"></span></p>
<button id="close-modal" style="margin-top:10px;">Закрыть</button>
</div>
</div>
<!-- Модальное окно для случайного числа -->
<div id="random-modal" class="modal">
<div class="modal-content">
<h3>Вставить случайное число</h3>
<label>Минимум:</label>
<input type="number" id="random-min" value="1">
<label>Максимум:</label>
<input type="number" id="random-max" value="100">
<div style="margin-top:15px;">
<button id="insert-random-btn">Вставить</button>
<button id="cancel-random-btn">Отмена</button>
</div>
</div>
</div>
<!-- Модальное окно для звука -->
<div id="song-modal" class="modal">
<div class="modal-content">
<h3>Вставить звук</h3>
<label>Выберите звуковой файл:</label>
<select id="song-select" style="width:100%;">
<option value="">-- нет звуков --</option>
</select>
<div style="margin-top:15px;">
<button id="insert-song-btn">Вставить</button>
<button id="cancel-song-btn">Отмена</button>
</div>
</div>
</div>
<!-- Модальное окно для группы -->
<div id="group-modal" class="modal">
<div class="modal-content">
<h3>Создать группу вариантов</h3>
<div id="group-variants">
<div class="group-variant" style="margin-bottom:8px;">
<input type="text" class="variant-input" placeholder="Вариант 1" style="width:80%;">
</div>
<div class="group-variant" style="margin-bottom:8px;">
<input type="text" class="variant-input" placeholder="Вариант 2" style="width:80%;">
</div>
</div>
<button type="button" id="add-variant-btn">+ Добавить вариант</button>
<button type="button" id="remove-variant-btn" style="margin-left:10px;"> Удалить последний</button>
<div style="margin-top:15px;">
<button id="insert-group-btn">Вставить</button>
<button id="cancel-group-btn">Отмена</button>
</div>
</div>
</div>
<!-- Модальное окно для отстранения (timeout) -->
<div id="timeout-modal" class="modal">
<div class="modal-content">
<h3>Отстранение (таймаут)</h3>
<label>Количество минут:</label>
<input type="number" id="timeout-minutes" value="5" min="1" max="1440">
<div style="margin-top:15px;">
<button id="insert-timeout-btn">Вставить</button>
<button id="cancel-timeout-btn">Отмена</button>
</div>
</div>
</div>
<script>
let commands = [];
let soundList = [];
async function loadSoundList() {
try {
const res = await fetch('/api/sounds/list');
if (res.ok) {
soundList = await res.json();
const select = document.getElementById('song-select');
select.innerHTML = '<option value="">-- выберите звук --</option>';
soundList.forEach(sound => {
const option = document.createElement('option');
option.value = sound;
option.textContent = sound;
select.appendChild(option);
});
}
} catch(e) { console.error('Failed to load sounds', e); }
}
function insertAtCursor(textareaId, text) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const value = textarea.value;
textarea.value = value.slice(0, start) + text + value.slice(end);
textarea.selectionStart = textarea.selectionEnd = start + text.length;
textarea.focus();
}
// Простые теги
document.querySelectorAll('.tag-btn').forEach(btn => {
btn.addEventListener('click', () => {
insertAtCursor('template', btn.dataset.tag);
});
});
// Random
document.getElementById('random-btn').addEventListener('click', () => {
document.getElementById('random-modal').style.display = 'flex';
});
document.getElementById('insert-random-btn').addEventListener('click', () => {
const min = document.getElementById('random-min').value;
const max = document.getElementById('random-max').value;
const tag = `<random s=${min} e=${max}/>`;
insertAtCursor('template', tag);
document.getElementById('random-modal').style.display = 'none';
});
document.getElementById('cancel-random-btn').addEventListener('click', () => {
document.getElementById('random-modal').style.display = 'none';
});
// Song
document.getElementById('song-btn').addEventListener('click', async () => {
await loadSoundList();
document.getElementById('song-modal').style.display = 'flex';
});
document.getElementById('insert-song-btn').addEventListener('click', () => {
const selected = document.getElementById('song-select').value;
if (!selected) {
alert('Выберите звуковой файл');
return;
}
const tag = `<song f="data/sounds/${selected}"/>`;
insertAtCursor('template', tag);
document.getElementById('song-modal').style.display = 'none';
});
document.getElementById('cancel-song-btn').addEventListener('click', () => {
document.getElementById('song-modal').style.display = 'none';
});
// Group
document.getElementById('group-btn').addEventListener('click', () => {
const container = document.getElementById('group-variants');
container.innerHTML = `
<div class="group-variant" style="margin-bottom:8px;">
<input type="text" class="variant-input" placeholder="Вариант 1" style="width:80%;">
</div>
<div class="group-variant" style="margin-bottom:8px;">
<input type="text" class="variant-input" placeholder="Вариант 2" style="width:80%;">
</div>
`;
document.getElementById('group-modal').style.display = 'flex';
});
document.getElementById('add-variant-btn').addEventListener('click', () => {
const container = document.getElementById('group-variants');
const newDiv = document.createElement('div');
newDiv.className = 'group-variant';
newDiv.style.marginBottom = '8px';
newDiv.innerHTML = `<input type="text" class="variant-input" placeholder="Новый вариант" style="width:80%;">`;
container.appendChild(newDiv);
});
document.getElementById('remove-variant-btn').addEventListener('click', () => {
const container = document.getElementById('group-variants');
if (container.children.length > 2) {
container.removeChild(container.lastChild);
} else {
alert('Должно быть хотя бы два варианта');
}
});
document.getElementById('insert-group-btn').addEventListener('click', () => {
const inputs = document.querySelectorAll('#group-variants .variant-input');
let variants = [];
inputs.forEach(inp => {
let val = inp.value.trim();
if (val !== '') variants.push(val);
});
if (variants.length < 2) {
alert('Введите хотя бы два непустых варианта');
return;
}
let groupHtml = '<group>\n';
variants.forEach(v => {
groupHtml += ` <g>${escapeHtmlForGroup(v)}</g>\n`;
});
groupHtml += '</group>';
insertAtCursor('template', groupHtml);
document.getElementById('group-modal').style.display = 'none';
});
document.getElementById('cancel-group-btn').addEventListener('click', () => {
document.getElementById('group-modal').style.display = 'none';
});
// Timeout
document.getElementById('timeout-btn').addEventListener('click', () => {
document.getElementById('timeout-modal').style.display = 'flex';
});
document.getElementById('insert-timeout-btn').addEventListener('click', () => {
const minutes = document.getElementById('timeout-minutes').value;
const tag = `<timeout minutes="${minutes}"/>`;
insertAtCursor('template', tag);
document.getElementById('timeout-modal').style.display = 'none';
});
document.getElementById('cancel-timeout-btn').addEventListener('click', () => {
document.getElementById('timeout-modal').style.display = 'none';
});
function escapeHtmlForGroup(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
// Закрытие модалок по клику вне области
window.addEventListener('click', (e) => {
const modals = ['random-modal', 'song-modal', 'group-modal', 'test-modal', 'timeout-modal'];
modals.forEach(id => {
const modal = document.getElementById(id);
if (e.target === modal) modal.style.display = 'none';
});
});
// --- Остальной код команд (без изменений) ---
async function loadCommands() {
const res = await fetch('/api/commands');
commands = await res.json();
renderTable();
}
function renderTable() {
const tbody = document.getElementById('commands-table');
if (!commands.length) {
tbody.innerHTML = '<tr><td colspan="6">Нет команд. Добавьте первую!</td></tr>';
return;
}
tbody.innerHTML = commands.map(cmd => `
<tr>
<td>!${escapeHtml(cmd.Trigger)}</td>
<td style="max-width: 300px; overflow-x: auto;">${escapeHtml(cmd.Template)}</td>
<td>${cmd.CooldownSec}</td>
<td>${cmd.Permission}</td>
<td>${cmd.Enabled ? '✅ Вкл' : '❌ Выкл'}</td>
<td>
<button onclick="editCommand(${cmd.ID})">✏️</button>
<button onclick="deleteCommand(${cmd.ID})">🗑️</button>
</td>
</tr>
`).join('');
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
function editCommand(id) {
const cmd = commands.find(c => c.ID === id);
if (!cmd) return;
document.getElementById('command-id').value = cmd.ID;
document.getElementById('trigger').value = cmd.Trigger;
document.getElementById('template').value = cmd.Template;
document.getElementById('enabled').checked = cmd.Enabled;
document.getElementById('cooldown').value = cmd.CooldownSec;
document.getElementById('permission').value = cmd.Permission;
document.querySelector('.card').scrollIntoView({ behavior: 'smooth' });
}
async function deleteCommand(id) {
if (!confirm('Удалить команду?')) return;
await fetch(`/api/commands?id=${id}`, { method: 'DELETE' });
loadCommands();
if (document.getElementById('command-id').value == id) resetForm();
}
function resetForm() {
document.getElementById('command-id').value = 0;
document.getElementById('trigger').value = '';
document.getElementById('template').value = '';
document.getElementById('enabled').checked = true;
document.getElementById('cooldown').value = 0;
document.getElementById('permission').value = 'everyone';
}
document.getElementById('cancel-edit').addEventListener('click', resetForm);
document.getElementById('command-form').addEventListener('submit', async (e) => {
e.preventDefault();
const id = parseInt(document.getElementById('command-id').value);
const trigger = document.getElementById('trigger').value.trim();
const template = document.getElementById('template').value;
const enabled = document.getElementById('enabled').checked;
const cooldown = parseInt(document.getElementById('cooldown').value);
const permission = document.getElementById('permission').value;
if (!trigger || !template) {
alert('Заполните триггер и шаблон');
return;
}
const method = id === 0 ? 'POST' : 'PUT';
const url = '/api/commands';
const body = JSON.stringify({ ID: id, Trigger: trigger, Template: template, Enabled: enabled, CooldownSec: cooldown, Permission: permission });
try {
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body });
if (res.ok) {
loadCommands();
resetForm();
} else {
const err = await res.text();
alert('Ошибка: ' + err);
}
} catch (err) {
alert('Ошибка соединения: ' + err.message);
}
});
document.getElementById('test-btn').addEventListener('click', async () => {
const template = document.getElementById('template').value;
if (!template) {
alert('Введите шаблон для тестирования');
return;
}
const username = prompt('Введите имя пользователя для подстановки (без @):', 'TestUser');
if (!username) return;
try {
const res = await fetch('/api/commands/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template, username })
});
if (!res.ok) {
const err = await res.text();
alert('Ошибка тестирования: ' + err);
return;
}
const data = await res.json();
document.getElementById('test-result').textContent = data.result;
const soundsSpan = document.getElementById('test-sounds');
if (data.soundFiles && data.soundFiles.length) {
soundsSpan.innerHTML = data.soundFiles.join('<br>');
} else {
soundsSpan.textContent = 'нет';
}
document.getElementById('test-modal').style.display = 'flex';
} catch (err) {
alert('Ошибка: ' + err.message);
}
});
document.getElementById('close-modal').addEventListener('click', () => {
document.getElementById('test-modal').style.display = 'none';
});
loadCommands();
loadSoundList();
</script>
{{end}}
+142
View File
@@ -0,0 +1,142 @@
{{define "content"}}
<h2>Пользователи чата</h2>
<div style="overflow-x: auto;">
<table id="users-table">
<thead>
<tr>
<th>Пользователь</th>
<th>Сообщений</th>
<th>Последняя активность</th>
<th>Модератор</th>
<th>VIP</th>
<th>Подписчик</th>
<th>Действия</th>
<th>Отмечать</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<script>
let users = [];
let refreshInterval = null;
async function loadUsers() {
try {
const res = await fetch('/api/users');
users = await res.json();
renderTable();
} catch(err) {
console.error('Failed to load users', err);
}
}
function renderTable() {
const tbody = document.querySelector('#users-table tbody');
if (!users.length) {
tbody.innerHTML = '<tr><td colspan="8">Нет активных пользователей</td></tr>';
return;
}
tbody.innerHTML = users.map(user => `
<tr data-username="${escapeHtml(user.username)}">
<td>${escapeHtml(user.username)}</td>
<td>${user.message_count}</td>
<td>${formatRelativeTime(user.last_active)}</td>
<td><input type="checkbox" class="mod-checkbox" ${user.is_mod ? 'checked' : ''} data-username="${escapeHtml(user.username)}"></td>
<td><input type="checkbox" class="vip-checkbox" ${user.is_vip ? 'checked' : ''} data-username="${escapeHtml(user.username)}"></td>
<td><input type="checkbox" disabled ${user.is_subscriber ? 'checked' : ''}></td>
<td>
<button class="warn-btn" data-user="${escapeHtml(user.username)}">Предупредить</button>
<button class="timeout10s-btn" data-user="${escapeHtml(user.username)}">10 сек</button>
<button class="timeout10m-btn" data-user="${escapeHtml(user.username)}">10 мин</button>
<button class="ban-btn" data-user="${escapeHtml(user.username)}">Забанить</button>
<button class="unban-btn" data-user="${escapeHtml(user.username)}">Разбанить</button>
</td>
<td><input type="checkbox" class="mark-checkbox" ${user.is_marked ? 'checked' : ''} data-username="${escapeHtml(user.username)}"></td>
</tr>
`).join('');
document.querySelectorAll('.mod-checkbox').forEach(cb => {
cb.addEventListener('change', (e) => toggleMod(cb.dataset.username, cb.checked));
});
document.querySelectorAll('.vip-checkbox').forEach(cb => {
cb.addEventListener('change', (e) => toggleVip(cb.dataset.username, cb.checked));
});
document.querySelectorAll('.warn-btn').forEach(btn => {
btn.addEventListener('click', () => userAction(btn.dataset.user, 'warn'));
});
document.querySelectorAll('.timeout10s-btn').forEach(btn => {
btn.addEventListener('click', () => userAction(btn.dataset.user, 'timeout10sec'));
});
document.querySelectorAll('.timeout10m-btn').forEach(btn => {
btn.addEventListener('click', () => userAction(btn.dataset.user, 'timeout10min'));
});
document.querySelectorAll('.ban-btn').forEach(btn => {
btn.addEventListener('click', () => userAction(btn.dataset.user, 'ban'));
});
document.querySelectorAll('.unban-btn').forEach(btn => {
btn.addEventListener('click', () => userAction(btn.dataset.user, 'unban'));
});
document.querySelectorAll('.mark-checkbox').forEach(cb => {
cb.addEventListener('change', (e) => toggleMark(cb.dataset.username, cb.checked));
});
}
async function toggleMod(username, isMod) {
const action = isMod ? 'set_mod' : 'unset_mod';
await userAction(username, action);
}
async function toggleVip(username, isVip) {
const action = isVip ? 'set_vip' : 'unset_vip';
await userAction(username, action);
}
async function toggleMark(username, isMarked) {
await userAction(username, 'toggle_mark');
}
async function userAction(username, action) {
try {
const res = await fetch('/api/users/action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ username, action, platform: 'twitch' })
});
if (!res.ok) throw new Error(await res.text());
await loadUsers();
} catch(err) {
alert('Ошибка: ' + err.message);
}
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
function formatRelativeTime(timestamp) {
if (!timestamp) return 'никогда';
const date = new Date(timestamp);
const now = new Date();
const seconds = Math.floor((now - date) / 1000);
if (seconds < 60) return 'только что';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} мин. назад`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} ч. назад`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days} дн. назад`;
const months = Math.floor(days / 30);
if (months < 12) return `${months} мес. назад`;
const years = Math.floor(months / 12);
return `${years} г. назад`;
}
loadUsers();
refreshInterval = setInterval(loadUsers, 3000);
</script>
{{end}}
+296
View File
@@ -0,0 +1,296 @@
{{define "content"}}
<h2>События Twitch</h2>
<p>Настройте цепочку действий, которые будут выполняться при наступлении события (подписка, рейд, награда и т.д.)</p>
<div class="card">
<label for="event-select">Выберите событие:</label>
<select id="event-select">
<option value="follow">Подписка на канал (follow)</option>
<option value="subscribe">Подписка (subscribe)</option>
<option value="gift_sub">Подарочная подписка (gift_sub)</option>
<option value="raid">Рейд (raid)</option>
<option value="reward_redemption">Награда за баллы (reward_redemption)</option>
</select>
<button id="load-actions">Загрузить действия</button>
</div>
<div class="card" id="actions-editor" style="display: none;">
<h3>Цепочка действий</h3>
<div id="actions-list"></div>
<button id="add-action"> Добавить действие</button>
<button id="save-actions">💾 Сохранить</button>
</div>
<!-- Модальное окно для создания/редактирования действия -->
<div id="action-modal" class="modal">
<div class="modal-content">
<h3 id="modal-title">Действие</h3>
<form id="action-form">
<input type="hidden" id="action-index" value="-1">
<label>Тип действия:</label>
<select id="action-type">
<option value="send_message">Отправить сообщение</option>
<option value="play_sound">Воспроизвести звук</option>
<option value="press_hotkey">Нажать горячую клавишу</option>
<option value="http_request">HTTP запрос (GET)</option>
<option value="run_program">Запустить программу</option>
<option value="send_alert">Отправить уведомление (alert)</option>
</select>
<div id="fields-container"></div>
<div style="margin-top: 15px;">
<button type="submit">Сохранить</button>
<button type="button" id="close-action-modal">Отмена</button>
</div>
</form>
</div>
</div>
<script>
let currentPlatform = "twitch";
let currentEvent = "";
let actions = [];
let editingIndex = -1;
// Списки для выпадающих полей
let soundFiles = [];
let imageFiles = [];
const eventSelect = document.getElementById('event-select');
const loadBtn = document.getElementById('load-actions');
const actionsDiv = document.getElementById('actions-list');
const editorDiv = document.getElementById('actions-editor');
const addBtn = document.getElementById('add-action');
const saveBtn = document.getElementById('save-actions');
const actionModal = document.getElementById('action-modal');
const actionTypeSelect = document.getElementById('action-type');
const fieldsContainer = document.getElementById('fields-container');
const actionForm = document.getElementById('action-form');
const actionIndexInput = document.getElementById('action-index');
const closeModalBtn = document.getElementById('close-action-modal');
// Загрузка списков звуков и изображений
async function loadSoundList() {
const res = await fetch('/api/sounds/list');
if (res.ok) {
soundFiles = await res.json();
} else {
soundFiles = [];
}
}
async function loadImageList() {
const res = await fetch('/api/images/list');
if (res.ok) {
imageFiles = await res.json();
} else {
imageFiles = [];
}
}
// Функция отображения полей в зависимости от типа
function renderFieldsForType(type, data = {}) {
let html = '';
switch (type) {
case 'send_message':
html = `<label>Текст сообщения:</label><textarea id="action-text" rows="3" style="width:100%;">${escapeHtml(data.text || '')}</textarea>
<small>Доступны плейсхолдеры: {{.username}}, {{.reward_title}}, {{.tier}}, {{.viewers}} и др.</small>`;
break;
case 'play_sound':
html = `<label>Звуковой файл:</label>
<select id="action-sound-file" style="width:100%;">
<option value="">— без звука —</option>
${soundFiles.map(f => `<option value="data/sounds/${f}" ${data.sound_file === `data/sounds/${f}` ? 'selected' : ''}>${f}</option>`).join('')}
</select>`;
break;
case 'press_hotkey':
html = `<label>Комбинация клавиш:</label><input type="text" id="action-keys" style="width:100%;" value="${escapeHtml(data.keys || '')}" placeholder="CTRL+ALT+Q">
<small>Поддерживаемые модификаторы: CTRL, ALT, SHIFT, WIN. Основные клавиши: A-Z, 0-9, F1-F12, SPACE, ENTER и др.</small>`;
break;
case 'http_request':
html = `<label>URL (GET):</label><input type="text" id="action-url" style="width:100%;" value="${escapeHtml(data.url || '')}">`;
break;
case 'run_program':
html = `<label>Полный путь к исполняемому файлу:</label><input type="text" id="action-executable" style="width:100%;" value="${escapeHtml(data.executable || '')}">
<label>Аргументы (через пробел):</label><input type="text" id="action-args" style="width:100%;" value="${escapeHtml(data.args || '')}">`;
break;
case 'send_alert':
html = `<label>Заголовок:</label><input type="text" id="action-title" style="width:100%;" value="${escapeHtml(data.title || '')}">
<label>Текст:</label><textarea id="action-alert-text" rows="2" style="width:100%;">${escapeHtml(data.alert_text || '')}</textarea>
<label>Изображение:</label>
<select id="action-image" style="width:100%;">
<option value="">— без изображения —</option>
${imageFiles.map(f => `<option value="/static/${f}" ${data.image === `/static/${f}` ? 'selected' : ''}>${f}</option>`).join('')}
</select>
<label>Звук:</label>
<select id="action-sound" style="width:100%;">
<option value="">— без звука —</option>
${soundFiles.map(f => `<option value="/sounds/${f}" ${data.sound_file === `/sounds/${f}` ? 'selected' : ''}>${f}</option>`).join('')}
</select>
<label>Длительность (сек):</label><input type="number" id="action-duration" value="${data.duration || 5}" min="1" max="30">
<label>Целевой веб-сервис (ID):</label><input type="number" id="action-target-id" value="${data.target_web_service_id || 0}" min="0">
<small>0 = все alert-сервисы. Узнать ID можно на странице "Веб-сервисы".</small>`;
break;
default:
html = '<p>Неизвестный тип действия</p>';
}
fieldsContainer.innerHTML = html;
}
// Открыть модалку для создания/редактирования
async function openActionModal(index) {
editingIndex = index;
actionIndexInput.value = index;
let data = {};
if (index >= 0 && index < actions.length) {
data = actions[index];
actionTypeSelect.value = data.type;
} else {
actionTypeSelect.value = 'send_message';
}
// Загружаем списки, если ещё не загружены
if (soundFiles.length === 0) await loadSoundList();
if (imageFiles.length === 0) await loadImageList();
renderFieldsForType(actionTypeSelect.value, data);
actionModal.style.display = 'flex';
document.getElementById('modal-title').innerText = index >= 0 ? 'Редактировать действие' : 'Новое действие';
}
// Собрать данные из формы
function collectActionData() {
const type = actionTypeSelect.value;
const base = { type: type };
switch (type) {
case 'send_message':
base.text = document.getElementById('action-text')?.value || '';
break;
case 'play_sound':
base.sound_file = document.getElementById('action-sound-file')?.value || '';
break;
case 'press_hotkey':
base.keys = document.getElementById('action-keys')?.value || '';
break;
case 'http_request':
base.url = document.getElementById('action-url')?.value || '';
break;
case 'run_program':
base.executable = document.getElementById('action-executable')?.value || '';
base.args = document.getElementById('action-args')?.value || '';
break;
case 'send_alert':
base.title = document.getElementById('action-title')?.value || '';
base.alert_text = document.getElementById('action-alert-text')?.value || '';
base.image = document.getElementById('action-image')?.value || '';
base.sound_file = document.getElementById('action-sound')?.value || '';
base.duration = parseInt(document.getElementById('action-duration')?.value) || 5;
base.target_web_service_id = parseInt(document.getElementById('action-target-id')?.value) || 0;
break;
}
return base;
}
// Отобразить список действий
function renderActions() {
actionsDiv.innerHTML = '';
if (actions.length === 0) {
actionsDiv.innerHTML = '<p>Нет действий. Нажмите "Добавить действие".</p>';
return;
}
actions.forEach((act, idx) => {
const div = document.createElement('div');
div.className = 'action-item';
let typeName = '';
switch (act.type) {
case 'send_message': typeName = '📨 Отправить сообщение'; break;
case 'play_sound': typeName = '🔊 Воспроизвести звук'; break;
case 'press_hotkey': typeName = '⌨️ Нажать клавиши'; break;
case 'http_request': typeName = '🌐 HTTP запрос'; break;
case 'run_program': typeName = '⚙️ Запустить программу'; break;
case 'send_alert': typeName = '🔔 Уведомление (alert)'; break;
default: typeName = act.type;
}
let summary = '';
if (act.type === 'send_message') summary = act.text;
else if (act.type === 'play_sound') summary = act.sound_file;
else if (act.type === 'press_hotkey') summary = act.keys;
else if (act.type === 'http_request') summary = act.url;
else if (act.type === 'run_program') summary = act.executable + (act.args ? ' ' + act.args : '');
else if (act.type === 'send_alert') summary = act.title + ' / ' + act.alert_text;
div.innerHTML = `
<strong>${typeName}</strong><br>
<small>${escapeHtml(summary)}</small><br>
<button class="edit-action" data-idx="${idx}">✏️ Редактировать</button>
<button class="remove-action" data-idx="${idx}">🗑️ Удалить</button>
`;
actionsDiv.appendChild(div);
});
document.querySelectorAll('.edit-action').forEach(btn => {
btn.addEventListener('click', (e) => openActionModal(parseInt(btn.dataset.idx)));
});
document.querySelectorAll('.remove-action').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt(btn.dataset.idx);
actions.splice(idx, 1);
renderActions();
});
});
}
// Загрузка действий с сервера
async function loadEventActions() {
currentEvent = eventSelect.value;
const res = await fetch(`/api/events?platform=${currentPlatform}&event=${currentEvent}`);
if (res.ok) {
actions = await res.json();
if (!Array.isArray(actions)) actions = [];
renderActions();
editorDiv.style.display = 'block';
} else {
alert('Ошибка загрузки действий');
}
}
// Сохранение действий
async function saveEventActions() {
const res = await fetch(`/api/events?platform=${currentPlatform}&event=${currentEvent}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(actions)
});
if (res.ok) {
alert('Действия сохранены!');
} else {
alert('Ошибка сохранения');
}
}
// Обработчики UI
loadBtn.addEventListener('click', loadEventActions);
addBtn.addEventListener('click', () => openActionModal(-1));
saveBtn.addEventListener('click', saveEventActions);
actionForm.addEventListener('submit', (e) => {
e.preventDefault();
const newAction = collectActionData();
if (editingIndex >= 0 && editingIndex < actions.length) {
actions[editingIndex] = newAction;
} else {
actions.push(newAction);
}
renderActions();
actionModal.style.display = 'none';
});
actionTypeSelect.addEventListener('change', () => {
const data = editingIndex >= 0 ? actions[editingIndex] : {};
renderFieldsForType(actionTypeSelect.value, data);
});
closeModalBtn.addEventListener('click', () => actionModal.style.display = 'none');
window.addEventListener('click', (e) => { if (e.target === actionModal) actionModal.style.display = 'none'; });
function escapeHtml(str) { if (!str) return ''; return str.replace(/[&<>]/g, function(m) { if (m === '&') return '&amp;'; if (m === '<') return '&lt;'; if (m === '>') return '&gt;'; return m; }); }
</script>
{{end}}
+28
View File
@@ -0,0 +1,28 @@
{{define "content"}}
<h2>Горячие клавиши по донатам</h2>
<p>Правила эмуляции нажатий клавиш при донатах.</p>
<div class="card">
<h3>Настройка эмуляции горячих клавиш</h3>
<label>
<input type="checkbox" id="hotkey-enable"> Включить эмуляцию горячих клавиш
</label>
<p class="help">При включении бот сможет имитировать нажатия клавиш (например, для управления OBS через донаты).</p>
</div>
<script>
async function loadHotkeySetting() {
const res = await fetch('/api/settings/hotkey');
const data = await res.json();
document.getElementById('hotkey-enable').checked = data.enabled;
}
document.getElementById('hotkey-enable').addEventListener('change', async (e) => {
await fetch('/api/settings/hotkey', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: e.target.checked })
});
});
loadHotkeySetting();
</script>
{{end}}
+60
View File
@@ -0,0 +1,60 @@
{{define "content"}}
<h2>Логи бота в реальном времени</h2>
<div style="margin-bottom: 10px;">
<button id="save-logs-btn">💾 Сохранить логи в файл</button>
</div>
<div id="log-container">
<div>Подключение к потоку логов...</div>
</div>
<script>
document.getElementById('save-logs-btn').addEventListener('click', () => {
window.location.href = '/api/logs/download';
});
const logContainer = document.getElementById('log-container');
let eventSource = null;
function addLogEntry(entry) {
const div = document.createElement('div');
const time = new Date(entry.time).toLocaleTimeString();
let levelClass = '';
switch (entry.level) {
case 'INFO': levelClass = 'color: #4ec9b0;'; break;
case 'WARN': levelClass = 'color: #dcdcaa;'; break;
case 'ERROR': levelClass = 'color: #f48771;'; break;
case 'FATAL': levelClass = 'color: #ff0000; font-weight: bold;'; break;
default: levelClass = '';
}
div.innerHTML = `<span style="color: #888;">[${time}]</span> <span style="${levelClass}">[${entry.level}]</span> ${escapeHtml(entry.message)}`;
logContainer.appendChild(div);
logContainer.scrollTop = logContainer.scrollHeight;
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
function connectLogStream() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/api/logs/stream');
eventSource.onmessage = function(event) {
const entry = JSON.parse(event.data);
addLogEntry(entry);
};
eventSource.onerror = function() {
logContainer.innerHTML += '<div style="color: red;">⚠️ Потеря соединения, переподключение через 3 секунды...</div>';
eventSource.close();
setTimeout(connectLogStream, 3000);
};
}
connectLogStream();
</script>
{{end}}
+174
View File
@@ -0,0 +1,174 @@
{{define "content"}}
<h2>Звуковые уведомления</h2>
<div class="card">
<h3>События чата и Twitch</h3>
<table id="notif-table">
<thead>
<tr><th>Событие</th><th>Звуковой файл</th><th>Громкость</th><th>Вкл.</th><th>Действия</th></tr>
</thead>
<tbody></tbody>
</table>
<button id="add-defaults">➕ Добавить события по умолчанию</button>
</div>
<div class="card">
<h3>Управление звуковыми файлами</h3>
<input type="file" id="sound-upload" accept=".mp3,.wav">
<button id="upload-btn">Загрузить</button>
<ul id="sound-list"></ul>
</div>
<div class="card">
<h3>Общие настройки звуков</h3>
<label>
<input type="checkbox" id="duplicate-sounds-checkbox"> Дублировать звуки из команд в локальное воспроизведение
</label>
<p class="help">При включении, звуки, отправляемые в веб-сервисы оповещений, также будут проигрываться на компьютере стримера.</p>
</div>
<script>
let settings = [];
let soundFiles = [];
async function loadSettings() {
const res = await fetch('/api/notifications');
settings = await res.json();
renderTable();
}
async function loadSounds() {
const res = await fetch('/api/sounds');
soundFiles = await res.json();
const list = document.getElementById('sound-list');
if (soundFiles.length === 0) {
list.innerHTML = '<li>Нет загруженных звуков. Загрузите MP3 или WAV.</li>';
} else {
list.innerHTML = soundFiles.map(f => `<li>${escapeHtml(f)} <button class="delete-sound" data-file="${f}">🗑️</button></li>`).join('');
document.querySelectorAll('.delete-sound').forEach(btn => {
btn.addEventListener('click', () => deleteSound(btn.dataset.file));
});
}
// Обновляем выпадающие списки в таблице
renderTable();
}
function renderTable() {
const tbody = document.querySelector('#notif-table tbody');
if (!settings.length) {
tbody.innerHTML = '<tr><td colspan="5">Нет настроек. Нажмите "Добавить события по умолчанию".</td></tr>';
return;
}
tbody.innerHTML = settings.map(s => `
<tr data-event="${s.event_name}">
<td>${escapeHtml(s.event_name)}</td>
<td>
<select class="sound-select" data-event="${s.event_name}">
<option value="">— без звука —</option>
${soundFiles.map(f => `<option value="data/sounds/${f}" ${s.sound_file === `data/sounds/${f}` ? 'selected' : ''}>${f}</option>`).join('')}
</select>
</td>
<td>
<input type="range" class="volume-slider" min="0" max="100" value="${s.volume}" data-event="${s.event_name}">
<span class="vol-value">${s.volume}</span>%
</td>
<td><input type="checkbox" class="enable-checkbox" ${s.enabled ? 'checked' : ''} data-event="${s.event_name}"></td>
<td><button class="test-btn" data-event="${s.event_name}">🔊 Тест</button></td>
</tr>
`).join('');
// Привязываем обработчики
document.querySelectorAll('.sound-select').forEach(sel => {
sel.addEventListener('change', (e) => updateSetting(e.target.dataset.event, 'sound_file', sel.value));
});
document.querySelectorAll('.volume-slider').forEach(slider => {
slider.addEventListener('input', (e) => {
const val = e.target.value;
const span = e.target.parentElement.querySelector('.vol-value');
span.innerText = val;
updateSetting(e.target.dataset.event, 'volume', parseInt(val));
});
});
document.querySelectorAll('.enable-checkbox').forEach(cb => {
cb.addEventListener('change', (e) => updateSetting(e.target.dataset.event, 'enabled', cb.checked));
});
document.querySelectorAll('.test-btn').forEach(btn => {
btn.addEventListener('click', () => testSound(btn.dataset.event));
});
}
async function updateSetting(eventName, field, value) {
const setting = settings.find(s => s.event_name === eventName);
if (!setting) return;
setting[field] = value;
const res = await fetch('/api/notifications', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(setting)
});
if (!res.ok) alert('Ошибка сохранения');
}
async function testSound(eventName) {
await fetch(`/api/notifications/test?event=${encodeURIComponent(eventName)}`);
}
async function deleteSound(filename) {
if (!confirm(`Удалить ${filename}?`)) return;
const res = await fetch(`/api/sounds?file=${encodeURIComponent(filename)}`, { method: 'DELETE' });
if (res.ok) {
await loadSounds();
await loadSettings();
} else {
alert('Ошибка удаления');
}
}
document.getElementById('upload-btn').onclick = async () => {
const fileInput = document.getElementById('sound-upload');
if (!fileInput.files.length) return;
const formData = new FormData();
formData.append('sound', fileInput.files[0]);
const res = await fetch('/api/sounds/upload', { method: 'POST', body: formData });
if (res.ok) {
await loadSounds();
await loadSettings();
fileInput.value = '';
} else {
alert('Ошибка загрузки');
}
};
document.getElementById('add-defaults').onclick = async () => {
const res = await fetch('/api/notifications/defaults', { method: 'POST' });
if (res.ok) {
await loadSettings();
await loadSounds();
} else {
alert('Ошибка добавления событий по умолчанию');
}
};
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
async function loadDuplicateSetting() {
const res = await fetch('/api/settings/duplicate_sounds');
const data = await res.json();
document.getElementById('duplicate-sounds-checkbox').checked = data.enabled;
}
document.getElementById('duplicate-sounds-checkbox').addEventListener('change', async (e) => {
await fetch('/api/settings/duplicate_sounds', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: e.target.checked })
});
});
loadDuplicateSetting();
loadSettings();
loadSounds();
</script>
{{end}}
+176
View File
@@ -0,0 +1,176 @@
{{define "content"}}
<div class="card">
<h3>Twitch</h3>
<div>
<p>Статус бота: <span id="twitch-status" class="status">Загрузка...</span></p>
<p>Имя канала: <span id="twitch-channel"></span></p>
</div>
<div style="margin: 10px 0;">
<button id="auth-user-btn">🔐 Авторизовать стримера</button>
<button id="auth-bot-btn">🤖 Авторизовать бота</button>
</div>
<div id="twitch-tokens-info"></div>
<div id="token-expiry-info" style="margin-top: 10px; font-size: 0.9em;">
<p>🕒 Срок действия токенов:</p>
<p>👤 Стример: <span id="user-expiry"></span></p>
<p>🤖 Бот: <span id="bot-expiry"></span></p>
</div>
<!-- Блок для отображения ссылки авторизации БОТА -->
<div id="auth-link-container" style="margin-top: 15px; display: none;">
<label>Скопируйте ссылку и откройте её в браузере, где залогинен аккаунт БОТА:</label>
<div style="display: flex; gap: 10px; margin-top: 5px;">
<input type="text" id="auth-link" readonly style="flex: 1; padding: 8px; font-family: monospace;">
<button id="copy-link-btn">📋 Копировать</button>
</div>
<p id="auth-wait-message" style="color: #666; margin-top: 10px;">Ожидание подтверждения авторизации...</p>
</div>
<div id="notification" style="position: fixed; top: 20px; right: 20px; z-index: 1000; background: #333; color: white; padding: 10px 20px; border-radius: 5px; display: none;"></div>
</div>
<script>
let pollInterval = null;
async function loadSettings() {
try {
const statusRes = await fetch('/api/platforms/twitch/status');
const statusData = await statusRes.json();
const statusSpan = document.getElementById('twitch-status');
if (statusData.connected) {
statusSpan.textContent = 'Подключен';
statusSpan.className = 'status online';
} else {
statusSpan.textContent = 'Отключен';
statusSpan.className = 'status offline';
}
const authRes = await fetch('/api/platforms/twitch/auth');
const authData = await authRes.json();
document.getElementById('twitch-channel').textContent = authData.username || 'не указан';
const tokensDiv = document.getElementById('twitch-tokens-info');
if (authData.hasToken) {
tokensDiv.innerHTML = `<p>🔑 Токен бота: ${authData.maskedToken}</p>`;
loadTokenExpiry();
} else {
tokensDiv.innerHTML = '<p style="color: orange;">⚠️ Токен бота не настроен. Авторизуйте бота.</p>';
}
} catch (err) {
console.error('Ошибка загрузки настроек:', err);
document.getElementById('twitch-status').textContent = 'Ошибка';
}
}
async function updateStatus() {
try {
const res = await fetch('/api/platforms/twitch/status');
const data = await res.json();
const statusSpan = document.getElementById('twitch-status');
if (data.connected) {
statusSpan.textContent = 'Подключен';
statusSpan.className = 'status online';
} else {
statusSpan.textContent = 'Отключен';
statusSpan.className = 'status offline';
}
} catch (err) {}
}
async function authStreamer() {
try {
const res = await fetch('/api/platforms/twitch/auth/user');
const data = await res.json();
if (data.url) {
window.open(data.url, '_blank', 'width=600,height=700');
if (pollInterval) clearInterval(pollInterval);
pollInterval = setInterval(() => checkTokenUpdated('user'), 2000);
} else {
alert('Не удалось получить ссылку для авторизации');
}
} catch (err) {
alert('Ошибка: ' + err.message);
}
}
async function authBot() {
try {
const res = await fetch('/api/platforms/twitch/auth/bot');
const data = await res.json();
if (data.url) {
document.getElementById('auth-link').value = data.url;
document.getElementById('auth-link-container').style.display = 'block';
if (pollInterval) clearInterval(pollInterval);
pollInterval = setInterval(() => checkTokenUpdated('bot'), 2000);
} else {
alert('Не удалось получить ссылку для авторизации');
}
} catch (err) {
alert('Ошибка: ' + err.message);
}
}
async function loadTokenExpiry() {
try {
const res = await fetch('/api/platforms/twitch/token_expiry');
const data = await res.json();
const userSpan = document.getElementById('user-expiry');
const botSpan = document.getElementById('bot-expiry');
if (data.user_expiry_days !== null && data.user_expiry_days !== undefined) {
userSpan.textContent = data.user_expiry_days + ' дн.';
if (data.user_expiry_days < 1) userSpan.style.color = 'red';
else if (data.user_expiry_days < 3) userSpan.style.color = 'orange';
else userSpan.style.color = 'green';
} else {
userSpan.textContent = 'не авторизован';
}
if (data.bot_expiry_days !== null && data.bot_expiry_days !== undefined) {
botSpan.textContent = data.bot_expiry_days + ' дн.';
if (data.bot_expiry_days < 1) botSpan.style.color = 'red';
else if (data.bot_expiry_days < 3) botSpan.style.color = 'orange';
else botSpan.style.color = 'green';
} else {
botSpan.textContent = 'не авторизован';
}
} catch (err) {
console.error('Ошибка загрузки срока токенов:', err);
}
}
function showMessage(text, isError = false) {
const notif = document.getElementById('notification');
notif.textContent = text;
notif.style.backgroundColor = isError ? '#d9534f' : '#5cb85c';
notif.style.display = 'block';
setTimeout(() => { notif.style.display = 'none'; }, 3000);
}
async function checkTokenUpdated(type) {
try {
const res = await fetch(`/api/platforms/twitch/token_check?type=${type}`);
const data = await res.json();
if (data.updated) {
clearInterval(pollInterval);
pollInterval = null;
if (type === 'bot') {
document.getElementById('auth-link-container').style.display = 'none';
}
showMessage(`✅ Токен ${type === 'user' ? 'стримера' : 'бота'} сохранён!`, false);
loadSettings();
}
} catch (err) {
console.error('Ошибка проверки токена:', err);
}
}
document.getElementById('copy-link-btn').addEventListener('click', () => {
const linkInput = document.getElementById('auth-link');
linkInput.select();
document.execCommand('copy');
alert('Ссылка скопирована! Откройте её в другом браузере.');
});
document.getElementById('auth-user-btn').onclick = authStreamer;
document.getElementById('auth-bot-btn').onclick = authBot;
loadSettings();
setInterval(updateStatus, 10000);
</script>
{{end}}
+296
View File
@@ -0,0 +1,296 @@
{{define "content"}}
<h2>Веб-сервисы для OBS</h2>
<p>Создавайте несколько независимых оверлеев чата и оповещений. Каждый сервис работает на своём порту.</p>
<div class="card">
<button id="add-chat-btn" style="margin-right:10px;">➕ Добавить чат-оверлей</button>
<button id="add-alert-btn">🔔 Добавить оверлей оповещений</button>
</div>
<div class="card">
<h3>Тестирование оповещений</h3>
<label>Выберите alert-сервис:</label>
<select id="test-alert-service"></select>
<label>Тип события:</label>
<select id="test-alert-event">
<option value="follow">Фолловер</option>
<option value="subscribe">Подписка</option>
<option value="gift_sub">Подарочная подписка</option>
<option value="raid">Рейд</option>
<option value="reward_redemption">Награда</option>
</select>
<button id="test-alert-btn">🔔 Тестировать оповещение</button>
</div>
<div class="card">
<h3>Список сервисов</h3>
<table id="services-table" style="width:100%; border-collapse: collapse;">
<thead>
<tr><th>ID</th><th>Тип</th><th>Порт</th><th>Статус</th><th>Действия</th></tr>
</thead>
<tbody>
<tr><td colspan="5">Загрузка...</td></tr>
</tbody>
</table>
</div>
<!-- Модальное окно для создания/редактирования -->
<div id="service-modal" class="modal">
<div class="modal-content">
<h3 id="modal-title">Создание сервиса</h3>
<form id="service-form">
<input type="hidden" id="service-id" value="0">
<label>Порт: <input type="number" id="service-port" required style="width:100%;"></label>
<label><input type="checkbox" id="service-enabled" checked> Сервис активен</label>
<!-- Настройки чата (показываются только для типа chat) -->
<div id="chat-config-fields" style="display:none;">
<h4>Настройки чата</h4>
<label>Фон (цвет): <input type="color" id="chat-bg-color" value="#000000"></label>
<label>Цвет текста: <input type="color" id="chat-text-color" value="#ffffff"></label>
<label>Размер шрифта (px): <input type="number" id="chat-font-size" min="10" max="50" value="14"></label>
<label>Шрифт: <input type="text" id="chat-font-family" value="Arial, sans-serif"></label>
<label>Прозрачность (%): <input type="range" id="chat-opacity" min="0" max="100" value="80"> <span id="opacity-val">80</span></label>
<label>Таймаут сообщения (сек): <input type="number" id="chat-timeout" min="0" max="60" value="10"></label>
<label>Макс. сообщений: <input type="number" id="chat-max-msgs" min="1" max="100" value="20"></label>
<label><input type="checkbox" id="chat-show-badges" checked> Показывать значки</label>
<label><input type="checkbox" id="chat-show-timestamps"> Показывать время</label>
<div class="preview-box">
<strong>Превью:</strong>
<div id="chat-preview" class="chat-preview" style="background:#000; color:#fff; padding:8px; border-radius:4px; margin-top:6px;">
<span style="font-weight:bold;">TestUser:</span> Привет, мир!
</div>
</div>
</div>
<!-- Для alert-сервиса ничего лишнего не показываем -->
<div id="alert-config-fields" style="display:none;">
<p class="info">Оповещения настраиваются через HTTP-запросы к эндпоинту <code>/notify</code> этого сервиса. Все уведомления будут отображаться автоматически.</p>
</div>
<div style="margin-top:15px;">
<button type="submit">Сохранить</button>
<button type="button" id="close-modal">Отмена</button>
</div>
</form>
</div>
</div>
<script>
let currentType = 'chat';
let services = [];
async function loadServices() {
try {
const res = await fetch('/api/webservices');
if (!res.ok) throw new Error('HTTP ' + res.status);
services = await res.json();
if (!Array.isArray(services)) services = [];
const tbody = document.querySelector('#services-table tbody');
if (!services.length) {
tbody.innerHTML = '<tr><td colspan="5">Нет сервисов. Создайте первый.</td></tr>';
return;
}
tbody.innerHTML = services.map(s => `
<tr>
<td>${s.id}</td>
<td>${s.service_type === 'chat' ? 'Чат' : 'Оповещения'}</td>
<td>${s.port}</td>
<td class="status-${s.id}">${s.running ? '✅ Запущен' : (s.enabled ? '⏹ Остановлен' : '❌ Отключен')}</td>
<td>
<button onclick="startService(${s.id})">▶ Запустить</button>
<button onclick="stopService(${s.id})">⏹ Остановить</button>
<button onclick="editService(${s.id})">✏️ Редактировать</button>
<button onclick="deleteService(${s.id})">🗑 Удалить</button>
</td>
</tr>
`).join('');
} catch (err) {
console.error('loadServices error:', err);
document.querySelector('#services-table tbody').innerHTML = '<tr><td colspan="5">Ошибка загрузки</td></tr>';
}
}
async function startService(id) {
const res = await fetch(`/api/webservices/start?id=${id}`, { method: 'POST' });
if (res.ok) loadServices(); else alert('Ошибка запуска');
}
async function stopService(id) {
const res = await fetch(`/api/webservices/stop?id=${id}`, { method: 'POST' });
if (res.ok) loadServices(); else alert('Ошибка остановки');
}
async function deleteService(id) {
if (!confirm('Удалить сервис?')) return;
const res = await fetch(`/api/webservices/delete?id=${id}`, { method: 'DELETE' });
if (res.ok) loadServices(); else alert('Ошибка удаления');
}
async function editService(id) {
const service = services.find(s => s.id === id);
if (!service) return;
currentType = service.service_type;
document.getElementById('service-id').value = service.id;
document.getElementById('service-port').value = service.port;
document.getElementById('service-enabled').checked = service.enabled;
if (currentType === 'chat') {
const cfg = service.config_json ? JSON.parse(service.config_json) : {};
document.getElementById('chat-bg-color').value = cfg.bg_color || '#000000';
document.getElementById('chat-text-color').value = cfg.text_color || '#ffffff';
document.getElementById('chat-font-size').value = cfg.font_size || 14;
document.getElementById('chat-font-family').value = cfg.font_family || 'Arial, sans-serif';
document.getElementById('chat-opacity').value = cfg.opacity || 80;
document.getElementById('opacity-val').innerText = cfg.opacity || 80;
document.getElementById('chat-timeout').value = cfg.message_timeout_sec || 10;
document.getElementById('chat-max-msgs').value = cfg.max_messages || 20;
document.getElementById('chat-show-badges').checked = cfg.show_badges !== undefined ? cfg.show_badges : true;
document.getElementById('chat-show-timestamps').checked = cfg.show_timestamps || false;
updateChatPreview();
}
showModal();
}
function updateChatPreview() {
const bg = document.getElementById('chat-bg-color').value;
const color = document.getElementById('chat-text-color').value;
const fontSize = document.getElementById('chat-font-size').value;
const fontFamily = document.getElementById('chat-font-family').value;
const opacity = document.getElementById('chat-opacity').value;
const showBadges = document.getElementById('chat-show-badges').checked;
const showTimestamps = document.getElementById('chat-show-timestamps').checked;
const previewDiv = document.getElementById('chat-preview');
previewDiv.style.backgroundColor = bg;
previewDiv.style.color = color;
previewDiv.style.fontSize = fontSize + 'px';
previewDiv.style.fontFamily = fontFamily;
previewDiv.style.opacity = opacity/100;
let badgesHtml = '';
if (showBadges) badgesHtml = '<span style="margin-right:4px;">🎭</span><span style="margin-right:4px;">👑</span><span>✔️</span> ';
let timeHtml = '';
if (showTimestamps) timeHtml = '<span style="color:#aaa; margin-right:8px;">12:34</span> ';
previewDiv.innerHTML = timeHtml + badgesHtml + '<span style="font-weight:bold;">TestUser:</span> Привет, мир!';
}
function showModal() {
document.getElementById('service-modal').style.display = 'flex';
document.getElementById('chat-config-fields').style.display = currentType === 'chat' ? 'block' : 'none';
document.getElementById('alert-config-fields').style.display = currentType === 'alert' ? 'block' : 'none';
document.getElementById('modal-title').innerText = (document.getElementById('service-id').value == 0 ? 'Создание' : 'Редактирование') + (currentType === 'chat' ? ' чат-оверлея' : ' оверлея оповещений');
}
async function openCreateDialog(type) {
currentType = type;
document.getElementById('service-id').value = 0;
document.getElementById('service-enabled').checked = true;
// Определяем свободный порт
const res = await fetch('/api/webservices');
let servicesList = [];
if (res.ok) {
servicesList = await res.json();
if (!Array.isArray(servicesList)) servicesList = [];
}
const usedPorts = servicesList.map(s => s.port);
let basePort = 9000;
while (usedPorts.includes(basePort)) basePort++;
document.getElementById('service-port').value = basePort;
if (type === 'chat') {
// Настройки чата по умолчанию
document.getElementById('chat-bg-color').value = '#000000';
document.getElementById('chat-text-color').value = '#ffffff';
document.getElementById('chat-font-size').value = 14;
document.getElementById('chat-font-family').value = 'Arial, sans-serif';
document.getElementById('chat-opacity').value = 80;
document.getElementById('opacity-val').innerText = '80';
document.getElementById('chat-timeout').value = 10;
document.getElementById('chat-max-msgs').value = 20;
document.getElementById('chat-show-badges').checked = true;
document.getElementById('chat-show-timestamps').checked = false;
updateChatPreview();
}
showModal();
}
document.getElementById('add-chat-btn').onclick = () => openCreateDialog('chat');
document.getElementById('add-alert-btn').onclick = () => openCreateDialog('alert');
document.getElementById('close-modal').onclick = () => document.getElementById('service-modal').style.display = 'none';
// Обработчики для превью чата
document.getElementById('chat-bg-color').addEventListener('input', updateChatPreview);
document.getElementById('chat-text-color').addEventListener('input', updateChatPreview);
document.getElementById('chat-font-size').addEventListener('input', updateChatPreview);
document.getElementById('chat-font-family').addEventListener('input', updateChatPreview);
document.getElementById('chat-opacity').addEventListener('input', (e) => { document.getElementById('opacity-val').innerText = e.target.value; updateChatPreview(); });
document.getElementById('chat-show-badges').addEventListener('change', updateChatPreview);
document.getElementById('chat-show-timestamps').addEventListener('change', updateChatPreview);
document.getElementById('service-form').onsubmit = async (e) => {
e.preventDefault();
const id = parseInt(document.getElementById('service-id').value);
const port = parseInt(document.getElementById('service-port').value);
const enabled = document.getElementById('service-enabled').checked;
let config = {};
if (currentType === 'chat') {
config = {
bg_color: document.getElementById('chat-bg-color').value,
text_color: document.getElementById('chat-text-color').value,
font_size: parseInt(document.getElementById('chat-font-size').value),
font_family: document.getElementById('chat-font-family').value,
opacity: parseInt(document.getElementById('chat-opacity').value),
message_timeout_sec: parseInt(document.getElementById('chat-timeout').value),
max_messages: parseInt(document.getElementById('chat-max-msgs').value),
show_badges: document.getElementById('chat-show-badges').checked,
show_timestamps: document.getElementById('chat-show-timestamps').checked
};
} else {
// Для alert-сервиса отправляем пустой конфиг
config = {};
}
const url = id === 0 ? '/api/webservices/create' : `/api/webservices/update?id=${id}`;
const method = id === 0 ? 'POST' : 'PUT';
const body = JSON.stringify({ type: currentType, port, config, enabled });
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body });
if (res.ok) {
document.getElementById('service-modal').style.display = 'none';
loadServices();
} else {
const err = await res.text();
alert('Ошибка: ' + err);
}
};
async function loadAlertServices() {
const res = await fetch('/api/webservices/alert/list');
const services = await res.json();
const select = document.getElementById('test-alert-service');
select.innerHTML = '<option value="">-- выберите сервис --</option>';
services.forEach(s => {
const option = document.createElement('option');
option.value = s.id;
option.textContent = s.name;
select.appendChild(option);
});
}
document.getElementById('test-alert-btn').onclick = async () => {
const serviceId = document.getElementById('test-alert-service').value;
if (!serviceId) {
alert('Выберите alert-сервис');
return;
}
const eventType = document.getElementById('test-alert-event').value;
const res = await fetch('/api/webservices/test/alert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serviceId: parseInt(serviceId), eventType: eventType })
});
if (res.ok) alert('Тестовое оповещение отправлено');
else alert('Ошибка');
};
// Вызываем при загрузке страницы
loadAlertServices();
loadServices();
setInterval(loadServices, 5000);
</script>
{{end}}