Skip to content

Commit d8ed568

Browse files
committed
feat: notify camera recordings in Google Chat
1 parent 7a5aaaf commit d8ed568

6 files changed

Lines changed: 220 additions & 13 deletions

File tree

cloud/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,9 @@ The service must receive camera values from Secret Manager. Do not put camera
1717
credentials or an ADC file in the image. The recorder has a default hard limit
1818
of 30 GB of uploaded video per calendar month, leaving headroom in the 50 GB 4G
1919
plan for interactive viewing and protocol overhead.
20+
21+
`GCHAT_WEBHOOK_URL` is also injected from Secret Manager. The poller sends one
22+
alert when an external camera session starts the VM. The recorder sends a final
23+
alert that always states whether an error occurred, whether video was uploaded,
24+
the elapsed time, and a private Cloud Console link to the session's one-minute
25+
segments. Notifications are best-effort and never block recording.

cloud/gchat_notifier.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Best-effort Google Chat notifications without leaking webhook details."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
import os
7+
8+
import requests
9+
10+
11+
LOG = logging.getLogger(__name__)
12+
13+
14+
def format_duration(seconds: float) -> str:
15+
"""Return a compact French duration suitable for an alert."""
16+
total_seconds = max(0, round(seconds))
17+
hours, remainder = divmod(total_seconds, 3600)
18+
minutes, seconds = divmod(remainder, 60)
19+
parts: list[str] = []
20+
if hours:
21+
parts.append(f"{hours} h")
22+
if minutes:
23+
parts.append(f"{minutes} min")
24+
if seconds or not parts:
25+
parts.append(f"{seconds} s")
26+
return " ".join(parts)
27+
28+
29+
def format_bytes(byte_count: int) -> str:
30+
"""Return a compact binary-size label."""
31+
value = float(max(0, byte_count))
32+
for unit in ("o", "Kio", "Mio", "Gio"):
33+
if value < 1024 or unit == "Gio":
34+
if unit == "o":
35+
return f"{int(value)} {unit}"
36+
return f"{value:.1f} {unit}"
37+
value /= 1024
38+
raise AssertionError("unreachable")
39+
40+
41+
def send_gchat_alert(message: str) -> bool:
42+
"""Send a message using the webhook supplied by Secret Manager."""
43+
webhook_url = os.environ.get("GCHAT_WEBHOOK_URL", "")
44+
if not webhook_url:
45+
LOG.warning("Google Chat notification skipped: webhook is not configured")
46+
return False
47+
48+
try:
49+
response = requests.post(webhook_url, json={"text": message}, timeout=15)
50+
response.raise_for_status()
51+
return True
52+
except requests.RequestException as exc:
53+
# Exception strings can contain the complete webhook URL. Log only its
54+
# class so the Google Chat token never reaches Cloud Logging.
55+
LOG.error(
56+
"Google Chat notification failed error_type=%s", type(exc).__name__
57+
)
58+
return False

cloud/recorder.py

Lines changed: 91 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99
import subprocess
1010
import time
1111
import uuid
12+
from dataclasses import dataclass
1213
from datetime import datetime, timezone
1314
from pathlib import Path
1415
from urllib.parse import quote
1516

1617
from google.cloud import storage
1718

19+
from gchat_notifier import format_bytes, format_duration, send_gchat_alert
1820
from runtime import Tunnel, required_env
1921

2022

@@ -23,6 +25,16 @@
2325
RTSP_USERINFO_RE = re.compile(r"rtsp://[^@\s]+@", re.IGNORECASE)
2426

2527

28+
@dataclass
29+
class RecordingState:
30+
bucket_name: str = ""
31+
project_id: str = ""
32+
object_prefix: str = ""
33+
uploaded_segments: int = 0
34+
uploaded_bytes: int = 0
35+
monthly_limit_reached: bool = False
36+
37+
2638
def safe_ffmpeg_diagnostic(stderr: str, limit: int = 2000) -> str:
2739
"""Return a short FFmpeg diagnostic with RTSP credentials removed."""
2840
sanitized = RTSP_USERINFO_RE.sub("rtsp://[REDACTED]@", stderr)
@@ -41,6 +53,44 @@ def monthly_usage_bytes(client: storage.Client, bucket_name: str, prefix: str) -
4153
)
4254

4355

56+
def storage_console_url(state: RecordingState) -> str:
57+
"""Build a private Cloud Console link to this recording session."""
58+
if not state.bucket_name or not state.object_prefix:
59+
return ""
60+
path = quote(
61+
f"{state.bucket_name}/{state.object_prefix.rstrip('/')}", safe="/"
62+
)
63+
project = quote(state.project_id, safe="")
64+
return f"https://console.cloud.google.com/storage/browser/{path}?project={project}"
65+
66+
67+
def recording_summary(
68+
state: RecordingState, elapsed_seconds: float, error_name: str | None = None
69+
) -> str:
70+
"""Build the structured end-of-session notification requested by the user."""
71+
recorded = state.uploaded_segments > 0
72+
title = "❌ *Session caméra interrompue*" if error_name else "✅ *Session caméra terminée*"
73+
lines = [
74+
title,
75+
f"Erreur : {'oui (' + error_name + ')' if error_name else 'non'}",
76+
f"Vidéo enregistrée : {'oui' if recorded else 'non'}",
77+
f"Durée : {format_duration(elapsed_seconds)}",
78+
]
79+
link = storage_console_url(state) if recorded else ""
80+
if link:
81+
lines.append(f"Lien : <{link}|ouvrir les vidéos>")
82+
else:
83+
lines.append("Lien : aucun")
84+
if recorded:
85+
lines.append(
86+
f"Fichiers : {state.uploaded_segments} segment(s), "
87+
f"{format_bytes(state.uploaded_bytes)}"
88+
)
89+
if state.monthly_limit_reached:
90+
lines.append("Limite mensuelle de 30 Go atteinte.")
91+
return "\n".join(lines)
92+
93+
4494
def capture_segment(rtsp_url: str, destination: Path, duration: int) -> None:
4595
command = [
4696
"ffmpeg",
@@ -93,14 +143,12 @@ def capture_segment(rtsp_url: str, destination: Path, duration: int) -> None:
93143
raise RuntimeError("ffmpeg produced insufficient video data")
94144

95145

96-
def main() -> None:
97-
signal.signal(signal.SIGTERM, _stop_on_signal)
98-
signal.signal(signal.SIGINT, _stop_on_signal)
99-
146+
def record(state: RecordingState) -> None:
100147
serial = required_env("CAMERA_SERIAL")
101148
username = required_env("CAMERA_USERNAME")
102149
password = required_env("CAMERA_PASSWORD")
103-
bucket_name = required_env("GCS_BUCKET")
150+
state.bucket_name = required_env("GCS_BUCKET")
151+
state.project_id = os.environ.get("PROJECT_ID", "calving-monitor-20260819")
104152
duration_seconds = int(os.environ.get("RECORD_DURATION_SECONDS", "600"))
105153
segment_seconds = int(os.environ.get("SEGMENT_SECONDS", "60"))
106154
monthly_limit = int(os.environ.get("MONTHLY_RECORDING_LIMIT_BYTES", "30000000000"))
@@ -111,9 +159,14 @@ def main() -> None:
111159

112160
now = datetime.now(timezone.utc)
113161
monthly_prefix = f"raw/{now:%Y/%m}/"
162+
session_id = f"{now:%Y%m%dT%H%M%SZ}_{uuid.uuid4().hex[:8]}"
163+
state.object_prefix = f"raw/{now:%Y/%m/%d}/{session_id}/"
114164
storage_client = storage.Client()
115-
used_bytes = monthly_usage_bytes(storage_client, bucket_name, monthly_prefix)
165+
used_bytes = monthly_usage_bytes(
166+
storage_client, state.bucket_name, monthly_prefix
167+
)
116168
if used_bytes >= monthly_limit:
169+
state.monthly_limit_reached = True
117170
LOG.warning("Monthly recording limit already reached; recording skipped")
118171
return
119172

@@ -122,10 +175,8 @@ def main() -> None:
122175
rtsp_url = (
123176
f"rtsp://{escaped_user}:{escaped_password}@127.0.0.1:{local_port}{stream_path}"
124177
)
125-
bucket = storage_client.bucket(bucket_name)
178+
bucket = storage_client.bucket(state.bucket_name)
126179
deadline = time.monotonic() + duration_seconds
127-
uploaded = 0
128-
129180
with Tunnel(
130181
serial=serial,
131182
local_port=local_port,
@@ -143,7 +194,7 @@ def main() -> None:
143194
try:
144195
capture_segment(rtsp_url, local_path, current_duration)
145196
size = local_path.stat().st_size
146-
object_name = f"raw/{timestamp:%Y/%m/%d}/{filename}"
197+
object_name = f"{state.object_prefix}{filename}"
147198
blob = bucket.blob(object_name)
148199
blob.upload_from_filename(
149200
local_path,
@@ -152,12 +203,40 @@ def main() -> None:
152203
timeout=180,
153204
)
154205
used_bytes += size
155-
uploaded += 1
206+
state.uploaded_segments += 1
207+
state.uploaded_bytes += size
156208
LOG.info("Uploaded segment object=%s bytes=%d", object_name, size)
157209
finally:
158210
local_path.unlink(missing_ok=True)
159211

160-
LOG.info("Recording job completed segments=%d monthly_bytes=%d", uploaded, used_bytes)
212+
if used_bytes >= monthly_limit:
213+
state.monthly_limit_reached = True
214+
LOG.info(
215+
"Recording job completed segments=%d monthly_bytes=%d",
216+
state.uploaded_segments,
217+
used_bytes,
218+
)
219+
220+
221+
def main() -> None:
222+
started_at = time.monotonic()
223+
state = RecordingState()
224+
signal.signal(signal.SIGTERM, _stop_on_signal)
225+
signal.signal(signal.SIGINT, _stop_on_signal)
226+
227+
try:
228+
record(state)
229+
except BaseException as exc:
230+
send_gchat_alert(
231+
recording_summary(
232+
state,
233+
time.monotonic() - started_at,
234+
error_name=type(exc).__name__,
235+
)
236+
)
237+
raise
238+
239+
send_gchat_alert(recording_summary(state, time.monotonic() - started_at))
161240

162241

163242
if __name__ == "__main__":

cloud/runtime.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import requests
1414
from requests.auth import HTTPDigestAuth
1515

16+
from gchat_notifier import send_gchat_alert
17+
1618

1719
LOG = logging.getLogger(__name__)
1820
ACTIVE_USER_RE = re.compile(r"^users\[(\d+)]\.([^=]+)=(.*)$", re.IGNORECASE)
@@ -245,6 +247,11 @@ def poll_once() -> dict[str, Any]:
245247
if session_types and os.environ.get("TRIGGER_RECORDING", "true").casefold() == "true":
246248
result["recorder_operation"] = start_recorder()
247249
result["recorder_started"] = True
250+
session_label = ", ".join(session_types)
251+
send_gchat_alert(
252+
"🐄 *Caméra allumée* — utilisation détectée "
253+
f"({session_label}). L'enregistrement GCP vient de démarrer."
254+
)
248255

249256
# Deliberately log only counts and client types, never usernames or addresses.
250257
LOG.info(

cloud/test_runtime.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import unittest
22
from unittest.mock import patch
33

4-
from recorder import safe_ffmpeg_diagnostic
4+
from gchat_notifier import format_bytes, format_duration, send_gchat_alert
5+
from recorder import RecordingState, recording_summary, safe_ffmpeg_diagnostic
56
from runtime import (
67
active_session_types,
78
execution_is_active,
@@ -12,6 +13,44 @@
1213

1314

1415
class RuntimeTests(unittest.TestCase):
16+
def test_formats_chat_metrics(self):
17+
self.assertEqual(format_duration(0.2), "0 s")
18+
self.assertEqual(format_duration(65.2), "1 min 5 s")
19+
self.assertEqual(format_duration(3661), "1 h 1 min 1 s")
20+
self.assertEqual(format_bytes(2_399_827), "2.3 Mio")
21+
22+
def test_recording_summary_contains_status_duration_and_private_link(self):
23+
state = RecordingState(
24+
bucket_name="private-bucket",
25+
project_id="camera-project",
26+
object_prefix="raw/2026/08/19/session/",
27+
uploaded_segments=2,
28+
uploaded_bytes=2_399_827,
29+
)
30+
summary = recording_summary(state, 65.2)
31+
self.assertIn("Erreur : non", summary)
32+
self.assertIn("Vidéo enregistrée : oui", summary)
33+
self.assertIn("Durée : 1 min 5 s", summary)
34+
self.assertIn("private-bucket/raw/2026/08/19/session", summary)
35+
self.assertIn("project=camera-project", summary)
36+
37+
def test_failed_recording_summary_says_when_no_video_exists(self):
38+
summary = recording_summary(
39+
RecordingState(), 4.8, error_name="TimeoutError"
40+
)
41+
self.assertIn("Erreur : oui (TimeoutError)", summary)
42+
self.assertIn("Vidéo enregistrée : non", summary)
43+
self.assertIn("Lien : aucun", summary)
44+
45+
@patch("gchat_notifier.requests.post")
46+
@patch.dict("os.environ", {"GCHAT_WEBHOOK_URL": "https://example.invalid/token"})
47+
def test_chat_notification_uses_json_payload(self, post):
48+
post.return_value.raise_for_status.return_value = None
49+
self.assertTrue(send_gchat_alert("test"))
50+
post.assert_called_once_with(
51+
"https://example.invalid/token", json={"text": "test"}, timeout=15
52+
)
53+
1554
def test_parse_and_ignore_own_cgi_session(self):
1655
payload = "\r\n".join(
1756
[
@@ -72,6 +111,19 @@ def test_transient_probe_timeout_is_unavailable(self, _active, _probe):
72111
},
73112
)
74113

114+
@patch("runtime.send_gchat_alert")
115+
@patch("runtime.start_recorder", return_value="operation")
116+
@patch("runtime.probe_active_users", return_value=([{}], ["Mobile"]))
117+
@patch("runtime.recorder_is_active", return_value=False)
118+
def test_notifies_after_starting_recorder(
119+
self, _active, _probe, start, notify
120+
):
121+
result = poll_once()
122+
self.assertTrue(result["recorder_started"])
123+
start.assert_called_once_with()
124+
notify.assert_called_once()
125+
self.assertIn("Caméra allumée", notify.call_args.args[0])
126+
75127

76128
if __name__ == "__main__":
77129
unittest.main()

vm/startup.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,16 @@ docker pull "${recorder_image}"
5151

5252
umask 077
5353
env_file=$(mktemp /run/calving-monitor.XXXXXX.env)
54+
gchat_webhook=$(secret_value calving-google-chat-webhook || true)
5455
{
5556
printf 'CAMERA_SERIAL=%s\n' "$(secret_value calving-camera-serial)"
5657
printf 'CAMERA_USERNAME=%s\n' "$(secret_value calving-camera-username)"
5758
printf 'CAMERA_PASSWORD=%s\n' "$(secret_value calving-camera-password)"
59+
if [ -n "${gchat_webhook}" ]; then
60+
printf 'GCHAT_WEBHOOK_URL=%s\n' "${gchat_webhook}"
61+
fi
5862
printf 'GCS_BUCKET=%s\n' "${bucket}"
63+
printf 'PROJECT_ID=%s\n' "${project}"
5964
printf 'RECORD_DURATION_SECONDS=%s\n' "${record_duration}"
6065
printf 'SEGMENT_SECONDS=60\n'
6166
printf 'MONTHLY_RECORDING_LIMIT_BYTES=30000000000\n'

0 commit comments

Comments
 (0)