diff --git a/cmd/sojuboy/main.go b/cmd/sojuboy/main.go
index 45e6117..25c1a0c 100644
--- a/cmd/sojuboy/main.go
+++ b/cmd/sojuboy/main.go
@@ -223,10 +223,11 @@ func main() {
Password: cfg.Password,
Channels: cfg.Channels,
BackfillLatest: backfill,
+ Store: st,
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})
+ api.Broadcast(strings.ToLower(channel), store.Message{Channel: channel, Author: author, Body: text, Time: at.UTC(), MsgID: msgid})
},
ConnectedGauge: &metrics.ConnectedGauge,
}
@@ -292,25 +293,3 @@ 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 da279a1..2cfa5a4 100644
--- a/internal/httpapi/server.go
+++ b/internal/httpapi/server.go
@@ -1,9 +1,10 @@
package httpapi
import (
- "encoding/json"
"context"
+ "encoding/json"
"fmt"
+ "io/fs"
"log/slog"
"net/http"
"net/url"
@@ -15,7 +16,8 @@ import (
"sojuboy/internal/store"
"sojuboy/internal/summarizer"
- xhtml "golang.org/x/net/html"
+
+ xhtml "golang.org/x/net/html"
)
type Metrics struct {
@@ -51,13 +53,20 @@ type Server struct {
// Link card cache
cardCache map[string]linkCard
cardCacheExp map[string]time.Time
+ // Link summary cache
+ summaryCache map[string]string
+ summaryCacheExp 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)
+ // Minimal web UI (templated)
+ mux.HandleFunc("/", s.handleUIDash)
+ mux.HandleFunc("/summarizer", s.handleUISummarizer)
+ // Serve embedded static files under /static/
+ if sub, err := fs.Sub(staticFS, "."); err == nil {
+ mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
+ }
mux.HandleFunc("/login", s.handleLogin)
mux.HandleFunc("/auth", s.handleAuth)
mux.HandleFunc("/logout", s.handleLogout)
@@ -85,6 +94,7 @@ func (s *Server) Start(ctx context.Context) error {
mux.HandleFunc("/api/history", s.handleHistory)
mux.HandleFunc("/api/stream", s.handleStream)
mux.HandleFunc("/api/linkcard", s.handleLinkCard)
+ mux.HandleFunc("/api/linksummary", s.handleLinkSummary)
srv := &http.Server{
Addr: s.ListenAddr,
@@ -198,67 +208,158 @@ 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 string `json:"url"`
- Title string `json:"title"`
- Description string `json:"description"`
- Image string `json:"image"`
+type linkCard struct {
+ URL string `json:"url"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Image string `json:"image"`
}
// 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 using a tolerant HTML parser
- 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 and parse tokens
- limited := http.MaxBytesReader(w, resp.Body, 262144)
- doc, err := xhtml.Parse(limited)
- if err != nil { w.WriteHeader(http.StatusBadGateway); _, _ = w.Write([]byte("parse error")); return }
- var title, desc, img string
- var walker func(*xhtml.Node)
- walker = func(n *xhtml.Node) {
- if n.Type == xhtml.ElementNode && strings.EqualFold(n.Data, "meta") {
- // property or name + content
- var pn = ""; var nm = ""; var content = ""
- for _, a := range n.Attr { if strings.EqualFold(a.Key, "property") { pn = a.Val } else if strings.EqualFold(a.Key, "name") { nm = a.Val } else if strings.EqualFold(a.Key, "content") { content = a.Val } }
- key := strings.ToLower(pn)
- if key == "" { key = strings.ToLower(nm) }
- switch key {
- case "og:title", "twitter:title": if title == "" { title = content }
- case "og:description", "twitter:description": if desc == "" { desc = content }
- case "og:image", "twitter:image": if img == "" { img = content }
- }
- }
- for c := n.FirstChild; c != nil; c = c.NextSibling { walker(c) }
- }
- walker(doc)
- card := linkCard{ URL: raw, Title: strings.TrimSpace(title), Description: strings.TrimSpace(desc), Image: strings.TrimSpace(img) }
- // 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)
+ 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 using a tolerant HTML parser
+ 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 and parse tokens
+ limited := http.MaxBytesReader(w, resp.Body, 262144)
+ doc, err := xhtml.Parse(limited)
+ if err != nil {
+ w.WriteHeader(http.StatusBadGateway)
+ _, _ = w.Write([]byte("parse error"))
+ return
+ }
+ var title, desc, img string
+ var walker func(*xhtml.Node)
+ walker = func(n *xhtml.Node) {
+ if n.Type == xhtml.ElementNode && strings.EqualFold(n.Data, "meta") {
+ // property or name + content
+ var pn = ""
+ var nm = ""
+ var content = ""
+ for _, a := range n.Attr {
+ if strings.EqualFold(a.Key, "property") {
+ pn = a.Val
+ } else if strings.EqualFold(a.Key, "name") {
+ nm = a.Val
+ } else if strings.EqualFold(a.Key, "content") {
+ content = a.Val
+ }
+ }
+ key := strings.ToLower(pn)
+ if key == "" {
+ key = strings.ToLower(nm)
+ }
+ switch key {
+ case "og:title", "twitter:title":
+ if title == "" {
+ title = content
+ }
+ case "og:description", "twitter:description":
+ if desc == "" {
+ desc = content
+ }
+ case "og:image", "twitter:image":
+ if img == "" {
+ img = content
+ }
+ }
+ }
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ walker(c)
+ }
+ }
+ walker(doc)
+ card := linkCard{URL: raw, Title: strings.TrimSpace(title), Description: strings.TrimSpace(desc), Image: strings.TrimSpace(img)}
+ // 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)
+}
+
+// handleLinkSummary returns a brief AI summary for a given URL, cached for 24h.
+func (s *Server) handleLinkSummary(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
+ }
+ if s.Summarizer == nil {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ _, _ = w.Write([]byte("summarizer not configured"))
+ return
+ }
+ if s.summaryCache == nil { s.summaryCache = make(map[string]string) }
+ if s.summaryCacheExp == nil { s.summaryCacheExp = make(map[string]time.Time) }
+ if exp, ok := s.summaryCacheExp[raw]; ok && time.Now().Before(exp) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"summary": s.summaryCache[raw]})
+ return
+ }
+ msgs := []store.Message{{Channel: "#links", Author: "link", Body: raw, Time: time.Now().UTC()}}
+ tout := s.SummarizerTimeout
+ if tout <= 0 { tout = 5 * time.Minute }
+ if tout > 2*time.Minute { tout = 2 * time.Minute }
+ ctx, cancel := context.WithTimeout(r.Context(), tout)
+ defer cancel()
+ sum, err := s.Summarizer.Summarize(ctx, "#links", msgs, 0)
+ if err != nil {
+ w.WriteHeader(http.StatusBadGateway)
+ _, _ = w.Write([]byte("summarizer error"))
+ return
+ }
+ if sum == "" { sum = "(no summary)" }
+ s.summaryCache[raw] = sum
+ s.summaryCacheExp[raw] = time.Now().Add(24 * time.Hour)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"summary": sum})
}
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
@@ -281,7 +382,7 @@ func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
// --- Web UI handlers ---
-func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
+func (s *Server) handleUIDash(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
w.WriteHeader(http.StatusNotFound)
return
@@ -293,202 +394,121 @@ func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
return
}
}
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- // Pico.css from CDN and a tiny app
- page := `
-
-
-
-
- sojuboy
-
-
-
-
-
-
-
-
-
-
-
-
-`
- _, _ = w.Write([]byte(page))
+ s.render(w, "dashboard.tmpl", map[string]any{})
}
-func (s *Server) handleSummarizerUI(w http.ResponseWriter, r *http.Request) {
+func (s *Server) handleUISummarizer(w http.ResponseWriter, r *http.Request) {
if s.AuthToken != "" {
if c, err := r.Cookie("auth_token"); err != nil || c.Value != s.AuthToken {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
}
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- page := `Summarizer · sojuboyOn-demand summarization
`
- _, _ = w.Write([]byte(page))
+ s.render(w, "summarizer.tmpl", map[string]any{})
}
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
- "version": s.Version,
- "commit": s.Commit,
- "builtAt": s.BuiltAt,
+ "version": s.Version,
+ "commit": s.Commit,
+ "builtAt": s.BuiltAt,
"startedAt": s.StartedAt.Format(time.RFC3339),
- "uptime": time.Since(s.StartedAt).Round(time.Second).String(),
- "messagesIngested": func() int64 { if s.Metrics==nil {return 0}; return atomic.LoadInt64(&s.Metrics.MessagesIngested) }(),
- "notificationsSent": func() int64 { if s.Metrics==nil {return 0}; return atomic.LoadInt64(&s.Metrics.NotificationsSent) }(),
- "messagesPruned": func() int64 { if s.Metrics==nil {return 0}; return atomic.LoadInt64(&s.Metrics.MessagesPruned) }(),
- "connected": func() bool { if s.Metrics==nil {return false}; return atomic.LoadInt64(&s.Metrics.ConnectedGauge)==1 }(),
+ "uptime": time.Since(s.StartedAt).Round(time.Second).String(),
+ "messagesIngested": func() int64 {
+ if s.Metrics == nil {
+ return 0
+ }
+ return atomic.LoadInt64(&s.Metrics.MessagesIngested)
+ }(),
+ "notificationsSent": func() int64 {
+ if s.Metrics == nil {
+ return 0
+ }
+ return atomic.LoadInt64(&s.Metrics.NotificationsSent)
+ }(),
+ "messagesPruned": func() int64 {
+ if s.Metrics == nil {
+ return 0
+ }
+ return atomic.LoadInt64(&s.Metrics.MessagesPruned)
+ }(),
+ "connected": func() bool {
+ if s.Metrics == nil {
+ return false
+ }
+ return atomic.LoadInt64(&s.Metrics.ConnectedGauge) == 1
+ }(),
}
_ = json.NewEncoder(w).Encode(resp)
}
-// Summarizer simple page placeholder (will reuse existing summarizer flow)
-// removed duplicate handleSummarizerUI definition (consolidated earlier)
-
// SSE stream of new messages for a channel
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
- if s.AuthToken != "" && !checkAuth(r, s.AuthToken) {
- w.WriteHeader(http.StatusUnauthorized)
- _, _ = w.Write([]byte("unauthorized"))
- return
- }
- ch := strings.TrimSpace(r.URL.Query().Get("channel"))
- if ch == "" { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("missing channel")); return }
- key := strings.ToLower(ch)
- w.Header().Set("Content-Type", "text/event-stream")
- w.Header().Set("Cache-Control", "no-cache")
- w.Header().Set("Connection", "keep-alive")
- flusher, ok := w.(http.Flusher)
- if !ok { w.WriteHeader(http.StatusInternalServerError); return }
+ if s.AuthToken != "" && !checkAuth(r, s.AuthToken) {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte("unauthorized"))
+ return
+ }
+ ch := strings.TrimSpace(r.URL.Query().Get("channel"))
+ if ch == "" {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte("missing channel"))
+ return
+ }
+ key := strings.ToLower(ch) // Normalize channel name to lowercase
- // register subscriber
- if s.subs == nil { s.subs = make(map[string][]chan store.Message) }
- sub := make(chan store.Message, 64)
- s.subsMu.Lock()
- s.subs[key] = append(s.subs[key], sub)
- s.subsMu.Unlock()
- defer func(){
- s.subsMu.Lock()
- subs := s.subs[key]
- for i := range subs {
- if subs[i] == sub {
- s.subs[key] = append(subs[:i], subs[i+1:]...)
- break
- }
- }
- s.subsMu.Unlock()
- close(sub)
- }()
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
- hb := time.NewTicker(15 * time.Second)
- defer hb.Stop()
- ctx := r.Context()
- for {
- select {
- case <-ctx.Done():
- return
- case m := <-sub:
- b, _ := json.Marshal(map[string]any{
- "time": m.Time.UTC().Format(time.RFC3339),
- "author": m.Author,
- "body": m.Body,
- "channel": m.Channel,
- })
- _, _ = fmt.Fprintf(w, "data: %s\n\n", b)
- flusher.Flush()
- case <-hb.C:
- _, _ = fmt.Fprintf(w, ": ping\n\n")
- flusher.Flush()
- }
- }
+ // register subscriber
+ if s.subs == nil {
+ s.subs = make(map[string][]chan store.Message)
+ }
+ sub := make(chan store.Message, 64)
+ s.subsMu.Lock()
+ s.subs[key] = append(s.subs[key], sub)
+ s.subsMu.Unlock()
+ defer func() {
+ s.subsMu.Lock()
+ subs := s.subs[key]
+ for i := range subs {
+ if subs[i] == sub {
+ s.subs[key] = append(subs[:i], subs[i+1:]...)
+ break
+ }
+ }
+ s.subsMu.Unlock()
+ close(sub)
+ }()
+
+ hb := time.NewTicker(15 * time.Second) // SSE heartbeat
+ defer hb.Stop()
+ ctx := r.Context()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case m := <-sub:
+ b, _ := json.Marshal(map[string]any{
+ "time": m.Time.UTC().Format(time.RFC3339),
+ "author": m.Author,
+ "body": m.Body,
+ "channel": m.Channel,
+ })
+ _, _ = fmt.Fprintf(w, "data: %s\n\n", b)
+ flusher.Flush()
+ case <-hb.C:
+ _, _ = fmt.Fprintf(w, ": ping\n\n")
+ flusher.Flush()
+ }
+ }
}
func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) {
@@ -517,14 +537,30 @@ func (s *Server) handleTailJSON(w http.ResponseWriter, r *http.Request) {
return
}
channel := r.URL.Query().Get("channel")
- if channel == "" { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("missing channel")); return }
+ if channel == "" {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte("missing channel"))
+ return
+ }
limit := getIntQuery(r, "limit", 100)
msgs, err := s.Store.ListRecentMessages(r.Context(), channel, limit)
- if err != nil { w.WriteHeader(http.StatusInternalServerError); _, _ = w.Write([]byte("store error")); return }
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte("store error"))
+ return
+ }
w.Header().Set("Content-Type", "application/json")
- type outMsg struct{ Time string `json:"time"`; Author string `json:"author"`; Body string `json:"body"`; Channel string `json:"channel"` }
+ type outMsg struct {
+ Time string `json:"time"`
+ Author string `json:"author"`
+ Body string `json:"body"`
+ Channel string `json:"channel"`
+ }
arr := make([]outMsg, 0, len(msgs))
- for i := len(msgs)-1; i>=0; i-- { m := msgs[i]; arr = append(arr, outMsg{Time: m.Time.UTC().Format(time.RFC3339), Author: m.Author, Body: m.Body, Channel: channel}) }
+ for i := len(msgs) - 1; i >= 0; i-- {
+ m := msgs[i]
+ arr = append(arr, outMsg{Time: m.Time.UTC().Format(time.RFC3339), Author: m.Author, Body: m.Body, Channel: channel})
+ }
_ = json.NewEncoder(w).Encode(arr)
}
@@ -535,43 +571,95 @@ func (s *Server) handleTriggerJSON(w http.ResponseWriter, r *http.Request) {
return
}
channel := r.URL.Query().Get("channel")
- if channel == "" { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("missing channel")); return }
- win := r.URL.Query().Get("window"); if win=="" { win = "6h" }
+ if channel == "" {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte("missing channel"))
+ return
+ }
+ win := r.URL.Query().Get("window")
+ if win == "" {
+ win = "6h"
+ }
push := r.URL.Query().Get("push") == "1"
dur, err := time.ParseDuration(win)
- if err != nil { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("bad window")); return }
+ if err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte("bad window"))
+ return
+ }
msgs, err := s.Store.ListMessagesSince(r.Context(), channel, time.Now().Add(-dur))
- if err != nil { w.WriteHeader(http.StatusInternalServerError); _, _ = w.Write([]byte("store error")); return }
- if s.Summarizer == nil { w.WriteHeader(http.StatusServiceUnavailable); _, _ = w.Write([]byte("summarizer not configured")); return }
- tout := s.SummarizerTimeout; if tout<=0 { tout = 5*time.Minute }
- ctx, cancel := context.WithTimeout(r.Context(), tout); defer cancel()
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte("store error"))
+ return
+ }
+ if s.Summarizer == nil {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ _, _ = w.Write([]byte("summarizer not configured"))
+ return
+ }
+ tout := s.SummarizerTimeout
+ if tout <= 0 {
+ tout = 5 * time.Minute
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), tout)
+ defer cancel()
sum, err := s.Summarizer.Summarize(ctx, channel, msgs, dur)
- if err != nil { w.WriteHeader(http.StatusBadGateway); _, _ = w.Write([]byte("summarizer error")); return }
- if push && s.Notifier != nil { title := fmt.Sprintf("IRC digest %s (%s)", channel, dur); _ = s.Notifier.Notify(r.Context(), title, sum); if s.Metrics != nil { atomic.AddInt64(&s.Metrics.NotificationsSent, 1) } }
+ if err != nil {
+ w.WriteHeader(http.StatusBadGateway)
+ _, _ = w.Write([]byte("summarizer error"))
+ return
+ }
+ if push && s.Notifier != nil {
+ title := fmt.Sprintf("IRC digest %s (%s)", channel, dur)
+ _ = s.Notifier.Notify(r.Context(), title, sum)
+ if s.Metrics != nil {
+ atomic.AddInt64(&s.Metrics.NotificationsSent, 1)
+ }
+ }
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"summary": sum})
}
// history paging
func (s *Server) handleHistory(w http.ResponseWriter, r *http.Request) {
- if s.AuthToken != "" && !checkAuth(r, s.AuthToken) {
- w.WriteHeader(http.StatusUnauthorized)
- _, _ = w.Write([]byte("unauthorized"))
- return
- }
- channel := r.URL.Query().Get("channel")
- beforeStr := r.URL.Query().Get("before")
- if channel == "" || beforeStr == "" { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("missing params")); return }
- t, err := time.Parse(time.RFC3339, beforeStr)
- if err != nil { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("bad before")); return }
- limit := getIntQuery(r, "limit", 50)
- msgs, err := s.Store.ListMessagesBefore(r.Context(), channel, t, limit)
- if err != nil { w.WriteHeader(http.StatusInternalServerError); _, _ = w.Write([]byte("store error")); return }
- w.Header().Set("Content-Type", "application/json")
- type outMsg struct{ Time string `json:"time"`; Author string `json:"author"`; Body string `json:"body"`; Channel string `json:"channel"` }
- arr := make([]outMsg, 0, len(msgs))
- for _, m := range msgs { arr = append(arr, outMsg{Time: m.Time.UTC().Format(time.RFC3339), Author: m.Author, Body: m.Body, Channel: m.Channel}) }
- _ = json.NewEncoder(w).Encode(arr)
+ if s.AuthToken != "" && !checkAuth(r, s.AuthToken) {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte("unauthorized"))
+ return
+ }
+ channel := r.URL.Query().Get("channel")
+ beforeStr := r.URL.Query().Get("before")
+ if channel == "" || beforeStr == "" {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte("missing params"))
+ return
+ }
+ t, err := time.Parse(time.RFC3339, beforeStr)
+ if err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte("bad before"))
+ return
+ }
+ limit := getIntQuery(r, "limit", 50)
+ msgs, err := s.Store.ListMessagesBefore(r.Context(), channel, t, limit)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte("store error"))
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ type outMsg struct {
+ Time string `json:"time"`
+ Author string `json:"author"`
+ Body string `json:"body"`
+ Channel string `json:"channel"`
+ }
+ arr := make([]outMsg, 0, len(msgs))
+ for _, m := range msgs {
+ arr = append(arr, outMsg{Time: m.Time.UTC().Format(time.RFC3339), Author: m.Author, Body: m.Body, Channel: m.Channel})
+ }
+ _ = json.NewEncoder(w).Encode(arr)
}
func checkAuth(r *http.Request, token string) bool {
@@ -647,7 +735,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) {
- if err := r.ParseForm(); err != nil { w.WriteHeader(http.StatusBadRequest); _, _ = w.Write([]byte("bad request")); return }
+ if err := r.ParseForm(); err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte("bad request"))
+ return
+ }
tok := r.Form.Get("token")
if tok == "" || s.AuthToken == "" || tok != s.AuthToken {
w.WriteHeader(http.StatusUnauthorized)
@@ -657,12 +749,33 @@ func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) {
// set cookie for 7 days
maxAge := 7 * 24 * 60 * 60
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
- http.SetCookie(w, &http.Cookie{Name:"auth_token", Value:tok, Path:"/", MaxAge:maxAge, HttpOnly:true, Secure:secure, SameSite:http.SameSiteLaxMode})
+ http.SetCookie(w, &http.Cookie{Name: "auth_token", Value: tok, Path: "/", MaxAge: maxAge, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode})
w.Header().Set("Location", "/")
w.WriteHeader(http.StatusFound)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
- http.SetCookie(w, &http.Cookie{Name:"auth_token", Value:"", Path:"/", MaxAge:-1})
+ http.SetCookie(w, &http.Cookie{Name: "auth_token", Value: "", Path: "/", MaxAge: -1})
http.Redirect(w, r, "/login", http.StatusFound)
}
+
+// Broadcast sends a message to all SSE subscribers for a given channel.
+func (s *Server) Broadcast(channel string, m store.Message) {
+ key := strings.ToLower(strings.TrimSpace(channel))
+ if key == "" {
+ return
+ }
+ s.subsMu.RLock()
+ subs := s.subs[key]
+ s.subsMu.RUnlock()
+ for _, sub := range subs {
+ select {
+ case sub <- m:
+ // ok
+ default:
+ if s.Logger != nil {
+ s.Logger.Warn("sse subscriber buffer full, dropping message", "channel", key)
+ }
+ }
+ }
+}
diff --git a/internal/httpapi/static/app.css b/internal/httpapi/static/app.css
new file mode 100644
index 0000000..b4959c3
--- /dev/null
+++ b/internal/httpapi/static/app.css
@@ -0,0 +1,13 @@
+body { padding: 0; }
+header.nav { position: sticky; top: 0; z-index: 10; padding: .6rem 1rem; border-bottom: 1px solid var(--muted-border-color); display: flex; justify-content: space-between; align-items: center; }
+header.nav a.brand { text-decoration: none; font-weight: 600; }
+main.container { display: grid; grid-template-columns: 220px 1fr; gap: 0; min-height: calc(100vh - 3rem); }
+aside.sidebar { border-right: 1px solid var(--muted-border-color); padding: .75rem; overflow-y: auto; }
+aside.sidebar a { display:block; padding:.25rem .5rem; border-radius:.25rem; text-decoration:none; }
+aside.sidebar a.active { background: var(--muted-color); color: var(--contrast); }
+section.chat { padding: .75rem 1rem; display:flex; flex-direction: column; height: calc(100vh - 3.5rem); }
+#tail { flex: 1; overflow: auto; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; height: calc(100vh - 4.5rem); }
+.ts { opacity: .66; }
+.msg { margin-bottom: .25rem; }
+footer { text-align: center; font-size: .85rem; padding: .5rem 0; opacity: .7; }
+@media (max-width: 900px) { main.container { grid-template-columns: 1fr; } aside.sidebar { display:none; } }
diff --git a/internal/httpapi/static/app.js b/internal/httpapi/static/app.js
new file mode 100644
index 0000000..9e396db
--- /dev/null
+++ b/internal/httpapi/static/app.js
@@ -0,0 +1,39 @@
+// Shared state
+const st = { tailLoading: false, atBottom: true, current: '#', earliest: null, sse: null, channels: [] };
+
+function colorFor(nick){ let h=0; for(let i=0;i>>0 } return 'hsl('+(h%360)+',60%,'+(window.matchMedia('(prefers-color-scheme: dark)').matches? '70%':'35%')+')'; }
+function escapeHtml(s){ return s.replace(/[&<>"]/g, c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
+function linkify(t){ return t.replace(/https?:\/\/\S+/g, function(u){ return '' + u + ''; }); }
+function lineHTML(m){ const ts = '[' + m.time + ']'; const nick = '' + m.author + ''; const body = escapeHtml(m.body); return ts + ' ' + nick + ': ' + linkify(body); }
+
+async function api(path, params){
+ const url = new URL(path, window.location.origin);
+ if(params && params.query){ Object.entries(params.query).forEach(([k,v])=>url.searchParams.set(k,v)); }
+ const res = await fetch(url);
+ if(!res.ok) throw new Error('HTTP '+res.status);
+ const ct = res.headers.get('content-type')||'';
+ if(ct.includes('application/json')) return res.json();
+ return res.text();
+}
+
+function appendBatch(arr){ const el=document.getElementById('tail'); const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.appendChild(frag); if(st.atBottom){ el.scrollTop = el.scrollHeight; } }
+function prependBatch(arr){ const el=document.getElementById('tail'); const oldTop=el.firstChild; const frag=document.createDocumentFragment(); arr.forEach(m=>{ const div=document.createElement('div'); div.className='msg'; div.innerHTML=lineHTML(m); frag.appendChild(div); processLinks(div); }); el.insertBefore(frag, el.firstChild); if(oldTop){ oldTop.scrollIntoView(); } }
+
+function processLinks(scope){ const links = scope.querySelectorAll('a[href]:not([data-card])'); links.forEach(a=>{ a.setAttribute('data-card','1'); fetch('/api/linkcard?url='+encodeURIComponent(a.href)).then(r=>r.json()).then(card=>{ if(!card) return; if(card.title||card.description||card.image){ const c=document.createElement('div'); c.className='card'; var html=''; if(card.image){ html += ''; } html += ''; if(card.title){ html += '
'+escapeHtml(card.title)+'
'; } if(card.description){ html += '
'+escapeHtml(card.description)+'
'; } html += '
'; c.innerHTML = ''+html+'
'; a.parentNode.insertBefore(c, a.nextSibling); } }).catch(()=>{}); }); }
+
+async function loadChannels(){ try{ const data = await api('/api/channels'); st.channels = data; renderChannels(); if(data.length>0){ selectChannel(data[0]); } } catch(e){} }
+function renderChannels(){ const list=document.getElementById('chanlist'); if(!list) return; list.innerHTML=''; st.channels.forEach(c=>{ const a=document.createElement('a'); a.href='#'; a.textContent=c; a.onclick=(ev)=>{ev.preventDefault(); selectChannel(c)}; if(c===st.current) a.className='active'; list.appendChild(a); }); }
+
+async function selectChannel(ch){ if(st.sse){ st.sse.close(); st.sse=null; } st.current=ch; renderChannels(); const el=document.getElementById('tail'); if(!el) return; el.textContent=''; const data = await api('/api/tail',{query:{channel:ch,limit:50}}); appendBatch(data); el.scrollTop = el.scrollHeight; st.atBottom=true; st.earliest = data.length? data[0].time : null; startStream(); initScrollHandlers(); }
+function initScrollHandlers(){ const el=document.getElementById('tail'); if(!el) return; el.onscroll=async ()=>{ st.atBottom = (el.scrollTop + el.clientHeight + 8) >= el.scrollHeight; if(el.scrollTop === 0 && st.earliest){ try{ const older = await api('/api/history',{query:{channel:st.current,before:st.earliest,limit:50}}); if(older.length){ const hBefore = el.scrollHeight; prependBatch(older); st.earliest = older[0].time; const hAfter = el.scrollHeight; el.scrollTop = hAfter - hBefore; } } catch(e){} } } }
+
+function startStream(){ const el=document.getElementById('tail'); if(!el) return; const url=new URL('/api/stream', window.location.origin); url.searchParams.set('channel', st.current); const es=new EventSource(url); st.sse=es; es.onmessage=(ev)=>{ try{ const m=JSON.parse(ev.data); appendBatch([m]); }catch(e){} }; es.onerror=()=>{ es.close(); st.sse=null; setTimeout(startStream, 3000); } }
+
+async function summarize(){ const ch=document.getElementById('channel'); const win=document.getElementById('window'); const push=document.getElementById('push'); const btn=document.getElementById('btn'); const out=document.getElementById('out'); if(!ch||!win||!btn||!out) return; btn.disabled=true; out.textContent=''; try{ const data = await api('/api/trigger',{query:{channel:ch.value,window:win.value||'6h',push:push && push.checked? '1':'0'}}); if(typeof data === 'string'){ out.textContent = data; } else { out.textContent = (data.summary||''); } } catch(e){ out.textContent = 'error: '+e; } btn.disabled=false; }
+
+window.addEventListener('DOMContentLoaded', ()=>{
+ if(document.getElementById('chanlist')){ loadChannels(); }
+ if(document.getElementById('channel')){
+ fetch('/api/channels').then(r=>r.json()).then(arr=>{ const sel=document.getElementById('channel'); arr.forEach(c=>{ const o=document.createElement('option'); o.value=c; o.textContent=c; sel.appendChild(o); }); }).catch(()=>{});
+ }
+});
diff --git a/internal/httpapi/templates.go b/internal/httpapi/templates.go
new file mode 100644
index 0000000..8f3fbe8
--- /dev/null
+++ b/internal/httpapi/templates.go
@@ -0,0 +1,47 @@
+package httpapi
+
+import (
+ "embed"
+ "html/template"
+ "net/http"
+ "path"
+ "strings"
+ "sync"
+)
+
+//go:embed templates/*.tmpl
+var uiFS embed.FS
+
+//go:embed static/*
+var staticFS embed.FS
+
+var (
+ tplOnce sync.Once
+ tpl *template.Template
+)
+
+func (s *Server) parseTemplatesOnce() {
+ tplOnce.Do(func() {
+ // Parse all templates under templates/
+ t := template.New("base").Funcs(template.FuncMap{})
+ t = template.Must(t.ParseFS(uiFS, "templates/*.tmpl"))
+ tpl = t
+ })
+}
+
+func (s *Server) render(w http.ResponseWriter, name string, data map[string]any) {
+ s.parseTemplatesOnce()
+ if data == nil {
+ data = map[string]any{}
+ }
+ data["Version"] = s.Version
+ data["Commit"] = s.Commit
+ base := strings.TrimSuffix(path.Base(name), path.Ext(name))
+ if base == "dashboard" {
+ data["Title"] = "sojuboy"
+ } else {
+ data["Title"] = strings.Title(base)
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _ = tpl.ExecuteTemplate(w, name, data)
+}
diff --git a/internal/httpapi/templates/dashboard.tmpl b/internal/httpapi/templates/dashboard.tmpl
new file mode 100644
index 0000000..a0fa421
--- /dev/null
+++ b/internal/httpapi/templates/dashboard.tmpl
@@ -0,0 +1,12 @@
+{{ define "content" }}
+
+
+
+
+{{ end }}
+{{ template "layout.tmpl" . }}
+
diff --git a/internal/httpapi/templates/layout.tmpl b/internal/httpapi/templates/layout.tmpl
new file mode 100644
index 0000000..d9a8b39
--- /dev/null
+++ b/internal/httpapi/templates/layout.tmpl
@@ -0,0 +1,27 @@
+{{ define "layout.tmpl" }}
+
+
+
+
+
+ {{ .Title }} · sojuboy
+
+
+
+
+
+
+ {{ block "content" . }}{{ end }}
+
+
+
+{{ end }}
+
diff --git a/internal/httpapi/templates/summarizer.tmpl b/internal/httpapi/templates/summarizer.tmpl
new file mode 100644
index 0000000..a22cd37
--- /dev/null
+++ b/internal/httpapi/templates/summarizer.tmpl
@@ -0,0 +1,14 @@
+{{ define "content" }}
+
+
+ On-demand summarization
+
+
+
+
+
+
+
+{{ end }}
+{{ template "layout.tmpl" . }}
+