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
2025-08-15 18:06:28 -05:00
|
|
|
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
|
2025-08-15 20:41:31 -05:00
|
|
|
LLMProvider string
|
|
|
|
|
OpenAIAPIKey string
|
|
|
|
|
OpenAIBaseURL string
|
|
|
|
|
OpenAIModel string
|
|
|
|
|
OpenAIMaxTokens int
|
|
|
|
|
SummFollowLinks bool
|
|
|
|
|
SummLinkTimeout time.Duration
|
|
|
|
|
SummLinkMaxBytes int
|
|
|
|
|
SummGroupWindow time.Duration
|
|
|
|
|
SummMaxLinks int
|
|
|
|
|
SummMaxGroups int
|
|
|
|
|
SummarizerTimeout time.Duration
|
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
2025-08-15 18:06:28 -05:00
|
|
|
|
|
|
|
|
// 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)
|
2025-08-15 20:41:31 -05:00
|
|
|
cfg.SummMaxGroups = getEnvInt("SUMM_MAX_GROUPS", 0)
|
|
|
|
|
cfg.SummarizerTimeout = getEnvDuration("SUMM_TIMEOUT", 5*time.Minute)
|
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
2025-08-15 18:06:28 -05:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|