"""Private configuration and durable, bounded event delivery outside motion detection. A successful receiver response is deduplicated durably. An interrupted request can have been accepted remotely: retries reuse the event/channel Idempotency-Key, but arbitrary webhooks and ntfy cannot promise exactly-once display. """ import copy import fcntl import hashlib import json import os from pathlib import Path import re import stat import threading import time from urllib.parse import urlsplit import uuid import requests from openpilot.starpilot.storage import starpilot_storage_root from openpilot.starpilot.sentry_mode.storage import EventStore from openpilot.starpilot.sentry_mode import web_push CHANNELS = ('webPush', 'discord', 'webhook', 'ntfy', 'appPush') APP_PUSH_RELAY_URL = '' APP_IDS = ('com.starpilot.galaxy', 'link.firestar.companion', 'link.galaxy.bluetooth.natehuby76') MAX_STATE_BYTES = 1024 * 1024 MAX_JOBS = 512 MAX_AGE = 86400 MAX_ATTEMPTS = 4 RETRY_SECONDS = (30, 120, 600, 600) ID = re.compile(r'[0-9a-f]{32}\Z') class NotificationUnavailable(Exception): pass def endpoint(value: object, *, allow_http: bool = False) -> str: if type(value) is not str or not 1 <= len(value) <= 2048 or any(ord(c) <= 32 for c in value): raise ValueError('Enter a valid notification URL') try: parsed = urlsplit(value) if (parsed.scheme not in (('https', 'http') if allow_http else ('https',)) or not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.fragment or parsed.port == 0): raise ValueError except ValueError: raise ValueError('Use an HTTPS notification URL') from None return value def fresh_state() -> dict: return {'version': 1, 'channels': {channel: {'enabled': False, 'url': '', 'token': '', 'generation': uuid.uuid4().hex, 'since': 0} for channel in CHANNELS}, 'appSubscriptions': [], 'subscriptions': [], 'vapid': '', 'jobs': {}, 'queueFull': False} def wall_seconds() -> float: # Durable retry deadlines and event timestamps must survive a process/device reboot. return time.time_ns() / 1e9 class NotificationOwner: def __init__(self, root: Path | None = None, store=None, *, clock=wall_seconds, transport=None, allow_http: bool = False): self.root = Path(root) if root is not None else starpilot_storage_root() / "sentry/notifications" self.store = store if store is not None else EventStore() self.clock, self.allow_http = clock, allow_http self.transport = transport if transport is not None else self._send self.lock = threading.RLock() self.stop = threading.Event() self.thread = None self.state = fresh_state() self.available = True self.lock_fd = None self.saved_bytes = None try: directory = self._directory() try: self.lock_fd = os.open('.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600, dir_fd=directory) lock_info = os.fstat(self.lock_fd) if not stat.S_ISREG(lock_info.st_mode) or lock_info.st_uid != os.getuid() or stat.S_IMODE(lock_info.st_mode) & 0o077: raise OSError('Invalid notification lock') fcntl.flock(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) finally: os.close(directory) self._load() except (OSError, ValueError, TypeError, RecursionError): self.available = False if self.lock_fd is not None: os.close(self.lock_fd) self.lock_fd = None def _directory(self) -> int: # Walk every component without following symlinks. This private state contains credentials. if not self.root.is_absolute() or ".." in self.root.parts: raise OSError('Private storage requires an absolute directory') fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY) try: for component in self.root.parts[1:]: try: os.mkdir(component, 0o700, dir_fd=fd) except FileExistsError: pass child = os.open(component, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd) os.close(fd) fd = child info = os.fstat(fd) if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077: raise OSError('Private storage permissions are invalid') return fd except BaseException: os.close(fd) raise def _load(self) -> None: directory = self._directory() try: try: fd = os.open('state.json', os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory) except FileNotFoundError: return try: info = os.fstat(fd) if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077: raise ValueError('Invalid private notification state') raw = os.read(fd, MAX_STATE_BYTES + 1) finally: os.close(fd) if len(raw) > MAX_STATE_BYTES: raise ValueError('Notification state exceeds limit') def unique(pairs): result = {} for key, value in pairs: if key in result: raise ValueError('Duplicate notification state') result[key] = value return result state = json.loads(raw, object_pairs_hook=unique) if type(state) is dict and 'appSubscriptions' not in state: state['appSubscriptions'] = [] if type(state.get('channels')) is dict: state['channels']['appPush'] = fresh_state()['channels']['appPush'] self._validate_state(state) self.state = state self.saved_bytes = json.dumps(state, separators=(',', ':'), allow_nan=False).encode() finally: os.close(directory) def _validate_state(self, state: dict) -> None: if (type(state) is not dict or set(state) != set(fresh_state()) or type(state['version']) is not int or state['version'] != 1 or type(state['channels']) is not dict or set(state['channels']) != set(CHANNELS) or type(state['subscriptions']) is not list or len(state['subscriptions']) > 16 or type(state['jobs']) is not dict or len(state['jobs']) > MAX_JOBS or type(state['queueFull']) is not bool): raise ValueError('Invalid notification state') if type(state['vapid']) is not str or len(state['vapid']) > 4096: raise ValueError('Invalid notification push key') if state['vapid']: web_push.load_key(state['vapid']) for name, channel in state['channels'].items(): if (type(channel) is not dict or set(channel) != {'enabled', 'url', 'token', 'generation', 'since'} or type(channel['enabled']) is not bool or type(channel['generation']) is not str or ID.fullmatch(channel['generation']) is None or type(channel['since']) not in (int, float) or not 0 <= channel['since'] < 2**63 or type(channel['url']) is not str or len(channel['url']) > 2048 or type(channel['token']) is not str or len(channel['token']) > 512 or any(ord(c) < 32 for c in channel['token'])): raise ValueError('Invalid notification channel') if channel['url']: endpoint(channel['url'], allow_http=self.allow_http) if name not in ('webPush', 'appPush') and channel['enabled'] and not channel['url']: raise ValueError('Missing notification endpoint') if type(state['appSubscriptions']) is not list or len(state['appSubscriptions']) > 8: raise ValueError('Invalid app subscriptions') for sub in state['appSubscriptions']: if (type(sub) is not dict or set(sub) != {'id', 'token', 'appId', 'label', 'addedAt', 'since', 'lastResult', 'lastAt'} or type(sub['token']) is not str or not 1 <= len(sub['token']) <= 4096 or any(ord(c) <= 32 for c in sub['token']) or sub['id'] != hashlib.sha256(sub['token'].encode()).hexdigest()[:12] or sub['appId'] not in APP_IDS or type(sub['label']) is not str or len(sub['label']) > 64 or type(sub['lastResult']) is not str or len(sub['lastResult']) > 64 or any(type(sub[key]) not in (int, float) or not 0 <= sub[key] < 2**63 for key in ('addedAt', 'since', 'lastAt'))): raise ValueError('Invalid app subscription') for sub in state['subscriptions']: if type(sub) is not dict or set(sub) != {'id', 'subscription', 'since'} or type(sub['id']) is not str or ID.fullmatch(sub['id']) is None: raise ValueError('Invalid browser subscription') web_push.validate_subscription(sub['subscription']) endpoint(sub['subscription']['endpoint'], allow_http=self.allow_http) if type(sub['since']) not in (int, float) or not 0 <= sub['since'] < 2**63: raise ValueError('Invalid subscription time') for key, job in state['jobs'].items(): if (type(job) is not dict or set(job) != {'eventId', 'kind', 'channel', 'generation', 'subscriptionId', 'created', 'next', 'attempts', 'state', 'error'} or type(key) is not str or len(key) != 64 or type(job['eventId']) is not str or ID.fullmatch(job['eventId']) is None or job['kind'] not in ('warning', 'alarm', 'test') or job['channel'] not in CHANNELS or type(job['generation']) is not str or ID.fullmatch(job['generation']) is None or (type(job['subscriptionId']) is not str or (re.fullmatch(r'[0-9a-f]{12}' if job['channel'] == 'appPush' else r'[0-9a-f]{32}' if job['channel'] == 'webPush' else r'', job['subscriptionId']) is None)) or type(job['attempts']) is not int or not 0 <= job['attempts'] <= MAX_ATTEMPTS or job['state'] not in ('queued', 'sending', 'sent', 'failed', 'cancelled') or job['error'] not in ('', 'http', 'expired', 'timeout', 'transport', 'delivery_unknown', 'stale') or any(type(job[k]) not in (float, int) or not 0 <= job[k] < 2**63 for k in ('created', 'next'))): raise ValueError('Invalid notification job') def _save(self) -> None: raw = json.dumps(self.state, separators=(',', ':'), allow_nan=False).encode() if len(raw) > MAX_STATE_BYTES: raise NotificationUnavailable('Notification storage is full') if raw == self.saved_bytes: return directory = self._directory() temporary = '.' + uuid.uuid4().hex + '.tmp' try: fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=directory) with os.fdopen(fd, 'wb') as output: output.write(raw) output.flush() os.fsync(output.fileno()) os.replace(temporary, 'state.json', src_dir_fd=directory, dst_dir_fd=directory) os.fsync(directory) self.saved_bytes = raw except OSError: self.available = False raise NotificationUnavailable('Notification storage is unavailable') from None finally: try: os.unlink(temporary, dir_fd=directory) except FileNotFoundError: pass os.close(directory) def snapshot(self) -> dict: with self.lock: if not self.available: raise NotificationUnavailable('Notification storage is unavailable') jobs = self.state['jobs'] result = {'schemaVersion': 1, 'channels': {}, 'queueFull': self.state['queueFull'], 'subscriptionCount': len(self.state['subscriptions']), 'subscriptions': [{'id': sub['id']} for sub in self.state['subscriptions']], 'deliverySemantics': 'Retries may repeat delivery after an interrupted remote response.'} result['appSubscriptions'] = [{key: sub[key] for key in ('id', 'label', 'appId', 'addedAt', 'lastResult', 'lastAt')} for sub in self.state['appSubscriptions']] result['removedAppSubscriptions'] = [ {'id': job['subscriptionId'], 'lastResult': 'Phone unregistered — removed', 'lastAt': job['next']} for job in jobs.values() if job['channel'] == 'appPush' and job['error'] == 'expired'][-8:] result['appPushAvailable'] = self.state['channels']['appPush']['enabled'] and bool(self.state['channels']['appPush']['url'] or APP_PUSH_RELAY_URL) for name, cfg in self.state['channels'].items(): relevant = [j for j in jobs.values() if j['channel'] == name and j['generation'] == cfg['generation']] latest = max(relevant, key=lambda j: j['created'], default=None) result['channels'][name] = {'enabled': cfg['enabled'], 'configured': bool(self.state['subscriptions']) if name == 'webPush' else bool(cfg['url']), 'pending': sum(j['state'] in ('queued', 'sending') for j in relevant), 'lastState': latest['state'] if latest else 'idle', 'lastError': latest['error'] if latest else ''} if self.state['vapid']: result['applicationServerKey'] = web_push.b64(web_push.public(web_push.load_key(self.state['vapid']))) return result def action(self, payload: dict, *, permitted=lambda: True) -> dict: with self.lock: if not self.available: raise NotificationUnavailable('Notification storage is unavailable') if not permitted(): raise PermissionError('A current Galaxy session is required') if type(payload) is not dict or type(payload.get('action')) is not str: raise ValueError('Choose a notification action') previous = copy.deepcopy(self.state) action = payload['action'] now = self.clock() if action == 'configure': if set(payload) != {'action', 'channel', 'enabled', 'url', 'token'} or payload['channel'] not in CHANNELS: raise ValueError('Invalid notification channel') name = payload['channel'] if (type(payload['enabled']) is not bool or type(payload['token']) is not str or len(payload['token']) > 512 or any(ord(c) < 32 for c in payload['token'])): raise ValueError('Invalid notification setting') url = payload['url'] if type(url) is not str: raise ValueError('Enter a valid notification URL') if url: endpoint(url, allow_http=self.allow_http) if name == 'webPush' and payload['enabled'] and not self.state['subscriptions']: raise ValueError('Subscribe a browser first') if name == 'webPush' and (url or payload['token']): raise ValueError('Browser push uses a browser subscription') old = self.state['channels'][name] # Empty fields preserve configured credentials; explicit disable does not delete them. url = url or old['url'] token = payload['token'] or old['token'] if payload['enabled'] and name != 'webPush' and not url: raise ValueError('Enter a notification URL') self.state['channels'][name] = {'enabled': payload['enabled'], 'url': url, 'token': token, 'generation': uuid.uuid4().hex, 'since': now} elif action == 'forget': if set(payload) != {'action', 'channel'} or payload['channel'] not in CHANNELS: raise ValueError('Choose a notification channel') self.state['channels'][payload['channel']] = fresh_state()['channels'][payload['channel']] if payload['channel'] == 'webPush': self.state['subscriptions'] = [] if payload['channel'] == 'appPush': self.state['appSubscriptions'] = [] elif action == 'pushKey': if set(payload) != {'action'}: raise ValueError('Invalid push key request') if not self.state['vapid']: self.state['vapid'] = web_push.new_key() elif action == 'subscribe': if set(payload) != {'action', 'subscription'} or not self.state['vapid']: raise ValueError('Prepare browser push first') sub = payload['subscription'] web_push.validate_subscription(sub) endpoint(sub['endpoint'], allow_http=self.allow_http) current = self.state['subscriptions'] existing = next((s for s in current if s['subscription']['endpoint'] == sub['endpoint']), None) if existing: existing['subscription'] = sub elif len(current) < 16: current.append({'id': uuid.uuid4().hex, 'subscription': sub, 'since': now}) else: raise ValueError('Remove a browser before adding another') elif action == 'unsubscribe': if set(payload) != {'action', 'id'} or type(payload['id']) is not str or ID.fullmatch(payload['id']) is None: raise ValueError('Choose a browser subscription') self.state['subscriptions'] = [s for s in self.state['subscriptions'] if s['id'] != payload['id']] elif action == 'appSubscribe': if (set(payload) != {'action', 'token', 'appId', 'label'} or type(payload['token']) is not str or not 1 <= len(payload['token']) <= 4096 or any(ord(c) <= 32 for c in payload['token']) or type(payload['appId']) is not str or payload['appId'] not in APP_IDS or type(payload['label']) is not str or len(payload['label']) > 64): raise ValueError('Invalid app subscription') current = self.state['appSubscriptions'] existing = next((sub for sub in current if sub['token'] == payload['token']), None) if existing: existing.update(appId=payload['appId'], label=payload['label']) elif len(current) < 8: current.append({'id': hashlib.sha256(payload['token'].encode()).hexdigest()[:12], 'token': payload['token'], 'appId': payload['appId'], 'label': payload['label'], 'addedAt': now, 'since': now, 'lastResult': '', 'lastAt': 0}) else: raise ValueError('Remove a phone before adding another') cfg = self.state['channels']['appPush'] cfg['enabled'] = bool(cfg['url'] or APP_PUSH_RELAY_URL) elif action == 'appUnsubscribe': if set(payload) not in ({'action', 'token'}, {'action', 'id'}): raise ValueError('Choose a phone subscription') key = 'token' if 'token' in payload else 'id' if type(payload[key]) is not str: raise ValueError('Choose a phone subscription') self.state['appSubscriptions'] = [sub for sub in self.state['appSubscriptions'] if sub[key] != payload[key]] elif action == 'test' and 'channel' not in payload: if set(payload) - {'action', 'target', 'id'} or payload.get('target', 'all') not in ('all', 'app'): raise ValueError('Choose a test target') target_id = payload.get('id') if payload.get('target') == 'app' and not any(sub['id'] == target_id for sub in self.state['appSubscriptions']): raise LookupError('Phone subscription not found') if sum(j['kind'] == 'test' and j['created'] > now - 60 for j in self.state['jobs'].values()) >= 32: raise ValueError('Wait a minute before sending more tests') self._enqueue({'eventId': uuid.uuid4().hex, 'kind': 'test', 'wallTimeNs': int(now * 1e9)}, only='appPush' if payload.get('target') == 'app' else None, target_id=target_id) elif action == 'test': if set(payload) != {'action', 'channel'} or payload['channel'] not in CHANNELS: raise ValueError('Choose a test channel') cfg = self.state['channels'][payload['channel']] if not cfg['enabled']: raise ValueError('Enable this notification channel first') if payload['channel'] == 'webPush' and not self.state['subscriptions']: raise ValueError('Subscribe a browser first') if sum(j['kind'] == 'test' and j['created'] > now - 60 for j in self.state['jobs'].values()) >= 4: raise ValueError('Wait a minute before sending more tests') self._enqueue({'eventId': uuid.uuid4().hex, 'kind': 'test', 'wallTimeNs': int(now * 1e9)}, only=payload['channel']) else: raise ValueError('Choose a supported notification action') if not permitted(): self.state = previous raise PermissionError('Galaxy session changed') self._save() return self.snapshot() def _enqueue(self, event: dict, *, only: str | None = None, target_id: str | None = None) -> None: if event['kind'] == 'selfie': return now = self.clock() for name, cfg in self.state['channels'].items(): if not cfg['enabled'] or (only is not None and only != name): continue targets = (self.state['subscriptions'] if name == 'webPush' else self.state['appSubscriptions'] if name == 'appPush' else [{'id': '', 'since': cfg['since']}]) for target in targets: if target_id is not None and target['id'] != target_id: continue if event['kind'] != 'test' and event['wallTimeNs'] / 1e9 <= max(cfg['since'], target['since']): continue key = hashlib.sha256((event['eventId'] + name + cfg['generation'] + target['id']).encode()).hexdigest() if key in self.state['jobs']: continue if len(self.state['jobs']) >= MAX_JOBS: self.state['queueFull'] = True continue self.state['jobs'][key] = {'eventId': event['eventId'], 'kind': event['kind'], 'channel': name, 'generation': cfg['generation'], 'subscriptionId': target['id'], 'created': event['wallTimeNs'] / 1e9, 'next': now, 'attempts': 0, 'state': 'queued', 'error': ''} def delete_events(self, remove, event_id=None): with self.lock: if not self.available: raise NotificationUnavailable('Sentry notifications are unavailable') original_jobs = self.state['jobs'] if event_id is None: self.state['jobs'] = {key: job for key, job in original_jobs.items() if job['kind'] == 'test'} else: self.state['jobs'] = {key: job for key, job in original_jobs.items() if job['eventId'] != event_id} try: self._save() # Durable pruning precedes any event/media removal. except BaseException: self.state['jobs'] = original_jobs raise return remove() def tick(self) -> None: with self.lock: if not self.available: return now = self.clock() self.state['jobs'] = {key: job for key, job in self.state['jobs'].items() if now - job['created'] <= MAX_AGE} self.state['queueFull'] = False if not any(cfg["enabled"] for cfg in self.state["channels"].values()): return snapshot = self.store.snapshot() for event in snapshot['events']: if 0 <= now - event['wallTimeNs'] / 1e9 <= MAX_AGE: self._enqueue(event) for key, job in self.state['jobs'].items(): if key not in self.state['jobs']: continue if job['state'] not in ('queued', 'sending'): continue cfg = self.state['channels'][job['channel']] sub = next((s['subscription'] for s in self.state['subscriptions'] if s['id'] == job['subscriptionId']), None) if job['channel'] == 'appPush': sub = next((s for s in self.state['appSubscriptions'] if s['id'] == job['subscriptionId']), None) if not cfg['enabled'] or cfg['generation'] != job['generation'] or (job['channel'] in ('webPush', 'appPush') and sub is None): job['state'] = 'cancelled' continue if now < job['created'] or now - job['created'] > MAX_AGE: job['state'], job['error'] = 'failed', 'stale' continue if job['state'] == 'sending': job['error'] = 'delivery_unknown' job['state'] = 'queued' if job['attempts'] < MAX_ATTEMPTS else 'failed' job['next'] = now + RETRY_SECONDS[min(job['attempts'], MAX_ATTEMPTS) - 1] if job['state'] != 'queued' or job['next'] > now: continue job['attempts'] += 1 job['state'] = 'sending' self._save() # Claim durably before issuing external IO; secrets stay in private config. try: # Keep status/configuration responsive while network IO is in flight. self.lock.release() try: code = self.transport(job['channel'], dict(cfg), sub, dict(job), key, self.state['vapid']) finally: self.lock.acquire() current_job = self.state['jobs'].get(key) if current_job is None: continue job = current_job if job['channel'] == 'appPush': current_sub = next((s for s in self.state['appSubscriptions'] if s['id'] == job['subscriptionId']), None) if current_sub is not None: current_sub['lastAt'] = self.clock() current_sub['lastResult'] = ('Delivered to relay' if 200 <= code < 300 else 'Relay not configured' if code == 0 else 'Phone unregistered — removed' if code == 410 else 'Rate limited' if code == 429 else f'Relay error {code}') if code == 410: self.state['appSubscriptions'] = [s for s in self.state['appSubscriptions'] if s['id'] != job['subscriptionId']] if 200 <= code < 300: job['state'], job['error'] = 'sent', '' elif code == 410 and job['channel'] == 'appPush': job['state'], job['error'], job['next'] = 'failed', 'expired', now elif code in (404, 410) and job['channel'] == 'webPush': job['state'], job['error'] = 'failed', 'expired' self.state['subscriptions'] = [s for s in self.state['subscriptions'] if s['id'] != job['subscriptionId'] or s['subscription'] != sub] else: job['state'], job['error'] = ('queued' if code in (408, 429) or code >= 500 else 'failed'), 'http' except (requests.Timeout, TimeoutError): job['state'], job['error'] = 'queued', 'timeout' if job['channel'] == 'appPush' and sub is not None: sub['lastAt'], sub['lastResult'] = self.clock(), 'Relay timeout' except Exception: # Never log response bodies, URLs, exception text, subscription secrets or auth headers. job['state'], job['error'] = 'queued', 'transport' if job['channel'] == 'appPush' and sub is not None: sub['lastAt'], sub['lastResult'] = self.clock(), 'Relay unavailable' if job['state'] == 'queued': if job['attempts'] >= MAX_ATTEMPTS: job['state'] = 'failed' job['next'] = now + RETRY_SECONDS[job['attempts'] - 1] self._save() return # At most one HTTP attempt per tick, per owner. self._save() def _attachments(self, job: dict) -> list: """Camera images saved with the event, one files[n] field each (Discord keeps one file per field name).""" if job['kind'] == 'test': return [] files = [] try: cameras = self.store.images(job['eventId']) except Exception: return [] for camera in cameras: try: body = self.store.image(job['eventId'], camera) except Exception: continue # A missing or invalid image never blocks the text alert. files.append((f'files[{len(files)}]', (camera + '.jpg', body, 'image/jpeg'))) return files def _send(self, name: str, cfg: dict, subscription: dict | None, job: dict, key: str, pem: str) -> int: message = 'This is a test StarPilot Sentry notification.' if job['kind'] == 'test' else f"Parked motion detected ({job['kind']})." event = {'eventId': job['eventId'], 'kind': job['kind'], 'systemTimeMs': int(job['created'] * 1000)} headers = {'Idempotency-Key': key} if name == 'appPush': if subscription is None: raise ValueError('Missing app subscription') relay = cfg['url'] or APP_PUSH_RELAY_URL if not relay: return 0 response = requests.post(relay.rstrip('/') + '/v1/sentry', json={ 'token': subscription['token'], 'appId': subscription['appId'], 'kind': job['kind'], 'eventId': job['eventId'], 'wallTimeNs': int(job['created'] * 1e9), 'label': subscription['label']}, headers=headers, timeout=(3, 10), allow_redirects=False, stream=True) elif name == 'webPush': if subscription is None: raise ValueError('Missing browser subscription') body, push_headers = web_push.request(subscription, {'title': 'StarPilot Sentry Mode', 'body': message, 'eventId': job['eventId'], 'url': '/#/cameras/events'}, pem) headers.update(push_headers) response = requests.post(subscription['endpoint'], data=body, headers=headers, timeout=(3, 10), allow_redirects=False, stream=True) else: if cfg['token']: headers['Authorization'] = 'Bearer ' + cfg['token'] files = self._attachments(job) if name in ('discord', 'webhook') else [] if name == 'discord': content = {'content': 'StarPilot Sentry Mode: ' + message} if files: # Discord takes multipart attachments with the message in payload_json. response = requests.post(cfg['url'], data={'payload_json': json.dumps(content)}, files=files, headers=headers, timeout=(3, 10), allow_redirects=False, stream=True) else: response = requests.post(cfg['url'], json=content, headers=headers, timeout=(3, 10), allow_redirects=False, stream=True) elif name == 'webhook': # Preserve the original general-webhook content/event form contract. response = requests.post(cfg['url'], data={'content': 'StarPilot Sentry Mode: ' + message, 'event': json.dumps(event, separators=(',', ':'))}, files=files or None, headers=headers, timeout=(3, 10), allow_redirects=False, stream=True) else: headers.update({'Title': 'StarPilot Sentry Mode', 'Priority': 'urgent', 'Tags': 'warning,car'}) response = requests.post(cfg['url'], data=message.encode(), headers=headers, timeout=(3, 10), allow_redirects=False, stream=True) try: return response.status_code finally: response.close() def start(self) -> None: with self.lock: if self.thread is not None: return def run(): while not self.stop.is_set(): try: self.tick() except Exception: # Storage/scanner failure is visible, never rewritten as a successful delivery. with self.lock: self.available = False return self.stop.wait(2.0) self.thread = threading.Thread(target=run, name='sentry-notifications', daemon=True) self.thread.start() def close(self) -> None: self.stop.set() if self.thread is not None: self.thread.join(timeout=15) if self.thread.is_alive(): return if self.lock_fd is not None: os.close(self.lock_fd) self.lock_fd = None