package main import ( "fmt" "strings" "time" "hamlog/internal/applog" wruntime "github.com/wailsapp/wails/v2/pkg/runtime" ) // Multi-operator chat over the SHARED MySQL logbook. The database is the message // bus: each OpsLog INSERTs into chat_messages and polls for new rows (~3 s), so // operators on the same shared log (e.g. a special-event call) can talk. No // extra server. Presence is a lightweight heartbeat into chat_presence. Chat is // only available on a MySQL logbook (SQLite/solo has no one else to talk to). const ( chatPollInterval = 3 * time.Second chatPresenceEvery = 20 * time.Second chatRetentionDays = 7 chatHistoryDefault = 80 chatPresenceStaleSecs = 120 // a presence row older than this = offline ) // ChatMessage is one chat line. type ChatMessage struct { ID int64 `json:"id"` Operator string `json:"operator"` Station string `json:"station"` Message string `json:"message"` CreatedAt string `json:"created_at"` // ISO UTC } // ChatPresence is one operator currently online (recent heartbeat). type ChatPresence struct { Operator string `json:"operator"` Station string `json:"station"` AgoSecs int `json:"ago_secs"` } // chatActive reports whether chat can run (shared MySQL logbook). func (a *App) chatActive() bool { return a.logDb != nil && a.dbBackend == "mysql" } // ChatAvailable lets the UI show/hide the chat icon (only on a shared log). func (a *App) ChatAvailable() bool { return a.chatActive() } func (a *App) ensureChatTables() error { if _, err := a.logDb.ExecContext(a.ctx, "CREATE TABLE IF NOT EXISTS chat_messages ("+ "id BIGINT AUTO_INCREMENT PRIMARY KEY, "+ "operator VARCHAR(32), station VARCHAR(32), "+ "message TEXT, created_at DATETIME)"); err != nil { return err } _, err := a.logDb.ExecContext(a.ctx, "CREATE TABLE IF NOT EXISTS chat_presence ("+ "operator VARCHAR(32) PRIMARY KEY, station VARCHAR(32), updated_at DATETIME)") return err } // SendChatMessage posts a message to the shared chat and returns the stored row // (with its id) so the UI can show it immediately; the poll loop dedupes by id. func (a *App) SendChatMessage(text string) (ChatMessage, error) { text = strings.TrimSpace(text) if text == "" { return ChatMessage{}, nil } if len(text) > 1000 { text = text[:1000] } if !a.chatActive() { return ChatMessage{}, fmt.Errorf("chat is only available on a shared MySQL logbook") } op, station := a.liveStatusOperator() if op == "" { return ChatMessage{}, fmt.Errorf("set your callsign/operator in Settings → Station first") } if err := a.ensureChatTables(); err != nil { return ChatMessage{}, err } res, err := a.logDb.ExecContext(a.ctx, "INSERT INTO chat_messages (operator, station, message, created_at) VALUES (?, ?, ?, UTC_TIMESTAMP())", op, station, text) if err != nil { return ChatMessage{}, err } id, _ := res.LastInsertId() return ChatMessage{ID: id, Operator: op, Station: station, Message: text, CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil } // GetChatHistory returns the most recent messages (oldest first) for the panel. func (a *App) GetChatHistory(limit int) ([]ChatMessage, error) { if !a.chatActive() { return nil, nil } if limit <= 0 || limit > 500 { limit = chatHistoryDefault } if err := a.ensureChatTables(); err != nil { return nil, err } rows, err := a.logDb.QueryContext(a.ctx, "SELECT id, operator, station, message, created_at FROM chat_messages ORDER BY id DESC LIMIT ?", limit) if err != nil { return nil, err } defer rows.Close() var out []ChatMessage for rows.Next() { var m ChatMessage if err := rows.Scan(&m.ID, &m.Operator, &m.Station, &m.Message, &m.CreatedAt); err != nil { return nil, err } out = append(out, m) } // Reverse to chronological order (we queried newest-first to honour LIMIT). for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { out[i], out[j] = out[j], out[i] } return out, rows.Err() } // GetOnlineOperators lists operators with a recent presence heartbeat. func (a *App) GetOnlineOperators() ([]ChatPresence, error) { if !a.chatActive() { return nil, nil } if err := a.ensureChatTables(); err != nil { return nil, err } rows, err := a.logDb.QueryContext(a.ctx, "SELECT operator, station, TIMESTAMPDIFF(SECOND, updated_at, UTC_TIMESTAMP()) AS ago "+ "FROM chat_presence WHERE updated_at > UTC_TIMESTAMP() - INTERVAL ? SECOND ORDER BY operator", chatPresenceStaleSecs) if err != nil { return nil, err } defer rows.Close() var out []ChatPresence for rows.Next() { var p ChatPresence if err := rows.Scan(&p.Operator, &p.Station, &p.AgoSecs); err != nil { return nil, err } out = append(out, p) } return out, rows.Err() } // chatLoop polls for new messages and heartbeats presence while on a shared // MySQL logbook. Started once at startup; a cheap no-op otherwise. func (a *App) chatLoop() { defer func() { _ = recover() }() var lastID int64 = -1 // -1 = not yet baselined lastPresence := time.Time{} lastPurge := time.Time{} t := time.NewTicker(chatPollInterval) defer t.Stop() for range t.C { if !a.chatActive() { lastID = -1 // re-baseline if the backend changes continue } if err := a.ensureChatTables(); err != nil { continue } now := time.Now() // Presence heartbeat. if now.Sub(lastPresence) >= chatPresenceEvery { if op, station := a.liveStatusOperator(); op != "" { _, _ = a.logDb.ExecContext(a.ctx, "INSERT INTO chat_presence (operator, station, updated_at) VALUES (?, ?, UTC_TIMESTAMP()) "+ "ON DUPLICATE KEY UPDATE station=VALUES(station), updated_at=UTC_TIMESTAMP()", op, station) } lastPresence = now } // Baseline on first run so existing history isn't replayed as "new" // (the panel loads it via GetChatHistory). if lastID < 0 { row := a.logDb.QueryRowContext(a.ctx, "SELECT COALESCE(MAX(id),0) FROM chat_messages") _ = row.Scan(&lastID) continue } // Emit new messages. rows, err := a.logDb.QueryContext(a.ctx, "SELECT id, operator, station, message, created_at FROM chat_messages WHERE id > ? ORDER BY id", lastID) if err != nil { continue } for rows.Next() { var m ChatMessage if err := rows.Scan(&m.ID, &m.Operator, &m.Station, &m.Message, &m.CreatedAt); err != nil { continue } if m.ID > lastID { lastID = m.ID } if a.ctx != nil { wruntime.EventsEmit(a.ctx, "chat:message", m) } } rows.Close() // Purge old messages occasionally (hourly). if now.Sub(lastPurge) >= time.Hour { _, err := a.logDb.ExecContext(a.ctx, "DELETE FROM chat_messages WHERE created_at < UTC_TIMESTAMP() - INTERVAL ? DAY", chatRetentionDays) if err != nil { applog.Printf("chat: purge failed: %v", err) } lastPurge = now } } }