залил
This commit is contained in:
@@ -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 '&'; 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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 '&'; if (m === '<') return '<'; if (m === '>') return '>'; 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package webservices
|
||||
|
||||
type Service interface {
|
||||
Start() error
|
||||
Stop() error
|
||||
ReloadConfig(config interface{}) error
|
||||
GetPort() int
|
||||
GetType() string
|
||||
IsRunning() bool
|
||||
}
|
||||
@@ -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"` // добавлено поле
|
||||
}
|
||||
Reference in New Issue
Block a user