diff --git a/README.md b/README.md index bf2bdb4..969cda4 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ When a rule fires, the monitor calls `DoCommand` on the notifier with the payloa Notifications are **edge-triggered**: a message is sent when a rule transitions from not-triggered to triggered. While the rule stays triggered no further message is sent, unless `cooldown_seconds` is set, in which case the message repeats at most once per cooldown window. When the reading clears the threshold and crosses it again, a new notification is sent. +**Reacting on resolve.** When a rule's `resolve_reaction` is set and that rule clears (its reading returns to the non-triggered side of the threshold), the monitor adds that emoji as a reaction to the rule's alert message, giving operators an at-a-glance "handled" marker without a follow-up message. To do this it calls `DoCommand({"command": "react", "name": , ...})` on the notifier, passing back the metadata the notifier returned when it sent the message, so it requires a notifier that supports the `react` command. When `resolve_reaction` is unset, no reaction is added. The reaction is best-effort: a failure is logged and never affects monitoring. + ### Configuration ```json @@ -92,7 +94,8 @@ Notifications are **edge-triggered**: a message is sent when a rule transitions "key": "", "operator": "", "threshold": , - "message": "" + "message": "", + "resolve_reaction": "" } ] } @@ -114,6 +117,7 @@ Each entry in `rules`: | `operator` | string | **Yes** | Comparison applied between the reading and `threshold`. One of `>`, `>=`, `<`, `<=`, `==`, `!=` (aliases: `gt`, `gte`, `lt`, `lte`, `eq`, `ne`). | | `threshold` | number | **Yes** | The value the reading is compared against. | | `message` | string | No | Notification template. Supports placeholders `{key}`, `{value}`, `{threshold}`, `{operator}`. If omitted, a message like `temperature is 95 (> 90)` is used. | +| `resolve_reaction` | string | No | Emoji name (e.g. `white_check_mark`) added as a reaction to this rule's alert message when the rule clears. When unset, no reaction is added. Requires a notifier that supports the `react` command. | ### Example Configuration @@ -128,7 +132,8 @@ Each entry in `rules`: "key": "temperature", "operator": ">", "threshold": 90, - "message": "High temperature alert: {key} is {value} (limit {threshold})" + "message": "High temperature alert: {key} is {value} (limit {threshold})", + "resolve_reaction": "white_check_mark" }, { "key": "humidity", diff --git a/resources/sensormonitor/sensormonitor.go b/resources/sensormonitor/sensormonitor.go index e91f601..c21dc51 100644 --- a/resources/sensormonitor/sensormonitor.go +++ b/resources/sensormonitor/sensormonitor.go @@ -46,6 +46,11 @@ type Rule struct { // Message is an optional notification template. It supports the placeholders // {key}, {value}, {threshold} and {operator}. If empty, a message is generated. Message string `json:"message,omitempty"` + // ResolveReaction is the emoji name (e.g. "white_check_mark") to add as a + // reaction to this rule's alert message when the rule reading returns to + // the non-triggered side of the threshold. When unset, no reaction is added. + // Requires the notifier to support a {"command": "react", ...} DoCommand + ResolveReaction string `json:"resolve_reaction,omitempty"` } // Config is the configuration for the sensor-monitor model. @@ -92,6 +97,9 @@ type ruleState struct { triggered bool lastNotified time.Time lastValue float64 + // sentMessage is opaque metadata about the most recently sent message, kept + // as-is for later use. nil when no alert is outstanding. + sentMessage map[string]interface{} } type sensorMonitor struct { @@ -225,6 +233,7 @@ func (m *sensorMonitor) poll(ctx context.Context) { fired := cmp(value, rule.Threshold) notify := false + var reactTo map[string]interface{} m.mu.Lock() st := &m.ruleStates[i] st.lastValue = value @@ -237,28 +246,67 @@ func (m *sensorMonitor) poll(ctx context.Context) { st.lastNotified = now notify = true case !fired: + // Edge from triggered to cleared: the rule just resolved. When a resolve + // reaction is configured and we captured the alert message, hand it back + // to react, then clear it so we react exactly once. + if st.triggered && st.sentMessage != nil && rule.ResolveReaction != "" { + reactTo = st.sentMessage + } st.triggered = false + st.sentMessage = nil } m.mu.Unlock() if notify { - m.sendNotification(ctx, rule, value) + resp := m.sendNotification(ctx, rule, value) + // Retain the notifier's response so the message can be reacted to on + // resolve. A re-notify (cooldown) overwrites it, so we react to the most + // recent alert. nil (send failure) leaves nothing to react to. + if resp != nil { + m.mu.Lock() + m.ruleStates[i].sentMessage = resp + m.mu.Unlock() + } + } + if reactTo != nil { + m.sendResolveReaction(ctx, rule, reactTo) } } } -// sendNotification renders the rule's message and calls DoCommand on the notifier. -func (m *sensorMonitor) sendNotification(ctx context.Context, rule Rule, value float64) { +// sendNotification renders the rule's message and sends it via the notifier, +// returning the notifier's response (nil on failure). +func (m *sensorMonitor) sendNotification(ctx context.Context, rule Rule, value float64) map[string]interface{} { msg := renderMessage(rule, value) cmd := map[string]interface{}{ "command": "send", "text": msg, } - if _, err := m.notifierDep.DoCommand(ctx, cmd); err != nil { + resp, err := m.notifierDep.DoCommand(ctx, cmd) + if err != nil { m.logger.Errorf("failed to notify via %q: %v", m.cfg.Notifier, err) - return + return nil } m.logger.Infof("notification sent: %s", msg) + return resp +} + +// sendResolveReaction reacts to the rule's earlier alert message with the +// configured emoji once the rule has cleared, passing the stored sentMessage +// metadata back through unchanged. Only called when resolve_reaction is set. +// Best-effort: a failure is logged and never affects monitoring. +func (m *sensorMonitor) sendResolveReaction(ctx context.Context, rule Rule, sentMessage map[string]interface{}) { + cmd := make(map[string]interface{}, len(sentMessage)+2) + for k, v := range sentMessage { + cmd[k] = v + } + cmd["command"] = "react" + cmd["name"] = rule.ResolveReaction + if _, err := m.notifierDep.DoCommand(ctx, cmd); err != nil { + m.logger.Warnf("failed to react to resolved alert for %q via %q: %v", rule.Key, m.cfg.Notifier, err) + return + } + m.logger.Infof("reacted :%s: to resolved alert for %q", rule.ResolveReaction, rule.Key) } // Readings returns the most recent sensor readings plus, per rule, a diff --git a/resources/sensormonitor/sensormonitor_test.go b/resources/sensormonitor/sensormonitor_test.go index d4d279f..37c6a35 100644 --- a/resources/sensormonitor/sensormonitor_test.go +++ b/resources/sensormonitor/sensormonitor_test.go @@ -4,6 +4,7 @@ import ( "context" "sync" "testing" + "time" sensor "go.viam.com/rdk/components/sensor" "go.viam.com/rdk/logging" @@ -45,28 +46,51 @@ func (f *fakeSensor) Readings(ctx context.Context, extra map[string]interface{}) func (f *fakeSensor) Close(context.Context) error { return nil } -// fakeNotifier is a generic.Service that records the DoCommands it receives. +// fakeNotifier is a generic.Service that records the DoCommands it receives. A +// "send" returns sendResp, an opaque map the monitor stores and later passes +// back; the contents are arbitrary so the tests don't assume any notifier shape. type fakeNotifier struct { resource.Named resource.AlwaysRebuild mu sync.Mutex received []map[string]interface{} + sendResp map[string]interface{} } func newFakeNotifier(name string) *fakeNotifier { - return &fakeNotifier{Named: generic.Named(name).AsNamed()} + return &fakeNotifier{Named: generic.Named(name).AsNamed(), sendResp: map[string]interface{}{"id": "m1"}} } func (f *fakeNotifier) DoCommand(ctx context.Context, cmd map[string]interface{}) (map[string]interface{}, error) { f.mu.Lock() defer f.mu.Unlock() f.received = append(f.received, cmd) - return map[string]interface{}{}, nil + if cmd["command"] == "send" { + resp := map[string]interface{}{} + for k, v := range f.sendResp { + resp[k] = v + } + return resp, nil + } + return map[string]interface{}{"ok": true}, nil } func (f *fakeNotifier) Close(context.Context) error { return nil } +// reactions returns the "react" DoCommands received so far. +func (f *fakeNotifier) reactions() []map[string]interface{} { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]map[string]interface{}, 0) + for _, cmd := range f.received { + if cmd["command"] == "react" { + out = append(out, cmd) + } + } + return out +} + // texts returns the notification message strings received so far. func (f *fakeNotifier) texts() []string { f.mu.Lock() @@ -277,6 +301,101 @@ func TestEdgeTriggeredNotifies(t *testing.T) { } } +func TestReactsOnResolve(t *testing.T) { + ctx := context.Background() + src := newFakeSensor("src") + notifier := newFakeNotifier("notify") + m := newTestMonitor(t, &Config{ + Sensor: "src", + Notifier: "notify", + Rules: []Rule{{Key: "usage", Operator: ">=", Threshold: 15, ResolveReaction: "white_check_mark"}}, + }, src, notifier) + + // Cross the threshold: one alert, no reaction yet. + src.set(map[string]interface{}{"usage": 15.0}) + m.poll(ctx) + if got := len(notifier.reactions()); got != 0 { + t.Fatalf("expected no reaction while triggered, got %d", got) + } + + // Still triggered: no reaction. + m.poll(ctx) + if got := len(notifier.reactions()); got != 0 { + t.Fatalf("expected no reaction while still triggered, got %d", got) + } + + // Reading returns below threshold: react exactly once, carrying the configured + // emoji plus the opaque metadata from the send (passed back unchanged). + src.set(map[string]interface{}{"usage": 0.0}) + m.poll(ctx) + reactions := notifier.reactions() + if len(reactions) != 1 { + t.Fatalf("expected 1 reaction on resolve, got %d: %v", len(reactions), reactions) + } + r := reactions[0] + if r["name"] != "white_check_mark" || r["id"] != "m1" { + t.Fatalf("reaction payload not as expected: %v", r) + } + + // Stays resolved: no further reactions (metadata cleared). + m.poll(ctx) + if got := len(notifier.reactions()); got != 1 { + t.Fatalf("expected no repeat reaction, got %d", got) + } +} + +func TestNoReactionWhenUnconfigured(t *testing.T) { + ctx := context.Background() + src := newFakeSensor("src") + notifier := newFakeNotifier("notify") + // resolve_reaction unset: alerts still fire, but nothing is reacted to. + m := newTestMonitor(t, &Config{ + Sensor: "src", + Notifier: "notify", + Rules: []Rule{{Key: "usage", Operator: ">=", Threshold: 15}}, + }, src, notifier) + + src.set(map[string]interface{}{"usage": 20.0}) + m.poll(ctx) + src.set(map[string]interface{}{"usage": 0.0}) + m.poll(ctx) + + if got := len(notifier.texts()); got != 1 { + t.Fatalf("expected the alert to still be sent, got %d", got) + } + if got := len(notifier.reactions()); got != 0 { + t.Fatalf("expected no reaction when resolve_reaction is unset, got %d", got) + } +} + +func TestReactsToLatestMessageAfterCooldownRenotify(t *testing.T) { + ctx := context.Background() + src := newFakeSensor("src") + notifier := newFakeNotifier("notify") + m := newTestMonitor(t, &Config{ + Sensor: "src", + Notifier: "notify", + CooldownSec: 60, // long cooldown; we backdate lastNotified to force a re-notify + Rules: []Rule{{Key: "usage", Operator: ">=", Threshold: 15, ResolveReaction: "white_check_mark"}}, + }, src, notifier) + + src.set(map[string]interface{}{"usage": 20.0}) + m.poll(ctx) // first alert: metadata id m1 + + // Backdate so the cooldown has deterministically elapsed, then re-notify. + m.ruleStates[0].lastNotified = m.ruleStates[0].lastNotified.Add(-time.Hour) + notifier.sendResp = map[string]interface{}{"id": "m2"} // the re-notify returns new metadata + m.poll(ctx) // cooldown elapsed: re-notify, id m2 + + src.set(map[string]interface{}{"usage": 0.0}) + m.poll(ctx) // resolve: should react to the latest message + + reactions := notifier.reactions() + if len(reactions) != 1 || reactions[0]["id"] != "m2" { + t.Fatalf("expected reaction to latest message m2, got %v", reactions) + } +} + func TestReadingsExposesTriggerState(t *testing.T) { ctx := context.Background() src := newFakeSensor("src")