module main import os import yaml import sources pub struct ServerConfig { pub mut: url string @[json: 'url'] auth_token string @[json: 'auth_token'] listen string @[json: 'listen'] hmac_secret string @[json: 'hmac_secret'] // shared secret for webhook HMAC-SHA256 validation } pub struct DefaultConfig { pub mut: priority int @[json: 'priority'] tags []string @[json: 'tags'] } pub struct RateLimitConfig { pub mut: max_per_minute int @[json: 'max_per_minute'] max_per_source int @[json: 'max_per_source'] } pub struct DedupConfig { pub mut: enabled bool @[json: 'enabled'] ttl_seconds int @[json: 'ttl_seconds'] rate_limit RateLimitConfig @[json: 'rate_limit'] } // v0.5 — Advanced filters pub struct FilterRule { pub mut: match_source string @[json: 'match_source'] // source type: gitea, docker, etc. match_name string @[json: 'match_name'] // source name (supports * glob) match_topic string @[json: 'match_topic'] // topic name action string @[json: 'action'] // drop, set_priority:N, add_tag:X value string @[json: 'value'] // value for set_priority / add_tag } // v0.5 — Outgoing webhook (Slack, Discord, etc.) pub struct OutgoingWebhook { pub mut: url string @[json: 'url'] format string @[json: 'format'] // slack, discord, json } pub struct FilterConfig { pub mut: rules []FilterRule @[json: 'rules'] } pub struct OutgoingConfig { pub mut: webhooks []OutgoingWebhook @[json: 'webhooks'] } pub struct SourcesConfig { pub mut: gitea []sources.GiteaSource @[json: 'gitea'] uptime_kuma []sources.UptimeKumaSource @[json: 'uptime_kuma'] docker []sources.DockerSource @[json: 'docker'] http_poll []sources.HttpPollSource @[json: 'http_poll'] cron []sources.CronSource @[json: 'cron'] generic []sources.GenericSource @[json: 'generic'] plugin []sources.PluginSource @[json: 'plugin'] } pub struct Config { pub mut: server ServerConfig @[json: 'server'] defaults DefaultConfig @[json: 'defaults'] sources SourcesConfig @[json: 'sources'] dedup DedupConfig @[json: 'dedup'] filters FilterConfig @[json: 'filters'] outgoing OutgoingConfig @[json: 'outgoing'] state StateConfig @[json: 'state'] } pub struct StateConfig { pub mut: file string @[json: 'file'] // path to state file for persisting poll_state } pub fn load_config(path string) !Config { if !os.exists(path) { return error('config file not found: ${path}') } mut cfg := yaml.decode_file[Config](path)! // Env override for auth token env_token := os.getenv('NTFY_TOKEN') if env_token != '' { cfg.server.auth_token = env_token } // Env override for HMAC secret env_hmac := os.getenv('NTFY_HMAC_SECRET') if env_hmac != '' { cfg.server.hmac_secret = env_hmac } validate_config(cfg)! return cfg } pub fn validate_config(cfg Config) ! { if cfg.server.url == '' { return error('server.url is required') } if cfg.server.listen == '' { return error('server.listen is required') } if cfg.defaults.priority < 1 || cfg.defaults.priority > 5 { return error('defaults.priority must be between 1 and 5') } // Collect all webhook paths and detect duplicates mut seen_paths := map[string]string{} // path -> source description for src in cfg.sources.gitea { if src.webhook_path == '' { return error('gitea source "${src.name}" missing webhook_path') } if src.topic == '' { return error('gitea source "${src.name}" missing topic') } check_duplicate_path(mut seen_paths, src.webhook_path, 'gitea:${src.name}')! } for src in cfg.sources.uptime_kuma { if src.webhook_path == '' { return error('uptime_kuma source "${src.name}" missing webhook_path') } if src.topic == '' { return error('uptime_kuma source "${src.name}" missing topic') } check_duplicate_path(mut seen_paths, src.webhook_path, 'uptime_kuma:${src.name}')! } for src in cfg.sources.docker { if src.topic == '' { return error('docker source "${src.name}" missing topic') } if src.hosts.len == 0 { return error('docker source "${src.name}" requires at least one host') } } for src in cfg.sources.http_poll { if src.interval <= 0 { return error('http_poll source "${src.name}" interval must be > 0') } for chk in src.checks { if chk.url == '' { return error('http_poll check missing url') } if chk.topic == '' { return error('http_poll check for "${chk.url}" missing topic') } } } for src in cfg.sources.cron { if src.webhook_path == '' { return error('cron source "${src.name}" missing webhook_path') } if src.topic == '' { return error('cron source "${src.name}" missing topic') } check_duplicate_path(mut seen_paths, src.webhook_path, 'cron:${src.name}')! } for src in cfg.sources.generic { if src.webhook_path == '' { return error('generic source "${src.name}" missing webhook_path') } if src.topic == '' { return error('generic source "${src.name}" missing topic') } check_duplicate_path(mut seen_paths, src.webhook_path, 'generic:${src.name}')! } } fn check_duplicate_path(mut seen map[string]string, path string, source_name string) ! { if existing := seen[path] { return error('duplicate webhook path "${path}" used by ${existing} and ${source_name}') } seen[path] = source_name }