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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue