Files
ntfy-bridge/filter_outgoing.v
T
bruno 4150c56054
CI / test (push) Successful in 2m34s
feat: Phase 5 — filtres, actions Ntfy, outgoing webhooks, state file
- 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
2026-08-03 17:41:53 -04:00

191 lines
4.5 KiB
V

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
}