module main import os import flag const version = '0.5.0' const author = 'Bruno Charest' fn main() { mut fp := flag.new_flag_parser(os.args) fp.application('ntfy-bridge') fp.version(version) fp.description('Homelab notification hub — aggregates Gitea, Docker, Uptime Kuma → Ntfy') fp.skip_executable() config_path := fp.string('config', `c`, 'ntfy-bridge.yaml', 'Path to config file (YAML)') validate_only := fp.bool('validate', `v`, false, 'Validate config and exit') quiet := fp.bool('quiet', `q`, false, 'Quiet mode — suppress startup banner (for scripts/systemd)') version_only := fp.bool('version', `V`, false, 'Print version and exit') install_svc := fp.bool('install-service', 0, false, 'Install as a system service (auto-detects OS)') uninstall_svc := fp.bool('uninstall-service', 0, false, 'Uninstall the system service') svc_status := fp.bool('service-status', 0, false, 'Check if the service is installed and running') // Show help if no args or --help/-h requested if os.args.len <= 1 || '--help' in os.args || '-h' in os.args { print_help() return } fp.finalize() or { eprintln(err) exit(1) return } if version_only { println('ntfy-bridge ${version}') return } if install_svc { service_install(config_path, quiet) or { eprintln('Error: ${err}') exit(1) return } println('') println('Service installed. Edit ${config_path} to configure sources,') println('then restart the service to apply changes.') return } if uninstall_svc { service_uninstall() or { eprintln('Error: ${err}') exit(1) return } return } if svc_status { service_status() or { eprintln('Error: ${err}') exit(1) return } return } cfg := load_config(config_path) or { eprintln('Failed to load config: ${err}') exit(1) return } if validate_only { println('Config is valid') return } if !quiet { print_header() println('') print_startup_info(cfg) } mut app := new_app(cfg, config_path, quiet) app.run() or { eprintln('Server error: ${err}') exit(1) } } // ── ASCII Art Header ───────────────────────────────────────────────────── const box_w = 60 // inner width between ║ borders fn build_header() string { // Tagline — centered within the box tag_text := 'Homelab Notification Hub · v${version}' tag_left := (box_w - tag_text.len) / 2 tag_right := box_w - tag_left - tag_text.len tag_line := ' '.repeat(tag_left) + tag_text + ' '.repeat(tag_right) // Version line — left-aligned with 5-space indent vinfo := ' Version ${version} │ ${author}' vinfo_pad := box_w - vinfo.len return '╔══════════════════════════════════════════════════════════════╗\n' + '║ __ ____ __ _ __ ║\n' + '║ ____ / /_/ __/_ __ / /_ _____(_)___/ /___ ____ ║\n' + '║ / __ \\/ __/ /_/ / / /_____/ __ \\/ ___/ / __ / __ `/ _ \\ ║\n' + '║ / / / / /_/ __/ /_/ /_____/ /_/ / / / / /_/ / /_/ / __/ ║\n' + '║ /_/ /_/\\__/_/ \\__, / /_.___/_/ /_/\\__,_/\\__, /\\___/ ║\n' + '║ /____/ /____/ ║\n' + '║' + tag_line + '║\n' + '╠══════════════════════════════════════════════════════════════╣\n' + '║' + vinfo + ' '.repeat(vinfo_pad) + '║\n' + '╚══════════════════════════════════════════════════════════════╝' } fn print_header() { println(build_header()) } fn print_help() { println('') println(build_header()) println('') println(' A simple daemon to aggregate notifications from multiple sources') println(' (Gitea, Uptime Kuma, Docker, HTTP health checks, Cron scripts)') println(' and forward them as formatted Ntfy push notifications.') println('') println('Usage:') println(' ntfy-bridge [flags]') println(' ntfy-bridge --config ntfy-bridge.yaml') println('') println('Flags:') println(' -c, --config Path to config file (default: ntfy-bridge.yaml)') println(' -v, --validate Validate config and exit') println(' -q, --quiet Quiet mode — suppress startup banner') println(' -V, --version Print version and exit') println(' -h, --help Show this help message') println('') println('Service management:') println(' --install-service Install as a system service (auto-detects OS)') println(' --uninstall-service Uninstall the system service') println(' --service-status Check if the service is installed and running') println('') println('Config:') println(' Copy ntfy-bridge.example.yaml → ntfy-bridge.yaml and edit it.') println(' Use NTFY_TOKEN and NTFY_HMAC_SECRET env vars for secrets.') println('') println('Docs: https://git.dracodev.net/bruno/ntfy-bridge') println(line_double_r) println(' Version: ${version} | Author: ${author}') println(line_double_r) println('') } fn print_startup_info(cfg Config) { println(line_double_r) println(' Ntfy server: ${cfg.server.url}') println(' Listening on: http://${cfg.server.listen}') println(' Dashboard: http://${normalize_listen(cfg.server.listen)}/') println(' Health check: http://${normalize_listen(cfg.server.listen)}/health') println('') println(' Sources configured:') gitea_count := cfg.sources.gitea.len if gitea_count > 0 { println(' Gitea: ${gitea_count} webhook${if gitea_count > 1 { 's' } else { '' }}') } uk_count := cfg.sources.uptime_kuma.len if uk_count > 0 { println(' Uptime Kuma: ${uk_count} webhook${if uk_count > 1 { 's' } else { '' }}') } docker_count := cfg.sources.docker.len if docker_count > 0 { println(' Docker: ${docker_count} watcher${if docker_count > 1 { 's' } else { '' }}') } poll_count := cfg.sources.http_poll.len if poll_count > 0 { mut total_checks := 0 for p in cfg.sources.http_poll { total_checks += p.checks.len } println(' HTTP Poll: ${poll_count} poller${if poll_count > 1 { 's' } else { '' }} (${total_checks} check${if total_checks > 1 { 's' } else { '' }})') } cron_count := cfg.sources.cron.len if cron_count > 0 { println(' Cron: ${cron_count} webhook${if cron_count > 1 { 's' } else { '' }}') } generic_count := cfg.sources.generic.len if generic_count > 0 { println(' Generic: ${generic_count} webhook${if generic_count > 1 { 's' } else { '' }}') } println('') println(' Webhook URLs:') for src in cfg.sources.gitea { println(' http://${normalize_listen(cfg.server.listen)}${src.webhook_path} → ${src.topic} (Gitea: ${src.name})') } for src in cfg.sources.uptime_kuma { println(' http://${normalize_listen(cfg.server.listen)}${src.webhook_path} → ${src.topic} (Uptime Kuma: ${src.name})') } for src in cfg.sources.cron { println(' http://${normalize_listen(cfg.server.listen)}${src.webhook_path} → ${src.topic} (Cron: ${src.name})') } for src in cfg.sources.generic { println(' http://${normalize_listen(cfg.server.listen)}${src.webhook_path} → ${src.topic} (Generic: ${src.name})') } println('') if cfg.dedup.enabled { println(' Dedup: enabled (TTL: ${cfg.dedup.ttl_seconds}s, rate: ${cfg.dedup.rate_limit.max_per_minute}/min)') } else { println(' Dedup: disabled') } println(line_double_r) println('') } // normalize_listen converts "0.0.0.0:9090" to "localhost:9090" for display URLs, // and leaves specific IPs intact (e.g. "192.168.30.101:9090" stays as-is). fn normalize_listen(listen string) string { if listen.starts_with('0.0.0.0') { return 'localhost' + listen[7..] } if listen.starts_with('127.0.0.1') { return listen // already localhost } return listen } // ── Display Constants ───────────────────────────────────────────────────── const line_double_r = '══════════════════════════════════════════════════════════════'