209 lines
6.4 KiB
Go
209 lines
6.4 KiB
Go
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 '&'; if (m === '<') return '<'; if (m === '>') return '>'; 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
|
||
}
|