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
This commit is contained in:
Thomas Cravey 2025-08-15 18:06:28 -05:00
commit 2954e85e7a
19 changed files with 1983 additions and 0 deletions

16
.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
# OS
.DS_Store
# Go build outputs
/sojuboy
/out/
# Local data and env
/data/
.env
# Logs
*.log
# Coverage
coverage.out

18
CHANGELOG.md Normal file
View file

@ -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)

23
Dockerfile Normal file
View file

@ -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"]

274
README.md Normal file
View file

@ -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 <username/network@client> <same> <host> :<realname>`
- Sojus 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 <channel> timestamp=<RFC3339Nano> <limit>` 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:<HTTP_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 binarys `--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` isnt 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 theres 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` isnt 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 repositorys license will follow your preference (add a LICENSE if needed).

254
cmd/sojuboy/main.go Normal file
View file

@ -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")
}

21
docker-compose.yml Normal file
View file

@ -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: {}

26
go.mod Normal file
View file

@ -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
)

57
go.sum Normal file
View file

@ -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=

297
internal/config/config.go Normal file
View file

@ -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
}

208
internal/httpapi/server.go Normal file
View file

@ -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
}

View file

@ -0,0 +1,7 @@
package ircclient
import "encoding/base64"
func base64Encode(b []byte) string {
return base64.StdEncoding.EncodeToString(b)
}

View file

@ -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)
}

View file

@ -0,0 +1,8 @@
package notifier
import "context"
type Notifier interface {
Notify(ctx context.Context, title, message string) error
}

View file

@ -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
}

View file

@ -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
}

309
internal/soju/rawclient.go Normal file
View file

@ -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 <password>
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
}

153
internal/store/store.go Normal file
View file

@ -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")

View file

@ -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 dont 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
}

View file

@ -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)
}