feat: initial Beta 1 release

- soju raw connector with event playback and CHATHISTORY fallback
- SQLite store with msgid de-dup and retention job
- Mentions + Pushover + tuning; structured JSON logs
- Summaries: concise, link-following, multi-line grouping
- HTTP: /healthz, /ready, /tail, /trigger, /metrics
- Docker: distroless, healthcheck, version metadata
- Docs: README, CHANGELOG, compose
This commit is contained in:
Thomas Cravey 2025-08-15 18:06:28 -05:00
commit 2954e85e7a
19 changed files with 1983 additions and 0 deletions

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

@ -0,0 +1,297 @@
package config
import (
"os"
"strconv"
"strings"
"time"
"unicode"
)
type Config struct {
// IRC/soju
IRCServer string
IRCPort int
IRCTLS bool
Nick string
Username string
Realname string
Password string
Network string
Channels []string
Keywords []string
AuthMethod string
// Notifier
Notifier string
PushoverUserKey string
PushoverAPIToken string
// Summarizer / LLM
LLMProvider string
OpenAIAPIKey string
OpenAIBaseURL string
OpenAIModel string
OpenAIMaxTokens int
SummFollowLinks bool
SummLinkTimeout time.Duration
SummLinkMaxBytes int
SummGroupWindow time.Duration
SummMaxLinks int
// Digests
DigestCron string
DigestWindow time.Duration
QuietHours string
NotifyBackfill bool
MentionMinInterval time.Duration
MentionsOnlyChannels []string
MentionsDenyChannels []string
UrgentKeywords []string
// HTTP API
HTTPListen string
HTTPToken string
// Storage
StorePath string
RetentionDays int
// Logging
LogLevel string
}
func FromEnv() Config {
cfg := Config{}
cfg.IRCServer = getEnv("SOJU_HOST", "127.0.0.1")
cfg.IRCPort = getEnvInt("SOJU_PORT", 6697)
cfg.IRCTLS = getEnvBool("SOJU_TLS", true)
cfg.Nick = getEnv("IRC_NICK", "sojuboy")
cfg.Username = getEnv("IRC_USERNAME", cfg.Nick)
cfg.Realname = getEnv("IRC_REALNAME", "sojuboy")
cfg.Password = getEnv("IRC_PASSWORD", "")
cfg.Network = getEnv("SOJU_NETWORK", "")
cfg.Channels = splitCSV(getEnv("CHANNELS", ""))
cfg.Keywords = splitCSV(getEnv("KEYWORDS", cfg.Nick))
cfg.AuthMethod = strings.ToLower(getEnv("SOJU_AUTH", "sasl"))
cfg.Notifier = getEnv("NOTIFIER", "pushover")
cfg.PushoverUserKey = getEnv("PUSHOVER_USER_KEY", "")
cfg.PushoverAPIToken = getEnv("PUSHOVER_API_TOKEN", "")
cfg.LLMProvider = getEnv("LLM_PROVIDER", "openai")
cfg.OpenAIAPIKey = getEnv("OPENAI_API_KEY", "")
cfg.OpenAIBaseURL = getEnv("OPENAI_BASE_URL", "")
cfg.OpenAIModel = getEnv("OPENAI_MODEL", "gpt-5")
cfg.OpenAIMaxTokens = getEnvInt("OPENAI_MAX_TOKENS", 700)
cfg.SummFollowLinks = getEnvBool("SUMM_FOLLOW_LINKS", true)
cfg.SummLinkTimeout = getEnvDuration("SUMM_LINK_TIMEOUT", 6*time.Second)
cfg.SummLinkMaxBytes = getEnvInt("SUMM_LINK_MAX_BYTES", 262144)
cfg.SummGroupWindow = getEnvDuration("SUMM_GROUP_WINDOW", 90*time.Second)
cfg.SummMaxLinks = getEnvInt("SUMM_MAX_LINKS", 5)
cfg.DigestCron = getEnv("DIGEST_CRON", "0 */6 * * *")
cfg.DigestWindow = getEnvDuration("DIGEST_WINDOW", 6*time.Hour)
cfg.QuietHours = getEnv("QUIET_HOURS", "")
cfg.NotifyBackfill = getEnvBool("NOTIFY_BACKFILL", false)
cfg.MentionMinInterval = getEnvDuration("MENTION_MIN_INTERVAL", 30*time.Second)
cfg.MentionsOnlyChannels = splitCSV(getEnv("MENTIONS_ONLY_CHANNELS", ""))
cfg.MentionsDenyChannels = splitCSV(getEnv("MENTIONS_DENY_CHANNELS", ""))
cfg.UrgentKeywords = splitCSV(getEnv("URGENT_KEYWORDS", ""))
cfg.HTTPListen = getEnv("HTTP_LISTEN", ":8080")
cfg.HTTPToken = getEnv("HTTP_TOKEN", "")
cfg.StorePath = getEnv("STORE_PATH", "/data/app.db")
cfg.RetentionDays = getEnvInt("STORE_RETENTION_DAYS", 7)
cfg.LogLevel = getEnv("LOG_LEVEL", "info")
return cfg
}
func (c Config) Redact() Config {
r := c
if r.Password != "" {
r.Password = "***"
}
if r.PushoverUserKey != "" {
r.PushoverUserKey = "***"
}
if r.PushoverAPIToken != "" {
r.PushoverAPIToken = "***"
}
if r.OpenAIAPIKey != "" {
r.OpenAIAPIKey = "***"
}
if r.HTTPToken != "" {
r.HTTPToken = "***"
}
return r
}
func getEnv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func getEnvInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func getEnvBool(key string, def bool) bool {
if v := os.Getenv(key); v != "" {
v = strings.ToLower(strings.TrimSpace(v))
return v == "1" || v == "true" || v == "yes" || v == "y"
}
return def
}
func getEnvDuration(key string, def time.Duration) time.Duration {
if v := os.Getenv(key); v != "" {
d, err := time.ParseDuration(v)
if err == nil {
return d
}
}
return def
}
func splitCSV(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
// ContainsMention checks if text contains the nick or any keyword as a word, case-insensitive.
func ContainsMention(text, nick string, keywords []string) bool {
if nick != "" {
if containsWordFold(text, nick) {
return true
}
}
return ContainsAnyWordFold(text, keywords)
}
// ContainsAnyWordFold returns true if any word in keywords appears as a word in text (case-insensitive).
func ContainsAnyWordFold(text string, keywords []string) bool {
for _, k := range keywords {
if k == "" {
continue
}
if containsWordFold(text, k) {
return true
}
}
return false
}
func containsWordFold(text, word string) bool {
if word == "" {
return false
}
w := strings.ToLower(word)
// Iterate tokens separated by non-nick characters.
var buf []rune
flush := func() bool {
if len(buf) == 0 {
return false
}
t := strings.ToLower(string(buf))
buf = buf[:0]
return t == w
}
isNickChar := func(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' }
for _, r := range text {
if isNickChar(r) {
buf = append(buf, r)
continue
}
if flush() {
return true
}
}
return flush()
}
// WithinQuietHours returns true if now is inside a quiet-hours window like "22:00-07:00" (24h local time).
func WithinQuietHours(now time.Time, window string) bool {
w := strings.TrimSpace(window)
if w == "" {
return false
}
parts := strings.Split(w, "-")
if len(parts) != 2 {
return false
}
parse := func(s string) (int, int, bool) {
p := strings.Split(strings.TrimSpace(s), ":")
if len(p) != 2 {
return 0, 0, false
}
h, err1 := strconv.Atoi(p[0])
m, err2 := strconv.Atoi(p[1])
if err1 != nil || err2 != nil {
return 0, 0, false
}
return h, m, true
}
sh, sm, ok1 := parse(parts[0])
eh, em, ok2 := parse(parts[1])
if !ok1 || !ok2 {
return false
}
start := time.Date(now.Year(), now.Month(), now.Day(), sh, sm, 0, 0, now.Location())
end := time.Date(now.Year(), now.Month(), now.Day(), eh, em, 0, 0, now.Location())
if !end.After(start) {
// window wraps past midnight
return now.After(start) || now.Before(end)
}
return now.After(start) && now.Before(end)
}
// GetEnvInt exports integer env getter for external use
func GetEnvInt(key string, def int) int { return getEnvInt(key, def) }
// IsChannelAllowed checks channel against allow/deny lists. If allow-list is non-empty, only those pass.
func (c Config) IsChannelAllowed(channel string) bool {
ch := strings.ToLower(strings.TrimSpace(channel))
if ch == "" {
return false
}
if len(c.MentionsOnlyChannels) > 0 {
allowed := false
for _, a := range c.MentionsOnlyChannels {
if strings.EqualFold(a, channel) {
allowed = true
break
}
}
if !allowed {
return false
}
}
for _, d := range c.MentionsDenyChannels {
if strings.EqualFold(d, channel) {
return false
}
}
return true
}