залил

This commit is contained in:
2026-04-15 08:00:15 +03:00
commit 5549b3545e
51 changed files with 8073 additions and 0 deletions
+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"` // добавлено поле
}