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