Skip to content

Commit c684d42

Browse files
committed
fix(telegram): send notifications for CONFIG_DETECTION_LABEL only
1 parent 404f547 commit c684d42

5 files changed

Lines changed: 410 additions & 10 deletions

File tree

docs/src/pages/components-explorer/components/telegram/config.json

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,32 @@
2525
"type": "map",
2626
"value": [
2727
{
28+
"type": "map",
29+
"value": [
30+
{
31+
"type": "string",
32+
"name": {
33+
"type": "deprecated",
34+
"name": "detection_label",
35+
"value": "Config option 'detection_label' is deprecated and will be removed in a future version."
36+
},
37+
"description": "Deprecated. Use detection_labels instead.",
38+
"deprecated": true,
39+
"default": null
40+
},
41+
{
42+
"type": "list",
43+
"values": [
44+
{
45+
"type": "string"
46+
}
47+
],
48+
"name": "detection_labels",
49+
"description": "Labels of objects to send notifications for.",
50+
"optional": true,
51+
"default": null
52+
}
53+
],
2854
"name": {
2955
"type": "CAMERA_IDENTIFIER"
3056
},
@@ -52,10 +78,28 @@
5278
},
5379
{
5480
"type": "string",
55-
"name": "detection_label",
56-
"description": "Label of the object to send notifications for.",
81+
"name": {
82+
"type": "deprecated",
83+
"name": "detection_label",
84+
"value": "Config option 'detection_label' is deprecated and will be removed in a future version."
85+
},
86+
"description": "Deprecated. Use detection_labels instead.",
87+
"deprecated": true,
88+
"default": null
89+
},
90+
{
91+
"type": "list",
92+
"values": [
93+
{
94+
"type": "string"
95+
}
96+
],
97+
"name": "detection_labels",
98+
"description": "Labels of objects to send notifications for.",
5799
"optional": true,
58-
"default": "person"
100+
"default": [
101+
"person"
102+
]
59103
},
60104
{
61105
"type": "boolean",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Tests for telegram component."""
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
"""Tests for TelegramEventNotifier detection_label filtering."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import datetime
7+
from typing import TYPE_CHECKING, Any
8+
from unittest.mock import AsyncMock, MagicMock, patch
9+
10+
import pytest
11+
12+
from viseron.components.storage.models import TriggerTypes
13+
from viseron.components.telegram import TelegramEventNotifier
14+
from viseron.components.telegram.const import (
15+
CONFIG_CAMERAS,
16+
CONFIG_DETECTION_LABEL,
17+
CONFIG_DETECTION_LABELS,
18+
CONFIG_SEND_MESSAGE,
19+
CONFIG_SEND_THUMBNAIL,
20+
CONFIG_SEND_VIDEO,
21+
CONFIG_TELEGRAM_BOT_TOKEN,
22+
CONFIG_TELEGRAM_CHAT_IDS,
23+
CONFIG_TELEGRAM_LOG_IDS,
24+
CONFIG_TELEGRAM_USER_IDS,
25+
DEFAULT_DETECTION_LABELS,
26+
DEFAULT_SEND_MESSAGE,
27+
DEFAULT_TELEGRAM_LOG_IDS,
28+
DEFAULT_TELEGRAM_USER_IDS,
29+
)
30+
from viseron.domains.camera.recorder import EventRecorderData, Recording
31+
from viseron.domains.object_detector.detected_object import DetectedObject
32+
from viseron.events import Event
33+
from viseron.helpers.validators import UNDEFINED
34+
35+
if TYPE_CHECKING:
36+
from tests.conftest import MockViseron
37+
38+
CAMERA_ID = "test_camera"
39+
40+
41+
def make_config(
42+
detection_labels: list[str] | None = None,
43+
cameras: dict | None = None,
44+
) -> dict:
45+
"""Build a minimal TelegramEventNotifier config."""
46+
return {
47+
CONFIG_TELEGRAM_BOT_TOKEN: "token",
48+
CONFIG_TELEGRAM_CHAT_IDS: [123],
49+
CONFIG_TELEGRAM_USER_IDS: DEFAULT_TELEGRAM_USER_IDS,
50+
CONFIG_TELEGRAM_LOG_IDS: DEFAULT_TELEGRAM_LOG_IDS,
51+
CONFIG_DETECTION_LABELS: detection_labels
52+
if detection_labels is not None
53+
else DEFAULT_DETECTION_LABELS,
54+
CONFIG_SEND_THUMBNAIL: False,
55+
CONFIG_SEND_VIDEO: False,
56+
CONFIG_SEND_MESSAGE: DEFAULT_SEND_MESSAGE,
57+
CONFIG_CAMERAS: cameras if cameras is not None else {CAMERA_ID: {}},
58+
}
59+
60+
61+
def make_detected_object(label: str) -> DetectedObject:
62+
"""Build a minimal DetectedObject with the given label."""
63+
return DetectedObject(
64+
label=label,
65+
confidence=0.9,
66+
x1=0.1,
67+
y1=0.1,
68+
x2=0.5,
69+
y2=0.5,
70+
frame_res=(1920, 1080),
71+
)
72+
73+
74+
def make_recording(objects: list[DetectedObject]) -> Recording:
75+
"""Build a minimal Recording with the given detected objects."""
76+
return Recording(
77+
id=1,
78+
start_time=datetime.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc),
79+
start_timestamp=0.0,
80+
end_time=datetime.datetime(2025, 1, 1, 0, 0, 10, tzinfo=datetime.timezone.utc),
81+
end_timestamp=10.0,
82+
date="2025-01-01",
83+
thumbnail=None,
84+
thumbnail_path=None,
85+
clip_path=None,
86+
objects=objects,
87+
trigger_type=TriggerTypes.OBJECT,
88+
)
89+
90+
91+
def make_event(recording: Recording) -> Event[EventRecorderData]:
92+
"""Build an Event wrapping the given recording."""
93+
camera_mock = MagicMock()
94+
camera_mock.identifier = CAMERA_ID
95+
event_data = EventRecorderData(camera=camera_mock, recording=recording)
96+
return Event(name="test", data=event_data, timestamp=0.0)
97+
98+
99+
@pytest.fixture
100+
def notifier(vis: MockViseron) -> Any:
101+
"""Return a TelegramEventNotifier with mocked internals."""
102+
config = make_config()
103+
with (
104+
patch("viseron.components.telegram.Bot"),
105+
patch("viseron.components.telegram.Application"),
106+
):
107+
n = TelegramEventNotifier(vis, config)
108+
# Replace bot with an AsyncMock so send_* calls can be awaited
109+
n._bot = MagicMock()
110+
n._bot.send_message = AsyncMock()
111+
n._bot.send_video = AsyncMock()
112+
n._bot.send_photo = AsyncMock()
113+
return n
114+
115+
116+
def run_send_notifications(
117+
notifier: TelegramEventNotifier, event: Event[EventRecorderData]
118+
):
119+
"""Run _send_notifications synchronously via a fresh event loop."""
120+
loop = asyncio.new_event_loop()
121+
try:
122+
loop.run_until_complete(notifier._send_notifications(event))
123+
finally:
124+
loop.close()
125+
126+
127+
class TestDetectionLabelFiltering:
128+
"""Tests for detection_label filtering in _send_notifications."""
129+
130+
def test_sends_when_label_matches_global(self, notifier: Any):
131+
"""Notification sent when detected object matches global detection_labels."""
132+
recording = make_recording([make_detected_object("person")])
133+
event = make_event(recording)
134+
notifier._config[CONFIG_SEND_MESSAGE] = True
135+
136+
run_send_notifications(notifier, event)
137+
138+
notifier._bot.send_message.assert_awaited_once()
139+
140+
def test_skips_when_label_does_not_match_global(self, notifier: Any):
141+
"""No notification sent when detected object does not match detection_labels."""
142+
recording = make_recording([make_detected_object("car")])
143+
event = make_event(recording)
144+
notifier._config[CONFIG_DETECTION_LABELS] = ["person"]
145+
notifier._config[CONFIG_SEND_MESSAGE] = True
146+
147+
run_send_notifications(notifier, event)
148+
149+
notifier._bot.send_message.assert_not_awaited()
150+
notifier._bot.send_video.assert_not_awaited()
151+
notifier._bot.send_photo.assert_not_awaited()
152+
153+
def test_sends_for_manual_recording_with_no_objects(self, notifier: Any):
154+
"""Notification always sent when recording has no detected objects."""
155+
recording = make_recording([])
156+
recording.trigger_type = TriggerTypes.MANUAL
157+
event = make_event(recording)
158+
notifier._config[CONFIG_DETECTION_LABELS] = ["person"]
159+
notifier._config[CONFIG_SEND_MESSAGE] = True
160+
161+
run_send_notifications(notifier, event)
162+
163+
notifier._bot.send_message.assert_awaited_once()
164+
165+
def test_comma_separated_labels_match(self, notifier: Any):
166+
"""Notification sent when comma-separated detection_label matches."""
167+
recording = make_recording([make_detected_object("cat")])
168+
event = make_event(recording)
169+
notifier._config[CONFIG_DETECTION_LABELS] = None
170+
notifier._config[CONFIG_DETECTION_LABEL] = "person,cat"
171+
notifier._config[CONFIG_SEND_MESSAGE] = True
172+
173+
run_send_notifications(notifier, event)
174+
175+
notifier._bot.send_message.assert_awaited_once()
176+
177+
def test_comma_separated_labels_no_match(self, notifier: Any):
178+
"""No notification when comma-separated detection_label has no match."""
179+
recording = make_recording([make_detected_object("car")])
180+
event = make_event(recording)
181+
notifier._config[CONFIG_DETECTION_LABELS] = None
182+
notifier._config[CONFIG_DETECTION_LABEL] = "person,cat"
183+
notifier._config[CONFIG_SEND_MESSAGE] = True
184+
185+
run_send_notifications(notifier, event)
186+
187+
notifier._bot.send_message.assert_not_awaited()
188+
189+
def test_camera_level_detection_label_overrides_global(self, vis: MockViseron):
190+
"""Camera-level detection_labels overrides the global setting."""
191+
cameras = {CAMERA_ID: {CONFIG_DETECTION_LABELS: ["car"]}}
192+
config = make_config(detection_labels=["person"], cameras=cameras)
193+
config[CONFIG_SEND_MESSAGE] = True
194+
with (
195+
patch("viseron.components.telegram.Bot"),
196+
patch("viseron.components.telegram.Application"),
197+
):
198+
notifier: Any = TelegramEventNotifier(vis, config)
199+
notifier._bot = MagicMock()
200+
notifier._bot.send_message = AsyncMock()
201+
notifier._bot.send_video = AsyncMock()
202+
notifier._bot.send_photo = AsyncMock()
203+
204+
# "car" matches camera-level override → should send
205+
recording = make_recording([make_detected_object("car")])
206+
run_send_notifications(notifier, make_event(recording))
207+
notifier._bot.send_message.assert_awaited_once()
208+
209+
notifier._bot.send_message.reset_mock()
210+
211+
# "person" matches global but NOT camera-level → should NOT send
212+
recording2 = make_recording([make_detected_object("person")])
213+
run_send_notifications(notifier, make_event(recording2))
214+
notifier._bot.send_message.assert_not_awaited()
215+
216+
def test_detection_labels_list(self, notifier: Any):
217+
"""Notification sent when detection_labels is a list with matching entry."""
218+
recording = make_recording([make_detected_object("cat")])
219+
event = make_event(recording)
220+
notifier._config[CONFIG_DETECTION_LABELS] = ["person", "cat"]
221+
notifier._config[CONFIG_SEND_MESSAGE] = True
222+
223+
run_send_notifications(notifier, event)
224+
225+
notifier._bot.send_message.assert_awaited_once()
226+
227+
def test_detection_labels_list_no_match(self, notifier: Any):
228+
"""No notification when detection_labels list has no matching entry."""
229+
recording = make_recording([make_detected_object("car")])
230+
event = make_event(recording)
231+
notifier._config[CONFIG_DETECTION_LABELS] = ["person", "cat"]
232+
notifier._config[CONFIG_SEND_MESSAGE] = True
233+
234+
run_send_notifications(notifier, event)
235+
236+
notifier._bot.send_message.assert_not_awaited()
237+
238+
239+
class TestGetEffectiveDetectionLabels:
240+
"""Tests for _get_effective_detection_labels."""
241+
242+
def test_undefined_camera_labels_falls_through_to_global(self, vis: MockViseron):
243+
"""When camera detection_labels is UNDEFINED, global labels used."""
244+
cameras = {CAMERA_ID: {CONFIG_DETECTION_LABELS: UNDEFINED()}}
245+
config = make_config(detection_labels=["car"], cameras=cameras)
246+
with (
247+
patch("viseron.components.telegram.Bot"),
248+
patch("viseron.components.telegram.Application"),
249+
):
250+
notifier: Any = TelegramEventNotifier(vis, config)
251+
252+
labels = notifier._get_effective_detection_labels(CAMERA_ID)
253+
254+
assert labels == ["car"]
255+
256+
def test_undefined_camera_labels_does_not_raise_on_notification(
257+
self, vis: MockViseron
258+
):
259+
"""Detection with UNDEFINED camera labels does not raise TypeError."""
260+
cameras = {CAMERA_ID: {CONFIG_DETECTION_LABELS: UNDEFINED()}}
261+
config = make_config(detection_labels=["person"], cameras=cameras)
262+
config[CONFIG_SEND_MESSAGE] = True
263+
with (
264+
patch("viseron.components.telegram.Bot"),
265+
patch("viseron.components.telegram.Application"),
266+
):
267+
notifier: Any = TelegramEventNotifier(vis, config)
268+
notifier._bot = MagicMock()
269+
notifier._bot.send_message = AsyncMock()
270+
271+
recording = make_recording([make_detected_object("person")])
272+
event = make_event(recording)
273+
run_send_notifications(notifier, event)
274+
275+
notifier._bot.send_message.assert_awaited_once()
276+
277+
def test_global_comma_deprecated_label_strips_empty_entries(self, notifier: Any):
278+
"""Global detection_label with surrounding commas strips empty entries."""
279+
notifier._config[CONFIG_DETECTION_LABELS] = None
280+
notifier._config[CONFIG_DETECTION_LABEL] = ",person,"
281+
282+
labels = notifier._get_effective_detection_labels(CAMERA_ID)
283+
284+
assert "" not in labels
285+
assert labels == ["person"]
286+
287+
def test_camera_comma_deprecated_label_strips_empty_entries(self, vis: MockViseron):
288+
"""Camera detection_label with surrounding commas strips empty entries."""
289+
cameras = {CAMERA_ID: {CONFIG_DETECTION_LABEL: ",car,"}}
290+
config = make_config(cameras=cameras)
291+
with (
292+
patch("viseron.components.telegram.Bot"),
293+
patch("viseron.components.telegram.Application"),
294+
):
295+
notifier: Any = TelegramEventNotifier(vis, config)
296+
297+
labels = notifier._get_effective_detection_labels(CAMERA_ID)
298+
299+
assert "" not in labels
300+
assert labels == ["car"]

0 commit comments

Comments
 (0)