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:
commit
2954e85e7a
19 changed files with 1983 additions and 0 deletions
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal 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
18
CHANGELOG.md
Normal 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
23
Dockerfile
Normal 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
274
README.md
Normal 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>`
|
||||||
|
- 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 <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 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).
|
||||||
254
cmd/sojuboy/main.go
Normal file
254
cmd/sojuboy/main.go
Normal 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
21
docker-compose.yml
Normal 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
26
go.mod
Normal 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
57
go.sum
Normal 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
297
internal/config/config.go
Normal 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
208
internal/httpapi/server.go
Normal 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
|
||||||
|
}
|
||||||
7
internal/ircclient/base64.go
Normal file
7
internal/ircclient/base64.go
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
package ircclient
|
||||||
|
|
||||||
|
import "encoding/base64"
|
||||||
|
|
||||||
|
func base64Encode(b []byte) string {
|
||||||
|
return base64.StdEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
24
internal/logging/logging.go
Normal file
24
internal/logging/logging.go
Normal 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)
|
||||||
|
}
|
||||||
8
internal/notifier/notifier.go
Normal file
8
internal/notifier/notifier.go
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
package notifier
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type Notifier interface {
|
||||||
|
Notify(ctx context.Context, title, message string) error
|
||||||
|
}
|
||||||
|
|
||||||
39
internal/notifier/pushover.go
Normal file
39
internal/notifier/pushover.go
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
31
internal/scheduler/scheduler.go
Normal file
31
internal/scheduler/scheduler.go
Normal 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
309
internal/soju/rawclient.go
Normal 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
153
internal/store/store.go
Normal 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")
|
||||||
204
internal/summarizer/openai.go
Normal file
204
internal/summarizer/openai.go
Normal 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 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
|
||||||
|
}
|
||||||
14
internal/summarizer/summarizer.go
Normal file
14
internal/summarizer/summarizer.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue