feat: Phase 5 — filtres, actions Ntfy, outgoing webhooks, state file
CI / test (push) Successful in 2m34s
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:
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)}')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user