Files
ntfy-bridge/main.v
T
bruno 72b797704f
CI / test (push) Failing after 24s
feat: phase 3 + phase 4 complete — v0.4.0
Phase 3:
- Config reload via SIGHUP (handle_signals loop: .hup reloads config, .int/.term shutdown)
- Hot reload: cfg, webhook_routes, ntfy client, dedup updated without restart

Phase 4:
- Silence rules: POST /api/silence?duration=30m, GET /api/silence, DELETE /api/silence
- Grouping: buffer 10s, events grouped with (×N in 10s) suffix
- CI/CD: .gitea/workflows/ci.yml (build + test on push/PR)

Docs:
- ROADMAP.md: phases 3 & 4 marked DONE, version table updated
- ARCHITECTURE.md: synced with actual code

Version: 0.1.0 → 0.4.0 (main.v, v.mod)
2026-08-03 14:35:59 -04:00

203 lines
7.3 KiB
V

module main
import os
import flag
const version = '0.4.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')
// 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
}
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> 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('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 = '══════════════════════════════════════════════════════════════'