feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
package httpapi
import (
2025-08-16 15:24:50 -05:00
"encoding/json"
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
"context"
"fmt"
"log/slog"
"net/http"
2025-08-16 19:18:31 -05:00
"net/url"
"io"
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
"strconv"
"strings"
2025-08-16 19:18:31 -05:00
"sync"
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
"sync/atomic"
"time"
"sojuboy/internal/store"
"sojuboy/internal/summarizer"
)
type Metrics struct {
MessagesIngested int64 // counter
NotificationsSent int64 // counter
MessagesPruned int64 // counter
ConnectedGauge int64 // 0/1
}
type Server struct {
ListenAddr string
AuthToken string
Store * store . Store
Summarizer summarizer . Summarizer
Notifier interface {
Notify ( context . Context , string , string ) error
}
Logger * slog . Logger
Metrics * Metrics
Ready func ( ) bool
2025-08-15 20:41:31 -05:00
// Optional timeout override for summarizer
SummarizerTimeout time . Duration
2025-08-16 15:24:50 -05:00
// Build/runtime info for UI
Version string
Commit string
BuiltAt string
StartedAt time . Time
2025-08-16 15:32:57 -05:00
// Optional seed list from config for /api/channels when DB is empty
KnownChannels [ ] string
2025-08-16 19:18:31 -05:00
// SSE subscribers map
subs map [ string ] [ ] chan store . Message
subsMu sync . RWMutex
// Link card cache
cardCache map [ string ] linkCard
cardCacheExp map [ string ] time . Time
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
}
func ( s * Server ) Start ( ctx context . Context ) error {
mux := http . NewServeMux ( )
2025-08-16 15:24:50 -05:00
// Minimal web UI
mux . HandleFunc ( "/" , s . handleUI )
2025-08-16 19:18:31 -05:00
mux . HandleFunc ( "/summarizer" , s . handleSummarizerUI )
2025-08-16 16:09:19 -05:00
mux . HandleFunc ( "/login" , s . handleLogin )
mux . HandleFunc ( "/auth" , s . handleAuth )
mux . HandleFunc ( "/logout" , s . handleLogout )
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
mux . HandleFunc ( "/healthz" , func ( w http . ResponseWriter , r * http . Request ) {
w . WriteHeader ( http . StatusOK )
_ , _ = w . Write ( [ ] byte ( "ok" ) )
} )
mux . HandleFunc ( "/ready" , func ( w http . ResponseWriter , r * http . Request ) {
if s . Ready != nil && ! s . Ready ( ) {
w . WriteHeader ( http . StatusServiceUnavailable )
_ , _ = w . Write ( [ ] byte ( "not ready" ) )
return
}
w . WriteHeader ( http . StatusOK )
_ , _ = w . Write ( [ ] byte ( "ready" ) )
} )
mux . HandleFunc ( "/trigger" , s . handleTrigger )
mux . HandleFunc ( "/tail" , s . handleTail )
mux . HandleFunc ( "/metrics" , s . handleMetrics )
2025-08-16 15:24:50 -05:00
// JSON endpoints for UI
mux . HandleFunc ( "/api/info" , s . handleInfo )
mux . HandleFunc ( "/api/channels" , s . handleChannels )
mux . HandleFunc ( "/api/tail" , s . handleTailJSON )
mux . HandleFunc ( "/api/trigger" , s . handleTriggerJSON )
2025-08-16 19:18:31 -05:00
mux . HandleFunc ( "/api/history" , s . handleHistory )
mux . HandleFunc ( "/api/stream" , s . handleStream )
mux . HandleFunc ( "/api/linkcard" , s . handleLinkCard )
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
srv := & http . Server {
Addr : s . ListenAddr ,
Handler : mux ,
}
go func ( ) {
<- ctx . Done ( )
_ = srv . Shutdown ( context . Background ( ) )
} ( )
if s . Logger != nil {
s . Logger . Info ( "http listening" , "addr" , s . ListenAddr )
}
return srv . ListenAndServe ( )
}
func ( s * Server ) handleTrigger ( w http . ResponseWriter , r * http . Request ) {
if s . AuthToken != "" {
if ! checkAuth ( r , s . AuthToken ) {
w . WriteHeader ( http . StatusUnauthorized )
_ , _ = w . Write ( [ ] byte ( "unauthorized" ) )
return
}
}
channel := r . URL . Query ( ) . Get ( "channel" )
if channel == "" {
w . WriteHeader ( http . StatusBadRequest )
_ , _ = w . Write ( [ ] byte ( "missing channel" ) )
return
}
windowStr := r . URL . Query ( ) . Get ( "window" )
if windowStr == "" {
windowStr = "6h"
}
window , err := time . ParseDuration ( windowStr )
if err != nil {
w . WriteHeader ( http . StatusBadRequest )
_ , _ = w . Write ( [ ] byte ( "bad window" ) )
return
}
ctx := r . Context ( )
msgs , err := s . Store . ListMessagesSince ( ctx , channel , time . Now ( ) . Add ( - window ) )
if err != nil {
if s . Logger != nil {
s . Logger . Error ( "http trigger store" , "err" , err )
}
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
}
2025-08-15 20:41:31 -05:00
// Timeout summarization using configurable timeout (default 5m)
tout := s . SummarizerTimeout
if tout <= 0 {
tout = 5 * time . Minute
}
ctxSum , cancel := context . WithTimeout ( ctx , tout )
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
defer cancel ( )
summary , err := s . Summarizer . Summarize ( ctxSum , channel , msgs , window )
if err != nil {
if s . Logger != nil {
s . Logger . Error ( "http trigger summarizer" , "err" , err )
}
w . WriteHeader ( http . StatusBadGateway )
_ , _ = w . Write ( [ ] byte ( "summarizer error" ) )
return
}
if s . Notifier != nil {
title := fmt . Sprintf ( "IRC digest %s (%s)" , channel , window )
_ = s . Notifier . Notify ( ctx , title , summary )
if s . Metrics != nil {
atomic . AddInt64 ( & s . Metrics . NotificationsSent , 1 )
}
}
w . Header ( ) . Set ( "Content-Type" , "text/plain; charset=utf-8" )
_ , _ = w . Write ( [ ] byte ( summary ) )
}
func ( s * Server ) handleTail ( w http . ResponseWriter , r * http . Request ) {
if s . AuthToken != "" {
if ! checkAuth ( r , s . AuthToken ) {
w . WriteHeader ( http . StatusUnauthorized )
_ , _ = w . Write ( [ ] byte ( "unauthorized" ) )
return
}
}
channel := r . URL . Query ( ) . Get ( "channel" )
if channel == "" {
w . WriteHeader ( http . StatusBadRequest )
_ , _ = w . Write ( [ ] byte ( "missing channel" ) )
return
}
limit := getIntQuery ( r , "limit" , 50 )
msgs , err := s . Store . ListRecentMessages ( r . Context ( ) , channel , limit )
if err != nil {
if s . Logger != nil {
s . Logger . Error ( "http tail store" , "err" , err )
}
w . WriteHeader ( http . StatusInternalServerError )
_ , _ = w . Write ( [ ] byte ( "store error" ) )
return
}
w . Header ( ) . Set ( "Content-Type" , "text/plain; charset=utf-8" )
for i := len ( msgs ) - 1 ; i >= 0 ; i -- {
m := msgs [ i ]
_ , _ = w . Write ( [ ] byte ( m . Time . UTC ( ) . Format ( time . RFC3339 ) + " " + m . Author + " " + channel + " " + m . Body + "\n" ) )
}
}
2025-08-16 19:18:31 -05:00
// 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 )
}
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
func ( s * Server ) handleMetrics ( w http . ResponseWriter , r * http . Request ) {
w . Header ( ) . Set ( "Content-Type" , "text/plain; version=0.0.4" )
msgs := int64 ( 0 )
nots := int64 ( 0 )
pruned := int64 ( 0 )
conn := int64 ( 0 )
if s . Metrics != nil {
msgs = atomic . LoadInt64 ( & s . Metrics . MessagesIngested )
nots = atomic . LoadInt64 ( & s . Metrics . NotificationsSent )
pruned = atomic . LoadInt64 ( & s . Metrics . MessagesPruned )
conn = atomic . LoadInt64 ( & s . Metrics . ConnectedGauge )
}
_ , _ = fmt . Fprintf ( w , "sojuboy_messages_ingested_total %d\n" , msgs )
_ , _ = fmt . Fprintf ( w , "sojuboy_notifications_sent_total %d\n" , nots )
_ , _ = fmt . Fprintf ( w , "sojuboy_messages_pruned_total %d\n" , pruned )
_ , _ = fmt . Fprintf ( w , "sojuboy_connected %d\n" , conn )
}
2025-08-16 15:24:50 -05:00
// --- Web UI handlers ---
func ( s * Server ) handleUI ( w http . ResponseWriter , r * http . Request ) {
if r . URL . Path != "/" {
w . WriteHeader ( http . StatusNotFound )
return
}
2025-08-16 16:09:19 -05:00
// redirect to login if token cookie missing/invalid
if s . AuthToken != "" {
if c , err := r . Cookie ( "auth_token" ) ; err != nil || c . Value != s . AuthToken {
http . Redirect ( w , r , "/login" , http . StatusFound )
return
}
}
2025-08-16 15:24:50 -05:00
w . Header ( ) . Set ( "Content-Type" , "text/html; charset=utf-8" )
// Pico.css from CDN and a tiny app
page := ` < ! doctype html >
< html >
< head >
< meta charset = "utf-8" / >
< meta name = "viewport" content = "width=device-width, initial-scale=1" / >
< title > sojuboy < / title >
< link rel = "stylesheet" href = "https://unpkg.com/@picocss/pico@2/css/pico.min.css" >
< style >
2025-08-16 19:18:31 -05:00
body { padding : 0 ; }
header . nav { position : sticky ; top : 0 ; z - index : 10 ; padding : .6 rem 1 rem ; border - bottom : 1 px 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 : 220 px 1 fr ; gap : 0 ; min - height : calc ( 100 vh - 3 rem ) ; }
aside . sidebar { border - right : 1 px solid var ( -- muted - border - color ) ; padding : .75 rem ; overflow - y : auto ; }
aside . sidebar a { display : block ; padding : .25 rem .5 rem ; border - radius : .25 rem ; text - decoration : none ; }
aside . sidebar a . active { background : var ( -- muted - color ) ; color : var ( -- contrast ) ; }
section . chat { padding : .75 rem 1 rem ; display : flex ; flex - direction : column ; }
# tail { flex : 1 ; overflow : auto ; white - space : pre - wrap ; word - break : break - word ; overflow - wrap : anywhere ; font - family : ui - monospace , SFMono - Regular , Menlo , monospace ; }
. ts { opacity : .66 ; }
. msg { margin - bottom : .25 rem ; }
footer { text - align : center ; font - size : .85 rem ; padding : .5 rem 0 ; opacity : .7 ; }
@ media ( max - width : 900 px ) { main . container { grid - template - columns : 1 fr ; } aside . sidebar { display : none ; } }
2025-08-16 15:24:50 -05:00
< / style >
< script >
2025-08-16 19:18:31 -05:00
const st = { tailLoading : false , atBottom : true , current : '#' , earliest : null , sse : null , channels : [ ] } ;
2025-08-16 15:24:50 -05:00
function setToken ( v ) { st . token = v ; localStorage . setItem ( ' token ' , v ) ; }
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 opts = { headers : { } } ;
2025-08-16 16:09:19 -05:00
// use cookie for auth; header optional if present
2025-08-16 15:24:50 -05:00
if ( st . token ) { opts . headers [ ' Authorization ' ] = ' Bearer ' + st . token ; }
const res = await fetch ( url , opts ) ;
if ( ! res . ok ) { throw new Error ( ' HTTP ' + res . status ) ; }
return res . json ( ) ;
}
async function loadInfo ( ) {
try { const data = await api ( ' / api / info ' ) ;
document . getElementById ( ' version ' ) . textContent = data . version + ' ( ' + data . commit + ')' ;
document . getElementById ( ' built ' ) . textContent = data . builtAt ;
document . getElementById ( ' uptime ' ) . textContent = data . uptime ;
document . getElementById ( ' connected ' ) . textContent = data . connected ? ' yes ':' no ' ;
2025-08-16 15:26:25 -05:00
document . getElementById ( ' counts ' ) . textContent = ' ingested ' + data . messagesIngested + ' , notified ' + data . notificationsSent + ' , pruned ' + data . messagesPruned ;
2025-08-16 15:24:50 -05:00
} catch ( e ) { console . error ( e ) ; }
}
async function loadChannels ( ) {
2025-08-16 19:18:31 -05:00
try { const data = await api ( ' / api / channels ' ) ; st . channels = data ; renderChannels ( ) ; if ( data . length > 0 ) { selectChannel ( data [ 0 ] ) ; } }
catch ( e ) { console . error ( e ) ; }
2025-08-16 15:24:50 -05:00
}
2025-08-16 19:18:31 -05:00
function renderChannels ( ) { const list = document . getElementById ( ' chanlist ' ) ; 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 ' ) ; el . textContent = ' ' ; // bootstrap 50
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 ' ) ; el . onscroll = async ( ) = > { st . atBottom = ( el . scrollTop + el . clientHeight + 8 ) >= el . scrollHeight ; if ( el . scrollTop == = 0 && st . earliest ) { // load more
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 colorFor ( nick ) { let h = 0 ; for ( let i = 0 ; i < nick . length ; i ++ ) { h = ( h * 31 + nick . charCodeAt ( i ) ) >> > 0 } return ' hsl ( ' + ( h % 360 ) + ' , 60 % , ' + ( window . matchMedia ( ' ( prefers - color - scheme : dark ) ' ) . matches ? ' 70 % ':' 35 % ' ) + ')' ; }
function lineHTML ( m ) { const ts = ` <span class=ts>[$ { m.time}]</span> ` ; const nick = ` <b style="color:$ { colorFor(m.author)}">$ { m.author}</b> ` ; const body = escapeHtml ( m . body ) ; return ` $ { ts} $ { nick}: $ { linkify(body)} ` ; }
function escapeHtml ( s ) { return s . replace ( / [ & < > "]/g, c=>({'&':'&','<':'<','>':'>','" ':' & quot ; ' } [ c ] ) ) ; }
function linkify ( t ) { return t . replace ( / https ? : \ / \ / \ S + / g , u = > ` <a href="$ { u}" target="_blank" rel="noopener">$ { u}</a> ` ) ; }
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 ) ; } ) ; 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 ) ; } ) ; el . insertBefore ( frag , el . firstChild ) ; if ( oldTop ) { oldTop . scrollIntoView ( ) ; } }
function startStream ( ) { 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 ) ; } }
2025-08-16 15:24:50 -05:00
async function doSumm ( ) {
const ch = document . getElementById ( ' channel ' ) . value ;
const win = document . getElementById ( ' window ' ) . value || ' 6 h ' ;
const push = document . getElementById ( ' push ' ) . checked ? '1' : '0' ;
2025-08-16 15:45:27 -05:00
const btn = document . getElementById ( ' summBtn ' ) ;
const prog = document . getElementById ( ' summProg ' ) ;
btn . disabled = true ; prog . style . display = ' inline - block ' ;
2025-08-16 15:24:50 -05:00
try { const data = await api ( ' / api / trigger ' , { query : { channel : ch , window : win , push : push } } ) ;
2025-08-16 16:09:19 -05:00
const el = document . getElementById ( ' summary ' ) ;
el . textContent = data . summary || ' ( empty ) ' ;
el . scrollTop = el . scrollHeight ;
2025-08-16 15:24:50 -05:00
} catch ( e ) { document . getElementById ( ' summary ' ) . textContent = ' error : ' + e ; }
2025-08-16 15:45:27 -05:00
btn . disabled = false ; prog . style . display = ' none ' ;
2025-08-16 15:24:50 -05:00
}
window . addEventListener ( ' DOMContentLoaded ' , ( ) = > { loadInfo ( ) ; loadChannels ( ) ; } ) ;
2025-08-16 15:45:27 -05:00
function onFollowToggle ( cb ) {
if ( cb . checked ) {
if ( st . tailTimer ) clearInterval ( st . tailTimer ) ;
st . tailTimer = setInterval ( doTail , 3000 ) ;
} else {
if ( st . tailTimer ) { clearInterval ( st . tailTimer ) ; st . tailTimer = null ; }
}
}
function onChannelChange ( ) { doTail ( ) ; }
2025-08-16 15:24:50 -05:00
< / script >
< / head >
< body >
2025-08-16 19:18:31 -05:00
< header class = "nav" >
< div > < a class = "brand" href = "/" > sojuboy < / a > < / div >
< nav >
2025-08-16 15:24:50 -05:00
< ul >
2025-08-16 19:18:31 -05:00
< li > < a href = "/summarizer" > Summarizer < / a > < / li >
< li > < a href = "/logout" > Logout < / a > < / li >
2025-08-16 15:24:50 -05:00
< / ul >
2025-08-16 19:18:31 -05:00
< / nav >
< / header >
< main class = "container" >
< aside class = "sidebar" >
< nav id = "chanlist" > < / nav >
< / aside >
< section class = "chat" >
< div id = "tail" > < / div >
< / section >
< / main >
< footer >
< small > ` + s.Version + ` < / small >
< / footer >
< / body >
< / html > `
_ , _ = w . Write ( [ ] byte ( page ) )
}
func ( s * Server ) handleSummarizerUI ( w http . ResponseWriter , r * http . Request ) {
if s . AuthToken != "" {
if ! checkAuth ( r , s . AuthToken ) {
w . WriteHeader ( http . StatusUnauthorized )
_ , _ = w . Write ( [ ] byte ( "unauthorized" ) )
return
}
}
w . Header ( ) . Set ( "Content-Type" , "text/html; charset=utf-8" )
page := ` < ! doctype html >
< html >
< head >
< meta charset = "utf-8" / >
< meta name = "viewport" content = "width=device-width, initial-scale=1" / >
< title > Summarizer · sojuboy < / title >
< link rel = "stylesheet" href = "https://unpkg.com/@picocss/pico@2/css/pico.min.css" >
< style > body { padding : 1 rem ; } main { max - width : 480 px ; margin : auto ; margin - top : 15 vh } < / style >
< / head >
< body >
< main class = "container" >
< article >
< h2 > Summarizer < / h2 >
< form id = "f" method = "post" action = "/api/summarize" >
< label > Channel
< input type = "text" name = "channel" id = "channel" required placeholder = "irc.example.com" / >
< / label >
< label > Window
< input type = "text" name = "window" id = "window" value = "6h" placeholder = "1h, 12h, 24h" / >
< / label >
< label > Push to Notifier
< input type = "checkbox" name = "push" id = "push" checked / >
< / label >
< button type = "submit" id = "summBtn" > Summarize < / button >
< / form >
< div id = "summary" style = "margin-top: 1rem; padding: 1rem; border: 1px solid var(--muted-border-color); border-radius: 0.5rem; background-color: var(--muted-color);" > < / div >
< progress id = "summProg" style = "display: none;" > < / progress >
2025-08-16 15:24:50 -05:00
< / article >
< / main >
< / body >
< / html > `
_ , _ = w . Write ( [ ] byte ( page ) )
}
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 ,
"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 } ( ) ,
}
_ = json . NewEncoder ( w ) . Encode ( resp )
}
2025-08-16 19:18:31 -05:00
// Summarizer simple page placeholder (will reuse existing summarizer flow)
func ( s * Server ) handleSummarizerUI ( 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 := ` <!doctype html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>Summarizer · sojuboy</title><link rel="stylesheet" href="https://unpkg.com/@picocss/pico@2/css/pico.min.css"><style>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 { max-width:900px;margin:0 auto;padding:1rem} footer { text-align:center;font-size:.85rem;padding:.5rem 0;opacity:.7}</style><script>async function api(path,params) { const url=new URL(path,window.location.origin);if(params&¶ms.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);return res.text();}async function summarize() { const ch=document.getElementById('channel').value;const win=document.getElementById('window').value||'6h';const push=document.getElementById('push').checked?'1':'0';const btn=document.getElementById('btn');const out=document.getElementById('out');btn.disabled=true;out.textContent='';try { const txt=await api('/trigger', { query: { channel:ch,window:win,token:' ` + s . AuthToken + ` '}});out.textContent=txt;}catch(e) { out.textContent='error: '+e;}btn.disabled=false;}</script></head><body><header class="nav"><div><a class="brand" href="/">sojuboy</a></div><nav><ul><li><a href="/summarizer" aria-current="page">Summarizer</a></li><li><a href="/logout">Logout</a></li></ul></nav></header><main><article><h3>On-demand summarization</h3><label>Channel<select id="channel"></select></label><label>Window<input id="window" value="6h"></label><label><input type="checkbox" id="push"> Send via Pushover</label><button id="btn" onclick="summarize()">Summarize</button><pre id="out"></pre></article></main><footer><small> ` + s . Version + ` </small></footer><script>(async()=> { try { const res=await fetch('/api/channels');const arr=await res.json();const sel=document.getElementById('channel');arr.forEach(c=> { const o=document.createElement('option');o.value=c;o.textContent=c;sel.appendChild(o);});}catch(e) { }})();</script></body></html> `
_ , _ = w . Write ( [ ] byte ( page ) )
}
// 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 }
// 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 )
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 ( )
}
}
}
2025-08-16 15:24:50 -05:00
func ( s * Server ) handleChannels ( w http . ResponseWriter , r * http . Request ) {
if s . AuthToken != "" && ! checkAuth ( r , s . AuthToken ) {
w . WriteHeader ( http . StatusUnauthorized )
_ , _ = w . Write ( [ ] byte ( "unauthorized" ) )
return
}
chs , err := s . Store . ListChannels ( r . Context ( ) )
if err != nil {
w . WriteHeader ( http . StatusInternalServerError )
_ , _ = w . Write ( [ ] byte ( "store error" ) )
return
}
2025-08-16 15:32:57 -05:00
if len ( chs ) == 0 && len ( s . KnownChannels ) > 0 {
chs = append ( chs , s . KnownChannels ... )
}
2025-08-16 15:24:50 -05:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
_ = json . NewEncoder ( w ) . Encode ( chs )
}
func ( s * Server ) handleTailJSON ( 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" )
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 }
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 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 )
}
func ( s * Server ) handleTriggerJSON ( 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" )
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 }
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 ( )
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 ) } }
w . Header ( ) . Set ( "Content-Type" , "application/json" )
_ = json . NewEncoder ( w ) . Encode ( map [ string ] any { "summary" : sum } )
}
2025-08-16 19:18:31 -05:00
// 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 )
}
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
func checkAuth ( r * http . Request , token string ) bool {
auth := r . Header . Get ( "Authorization" )
if strings . HasPrefix ( auth , "Bearer " ) {
if strings . TrimPrefix ( auth , "Bearer " ) == token {
return true
}
}
if r . URL . Query ( ) . Get ( "token" ) == token {
return true
}
user , pass , ok := r . BasicAuth ( )
if ok && user == "token" && pass == token {
return true
}
if r . Header . Get ( "X-Auth-Token" ) == token {
return true
}
2025-08-16 16:09:19 -05:00
// Cookie-based
if c , err := r . Cookie ( "auth_token" ) ; err == nil && c . Value == token {
return true
}
feat: initial Beta 1 release
- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
2025-08-15 18:06:28 -05:00
return false
}
func getIntQuery ( r * http . Request , key string , def int ) int {
if v := r . URL . Query ( ) . Get ( key ) ; v != "" {
if n , err := strconv . Atoi ( v ) ; err == nil {
return n
}
}
return def
}
2025-08-16 16:09:19 -05:00
// --- Login handlers ---
func ( s * Server ) handleLogin ( w http . ResponseWriter , r * http . Request ) {
if s . AuthToken == "" {
http . Redirect ( w , r , "/" , http . StatusFound )
return
}
// If already authed, go to UI
if c , err := r . Cookie ( "auth_token" ) ; err == nil && c . Value == s . AuthToken {
http . Redirect ( w , r , "/" , http . StatusFound )
return
}
w . Header ( ) . Set ( "Content-Type" , "text/html; charset=utf-8" )
page := ` < ! doctype html >
< html >
< head >
< meta charset = "utf-8" / >
< meta name = "viewport" content = "width=device-width, initial-scale=1" / >
< title > Sign in · sojuboy < / title >
< link rel = "stylesheet" href = "https://unpkg.com/@picocss/pico@2/css/pico.min.css" >
< style > body { padding : 1 rem ; } main { max - width : 480 px ; margin : auto ; margin - top : 15 vh } < / style >
< / head >
< body >
< main class = "container" >
< article >
< h2 > Sign in < / h2 >
< form id = "f" method = "post" action = "/auth" >
< label > Access token
< input type = "password" name = "token" autocomplete = "current-password" required placeholder = "HTTP_TOKEN" / >
< / label >
< button type = "submit" > Continue < / button >
< / form >
< / article >
< / main >
< / body >
< / html > `
_ , _ = w . Write ( [ ] byte ( page ) )
}
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 }
tok := r . Form . Get ( "token" )
if tok == "" || s . AuthToken == "" || tok != s . AuthToken {
w . WriteHeader ( http . StatusUnauthorized )
_ , _ = w . Write ( [ ] byte ( "unauthorized" ) )
return
}
// 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 } )
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 . Redirect ( w , r , "/login" , http . StatusFound )
}