220 lines
6.6 KiB
Go
220 lines
6.6 KiB
Go
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
|
|
}
|
|
}
|
|
}
|