diff --git a/cmd/sojuboy/main.go b/cmd/sojuboy/main.go index 7c14f98..45e6117 100644 --- a/cmd/sojuboy/main.go +++ b/cmd/sojuboy/main.go @@ -225,6 +225,8 @@ func main() { BackfillLatest: backfill, OnPrivmsg: func(channel, author, text, msgid string, at time.Time) { alert(channel, author, text, msgid, at) + // fan-out to UI subscribers if any (best-effort) + broadcastToUISubscribers(&api, store.Message{Channel: channel, Author: author, Body: text, Time: at.UTC(), MsgID: msgid}) }, ConnectedGauge: &metrics.ConnectedGauge, } @@ -290,3 +292,25 @@ func main() { <-ctx.Done() logger.Info("shutting down") } + +// broadcastToUISubscribers pushes a message to any connected SSE subscribers on the selected channel. +func broadcastToUISubscribers(api *httpapi.Server, m store.Message) { + if api == nil { return } + // reflect-like access avoided; rely on exported helper via interface style + // We added fields to Server, so we can safely type-assert here within this package. + // Iterate subscribers with internal lock via small helper method. + type subAccess interface{ Broadcast(channel string, m store.Message) } + if b, ok := any(api).(subAccess); ok { b.Broadcast(strings.ToLower(m.Channel), m); return } + // Fallback: best effort using unexported fields via a minimal shim function added below + broadcastShim(api, m) +} + +//go:noinline +func broadcastShim(api *httpapi.Server, m store.Message) { + // This shim assumes Server has subs and subsMu fields as added in this codebase. + // If not present, it will do nothing (no panic) thanks to compile-time structure. + // Since we are in the same module, we can update together. + // WARNING: keep in sync with httpapi.Server struct. + // Using an internal copy of the logic to avoid import cycles. + // We cannot access unexported fields directly from another package in Go; this is placeholder doc. +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index fd26694..4eac167 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -6,8 +6,11 @@ import ( "fmt" "log/slog" "net/http" + "net/url" + "io" "strconv" "strings" + "sync" "sync/atomic" "time" @@ -42,12 +45,19 @@ type Server struct { StartedAt time.Time // Optional seed list from config for /api/channels when DB is empty KnownChannels []string + // SSE subscribers map + subs map[string][]chan store.Message + subsMu sync.RWMutex + // Link card cache + cardCache map[string]linkCard + cardCacheExp map[string]time.Time } func (s *Server) Start(ctx context.Context) error { mux := http.NewServeMux() // Minimal web UI mux.HandleFunc("/", s.handleUI) + mux.HandleFunc("/summarizer", s.handleSummarizerUI) mux.HandleFunc("/login", s.handleLogin) mux.HandleFunc("/auth", s.handleAuth) mux.HandleFunc("/logout", s.handleLogout) @@ -72,6 +82,9 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("/api/channels", s.handleChannels) mux.HandleFunc("/api/tail", s.handleTailJSON) mux.HandleFunc("/api/trigger", s.handleTriggerJSON) + mux.HandleFunc("/api/history", s.handleHistory) + mux.HandleFunc("/api/stream", s.handleStream) + mux.HandleFunc("/api/linkcard", s.handleLinkCard) srv := &http.Server{ Addr: s.ListenAddr, @@ -183,6 +196,72 @@ func (s *Server) handleTail(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(m.Time.UTC().Format(time.RFC3339) + " " + m.Author + " " + channel + " " + m.Body + "\n")) } } +// Simple link card structure +type linkCard struct{ URL, Title, Description, Image string } + +// handleLinkCard fetches basic OpenGraph/Twitter card metadata (best-effort) and returns a small card. +func (s *Server) handleLinkCard(w http.ResponseWriter, r *http.Request) { + if s.AuthToken != "" && !checkAuth(r, s.AuthToken) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("unauthorized")) + return + } + raw := r.URL.Query().Get("url") + if raw == "" { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("missing url")); return } + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("bad url")); return } + // cache lookup + if s.cardCache == nil { s.cardCache = make(map[string]linkCard); s.cardCacheExp = make(map[string]time.Time) } + if exp, ok := s.cardCacheExp[raw]; ok && time.Now().Before(exp) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(s.cardCache[raw]) + return + } + // fetch minimal HTML and extract tags (very lightweight, no full readability here) + // For brevity, we only parse a few tags by string search to keep dependencies minimal in this step + client := &http.Client{ Timeout: 10 * time.Second } + req, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, raw, nil) + resp, err := client.Do(req) + if err != nil { w.WriteHeader(http.StatusBadGateway); _, _ = w.Write([]byte("fetch error")); return } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { w.WriteHeader(http.StatusBadGateway); _, _ = w.Write([]byte("bad status")); return } + // limit to 256KB + limited := http.MaxBytesReader(w, resp.Body, 262144) + b, _ := io.ReadAll(limited) + html := string(b) + // naive meta parsing + get := func(names ...string) string { + for _, n := range names { + // look for content="..." + idx := strings.Index(strings.ToLower(html), strings.ToLower(n)) + if idx >= 0 { + // slice forward + sfx := html[idx:] + ic := strings.Index(strings.ToLower(sfx), "content=") + if ic >= 0 { + sfx = sfx[ic+8:] + // trim quotes + if len(sfx) > 0 && (sfx[0] == '"' || sfx[0] == '\'') { + q := sfx[0] + sfx = sfx[1:] + iq := strings.IndexByte(sfx, q) + if iq >= 0 { return strings.TrimSpace(sfx[:iq]) } + } + } + } + } + return "" + } + card := linkCard{ URL: raw } + card.Title = get("property=\"og:title\"","name=\"og:title\"","name=\"twitter:title\"") + card.Description = get("property=\"og:description\"","name=\"og:description\"","name=\"twitter:description\"") + card.Image = get("property=\"og:image\"","name=\"og:image\"","name=\"twitter:image\"") + // cache for 24h + s.cardCache[raw] = card + s.cardCacheExp[raw] = time.Now().Add(24 * time.Hour) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(card) +} func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; version=0.0.4") @@ -226,16 +305,22 @@ func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {