chore: release v0.12
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user