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
191 lines
4.5 KiB
V
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
|
|
}
|