commit 2954e85e7ade0f6cc1cc5facc6dcbcc50c3fa3ed Author: Thomas Cravey Date: Fri Aug 15 18:06:28 2025 -0500 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0503b79 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# OS +.DS_Store + +# Go build outputs +/sojuboy +/out/ + +# Local data and env +/data/ +.env + +# Logs +*.log + +# Coverage +coverage.out diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b573d1c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## Beta 1 (initial release) +- soju-specific raw connector with event playback and CHATHISTORY fallback +- Message storage (SQLite, WAL), msgid de-dup, retention job +- Mentions with tuning (quiet hours, rate limiting, allow/deny, urgent keywords) +- Pushover notifications +- OpenAI summarization: concise natural summaries, link-following, multi-line grouping +- HTTP: /healthz, /ready, /tail, /trigger, /metrics (connected gauge and counters) +- Structured JSON logs (slog) +- Docker: distroless, built-in healthcheck, version/commit injected + +## Unreleased +- Additional notifiers (ntfy, Telegram) +- Long-form HTML digest rendering +- Admin endpoints (e.g., /join) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..54bdb79 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# Build stage +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY . . +ARG VERSION=dev +ARG VCS_REF=unknown +ARG BUILD_DATE +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + apk add --no-cache ca-certificates && \ + CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${VCS_REF} -X main.builtAt=${BUILD_DATE}" -o /out/sojuboy ./cmd/sojuboy + +# Final image +FROM gcr.io/distroless/static +WORKDIR / +COPY --from=build /out/sojuboy /sojuboy +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD ["/sojuboy","--health"] +ENTRYPOINT ["/sojuboy"] + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..58d14d8 --- /dev/null +++ b/README.md @@ -0,0 +1,274 @@ +# sojuboy + +An IRC bouncer companion service for soju that: + +- Watches your bouncer-connected channels continuously +- Notifies you on mentions via Pushover (default) +- Stores messages in SQLite for summaries and on-demand inspection +- Generates AI digests (OpenAI by default) on schedule or on demand +- Exposes a small HTTP API for health, tailing messages, metrics, and triggering digests + +Note: this is not a bot and never replies in IRC. It passively attaches as a soju multi-client on your main account. + +## Why + +If you use soju as a bouncer, you may want per-client alerts and AI summaries without running a heavy IRC client all the time. This service connects to soju as a distinct client identity (e.g., `username/network@client`) and handles notifications and summaries for you, containerized and easy to run on a Synology or any Docker host. + +## High-level architecture + +- Language: Go (single static binary, low memory footprint) +- Long-lived IRC client: raw IRC using a lightweight parser (sorcix/irc) with an irssi-style handshake tailored for soju +- Message storage: SQLite via modernc.org/sqlite +- Scheduling: github.com/robfig/cron/v3 +- Notifications: github.com/gregdel/pushover +- Summarization (LLM): github.com/sashabaranov/go-openai +- HTTP API: Go stdlib `net/http` + +Runtime modules: + +- `internal/soju`: soju connection, capability negotiation, irssi-style PASS/USER auth, joins, message ingestion, event playback, CHATHISTORY fallback +- `internal/store`: SQLite schema and queries +- `internal/notifier`: Pushover notifier (pluggable interface) +- `internal/summarizer`: OpenAI client with GPT-5 defaults, GPT-4o-mini fallback +- `internal/scheduler`: cron-based digest scheduling and daily retention job +- `internal/httpapi`: `/healthz`, `/tail`, `/trigger`, `/metrics` +- `internal/config`: env config loader and helpers + +## Features + +- Mention/keyword detection: punctuation-tolerant (letters, digits, `_` and `-` are word chars) +- Mention tuning: allow/deny channels, urgent keywords bypass quiet hours, rate limiting +- AI digest generation: concise natural summaries (no rigid sections); integrates pasted multi-line posts and referenced link context +- Configurable schedules (cron), quiet hours, and summary parameters +- Local persistence with retention pruning (daily at 03:00) +- HTTP endpoints: health, tail, metrics, on-demand digests + +## How it works + +1) The service connects to soju and negotiates IRCv3 capabilities: + - Requests: `server-time`, `message-tags`, `batch`, `cap-notify`, `echo-message`, `draft/event-playback`; optional fallback `draft/chathistory` when needed + - Joins happen after numeric 001 (welcome) + +2) Authentication: + - PASS then irssi-style `USER :` + - Soju’s per-client identity preserves distinct history + +3) Playback and backfill: + - If `draft/event-playback` is enabled, soju replays missed messages automatically + - Optional fallback: `CHATHISTORY LATEST timestamp= ` using the last stored timestamp per channel (disabled by default) + +4) Messages and mentions: + - Each `PRIVMSG` is stored with server-time when available + - Mentions trigger Pushover notifications subject to quiet hours, urgency, and rate limits + - Debug logs include: mention delivered or suppression reason (backfill, quiet hours, rate limit) + +5) Summarization: + - `/trigger` or the scheduler loads a window and calls OpenAI (with a 60s timeout) + - Defaults to `OPENAI_MODEL=gpt-5` with `MaxCompletionTokens`; temperature omitted for reasoning-like models + - Tunables let you follow link targets and group multi-line posts (see env below) + +6) HTTP API: + - `/healthz` → `200 ok` + - `/ready` → `200` only when connected to soju + - `/tail?channel=#chan&limit=N` → plaintext tail (chronological) + - `/trigger?channel=#chan&window=6h` → returns digest and sends via notifier + - `/metrics` → Prometheus text metrics + - Protect `/tail` and `/trigger` with `HTTP_TOKEN` via Bearer, `token` query, `X-Auth-Token`, or basic auth (`token:`) + +## Health and readiness + +- `/healthz` always returns 200 +- `/ready` returns 200 only when connected to soju +- Binary supports `--health` to perform a local readiness check and exit 0/1. Example Docker healthcheck: + +```yaml +healthcheck: + test: ["/sojuboy", "--health"] + interval: 30s + timeout: 3s + retries: 3 +``` + +## Installation + +### Prerequisites + +- Docker (or Synology Container Manager) +- A soju bouncer you can connect to +- Pushover account and app token (for push) +- OpenAI API key (for AI summaries) + +### Build and run (Docker Compose) + +1) Create `.env` in repo root (see example below) + +2) Start: + +```bash +docker-compose up -d --build +``` + +3) Health check: + +```bash +curl -s http://localhost:8080/healthz +``` + +4) Tail last messages (remember to URL-encode `#` as `%23`): + +```bash +curl -s "http://localhost:8080/tail?channel=%23animaniacs&limit=50" \ + -H "Authorization: Bearer $HTTP_TOKEN" +``` + +5) Trigger a digest for the last 6 hours: + +```bash +curl -s "http://localhost:8080/trigger?channel=%23animaniacs&window=6h" \ + -H "Authorization: Bearer $HTTP_TOKEN" +``` + +6) Metrics: + +```bash +curl -s http://localhost:8080/metrics +``` + +## Quick start (Docker Compose) + +```bash +docker-compose up -d --build +# wait for healthy +docker inspect --format='{{json .State.Health}}' sojuboy | jq +``` + +Compose includes a healthcheck calling the binary’s `--health` flag, which returns 0 only when `/ready` is 200. + +## Configuration (.env example) + +```env +# soju / IRC +SOJU_HOST=bnc.example.org +SOJU_PORT=6697 +SOJU_TLS=true +SOJU_NETWORK=your-network + +# Client identity: include client suffix for per-client history in soju +IRC_NICK=yourNick +IRC_USERNAME=yourUser/your-network@sojuboy +IRC_REALNAME=Your Real Name +IRC_PASSWORD=yourSojuClientPassword + +# Channels to auto-join (comma-separated) +CHANNELS=#animaniacs,#general +KEYWORDS=yourNick,YourCompany + +# Auth method hint (raw is used; value is ignored but kept for compatibility) +SOJU_AUTH=raw + +# Notifier (Pushover) +NOTIFIER=pushover +PUSHOVER_USER_KEY=your-pushover-user-key +PUSHOVER_API_TOKEN=your-pushover-app-token + +# Summarizer (OpenAI) +LLM_PROVIDER=openai +OPENAI_API_KEY=sk-... +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_MODEL=gpt-5 +OPENAI_MAX_TOKENS=700 +# Summarizer tuning +SUMM_FOLLOW_LINKS=true # fetch small snippets from referenced links +SUMM_LINK_TIMEOUT=6s # HTTP timeout per link +SUMM_LINK_MAX_BYTES=262144 # max bytes fetched per link +SUMM_GROUP_WINDOW=90s # group multi-line posts within this window +SUMM_MAX_LINKS=5 # limit links fetched per summary + +# Digests +DIGEST_CRON=0 */6 * * * +DIGEST_WINDOW=6h +QUIET_HOURS= + +# Mentions/alerts +NOTIFY_BACKFILL=false # if true, notify even for replayed (older) messages +MENTION_MIN_INTERVAL=30s # min interval between alerts per channel/keyword +MENTIONS_ONLY_CHANNELS= # optional allow-list (CSV) +MENTIONS_DENY_CHANNELS= # optional deny-list (CSV) +URGENT_KEYWORDS=urgent,priority # bypass quiet hours + +# HTTP API +HTTP_LISTEN=:8080 +HTTP_TOKEN=put-a-long-random-token-here + +# Storage +STORE_PATH=/data/app.db +STORE_RETENTION_DAYS=7 + +# Logging +LOG_LEVEL=info +``` + +## Pushover setup + +1) Install Pushover iOS app and log in +2) Get your User Key (in the app or on the website) +3) Create an application at `pushover.net/apps/build` to get an API token +4) Put them in `.env` as `PUSHOVER_USER_KEY` and `PUSHOVER_API_TOKEN` + +## OpenAI setup + +- Set `OPENAI_API_KEY` +- Set `OPENAI_BASE_URL` to exactly `https://api.openai.com/v1` +- If `gpt-5` isn’t available on your account, use a supported model like `gpt-4o-mini` +- GPT-5 beta limitations: temperature fixed; use `MaxCompletionTokens` + +## HTTP API + +- `GET /healthz` → `200 ok` +- `GET /tail?channel=%23chan&limit=50` + - Returns plaintext messages (chronological) + - Auth: provide `HTTP_TOKEN` as a Bearer token (or query param `token=`) +- `GET /trigger?channel=%23chan&window=6h` + - Returns plaintext digest + - Also sends via notifier when configured + - Auth as above +- `GET /metrics` + - Prometheus metrics: `sojuboy_messages_ingested_total`, `sojuboy_notifications_sent_total`, `sojuboy_messages_pruned_total`, `sojuboy_connected` + +## Troubleshooting + +- Empty tail while there’s activity + - Ensure the service logs `join requested:` followed by `joined` for your channels + - Confirm `.env` `CHANNELS` contains your channels + - Check for `/metrics` and logs for recent message ingestion + +- 401 Unauthorized from `/tail` or `/trigger` + - Provide `Authorization: Bearer $HTTP_TOKEN` or `?token=$HTTP_TOKEN` + +- OpenAI 502/URL errors + - Ensure `OPENAI_BASE_URL=https://api.openai.com/v1` + - Try `OPENAI_MODEL=gpt-4o-mini` if `gpt-5` isn’t enabled for your account + +## Roadmap + +- Additional notifiers (ntfy, Telegram) +- Long-form HTML digest rendering +- Admin endpoints (e.g., `/join?channel=#chan`) + +## Development notes + +Project layout (selected): + +- `cmd/sojuboy/main.go` – entrypoint, wiring config/services +- `internal/soju` – soju connector and ingestion +- `internal/store` – SQLite schema and queries +- `internal/notifier` – Pushover notifier +- `internal/summarizer` – OpenAI client and prompts +- `internal/httpapi` – health, tail, trigger, metrics endpoints +- `internal/scheduler` – cron jobs + +Go toolchain: see `go.mod` (Go 1.23), Dockerfile builds static binary for a distroless image. + +## License + +MIT for code dependencies; this repository’s license will follow your preference (add a LICENSE if needed). diff --git a/cmd/sojuboy/main.go b/cmd/sojuboy/main.go new file mode 100644 index 0000000..81faf2d --- /dev/null +++ b/cmd/sojuboy/main.go @@ -0,0 +1,254 @@ +package main + +import ( + "context" + "flag" + "io" + "log/slog" + "net/http" + "os" + "os/signal" + "sync" + "sync/atomic" + "syscall" + "time" + + "sojuboy/internal/config" + "sojuboy/internal/httpapi" + "sojuboy/internal/logging" + "sojuboy/internal/notifier" + "sojuboy/internal/scheduler" + "sojuboy/internal/soju" + "sojuboy/internal/store" + "sojuboy/internal/summarizer" +) + +var ( + version = "dev" + commit = "" + builtAt = "" +) + +type rateLimiter struct { + mu sync.Mutex + last map[string]time.Time // key: channel|keyword +} + +func (r *rateLimiter) allow(key string, minInterval time.Duration) bool { + r.mu.Lock() + defer r.mu.Unlock() + if r.last == nil { + r.last = make(map[string]time.Time) + } + now := time.Now() + if t, ok := r.last[key]; ok { + if now.Sub(t) < minInterval { + return false + } + } + r.last[key] = now + return true +} + +func main() { + cfg := config.FromEnv() + + health := flag.Bool("health", false, "run healthcheck and exit") + flag.Parse() + if *health { + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Get("http://127.0.0.1:8080/ready") + if err != nil { + os.Exit(1) + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + if resp.StatusCode == 200 { + os.Exit(0) + } + os.Exit(1) + } + + logger := logging.New(cfg.LogLevel) + logger.Info("starting sojuboy", "version", version, "commit", commit, "builtAt", builtAt) + + if cfg.LogLevel == "debug" { + logger.Info("config loaded", "config", cfg.Redact()) + } + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + st, err := store.Open(ctx, cfg.StorePath) + if err != nil { + logger.Error("open store", "err", err) + os.Exit(1) + } + defer st.Close() + + metrics := &httpapi.Metrics{} + + var nt notifier.Notifier + if cfg.Notifier == "pushover" { + nt = notifier.NewPushover(cfg.PushoverUserKey, cfg.PushoverAPIToken) + } else { + logger.Error("unsupported notifier", "notifier", cfg.Notifier) + os.Exit(1) + } + + var sum summarizer.Summarizer + if cfg.LLMProvider == "openai" { + ai := summarizer.NewOpenAI(cfg.OpenAIAPIKey, cfg.OpenAIBaseURL, cfg.OpenAIModel, cfg.OpenAIMaxTokens) + // apply summarizer tuning from env + ai.ApplyConfig(cfg) + sum = ai + } else { + logger.Warn("no summarizer configured", "provider", cfg.LLMProvider) + } + + // HTTP API + api := httpapi.Server{ + ListenAddr: cfg.HTTPListen, + AuthToken: cfg.HTTPToken, + Store: st, + Summarizer: sum, + Notifier: nt, + Logger: slog.New(slog.NewJSONHandler(os.Stdout, nil)), // legacy interface still expects *log.Logger; keep minimal text via adapter if needed + Metrics: metrics, + Ready: func() bool { + return atomic.LoadInt64(&metrics.ConnectedGauge) == 1 + }, + } + go func() { + if err := api.Start(ctx); err != nil && err != http.ErrServerClosed { + logger.Error("http api", "err", err) + os.Exit(1) + } + }() + + // Rate limiter and mention filter + rl := &rateLimiter{} + allowedChannel := func(channel string) bool { return cfg.IsChannelAllowed(channel) } + isUrgent := func(text string) bool { return config.ContainsAnyWordFold(text, cfg.UrgentKeywords) } + + // IRC ingestion alert function + mentionChecker := func(text string) bool { + return config.ContainsMention(text, cfg.Nick, cfg.Keywords) + } + alert := func(channel, author, text, msgid string, at time.Time) { + logger.Debug("ingest", "ts", at.UTC(), "channel", channel, "author", author, "body", text, "msgid", msgid) + if err := st.InsertMessage(ctx, store.Message{ + Channel: channel, + Author: author, + Body: text, + Time: at.UTC(), + MsgID: msgid, + }); err != nil { + logger.Error("store insert", "err", err) + } else { + atomic.AddInt64(&metrics.MessagesIngested, 1) + } + if mentionChecker(text) && allowedChannel(channel) { + if nt != nil { + if !cfg.NotifyBackfill && time.Since(at) > 5*time.Minute { + logger.Debug("mention suppressed", "reason", "backfill", "channel", channel, "author", author) + return + } + if config.WithinQuietHours(at, cfg.QuietHours) && !isUrgent(text) { + logger.Debug("mention suppressed", "reason", "quiet_hours", "channel", channel, "author", author) + return + } + key := channel + "|" + cfg.Nick + if !rl.allow(key, cfg.MentionMinInterval) { + logger.Debug("mention suppressed", "reason", "rate_limit", "channel", channel, "author", author) + return + } + if err := nt.Notify(ctx, "IRC mention in "+channel, author+": "+text); err != nil { + logger.Error("mention notify", "err", err) + } else { + atomic.AddInt64(&metrics.NotificationsSent, 1) + logger.Debug("mention notified", "channel", channel, "author", author) + } + } + } + } + + // Choose connector (raw only for soju) + backfill := config.GetEnvInt("CHATHISTORY_LATEST", 0) + raw := soju.RawClient{ + Server: cfg.IRCServer, + Port: cfg.IRCPort, + UseTLS: cfg.IRCTLS, + Nick: cfg.Nick, + Username: cfg.Username, + Realname: cfg.Realname, + Password: cfg.Password, + Channels: cfg.Channels, + BackfillLatest: backfill, + OnPrivmsg: func(channel, author, text, msgid string, at time.Time) { + alert(channel, author, text, msgid, at) + }, + ConnectedGauge: &metrics.ConnectedGauge, + } + logger.Info("irc", "auth", "raw", "username", cfg.Username) + go func() { + if err := raw.Run(ctx); err != nil { + logger.Error("irc client", "err", err) + } + }() + + // Scheduler for digests + if sum != nil && cfg.DigestCron != "" { + job := func(now time.Time) { + for _, ch := range cfg.Channels { + if config.WithinQuietHours(now, cfg.QuietHours) { + continue + } + window := cfg.DigestWindow + since := now.Add(-window) + msgs, err := st.ListMessagesSince(ctx, ch, since) + if err != nil { + logger.Error("digest fetch", "err", err) + continue + } + summary, err := sum.Summarize(ctx, ch, msgs, window) + if err != nil { + logger.Error("digest summarize", "err", err) + continue + } + if nt != nil { + title := "IRC digest " + ch + " (" + window.String() + ")" + if err := nt.Notify(ctx, title, summary); err != nil { + logger.Error("digest notify", "err", err) + } else { + atomic.AddInt64(&metrics.NotificationsSent, 1) + logger.Debug("digest notified", "channel", ch, "window", window.String()) + } + } + } + } + if err := scheduler.Start(ctx, cfg.DigestCron, job, slog.NewLogLogger(logger.Handler(), slog.LevelInfo)); err != nil { + logger.Error("scheduler", "err", err) + } + } + + // Daily retention prune job at 03:00 local + if cfg.RetentionDays > 0 { + pruneSpec := "0 0 3 * * *" // sec min hour dom mon dow + pruneJob := func(now time.Time) { + cutoff := now.AddDate(0, 0, -cfg.RetentionDays) + if n, err := st.DeleteOlderThan(ctx, cutoff); err != nil { + logger.Error("retention prune", "err", err) + } else if n > 0 { + atomic.AddInt64(&metrics.MessagesPruned, n) + logger.Info("retention pruned", "count", n, "cutoff", cutoff.Format(time.RFC3339)) + } + } + if err := scheduler.Start(ctx, pruneSpec, pruneJob, slog.NewLogLogger(logger.Handler(), slog.LevelInfo)); err != nil { + logger.Error("scheduler prune", "err", err) + } + } + + <-ctx.Done() + logger.Info("shutting down") +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f41e8bd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + sojuboy: + build: . + image: sojuboy:latest + container_name: sojuboy + restart: unless-stopped + env_file: .env + ports: + - "8080:8080" + volumes: + - sojuboy_data:/data + healthcheck: + test: ["CMD", "/sojuboy", "--health"] + interval: 30s + timeout: 3s + retries: 3 +volumes: + sojuboy_data: {} + + + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..580ae77 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module sojuboy + +go 1.23.0 + +toolchain go1.24.6 + +require ( + github.com/gregdel/pushover v1.3.1 + github.com/robfig/cron/v3 v3.0.1 + github.com/sashabaranov/go-openai v1.41.1 + github.com/sorcix/irc v1.1.4 + modernc.org/sqlite v1.38.2 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.34.0 // indirect + modernc.org/libc v1.66.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e250cc7 --- /dev/null +++ b/go.sum @@ -0,0 +1,57 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gregdel/pushover v1.3.1 h1:4bMLITOZ15+Zpi6qqoGqOPuVHCwSUvMCgVnN5Xhilfo= +github.com/gregdel/pushover v1.3.1/go.mod h1:EcaO66Nn1StkpEm1iKtBTV3d2A16SoMsVER1PthX7to= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/sashabaranov/go-openai v1.41.1 h1:zf5tM+GuxpyiyD9XZg8nCqu52eYFQg9OOew0gnIuDy4= +github.com/sashabaranov/go-openai v1.41.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= +github.com/sorcix/irc v1.1.4 h1:KDmVMPPzK4kbf3TQw1RsZAqTsh2JL9Zw69hYduX9Ykw= +github.com/sorcix/irc v1.1.4/go.mod h1:MhzbySH63tDknqfvAAFK3ps/942g4z9EeJ/4lGgHyZc= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= +modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= +modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..44236f3 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,297 @@ +package config + +import ( + "os" + "strconv" + "strings" + "time" + "unicode" +) + +type Config struct { + // IRC/soju + IRCServer string + IRCPort int + IRCTLS bool + Nick string + Username string + Realname string + Password string + Network string + Channels []string + Keywords []string + AuthMethod string + + // Notifier + Notifier string + PushoverUserKey string + PushoverAPIToken string + + // Summarizer / LLM + LLMProvider string + OpenAIAPIKey string + OpenAIBaseURL string + OpenAIModel string + OpenAIMaxTokens int + SummFollowLinks bool + SummLinkTimeout time.Duration + SummLinkMaxBytes int + SummGroupWindow time.Duration + SummMaxLinks int + + // Digests + DigestCron string + DigestWindow time.Duration + QuietHours string + NotifyBackfill bool + MentionMinInterval time.Duration + MentionsOnlyChannels []string + MentionsDenyChannels []string + UrgentKeywords []string + + // HTTP API + HTTPListen string + HTTPToken string + + // Storage + StorePath string + RetentionDays int + + // Logging + LogLevel string +} + +func FromEnv() Config { + cfg := Config{} + + cfg.IRCServer = getEnv("SOJU_HOST", "127.0.0.1") + cfg.IRCPort = getEnvInt("SOJU_PORT", 6697) + cfg.IRCTLS = getEnvBool("SOJU_TLS", true) + cfg.Nick = getEnv("IRC_NICK", "sojuboy") + cfg.Username = getEnv("IRC_USERNAME", cfg.Nick) + cfg.Realname = getEnv("IRC_REALNAME", "sojuboy") + cfg.Password = getEnv("IRC_PASSWORD", "") + cfg.Network = getEnv("SOJU_NETWORK", "") + cfg.Channels = splitCSV(getEnv("CHANNELS", "")) + cfg.Keywords = splitCSV(getEnv("KEYWORDS", cfg.Nick)) + cfg.AuthMethod = strings.ToLower(getEnv("SOJU_AUTH", "sasl")) + + cfg.Notifier = getEnv("NOTIFIER", "pushover") + cfg.PushoverUserKey = getEnv("PUSHOVER_USER_KEY", "") + cfg.PushoverAPIToken = getEnv("PUSHOVER_API_TOKEN", "") + + cfg.LLMProvider = getEnv("LLM_PROVIDER", "openai") + cfg.OpenAIAPIKey = getEnv("OPENAI_API_KEY", "") + cfg.OpenAIBaseURL = getEnv("OPENAI_BASE_URL", "") + cfg.OpenAIModel = getEnv("OPENAI_MODEL", "gpt-5") + cfg.OpenAIMaxTokens = getEnvInt("OPENAI_MAX_TOKENS", 700) + cfg.SummFollowLinks = getEnvBool("SUMM_FOLLOW_LINKS", true) + cfg.SummLinkTimeout = getEnvDuration("SUMM_LINK_TIMEOUT", 6*time.Second) + cfg.SummLinkMaxBytes = getEnvInt("SUMM_LINK_MAX_BYTES", 262144) + cfg.SummGroupWindow = getEnvDuration("SUMM_GROUP_WINDOW", 90*time.Second) + cfg.SummMaxLinks = getEnvInt("SUMM_MAX_LINKS", 5) + + cfg.DigestCron = getEnv("DIGEST_CRON", "0 */6 * * *") + cfg.DigestWindow = getEnvDuration("DIGEST_WINDOW", 6*time.Hour) + cfg.QuietHours = getEnv("QUIET_HOURS", "") + cfg.NotifyBackfill = getEnvBool("NOTIFY_BACKFILL", false) + cfg.MentionMinInterval = getEnvDuration("MENTION_MIN_INTERVAL", 30*time.Second) + cfg.MentionsOnlyChannels = splitCSV(getEnv("MENTIONS_ONLY_CHANNELS", "")) + cfg.MentionsDenyChannels = splitCSV(getEnv("MENTIONS_DENY_CHANNELS", "")) + cfg.UrgentKeywords = splitCSV(getEnv("URGENT_KEYWORDS", "")) + + cfg.HTTPListen = getEnv("HTTP_LISTEN", ":8080") + cfg.HTTPToken = getEnv("HTTP_TOKEN", "") + + cfg.StorePath = getEnv("STORE_PATH", "/data/app.db") + cfg.RetentionDays = getEnvInt("STORE_RETENTION_DAYS", 7) + + cfg.LogLevel = getEnv("LOG_LEVEL", "info") + + return cfg +} + +func (c Config) Redact() Config { + r := c + if r.Password != "" { + r.Password = "***" + } + if r.PushoverUserKey != "" { + r.PushoverUserKey = "***" + } + if r.PushoverAPIToken != "" { + r.PushoverAPIToken = "***" + } + if r.OpenAIAPIKey != "" { + r.OpenAIAPIKey = "***" + } + if r.HTTPToken != "" { + r.HTTPToken = "***" + } + return r +} + +func getEnv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func getEnvInt(key string, def int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} + +func getEnvBool(key string, def bool) bool { + if v := os.Getenv(key); v != "" { + v = strings.ToLower(strings.TrimSpace(v)) + return v == "1" || v == "true" || v == "yes" || v == "y" + } + return def +} + +func getEnvDuration(key string, def time.Duration) time.Duration { + if v := os.Getenv(key); v != "" { + d, err := time.ParseDuration(v) + if err == nil { + return d + } + } + return def +} + +func splitCSV(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +// ContainsMention checks if text contains the nick or any keyword as a word, case-insensitive. +func ContainsMention(text, nick string, keywords []string) bool { + if nick != "" { + if containsWordFold(text, nick) { + return true + } + } + return ContainsAnyWordFold(text, keywords) +} + +// ContainsAnyWordFold returns true if any word in keywords appears as a word in text (case-insensitive). +func ContainsAnyWordFold(text string, keywords []string) bool { + for _, k := range keywords { + if k == "" { + continue + } + if containsWordFold(text, k) { + return true + } + } + return false +} + +func containsWordFold(text, word string) bool { + if word == "" { + return false + } + w := strings.ToLower(word) + // Iterate tokens separated by non-nick characters. + var buf []rune + flush := func() bool { + if len(buf) == 0 { + return false + } + t := strings.ToLower(string(buf)) + buf = buf[:0] + return t == w + } + isNickChar := func(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' } + for _, r := range text { + if isNickChar(r) { + buf = append(buf, r) + continue + } + if flush() { + return true + } + } + return flush() +} + +// WithinQuietHours returns true if now is inside a quiet-hours window like "22:00-07:00" (24h local time). +func WithinQuietHours(now time.Time, window string) bool { + w := strings.TrimSpace(window) + if w == "" { + return false + } + parts := strings.Split(w, "-") + if len(parts) != 2 { + return false + } + parse := func(s string) (int, int, bool) { + p := strings.Split(strings.TrimSpace(s), ":") + if len(p) != 2 { + return 0, 0, false + } + h, err1 := strconv.Atoi(p[0]) + m, err2 := strconv.Atoi(p[1]) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return h, m, true + } + sh, sm, ok1 := parse(parts[0]) + eh, em, ok2 := parse(parts[1]) + if !ok1 || !ok2 { + return false + } + start := time.Date(now.Year(), now.Month(), now.Day(), sh, sm, 0, 0, now.Location()) + end := time.Date(now.Year(), now.Month(), now.Day(), eh, em, 0, 0, now.Location()) + if !end.After(start) { + // window wraps past midnight + return now.After(start) || now.Before(end) + } + return now.After(start) && now.Before(end) +} + +// GetEnvInt exports integer env getter for external use +func GetEnvInt(key string, def int) int { return getEnvInt(key, def) } + +// IsChannelAllowed checks channel against allow/deny lists. If allow-list is non-empty, only those pass. +func (c Config) IsChannelAllowed(channel string) bool { + ch := strings.ToLower(strings.TrimSpace(channel)) + if ch == "" { + return false + } + if len(c.MentionsOnlyChannels) > 0 { + allowed := false + for _, a := range c.MentionsOnlyChannels { + if strings.EqualFold(a, channel) { + allowed = true + break + } + } + if !allowed { + return false + } + } + for _, d := range c.MentionsDenyChannels { + if strings.EqualFold(d, channel) { + return false + } + } + return true +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go new file mode 100644 index 0000000..6f763d3 --- /dev/null +++ b/internal/httpapi/server.go @@ -0,0 +1,208 @@ +package httpapi + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "strconv" + "strings" + "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 +} + +func (s *Server) Start(ctx context.Context) error { + mux := http.NewServeMux() + 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) + + 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 + } + // Timeout summarization to avoid hung requests. + ctxSum, cancel := context.WithTimeout(ctx, 60*time.Second) + 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")) + } +} + +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) +} + +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 + } + 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 +} diff --git a/internal/ircclient/base64.go b/internal/ircclient/base64.go new file mode 100644 index 0000000..c125f70 --- /dev/null +++ b/internal/ircclient/base64.go @@ -0,0 +1,7 @@ +package ircclient + +import "encoding/base64" + +func base64Encode(b []byte) string { + return base64.StdEncoding.EncodeToString(b) +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..8074477 --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,24 @@ +package logging + +import ( + "log/slog" + "os" + "strings" +) + +// New returns a JSON slog logger configured to the provided level string (debug, info, warn, error). +func New(level string) *slog.Logger { + lvl := slog.LevelInfo + switch strings.ToLower(strings.TrimSpace(level)) { + case "debug": + lvl = slog.LevelDebug + case "info", "": + lvl = slog.LevelInfo + case "warn", "warning": + lvl = slog.LevelWarn + case "err", "error": + lvl = slog.LevelError + } + h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}) + return slog.New(h) +} diff --git a/internal/notifier/notifier.go b/internal/notifier/notifier.go new file mode 100644 index 0000000..4950897 --- /dev/null +++ b/internal/notifier/notifier.go @@ -0,0 +1,8 @@ +package notifier + +import "context" + +type Notifier interface { + Notify(ctx context.Context, title, message string) error +} + diff --git a/internal/notifier/pushover.go b/internal/notifier/pushover.go new file mode 100644 index 0000000..652448d --- /dev/null +++ b/internal/notifier/pushover.go @@ -0,0 +1,39 @@ +package notifier + +import ( + "context" + "strings" + + "github.com/gregdel/pushover" +) + +type PushoverNotifier struct { + app *pushover.Pushover + userKey string +} + +func NewPushover(userKey, apiToken string) *PushoverNotifier { + return &PushoverNotifier{ + app: pushover.New(apiToken), + userKey: userKey, + } +} + +func (p *PushoverNotifier) Notify(ctx context.Context, title, message string) error { + if p == nil || p.app == nil || p.userKey == "" { + return nil + } + if len(message) > 1024 { + message = message[:1024] + } + title = strings.TrimSpace(title) + msg := &pushover.Message{ + Title: title, + Message: message, + } + recipient := pushover.NewRecipient(p.userKey) + _, err := p.app.SendMessage(msg, recipient) + return err +} + + diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 0000000..0d1ad83 --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -0,0 +1,31 @@ +package scheduler + +import ( + "context" + "log" + "time" + + "github.com/robfig/cron/v3" +) + +// Start runs the cron scheduler until ctx is done. +func Start(ctx context.Context, spec string, job func(now time.Time), logger *log.Logger) error { + c := cron.New(cron.WithParser(cron.NewParser(cron.SecondOptional|cron.Minute|cron.Hour|cron.Dom|cron.Month|cron.Dow|cron.Descriptor))) + _, err := c.AddFunc(spec, func() { + job(time.Now()) + }) + if err != nil { + return err + } + if logger != nil { + logger.Printf("scheduler started: %s", spec) + } + c.Start() + go func() { + <-ctx.Done() + c.Stop() + }() + return nil +} + + diff --git a/internal/soju/rawclient.go b/internal/soju/rawclient.go new file mode 100644 index 0000000..120c575 --- /dev/null +++ b/internal/soju/rawclient.go @@ -0,0 +1,309 @@ +package soju + +import ( + "bufio" + "context" + "crypto/tls" + "fmt" + "log/slog" + "net" + "strconv" + "strings" + "sync/atomic" + "time" + + "sojuboy/internal/store" + + irc "github.com/sorcix/irc" +) + +type RawClient struct { + Server string + Port int + UseTLS bool + Nick string + Username string // full identity: username/network@client + Realname string + Password string // PASS + Channels []string + + // Number of messages to fetch via CHATHISTORY LATEST per channel after join. + BackfillLatest int + + OnPrivmsg func(channel, author, text, msgid string, at time.Time) + + Logger *slog.Logger + Debug bool + + // Store is used to compute last-seen timestamp for CHATHISTORY. + Store *store.Store + + // Readiness/metrics hooks + ConnectedGauge *int64 // 0/1 + IsReady *int32 // 0/1 atomic flag +} + +func (c *RawClient) setConnected(v bool) { + if c.ConnectedGauge != nil { + if v { + atomic.StoreInt64(c.ConnectedGauge, 1) + } else { + atomic.StoreInt64(c.ConnectedGauge, 0) + } + } + if c.IsReady != nil { + if v { + atomic.StoreInt32(c.IsReady, 1) + } else { + atomic.StoreInt32(c.IsReady, 0) + } + } +} + +func (c *RawClient) Run(ctx context.Context) error { + backoff := time.Second + for { + if err := c.runOnce(ctx); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + if c.Logger != nil { + c.Logger.Error("raw soju client stopped", "err", err) + } + time.Sleep(backoff) + if backoff < 30*time.Second { + backoff *= 2 + } + continue + } + return nil + } +} + +func (c *RawClient) runOnce(ctx context.Context) error { + address := net.JoinHostPort(c.Server, strconv.Itoa(c.Port)) + var conn net.Conn + var err error + if c.UseTLS { + tlsCfg := &tls.Config{ServerName: c.Server, MinVersion: tls.VersionTLS12} + conn, err = tls.Dial("tcp", address, tlsCfg) + } else { + conn, err = net.Dial("tcp", address) + } + if err != nil { + return err + } + defer conn.Close() + + rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn)) + + write := func(line string) error { + out := line + if strings.HasPrefix(strings.ToUpper(line), "PASS ") { + out = "PASS ********" + } + if c.Debug && c.Logger != nil { + c.Logger.Debug("irc>", "line", out) + } + if _, err := rw.WriteString(line + "\r\n"); err != nil { + return err + } + return rw.Flush() + } + + // Request capabilities needed for chathistory and accurate timestamps. + _ = write("CAP LS 302") + _ = write("CAP REQ :server-time batch message-tags draft/chathistory draft/event-playback echo-message cap-notify") + _ = write("CAP END") + + // Authenticate with PASS/NICK/USER + if c.Password != "" { + if err := write("PASS " + c.Password); err != nil { + return err + } + } + if err := write("NICK " + c.Nick); err != nil { + return err + } + user := c.Username + if user == "" { + user = c.Nick + } + host := c.Server + if err := write(fmt.Sprintf("USER %s %s %s :%s", user, user, host, c.Realname)); err != nil { + return err + } + + // Reader loop + connected := false + eventPlayback := false + selfJoined := map[string]bool{} + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + rawLine, err := rw.ReadString('\n') + if err != nil { + return err + } + rawLine = strings.TrimRight(rawLine, "\r\n") + if rawLine == "" { + continue + } + if c.Debug && c.Logger != nil { + c.Logger.Debug("irc<", "line", rawLine) + } + + // Parse IRCv3 tags if present + var tags map[string]string + line := rawLine + if strings.HasPrefix(line, "@") { + sp := strings.IndexByte(line, ' ') + if sp > 0 { + tags = parseTags(line[1:sp]) + line = strings.TrimSpace(line[sp+1:]) + } + } + + msg := irc.ParseMessage(line) + if msg == nil { + continue + } + cmd := strings.ToUpper(msg.Command) + switch cmd { + case "CAP": + // Examples: :bnc CAP * ACK :server-time batch message-tags draft/chathistory draft/event-playback + if len(msg.Params) >= 3 { + sub := strings.ToUpper(msg.Params[1]) + caps := strings.TrimPrefix(msg.Params[2], ":") + switch sub { + case "ACK": + if strings.Contains(caps, "draft/event-playback") { + eventPlayback = true + if c.Logger != nil { + c.Logger.Info("cap enabled", "cap", "draft/event-playback") + } + } + case "NEW": + if strings.Contains(caps, "draft/event-playback") && !eventPlayback { + _ = write("CAP REQ :draft/event-playback") + } + } + } + case "PING": + if len(msg.Params) > 0 { + _ = write("PONG :" + msg.Params[len(msg.Params)-1]) + } + case "001": // welcome + connected = true + c.setConnected(true) + if c.Logger != nil { + c.Logger.Info("connected", "server", c.Server, "auth", "raw") + } + for _, ch := range c.Channels { + _ = write("JOIN " + ch) + if c.Logger != nil { + c.Logger.Info("join requested", "channel", ch) + } + } + case "JOIN": + if len(msg.Params) == 0 { + break + } + ch := msg.Params[0] + nick := nickFromPrefix(msg.Prefix) + if c.Logger != nil { + c.Logger.Info("joined", "channel", ch, "nick", nick) + } + if nick == c.Nick && !selfJoined[ch] { + selfJoined[ch] = true + if !eventPlayback && c.BackfillLatest > 0 { + // Use last seen timestamp if available + since := time.Now().Add(-24 * time.Hour) // default fallback + if c.Store != nil { + if t, ok, err := c.Store.LastMessageTime(ctx, ch); err == nil && ok { + since = t + } + } + // ISO-8601 / RFC3339 format + ts := since.UTC().Format(time.RFC3339Nano) + _ = write(fmt.Sprintf("CHATHISTORY LATEST %s timestamp=%s %d", ch, ts, c.BackfillLatest)) + } + } + case "PRIVMSG": + if len(msg.Params) < 1 { + continue + } + target := msg.Params[0] + var text string + if len(msg.Params) >= 2 { + text = msg.Params[1] + } else if msg.Trailing != "" { + text = msg.Trailing + } else { + continue + } + at := time.Now() + if ts, ok := tags["time"]; ok && ts != "" { + if t, e := time.Parse(time.RFC3339Nano, ts); e == nil { + at = t + } else if t2, e2 := time.Parse(time.RFC3339, ts); e2 == nil { + at = t2 + } + } + msgid := tags["soju-msgid"] + if msgid == "" { + msgid = tags["msgid"] + } + if c.OnPrivmsg != nil { + c.OnPrivmsg(target, nickFromPrefix(msg.Prefix), text, msgid, at) + } + case "ERROR": + c.setConnected(false) + return fmt.Errorf("server closed: %s", strings.Join(msg.Params, " ")) + } + + _ = connected + } +} + +func nickFromPrefix(pfx *irc.Prefix) string { + if pfx == nil { + return "" + } + if pfx.Name != "" { + return pfx.Name + } + if pfx.User != "" { + return pfx.User + } + if pfx.Host != "" { + return pfx.Host + } + return "" +} + +func parseTags(s string) map[string]string { + out := make(map[string]string) + if s == "" { + return out + } + parts := strings.Split(s, ";") + for _, p := range parts { + if p == "" { + continue + } + kv := strings.SplitN(p, "=", 2) + key := kv[0] + val := "" + if len(kv) == 2 { + val = kv[1] + } + // No unescape implemented; good enough for 'time' and 'batch' + out[key] = val + } + return out +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..d7269cf --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,153 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "time" + + _ "modernc.org/sqlite" +) + +type Store struct { + db *sql.DB +} + +type Message struct { + ID int64 + Channel string + Author string + Body string + Time time.Time + MsgID string +} + +func Open(ctx context.Context, path string) (*Store, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + if _, err := db.ExecContext(ctx, `PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;`); err != nil { + _ = db.Close() + return nil, err + } + if err := initSchema(ctx, db); err != nil { + _ = db.Close() + return nil, err + } + // Best-effort migration: add msgid column and unique index if missing + _, _ = db.ExecContext(ctx, `ALTER TABLE messages ADD COLUMN msgid TEXT`) + _, _ = db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_msgid ON messages(msgid) WHERE msgid IS NOT NULL`) + return &Store{db: db}, nil +} + +func (s *Store) Close() error { return s.db.Close() } + +func initSchema(ctx context.Context, db *sql.DB) error { + const schema = ` + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel TEXT NOT NULL, + author TEXT NOT NULL, + body TEXT NOT NULL, + at TIMESTAMP NOT NULL, + msgid TEXT + ); + CREATE INDEX IF NOT EXISTS idx_messages_channel_at ON messages(channel, at); + ` + _, err := db.ExecContext(ctx, schema) + return err +} + +func (s *Store) InsertMessage(ctx context.Context, m Message) error { + _, err := s.db.ExecContext(ctx, + "INSERT OR IGNORE INTO messages(channel, author, body, at, msgid) VALUES(?,?,?,?,?)", + m.Channel, m.Author, m.Body, m.Time.UTC(), nullIfEmpty(m.MsgID)) + return err +} + +func nullIfEmpty(s string) any { + if s == "" { + return nil + } + return s +} + +func (s *Store) ListMessagesSince(ctx context.Context, channel string, since time.Time) ([]Message, error) { + rows, err := s.db.QueryContext(ctx, + "SELECT id, channel, author, body, at, msgid FROM messages WHERE lower(channel) = lower(?) AND at >= ? ORDER BY at ASC", + channel, since.UTC()) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Message + for rows.Next() { + var m Message + var at time.Time + var msgid sql.NullString + if err := rows.Scan(&m.ID, &m.Channel, &m.Author, &m.Body, &at, &msgid); err != nil { + return nil, err + } + m.Time = at + if msgid.Valid { + m.MsgID = msgid.String + } + out = append(out, m) + } + return out, rows.Err() +} + +// ListRecentMessages returns the most recent N messages for a channel. +func (s *Store) ListRecentMessages(ctx context.Context, channel string, limit int) ([]Message, error) { + if limit <= 0 { + limit = 50 + } + rows, err := s.db.QueryContext(ctx, + "SELECT id, channel, author, body, at, msgid FROM messages WHERE lower(channel) = lower(?) ORDER BY at DESC LIMIT ?", + channel, limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Message + for rows.Next() { + var m Message + var at time.Time + var msgid sql.NullString + if err := rows.Scan(&m.ID, &m.Channel, &m.Author, &m.Body, &at, &msgid); err != nil { + return nil, err + } + m.Time = at + if msgid.Valid { + m.MsgID = msgid.String + } + out = append(out, m) + } + return out, rows.Err() +} + +// LastMessageTime returns the last stored timestamp for a channel. +func (s *Store) LastMessageTime(ctx context.Context, channel string) (time.Time, bool, error) { + var nt sql.NullTime + err := s.db.QueryRowContext(ctx, "SELECT MAX(at) FROM messages WHERE lower(channel) = lower(?)", channel).Scan(&nt) + if err != nil { + return time.Time{}, false, err + } + if !nt.Valid { + return time.Time{}, false, nil + } + return nt.Time, true, nil +} + +func (s *Store) DeleteOlderThan(ctx context.Context, cutoff time.Time) (int64, error) { + res, err := s.db.ExecContext(ctx, "DELETE FROM messages WHERE at < ?", cutoff.UTC()) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +var ErrNotFound = errors.New("not found") diff --git a/internal/summarizer/openai.go b/internal/summarizer/openai.go new file mode 100644 index 0000000..c8865c8 --- /dev/null +++ b/internal/summarizer/openai.go @@ -0,0 +1,204 @@ +package summarizer + +import ( + "context" + "io" + "net/http" + "regexp" + "strings" + "time" + + openai "github.com/sashabaranov/go-openai" + + "sojuboy/internal/config" + "sojuboy/internal/store" +) + +type OpenAI struct { + apiKey string + baseURL string + model string + maxTokens int + // runtime cfg + followLinks bool + linkTimeout time.Duration + linkMaxBytes int + groupWindow time.Duration + maxLinks int +} + +func NewOpenAI(apiKey, baseURL, model string, maxTokens int) *OpenAI { + return &OpenAI{apiKey: apiKey, baseURL: baseURL, model: model, maxTokens: maxTokens, + followLinks: true, linkTimeout: 6 * time.Second, linkMaxBytes: 262144, groupWindow: 90 * time.Second, maxLinks: 5, + } +} + +// Configure from app config +func (o *OpenAI) ApplyConfig(cfg config.Config) { + o.followLinks = cfg.SummFollowLinks + o.linkTimeout = cfg.SummLinkTimeout + o.linkMaxBytes = cfg.SummLinkMaxBytes + o.groupWindow = cfg.SummGroupWindow + o.maxLinks = cfg.SummMaxLinks +} + +func (o *OpenAI) Summarize(ctx context.Context, channel string, msgs []store.Message, window time.Duration) (string, error) { + if o == nil || o.apiKey == "" { + return "", nil + } + cfg := openai.DefaultConfig(o.apiKey) + if strings.TrimSpace(o.baseURL) != "" { + cfg.BaseURL = o.baseURL + } + client := openai.NewClientWithConfig(cfg) + + // 1) Group multiline posts from same author within groupWindow + grouped := groupMessages(msgs, o.groupWindow) + + // 2) Extract links and optionally fetch a small amount of content + links := extractLinks(grouped) + if o.followLinks && len(links) > 0 { + links = fetchLinkSnippets(ctx, links, o.linkTimeout, o.linkMaxBytes, o.maxLinks) + } + + // 3) Build a concise, natural prompt + var b strings.Builder + b.WriteString("Channel: ") + b.WriteString(channel) + b.WriteString("\nTime window: ") + b.WriteString(window.String()) + b.WriteString("\n\nTranscript (grouped by author):\n") + for _, g := range grouped { + b.WriteString(g.time.Format(time.RFC3339)) + b.WriteString(" ") + b.WriteString(g.author) + b.WriteString(": ") + b.WriteString(g.text) + b.WriteString("\n") + } + if len(links) > 0 { + b.WriteString("\nReferenced content (snippets):\n") + for _, ln := range links { + b.WriteString("- ") + b.WriteString(ln.url) + b.WriteString(" → ") + b.WriteString(ln.snippet) + b.WriteString("\n") + } + } + b.WriteString("\nWrite a concise, readable summary of the conversation above.\n") + b.WriteString("- Focus on what happened and why it matters.\n") + b.WriteString("- Integrate linked content and pasted multi-line posts naturally.\n") + b.WriteString("- Avoid rigid sections; use short paragraphs or light bullets if helpful.\n") + b.WriteString("- Keep it compact but don’t omit important context.\n") + prompt := b.String() + + sys := "You summarize IRC transcripts. Be concise, natural, and informative." + + model := o.model + if strings.TrimSpace(model) == "" { + model = "gpt-4o-mini" + } + reasoningLike := strings.HasPrefix(model, "gpt-5") || strings.HasPrefix(model, "o1") || strings.Contains(model, "reasoning") + + req := openai.ChatCompletionRequest{ + Model: model, + Messages: []openai.ChatCompletionMessage{ + {Role: openai.ChatMessageRoleSystem, Content: sys}, + {Role: openai.ChatMessageRoleUser, Content: prompt}, + }, + MaxCompletionTokens: o.maxTokens, + } + if !reasoningLike { + req.Temperature = 0.3 + } + + resp, err := client.CreateChatCompletion(ctx, req) + if err != nil { + return "", err + } + if len(resp.Choices) == 0 { + return "", nil + } + return strings.TrimSpace(resp.Choices[0].Message.Content), nil +} + +type linkSnippet struct { + url string + snippet string +} + +type groupedMsg struct { + time time.Time + author string + text string +} + +func groupMessages(msgs []store.Message, window time.Duration) []groupedMsg { + if len(msgs) == 0 { + return nil + } + var out []groupedMsg + cur := groupedMsg{time: msgs[0].Time, author: msgs[0].Author, text: msgs[0].Body} + for i := 1; i < len(msgs); i++ { + m := msgs[i] + if m.Author == cur.author && m.Time.Sub(cur.time) <= window { + cur.text += "\n" + m.Body + continue + } + out = append(out, cur) + cur = groupedMsg{time: m.Time, author: m.Author, text: m.Body} + } + out = append(out, cur) + return out +} + +var linkRe = regexp.MustCompile(`https?://\S+`) + +func extractLinks(msgs []groupedMsg) []linkSnippet { + var links []linkSnippet + for _, g := range msgs { + for _, m := range linkRe.FindAllString(g.text, -1) { + links = append(links, linkSnippet{url: m}) + } + } + return links +} + +func fetchLinkSnippets(ctx context.Context, links []linkSnippet, timeout time.Duration, maxBytes int, maxLinks int) []linkSnippet { + client := &http.Client{Timeout: timeout} + if len(links) > maxLinks { + links = links[:maxLinks] + } + out := make([]linkSnippet, 0, len(links)) + for _, ln := range links { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ln.url, nil) + if err != nil { + continue + } + resp, err := client.Do(req) + if err != nil { + continue + } + func() { + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return + } + limited := io.LimitedReader{R: resp.Body, N: int64(maxBytes)} + b, err := io.ReadAll(&limited) + if err != nil || len(b) == 0 { + return + } + // naive text cleanup + text := string(b) + text = strings.ReplaceAll(text, "\r", "") + text = strings.TrimSpace(text) + if len(text) > 800 { + text = text[:800] + } + out = append(out, linkSnippet{url: ln.url, snippet: text}) + }() + } + return out +} diff --git a/internal/summarizer/summarizer.go b/internal/summarizer/summarizer.go new file mode 100644 index 0000000..c5ce0df --- /dev/null +++ b/internal/summarizer/summarizer.go @@ -0,0 +1,14 @@ +package summarizer + +import ( + "context" + "time" + + "sojuboy/internal/store" +) + +type Summarizer interface { + Summarize(ctx context.Context, channel string, msgs []store.Message, window time.Duration) (string, error) +} + +