feat: Phase 5 — filtres, actions Ntfy, outgoing webhooks, state file
CI / test (push) Successful in 2m34s

- Filtres avancés: drop, set_priority, add_tag avec wildcard * (filter_outgoing.v)
- Notifications structurées: click_url + actions (ntfy.v + sources/event.v)
- Webhook sortant: Slack, Discord, JSON (filter_outgoing.v)
- Fichier d'état: persistance poll_state + silence_until (filter_outgoing.v)
- Config: sections filters, outgoing, state (config.v)
- Tests: 9 nouveaux tests unitaires (filter_outgoing_test.v)
- Docs: ROADMAP Phase 5 4/8, version 0.5.0
This commit is contained in:
2026-08-03 17:41:53 -04:00
parent 5d0db9478c
commit 4150c56054
9 changed files with 422 additions and 30 deletions
+35
View File
@@ -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']
@@ -47,6 +74,14 @@ pub mut:
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 {
+32 -9
View File
@@ -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-*" }
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 |
+190
View File
@@ -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
}
+111
View File
@@ -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
}
+1 -1
View File
@@ -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() {
+12
View File
@@ -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)}')
+9
View File
@@ -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()
+12
View File
@@ -9,6 +9,8 @@ pub mut:
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 {
@@ -20,9 +22,19 @@ pub fn (e Event) clone() Event {
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']
+1 -1
View File
@@ -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']