gomeshalerter/broadcaster.go

241 lines
7.3 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"sort"
"strings"
"time"
)
// broadcaster runs on startup and every hour to select and broadcast the most important article
func broadcaster(shutdown chan struct{}, dryRun bool) {
logInfo("broadcaster", fmt.Sprintf("Starting broadcaster (waiting %d seconds before first broadcast)", int(STARTUP_DELAY.Seconds())), map[string]interface{}{
"interval": BROADCAST_INTERVAL.String(),
"dryRun": dryRun,
})
// Sleep on startup
logInfo("broadcaster", fmt.Sprintf("Sleeping for %d seconds before first broadcast", int(STARTUP_DELAY.Seconds())), nil)
select {
case <-time.After(STARTUP_DELAY):
// Continue after sleep
case <-shutdown:
logInfo("broadcaster", "Shutdown signal received during startup sleep", nil)
return
}
// Run immediately after initial sleep
checkAndBroadcast(dryRun)
// Then run on interval
ticker := time.NewTicker(BROADCAST_INTERVAL)
defer ticker.Stop()
for {
select {
case <-ticker.C:
checkAndBroadcast(dryRun)
case <-shutdown:
logInfo("broadcaster", "Shutting down broadcaster", nil)
return
}
}
}
// checkAndBroadcast checks if there are any unsummarized articles before broadcasting
func checkAndBroadcast(dryRun bool) {
// Check if there are any unsummarized articles
articles := loadArticles()
unsummarizedCount := 0
for _, article := range articles {
if article.Summary == "" || article.Importance == 0 {
unsummarizedCount++
}
}
if unsummarizedCount > 0 {
logInfo("broadcaster", "Postponing broadcast - waiting for articles to be summarized", map[string]interface{}{
"unsummarizedCount": unsummarizedCount,
"totalArticles": len(articles),
})
return
}
// No unsummarized articles, proceed with broadcast
broadcastWithRedundancyCheck(dryRun)
}
// broadcastWithRedundancyCheck attempts to find a non-redundant article to broadcast
func broadcastWithRedundancyCheck(dryRun bool) {
articles := loadArticles()
now := time.Now()
cutoff := now.Add(-ARTICLE_FRESHNESS_WINDOW) // Time window for considering articles fresh
var candidates []Article
for _, a := range articles {
// Only include articles that haven't been broadcast, have summaries, and are fresh
if a.Summary != "" && a.BroadcastTime.IsZero() && a.FirstSeen.After(cutoff) {
candidates = append(candidates, a)
}
}
if len(candidates) == 0 {
logInfo("broadcaster", "No fresh articles found for broadcasting", map[string]interface{}{
"totalArticles": len(articles),
})
return
}
// Sort by importance
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].Importance > candidates[j].Importance
})
// Get recent broadcasts
recentBroadcasts := getRecentBroadcasts(12)
// Try candidates until we find a non-redundant one
for i := 0; i < len(candidates); i++ {
candidate := candidates[i]
// Check if this candidate would be redundant
isRedundant, reason, err := checkRedundancy(candidate, recentBroadcasts)
if err != nil {
logInfo("redundancy", "Error checking redundancy, proceeding anyway", map[string]interface{}{
"error": err.Error(),
"id": candidate.ID,
})
// Continue with this candidate despite the error
broadcastArticle(candidate, dryRun)
return
}
if isRedundant {
logInfo("redundancy", "Article deemed redundant, marking as 'already broadcast'", map[string]interface{}{
"id": candidate.ID,
"summary": candidate.Summary,
"reason": reason,
})
// Mark as broadcast with special timestamp to prevent future selection
candidate.BroadcastTime = time.Unix(1, 0) // epoch + 1 second
if err := updateArticle(candidate); err != nil {
logInfo("redundancy", "Error updating article", map[string]interface{}{
"id": candidate.ID,
"error": err.Error(),
})
}
continue // Try next candidate
}
// Not redundant, proceed with this candidate
logInfo("redundancy", "Article passed redundancy check", map[string]interface{}{
"id": candidate.ID,
"candidateNumber": i + 1,
})
broadcastArticle(candidate, dryRun)
return
}
// If we got here, all candidates were redundant
logInfo("broadcaster", "All candidates were deemed redundant, no broadcast", map[string]interface{}{
"candidatesChecked": len(candidates),
})
}
// broadcastArticle broadcasts the chosen article
func broadcastArticle(chosen Article, dryRun bool) {
// Get the abbreviated source name
sourceAbbr := sourceAbbreviations[chosen.Source]
if sourceAbbr == "" {
// Default to first 3 characters if no abbreviation defined
if len(chosen.Source) >= 3 {
sourceAbbr = chosen.Source[:3]
} else {
sourceAbbr = chosen.Source
}
}
ts := time.Now().Format("15:04 MST")
msg := fmt.Sprintf("%s: [%s] [AI/LLM] %s", ts, sourceAbbr, strings.TrimSpace(chosen.Summary))
// Ensure message is under 200 characters
if len(msg) > MAX_MESSAGE_LENGTH {
// Calculate max summary length including timestamp, source prefix, AI/LLM prefix, and ellipsis
maxSummaryLen := MAX_MESSAGE_LENGTH - len(ts) - len(sourceAbbr) - len(": [] [AI/LLM] ") - 3 // 3 for "..."
truncatedSummary := strings.TrimSpace(chosen.Summary)[:maxSummaryLen] + "..."
msg = fmt.Sprintf("%s: [%s] [AI/LLM] %s", ts, sourceAbbr, truncatedSummary)
logInfo("broadcaster", fmt.Sprintf("Message truncated to fit %d character limit", MAX_MESSAGE_LENGTH), map[string]interface{}{
"originalLength": len(chosen.Summary),
"truncatedLength": maxSummaryLen,
})
}
// Print info about what will be broadcast
cmdStr := fmt.Sprintf("meshtastic --sendtext \"%s\"", msg)
logInfo("broadcaster", "Preparing to broadcast", map[string]interface{}{
"message": msg,
"length": len(msg),
"command": cmdStr,
"dryRun": dryRun,
"id": chosen.ID,
"importance": chosen.Importance,
"source": chosen.Source,
"sourceAbbr": sourceAbbr,
})
// Wait before broadcasting to allow time to see the message
logInfo("broadcaster", fmt.Sprintf("Waiting %d seconds before broadcasting...", int(BROADCAST_PREPARATION_DELAY.Seconds())), nil)
time.Sleep(BROADCAST_PREPARATION_DELAY)
// Update broadcast time and save to database
chosen.BroadcastTime = time.Now()
if err := updateArticle(chosen); err != nil {
logInfo("broadcaster", "Error updating article broadcast time", map[string]interface{}{
"id": chosen.ID,
"error": err.Error(),
})
}
logInfo("broadcaster", "Set broadcast time for article", map[string]interface{}{
"id": chosen.ID,
"summary": chosen.Summary,
"importance": chosen.Importance,
"broadcastTime": chosen.BroadcastTime.Format(time.RFC3339),
})
output, _ := json.MarshalIndent(chosen, "", " ")
fmt.Println(string(output))
if !dryRun {
logInfo("broadcaster", "Broadcasting message", map[string]interface{}{
"message": msg,
"length": len(msg),
})
cmd := exec.Command("meshtastic", "--sendtext", msg)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
logInfo("broadcaster", "Broadcast failed", map[string]interface{}{
"error": err.Error(),
"id": chosen.ID,
})
} else {
logInfo("broadcaster", "Broadcast success", map[string]interface{}{
"id": chosen.ID,
})
}
} else {
// In dry-run mode, just print the command
logInfo("broadcaster", "DRY RUN - would run command", map[string]interface{}{
"command": cmdStr,
"id": chosen.ID,
})
}
}