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 SummMaxGroups int SummarizerTimeout time.Duration // 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", 128000) cfg.SummFollowLinks = getEnvBool("SUMM_FOLLOW_LINKS", true) cfg.SummLinkTimeout = getEnvDuration("SUMM_LINK_TIMEOUT", 20*time.Second) cfg.SummLinkMaxBytes = getEnvInt("SUMM_LINK_MAX_BYTES", 1048576) cfg.SummGroupWindow = getEnvDuration("SUMM_GROUP_WINDOW", 120*time.Second) cfg.SummMaxLinks = getEnvInt("SUMM_MAX_LINKS", 20) cfg.SummMaxGroups = getEnvInt("SUMM_MAX_GROUPS", 0) cfg.SummarizerTimeout = getEnvDuration("SUMM_TIMEOUT", 10*time.Minute) cfg.DigestCron = getEnv("DIGEST_CRON", "0 */6 * * *") cfg.DigestWindow = getEnvDuration("DIGEST_WINDOW", 24*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", 365) 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 }