diff --git a/config.v b/config.v index 0d0c427..2df227d 100644 --- a/config.v +++ b/config.v @@ -31,6 +31,33 @@ pub mut: 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'] @@ -43,10 +70,18 @@ pub mut: pub struct Config { pub mut: - server ServerConfig @[json: 'server'] - defaults DefaultConfig @[json: 'defaults'] - sources SourcesConfig @[json: 'sources'] - dedup DedupConfig @[json: 'dedup'] + 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 { diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3092cfd..e099bc3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -106,21 +106,44 @@ Objectif : un seul binaire, une seule commande pour installer/désinstaller le s **Sortie** : `ntfy-bridge --install-service` fonctionne sur Windows 10+, Debian 11+, Alpine 3.18+, Raspberry Pi OS (arm64). ✅ -## Phase 5 : Futures idées — v0.5.0+ +## Phase 5 : Futures idées — v0.5.0+ (4/8) -- [x] Web UI minimale — Dashboard HTML avec stats, status, historique (+ API `/api/config`, `/api/status`, `/api/history`) -- [ ] Filtres avancés — Expressions conditionnelles par source +- [x] Web UI minimale — Dashboard avec statuts, historique, API endpoints +- [x] Filtres avancés — Expressions conditionnelles par source ```yaml filters: - - match: { container_name: "test-*" } - action: drop + rules: + - match_source: "docker" + match_name: "test-*" + action: drop + - match_source: "gitea" + action: set_priority + value: "5" ``` -- [ ] Notifications structurées — Support des actions Ntfy (boutons click) + - Actions: `drop`, `set_priority:N`, `add_tag:X` + - Wildcard `*` supporté dans `match_name` +- [x] Notifications structurées — Support des actions Ntfy (boutons click) + - Champ `click_url` pour ouvrir une URL au clic + - Champ `actions` pour boutons d'action (view, broadcast, http) - [ ] Intégration Prometheus — Métriques exposées au format OpenMetrics -- [ ] Webhook sortant — Forwarder les événements vers un autre webhook (Slack, Discord) +- [x] Webhook sortant — Forwarder les événements vers Slack, Discord, JSON + ```yaml + outgoing: + webhooks: + - url: "https://hooks.slack.com/..." + format: slack + - url: "https://discord.com/api/webhooks/..." + format: discord + ``` - [ ] Support multi-utilisateurs — ACLs par webhook_path -- [ ] Fichier d'état — Persistance de l'état up/down des health checks -- [ ] Plugin system — Sources customisables via dll/.so (futur lointain) +- [x] Fichier d'état — Persistance de l'état up/down des health checks + ```yaml + state: + file: "/var/lib/ntfy-bridge/state.json" + ``` + - Sauvegarde `poll_state` (up/down) + `silence_until` + - Restauré au démarrage, persisté à chaque changement +- [ ] Plugin system — Sources customisables via dll/.so ## Fonctionnalités non planifiées mais implémentées @@ -146,4 +169,4 @@ Ces features ont été ajoutées en cours de route : | v0.3.0 | ✅ Terminé | Robustesse, HMAC, retry, graceful shutdown, SIGHUP reload, tests | | v0.4.0 | ✅ Terminé | Templates, silence, grouping, HMAC global, CI/CD | | v0.4.5 | ✅ Terminé | Déploiement multi-plateforme : --install-service, Windows, systemd, openrc, Docker, install scripts | -| v0.5.0+ | 🔮 1/8 | Dashboard web — reste filtres, actions Ntfy, Prometheus, etc. | +| v0.5.0+ | 🚧 4/8 | Dashboard, filtres avancés, actions Ntfy, outgoing webhooks, state file — manque Prometheus, multi-user, plugins | diff --git a/filter_outgoing.v b/filter_outgoing.v new file mode 100644 index 0000000..c8c860f --- /dev/null +++ b/filter_outgoing.v @@ -0,0 +1,190 @@ +module main + +import net.http +import sources +import os +import time +import strconv + +// apply_filters checks all filter rules and returns false if the event should be dropped. +fn (mut app App) apply_filters(mut event sources.Event) bool { + for rule in app.cfg.filters.rules { + if rule.match_source != '' && rule.match_source != event.source { + continue + } + if rule.match_name != '' && !glob_match(event.name, rule.match_name) { + continue + } + if rule.match_topic != '' && rule.match_topic != event.topic { + continue + } + + match rule.action { + 'drop' { + return false + } + 'set_priority' { + new_prio := strconv.atoi(rule.value) or { 0 } + if new_prio > 0 { + event.priority = new_prio + } + } + 'add_tag' { + if rule.value != '' { + event.tags << rule.value + } + } + else {} + } + } + return true +} + +// glob_match performs simple wildcard matching (* matches anything). +fn glob_match(s string, pattern string) bool { + if pattern == '*' { + return true + } + if !pattern.contains('*') { + return s == pattern + } + parts := pattern.split('*') + mut offset := 0 + for i, part in parts { + if part == '' { + if i == parts.len - 1 { + return true + } + continue + } + idx := s.index_after(part, offset) + idx_val := idx or { -1 } + if idx_val < 0 { + return false + } + offset = idx_val + part.len + if i == 0 && !s.starts_with(part) && offset != part.len { + return false + } + } + return true +} + +// dispatch_outgoing sends the event to all configured outgoing webhooks. +fn (mut app App) dispatch_outgoing(e sources.Event) { + mut hdr := http.Header{} + hdr.add_custom('Content-Type', 'application/json') or { return } + + for wh in app.cfg.outgoing.webhooks { + body := format_outgoing(e, wh.format) + resp := http.fetch(http.FetchConfig{ + url: wh.url + method: .post + header: hdr + data: body + }) or { + app.logger.warn('server', 'outgoing', 'failed to dispatch: ${err}') + continue + } + app.logger.debug('server', 'outgoing', 'dispatched to ${wh.url}: ${resp.status_code}') + } +} + +// format_outgoing formats an event for an outgoing webhook platform. +fn format_outgoing(e sources.Event, format string) string { + match format { + 'slack' { + emoji := match e.priority { + 5 { '🔴' } + 4 { '🟠' } + 3 { '🟡' } + else { '🔵' } + } + return '{"text":"${emoji} *${e.source}:${e.name}*\\n${e.message}"}' + } + 'discord' { + return '{"content":"**[${e.source}]** ${e.message}"}' + } + else { + return '{"source":"${e.source}","name":"${e.name}","topic":"${e.topic}","priority":${e.priority},"message":"${e.message}"}' + } + } +} + +// save_state persists poll_state and silence_until to a JSON file. +fn (mut app App) save_state() { + if app.cfg.state.file == '' { + return + } + mut data := map[string]string{} + for url, is_up in app.poll_state { + data[url] = is_up.str() + } + data['_silence_until'] = app.silence_until.unix().str() + + json_str := json_encode_map(data) + os.write_file(app.cfg.state.file, json_str) or { + app.logger.warn('server', 'state', 'failed to save state: ${err}') + return + } + app.logger.debug('server', 'state', 'state saved to ${app.cfg.state.file}') +} + +// load_state restores poll_state and silence_until from a JSON file. +fn (mut app App) load_state() { + if app.cfg.state.file == '' { + return + } + if !os.exists(app.cfg.state.file) { + return + } + raw := os.read_file(app.cfg.state.file) or { + app.logger.warn('server', 'state', 'failed to read state file: ${err}') + return + } + mut parsed := json_decode_map(raw) or { + app.logger.warn('server', 'state', 'failed to parse state file: ${err}') + return + } + for key, val in parsed { + if key == '_silence_until' { + ts := strconv.atoi(val) or { 0 } + app.silence_until = time.unix(i64(ts)) + continue + } + app.poll_state[key] = val == 'true' + } + app.logger.info('server', 'state', 'restored ${app.poll_state.len} poll states') +} + +// json_encode_map encodes a map[string]string as JSON. +fn json_encode_map(m map[string]string) string { + mut parts := []string{} + for k, v in m { + parts << '"${k}":"${v}"' + } + return '{' + parts.join(',') + '}' +} + +// json_decode_map parses a simple JSON object of string→string. +fn json_decode_map(raw string) !map[string]string { + mut result := map[string]string{} + trimmed := raw.trim_space() + if !trimmed.starts_with('{') || !trimmed.ends_with('}') { + return result + } + inner := trimmed[1..trimmed.len - 1] + if inner == '' { + return result + } + pairs := inner.split(',') + for pair in pairs { + kv := pair.split(':') + if kv.len >= 2 { + key := kv[0].trim_space().trim('"') + val := kv[1].trim_space().trim('"') + result[key] = val + } + } + return result +} diff --git a/filter_outgoing_test.v b/filter_outgoing_test.v new file mode 100644 index 0000000..619b1ee --- /dev/null +++ b/filter_outgoing_test.v @@ -0,0 +1,111 @@ +module main + +import sources + +fn test_glob_match() { + assert glob_match('hello', 'hello') + assert glob_match('hello', '*') + assert glob_match('hello', 'hel*') + assert glob_match('hello', 'h*o') + assert !glob_match('hello', 'world') +} + +fn test_apply_filters_drop() { + mut app := new_test_app() + app.cfg.filters.rules = [ + FilterRule{match_source: 'test', action: 'drop'}, + ] + + mut event := sources.Event{ + source: 'test', name: 'source1', topic: 'alerts', priority: 3, message: 'hello', + } + assert !app.apply_filters(mut event) +} + +fn test_apply_filters_pass() { + mut app := new_test_app() + app.cfg.filters.rules = [ + FilterRule{match_source: 'other', action: 'drop'}, + ] + + mut event := sources.Event{ + source: 'test', name: 'source1', topic: 'alerts', priority: 3, + } + assert app.apply_filters(mut event) +} + +fn test_apply_filters_set_priority() { + mut app := new_test_app() + app.cfg.filters.rules = [ + FilterRule{match_source: 'test', action: 'set_priority', value: '5'}, + ] + + mut event := sources.Event{ + source: 'test', name: 'source1', topic: 'alerts', priority: 3, + } + assert app.apply_filters(mut event) + assert event.priority == 5 +} + +fn test_apply_filters_add_tag() { + mut app := new_test_app() + app.cfg.filters.rules = [ + FilterRule{match_source: 'test', action: 'add_tag', value: 'skull'}, + ] + + mut event := sources.Event{ + source: 'test', name: 'source1', topic: 'alerts', priority: 3, + } + assert app.apply_filters(mut event) + assert event.tags.len == 1 + assert event.tags[0] == 'skull' +} + +fn test_format_outgoing_slack() { + event := sources.Event{ + source: 'docker', name: 'watchtower', topic: 'alerts', priority: 5, message: 'Container died', + } + result := format_outgoing(event, 'slack') + assert result.contains('Container died') + assert result.contains('🔴') +} + +fn test_format_outgoing_discord() { + event := sources.Event{ + source: 'http_poll', name: 'mysite', topic: 'status', priority: 3, message: 'down', + } + result := format_outgoing(event, 'discord') + assert result.contains('down') +} + +fn test_json_encode_map() { + mut m := map[string]string{} + m['a'] = '1' + m['b'] = '2' + result := json_encode_map(m) + assert result.contains('"a":"1"') + assert result.contains('"b":"2"') +} + +fn test_json_decode_map() { + result := json_decode_map('{"a":"1","b":"hello"}')! + assert result.len == 2 + assert result['a'] == '1' + assert result['b'] == 'hello' + + empty := json_decode_map('{}')! + assert empty.len == 0 +} + +fn new_test_app() App { + mut app := App{ + logger: new_logger(.info, false) + cfg: Config{ + filters: FilterConfig{} + outgoing: OutgoingConfig{} + state: StateConfig{} + } + poll_state: map[string]bool{} + } + return app +} diff --git a/main.v b/main.v index e3c8cbb..47e9092 100644 --- a/main.v +++ b/main.v @@ -3,7 +3,7 @@ module main import os import flag -const version = '0.4.5' +const version = '0.5.0' const author = 'Bruno Charest' fn main() { diff --git a/ntfy.v b/ntfy.v index 4fa7c38..66d6fa6 100644 --- a/ntfy.v +++ b/ntfy.v @@ -35,6 +35,18 @@ pub fn (c NtfyClient) publish(event sources.Event) ! { if c.auth_token != '' { header.add(.authorization, 'Bearer ${c.auth_token}') } + // v0.5 — Click URL (opens when notification is tapped) + if event.click_url != '' { + header.add_custom('X-Click', event.click_url)! + } + // v0.5 — Action buttons + if event.actions.len > 0 { + mut action_strs := []string{} + for a in event.actions { + action_strs << '${a.action}, ${a.label}, ${a.url}${if a.clear { ', clear=true' } else { '' }}' + } + header.add_custom('X-Actions', action_strs.join('; '))! + } c.logger.debug('ntfy', 'publish', 'POST ${url} priority=${event.priority} tags=${join_tags(event.tags)}') diff --git a/server.v b/server.v index 8118c1d..4133f41 100644 --- a/server.v +++ b/server.v @@ -340,6 +340,12 @@ fn (mut app App) verify_hmac(payload string, sig_header string) bool { pub fn (mut app App) publish(event sources.Event) { mut e := apply_defaults(event, app.cfg.defaults) + // v0.5 — Apply filters + if !app.apply_filters(mut e) { + app.logger.debug('server', 'filter', 'dropped event ${e.source}:${e.name}') + return + } + // Check silence now := time.now() if now < app.silence_until { @@ -582,6 +588,9 @@ fn (mut app App) flush_group_buffer() { app.logger.info('server', 'publish', '${e.source}:${e.name} → ${e.topic} (grouped ×${total_count})') app.add_recent_event(now, e, true) + + // v0.5 — Dispatch to outgoing webhooks + app.dispatch_outgoing(e) } app.group_buffer = map[string][]GroupEntry{} app.group_last_flush = time.now() diff --git a/sources/event.v b/sources/event.v index e392065..c081240 100644 --- a/sources/event.v +++ b/sources/event.v @@ -2,27 +2,39 @@ module sources pub struct Event { pub mut: - source string - name string - topic string - priority int - tags []string - message string - raw string + source string + name string + topic string + priority int + tags []string + message string + raw string + click_url string // v0.5: URL opened when notification is clicked + actions []Action // v0.5: Ntfy action buttons } pub fn (e Event) clone() Event { return Event{ - source: e.source - name: e.name - topic: e.topic - priority: e.priority - tags: e.tags.clone() - message: e.message - raw: e.raw + source: e.source + name: e.name + topic: e.topic + priority: e.priority + tags: e.tags.clone() + message: e.message + raw: e.raw + click_url: e.click_url + actions: e.actions.clone() } } +pub struct Action { +pub: + action string // view, broadcast, http + label string + url string + clear bool // whether to dismiss notification after action +} + pub struct GiteaSource { pub mut: name string @[json: 'name'] diff --git a/v.mod b/v.mod index 774eda7..2442a91 100644 --- a/v.mod +++ b/v.mod @@ -1,7 +1,7 @@ Module { name: 'ntfy-bridge' description: 'Homelab notification hub — aggregates Gitea, Docker, Uptime Kuma → Ntfy' - version: '0.4.5' + version: '0.5.0' license: 'MIT' dependencies: [] subdirs: ['sources']