host.match_monitor.discord_notifier

Discord webhook notifier for post-match summaries.

Requires a webhook URL in match_monitor_config.json: { "discord_webhook_url": "https://discord.com/api/webhooks//" }

Setup: Discord server → Server Settings → Integrations → Webhooks → New Webhook → Copy URL.

Controller config hash verification: The robot should publish its controller config hash to NT as a string entry with a name matching 'controller.*hash' (case-insensitive). The hash is computed as SHA-256 of the raw YAML bytes, first 8 hex characters. See utils/input/factory.py — publish via DataLog when config is loaded.

  1"""
  2Discord webhook notifier for post-match summaries.
  3
  4Requires a webhook URL in match_monitor_config.json:
  5    {
  6        "discord_webhook_url": "https://discord.com/api/webhooks/<id>/<token>"
  7    }
  8
  9Setup: Discord server → Server Settings → Integrations → Webhooks → New Webhook → Copy URL.
 10
 11Controller config hash verification:
 12  The robot should publish its controller config hash to NT as a string entry
 13  with a name matching 'controller.*hash' (case-insensitive).  The hash is
 14  computed as SHA-256 of the raw YAML bytes, first 8 hex characters.
 15  See utils/input/factory.py — publish via DataLog when config is loaded.
 16"""
 17
 18import hashlib
 19import json
 20import logging
 21import subprocess
 22import urllib.error
 23import urllib.request
 24from datetime import datetime
 25from pathlib import Path
 26from typing import Optional, TYPE_CHECKING
 27
 28from .callbacks import MatchCompleteCallback
 29from .analyzer import MatchStats
 30from .quotes import format_quote
 31
 32if TYPE_CHECKING:
 33    from .match_data_client import MatchResult, EventStats
 34
 35logger = logging.getLogger("match_monitor")
 36
 37_RESULT_COLORS = {
 38    'win':     0x57F287,   # Discord green
 39    'loss':    0xED4245,   # Discord red
 40    'tie':     0x95A5A6,   # Gray
 41    'unknown': 0xFEE75C,   # Yellow
 42}
 43_RESULT_EMOJI = {
 44    'win':     '✅',
 45    'loss':    '❌',
 46    'tie':     '➖',
 47    'unknown': '❓',
 48}
 49
 50
 51def _git_short_hash(repo_root: Path) -> Optional[str]:
 52    """Return current git short hash, or None on failure."""
 53    try:
 54        r = subprocess.run(
 55            ['git', 'rev-parse', '--short', 'HEAD'],
 56            cwd=repo_root, capture_output=True, text=True, timeout=5,
 57        )
 58        if r.returncode == 0:
 59            return r.stdout.strip()
 60    except Exception as exc:
 61        logger.debug("Could not get git short hash: %s", exc)
 62    return None
 63
 64
 65def _git_is_clean(repo_root: Path) -> Optional[bool]:
 66    """Return True if working tree is clean, False if dirty, None on failure."""
 67    try:
 68        r = subprocess.run(
 69            ['git', 'status', '--porcelain'],
 70            cwd=repo_root, capture_output=True, text=True, timeout=5,
 71        )
 72        if r.returncode == 0:
 73            return r.stdout.strip() == ''
 74    except Exception:
 75        logger.debug("Could not check git status", exc_info=True)
 76    return None
 77
 78
 79def _controller_yaml_hash(repo_root: Path) -> Optional[str]:
 80    """SHA-256 of data/controller.yaml (first 8 hex chars), or None if missing."""
 81    config_path = repo_root / 'data' / 'controller.yaml'
 82    try:
 83        return hashlib.sha256(config_path.read_bytes()).hexdigest()[:8]
 84    except Exception:
 85        return None  # file missing or unreadable — not an error condition
 86
 87
 88def _teams_str(teams: list) -> str:
 89    """Format team list: 'frc3200, frc1234' → '3200, 1234'."""
 90    return ', '.join(t.replace('frc', '') for t in teams) if teams else '—'
 91
 92
 93class DiscordNotifier(MatchCompleteCallback):
 94    """Posts a rich embed to a Discord webhook after each match."""
 95
 96    def __init__(self, webhook_url: str,
 97                 repo_root: Optional[Path] = None) -> None:
 98        self._webhook_url = webhook_url
 99        self._repo_root = repo_root
100
101    @property
102    def webhook_url(self) -> str:
103        return self._webhook_url
104
105    @webhook_url.setter
106    def webhook_url(self, url: str) -> None:
107        self._webhook_url = url.strip()
108
109    def on_match_complete(self, match_dir: Path, metadata: dict,
110                          stats: MatchStats,
111                          match_result: Optional['MatchResult'] = None,
112                          event_stats: Optional['EventStats'] = None) -> None:
113        if not self._webhook_url:
114            logger.debug("Discord notification skipped — no webhook URL")
115            return
116        try:
117            embed = self._build_embed(metadata, stats, match_result, event_stats)
118
119            # Generate voltage chart if timeseries data is available
120            chart_png = b''
121            if stats.voltage_timeseries:
122                try:
123                    from .chart_generator import voltage_chart
124                    chart_png = voltage_chart(
125                        stats.voltage_timeseries,
126                        brownout_times=stats.brownout_timestamps_sec,
127                        mode_transitions=stats.mode_transitions,
128                        match_duration=stats.match_duration_seconds,
129                    )
130                except Exception:
131                    logger.exception("Chart generation failed (non-fatal)")
132
133            if chart_png:
134                embed['image'] = {'url': 'attachment://voltage.png'}
135                self._post_multipart(
136                    {'embeds': [embed]},
137                    files=[('voltage.png', chart_png, 'image/png')],
138                )
139            else:
140                self._post({'embeds': [embed]})
141            logger.info("Discord notification sent")
142        except Exception:
143            logger.exception("Discord notification failed (non-fatal)")
144
145    # ------------------------------------------------------------------
146    # Embed construction
147    # ------------------------------------------------------------------
148
149    def _build_embed(self, metadata: dict, stats: MatchStats,
150                     match_result: Optional['MatchResult'],
151                     event_stats: Optional['EventStats']) -> dict:
152        match_type = metadata.get('match_type', 'Match')
153        match_number = metadata.get('match_number', '?')
154        event_name = metadata.get('event_name', '')
155
156        result_str = (match_result.result if match_result else 'unknown') or 'unknown'
157        emoji = _RESULT_EMOJI.get(result_str, '❓')
158        color = _RESULT_COLORS.get(result_str, _RESULT_COLORS['unknown'])
159
160        # Title
161        type_label = match_type.capitalize() if match_type else 'Match'
162        title = f"{type_label} {match_number} — {result_str.upper()} {emoji}"
163        if event_name:
164            title = f"[{event_name.upper()}] {title}"
165
166        fields = []
167
168        # --- Score + alliances ---
169        if match_result:
170            our = (match_result.our_alliance or '').upper()
171            opp = 'BLUE' if our == 'RED' else 'RED' if our == 'BLUE' else '?'
172            our_score = match_result.our_score if match_result.our_score is not None else '?'
173            opp_score = match_result.opponent_score if match_result.opponent_score is not None else '?'
174            fields.append({
175                'name': '🏁 Score',
176                'value': f"**{our or '?'}** {our_score} — {opp_score} **{opp}**",
177                'inline': True,
178            })
179            fields.append({
180                'name': '🔴 Red Alliance',
181                'value': _teams_str(match_result.red.teams),
182                'inline': True,
183            })
184            fields.append({
185                'name': '🔵 Blue Alliance',
186                'value': _teams_str(match_result.blue.teams),
187                'inline': True,
188            })
189
190        # --- Battery ---
191        v = stats
192        if v.avg_voltage is not None:
193            parts = []
194            if v.start_voltage is not None:
195                parts.append(f"Start **{v.start_voltage}V**")
196            if v.avg_voltage is not None:
197                parts.append(f"Avg **{v.avg_voltage}V**")
198            if v.min_voltage is not None:
199                parts.append(f"Min **{v.min_voltage}V**")
200            fields.append({
201                'name': '🔋 Battery',
202                'value': ' · '.join(parts),
203                'inline': True,
204            })
205
206        # --- Brownouts ---
207        if v.brownout_count > 0:
208            ts = ', '.join(f"{t}s" for t in v.brownout_timestamps_sec[:5])
209            extra = f' (+{v.brownout_count - 5} more)' if v.brownout_count > 5 else ''
210            bo_val = f"**{v.brownout_count}** @ {ts}{extra}"
211        else:
212            bo_val = "None ✅"
213        fields.append({'name': '⚡ Brownouts', 'value': bo_val, 'inline': True})
214
215        # --- Disconnects ---
216        if v.disconnect_count > 0:
217            ts = ', '.join(f"{t}s" for t in v.disconnect_timestamps_sec[:3])
218            extra = f' (+{v.disconnect_count - 3} more)' if v.disconnect_count > 3 else ''
219            fields.append({
220                'name': '🔌 Disconnects',
221                'value': f"**{v.disconnect_count}** @ {ts}{extra}",
222                'inline': True,
223            })
224
225        # --- DS log stats ---
226        if stats.ds_log and stats.ds_log.records:
227            ds = stats.ds_log
228            if ds.avg_packet_loss_pct is not None:
229                fields.append({
230                    'name': '📡 Packet Loss',
231                    'value': f"**{ds.avg_packet_loss_pct}%**",
232                    'inline': True,
233                })
234            if ds.avg_trip_time_ms is not None:
235                fields.append({
236                    'name': '⏱ Latency',
237                    'value': f"avg **{ds.avg_trip_time_ms}ms** / max **{ds.max_trip_time_ms}ms**",
238                    'inline': True,
239                })
240
241        # --- OPR / rank ---
242        if event_stats:
243            es = event_stats
244            opr_parts = []
245            if es.opr is not None:
246                opr_parts.append(f"OPR **{es.opr:.1f}**")
247            if es.dpr is not None:
248                opr_parts.append(f"DPR **{es.dpr:.1f}**")
249            if es.ccwm is not None:
250                opr_parts.append(f"CCWM **{es.ccwm:.1f}**")
251            if opr_parts:
252                fields.append({
253                    'name': '📊 Event Stats',
254                    'value': ' · '.join(opr_parts),
255                    'inline': True,
256                })
257            if es.rank is not None:
258                record = ''
259                if es.wins is not None:
260                    record = f" ({es.wins}W-{es.losses}L-{es.ties}T"
261                    if es.ranking_points is not None:
262                        record += f", {es.ranking_points:.0f} RP"
263                    record += ")"
264                fields.append({
265                    'name': '🏆 Rank',
266                    'value': f"**#{es.rank}**{record}",
267                    'inline': True,
268                })
269
270        # --- Code / controller hash ---
271        if self._repo_root:
272            git_hash = _git_short_hash(self._repo_root)
273            if git_hash:
274                is_clean = _git_is_clean(self._repo_root)
275                dirty = '' if is_clean else ' ⚠ dirty'
276                fields.append({
277                    'name': '🔧 Robot Code',
278                    'value': f"`{git_hash}`{dirty}",
279                    'inline': True,
280                })
281
282            if stats.controller_config_hash:
283                local_hash = _controller_yaml_hash(self._repo_root)
284                if local_hash is not None:
285                    match_icon = '✅' if local_hash == stats.controller_config_hash else '⚠ mismatch'
286                    fields.append({
287                        'name': '🎮 Controller Config',
288                        'value': f"`{stats.controller_config_hash}` {match_icon}",
289                        'inline': True,
290                    })
291                else:
292                    fields.append({
293                        'name': '🎮 Controller Config',
294                        'value': f"`{stats.controller_config_hash}`",
295                        'inline': True,
296                    })
297
298        # --- Links ---
299        links = []
300        if match_result and match_result.match_key:
301            links.append(
302                f"[Blue Alliance](https://www.thebluealliance.com/match/{match_result.match_key})"
303            )
304        if match_result and match_result.event_key:
305            year = match_result.event_key[:4]
306            code = match_result.event_key[4:]
307            links.append(
308                f"[FRC Events](https://frc-events.firstinspires.org/{year}/{code})"
309            )
310        if match_result and match_result.video_url:
311            links.append(f"[Match Video]({match_result.video_url})")
312        if links:
313            fields.append({
314                'name': '🔗 Links',
315                'value': ' · '.join(links),
316                'inline': False,
317            })
318
319        # --- Quote ---
320        fields.append({
321            'name': '\u200b',   # zero-width space separator
322            'value': format_quote(),
323            'inline': False,
324        })
325
326        embed = {
327            'title': title,
328            'color': color,
329            'fields': fields,
330            'footer': {
331                'text': f"Team 3200 Raptacon · {datetime.now():%Y-%m-%d %H:%M:%S}",
332            },
333        }
334        # Clickable title → TBA event page
335        if match_result and match_result.event_key:
336            embed['url'] = (
337                f"https://www.thebluealliance.com/event/{match_result.event_key}"
338            )
339
340        return embed
341
342    # ------------------------------------------------------------------
343    # HTTP
344    # ------------------------------------------------------------------
345
346    def _post(self, payload: dict) -> None:
347        data = json.dumps(payload).encode('utf-8')
348        req = urllib.request.Request(
349            self._webhook_url,
350            data=data,
351            headers={
352                'Content-Type': 'application/json',
353                'User-Agent': 'DiscordBot (https://github.com/Raptacon/Robot-2026, 1.0)',
354            },
355            method='POST',
356        )
357        try:
358            with urllib.request.urlopen(req, timeout=10) as resp:
359                if resp.status not in (200, 204):
360                    logger.warning(f"Discord webhook returned {resp.status}")
361        except urllib.error.HTTPError as e:
362            body = e.read().decode(errors='replace')[:200]
363            logger.warning(f"Discord webhook HTTP error {e.code}: {body}")
364        except urllib.error.URLError as e:
365            logger.warning(f"Discord webhook unreachable: {e.reason}")
366
367    def _post_multipart(self, payload: dict,
368                        files: list[tuple[str, bytes, str]]) -> None:
369        """POST with multipart/form-data to attach files alongside JSON payload.
370
371        Args:
372            payload: JSON payload (sent as 'payload_json' part).
373            files: List of (filename, data_bytes, content_type) tuples.
374        """
375        import uuid
376        boundary = uuid.uuid4().hex
377
378        parts = []
379        # JSON payload part
380        parts.append(
381            f'--{boundary}\r\n'
382            f'Content-Disposition: form-data; name="payload_json"\r\n'
383            f'Content-Type: application/json\r\n\r\n'
384            f'{json.dumps(payload)}\r\n'
385        )
386        # File parts
387        for i, (filename, data, content_type) in enumerate(files):
388            header = (
389                f'--{boundary}\r\n'
390                f'Content-Disposition: form-data; name="files[{i}]"; '
391                f'filename="{filename}"\r\n'
392                f'Content-Type: {content_type}\r\n\r\n'
393            )
394            parts.append(header)
395            parts.append(data)
396            parts.append(b'\r\n')
397
398        parts.append(f'--{boundary}--\r\n')
399
400        # Build body
401        body = b''
402        for part in parts:
403            if isinstance(part, str):
404                body += part.encode('utf-8')
405            else:
406                body += part
407
408        req = urllib.request.Request(
409            self._webhook_url,
410            data=body,
411            headers={
412                'Content-Type': f'multipart/form-data; boundary={boundary}',
413                'User-Agent': 'DiscordBot (https://github.com/Raptacon/Robot-2026, 1.0)',
414            },
415            method='POST',
416        )
417        try:
418            with urllib.request.urlopen(req, timeout=15) as resp:
419                if resp.status not in (200, 204):
420                    logger.warning(f"Discord webhook returned {resp.status}")
421        except urllib.error.HTTPError as e:
422            body_text = e.read().decode(errors='replace')[:200]
423            logger.warning(f"Discord webhook HTTP error {e.code}: {body_text}")
424        except urllib.error.URLError as e:
425            logger.warning(f"Discord webhook unreachable: {e.reason}")
logger = <Logger match_monitor (DEBUG)>
class DiscordNotifier(host.match_monitor.callbacks.MatchCompleteCallback):
 94class DiscordNotifier(MatchCompleteCallback):
 95    """Posts a rich embed to a Discord webhook after each match."""
 96
 97    def __init__(self, webhook_url: str,
 98                 repo_root: Optional[Path] = None) -> None:
 99        self._webhook_url = webhook_url
100        self._repo_root = repo_root
101
102    @property
103    def webhook_url(self) -> str:
104        return self._webhook_url
105
106    @webhook_url.setter
107    def webhook_url(self, url: str) -> None:
108        self._webhook_url = url.strip()
109
110    def on_match_complete(self, match_dir: Path, metadata: dict,
111                          stats: MatchStats,
112                          match_result: Optional['MatchResult'] = None,
113                          event_stats: Optional['EventStats'] = None) -> None:
114        if not self._webhook_url:
115            logger.debug("Discord notification skipped — no webhook URL")
116            return
117        try:
118            embed = self._build_embed(metadata, stats, match_result, event_stats)
119
120            # Generate voltage chart if timeseries data is available
121            chart_png = b''
122            if stats.voltage_timeseries:
123                try:
124                    from .chart_generator import voltage_chart
125                    chart_png = voltage_chart(
126                        stats.voltage_timeseries,
127                        brownout_times=stats.brownout_timestamps_sec,
128                        mode_transitions=stats.mode_transitions,
129                        match_duration=stats.match_duration_seconds,
130                    )
131                except Exception:
132                    logger.exception("Chart generation failed (non-fatal)")
133
134            if chart_png:
135                embed['image'] = {'url': 'attachment://voltage.png'}
136                self._post_multipart(
137                    {'embeds': [embed]},
138                    files=[('voltage.png', chart_png, 'image/png')],
139                )
140            else:
141                self._post({'embeds': [embed]})
142            logger.info("Discord notification sent")
143        except Exception:
144            logger.exception("Discord notification failed (non-fatal)")
145
146    # ------------------------------------------------------------------
147    # Embed construction
148    # ------------------------------------------------------------------
149
150    def _build_embed(self, metadata: dict, stats: MatchStats,
151                     match_result: Optional['MatchResult'],
152                     event_stats: Optional['EventStats']) -> dict:
153        match_type = metadata.get('match_type', 'Match')
154        match_number = metadata.get('match_number', '?')
155        event_name = metadata.get('event_name', '')
156
157        result_str = (match_result.result if match_result else 'unknown') or 'unknown'
158        emoji = _RESULT_EMOJI.get(result_str, '❓')
159        color = _RESULT_COLORS.get(result_str, _RESULT_COLORS['unknown'])
160
161        # Title
162        type_label = match_type.capitalize() if match_type else 'Match'
163        title = f"{type_label} {match_number} — {result_str.upper()} {emoji}"
164        if event_name:
165            title = f"[{event_name.upper()}] {title}"
166
167        fields = []
168
169        # --- Score + alliances ---
170        if match_result:
171            our = (match_result.our_alliance or '').upper()
172            opp = 'BLUE' if our == 'RED' else 'RED' if our == 'BLUE' else '?'
173            our_score = match_result.our_score if match_result.our_score is not None else '?'
174            opp_score = match_result.opponent_score if match_result.opponent_score is not None else '?'
175            fields.append({
176                'name': '🏁 Score',
177                'value': f"**{our or '?'}** {our_score} — {opp_score} **{opp}**",
178                'inline': True,
179            })
180            fields.append({
181                'name': '🔴 Red Alliance',
182                'value': _teams_str(match_result.red.teams),
183                'inline': True,
184            })
185            fields.append({
186                'name': '🔵 Blue Alliance',
187                'value': _teams_str(match_result.blue.teams),
188                'inline': True,
189            })
190
191        # --- Battery ---
192        v = stats
193        if v.avg_voltage is not None:
194            parts = []
195            if v.start_voltage is not None:
196                parts.append(f"Start **{v.start_voltage}V**")
197            if v.avg_voltage is not None:
198                parts.append(f"Avg **{v.avg_voltage}V**")
199            if v.min_voltage is not None:
200                parts.append(f"Min **{v.min_voltage}V**")
201            fields.append({
202                'name': '🔋 Battery',
203                'value': ' · '.join(parts),
204                'inline': True,
205            })
206
207        # --- Brownouts ---
208        if v.brownout_count > 0:
209            ts = ', '.join(f"{t}s" for t in v.brownout_timestamps_sec[:5])
210            extra = f' (+{v.brownout_count - 5} more)' if v.brownout_count > 5 else ''
211            bo_val = f"**{v.brownout_count}** @ {ts}{extra}"
212        else:
213            bo_val = "None ✅"
214        fields.append({'name': '⚡ Brownouts', 'value': bo_val, 'inline': True})
215
216        # --- Disconnects ---
217        if v.disconnect_count > 0:
218            ts = ', '.join(f"{t}s" for t in v.disconnect_timestamps_sec[:3])
219            extra = f' (+{v.disconnect_count - 3} more)' if v.disconnect_count > 3 else ''
220            fields.append({
221                'name': '🔌 Disconnects',
222                'value': f"**{v.disconnect_count}** @ {ts}{extra}",
223                'inline': True,
224            })
225
226        # --- DS log stats ---
227        if stats.ds_log and stats.ds_log.records:
228            ds = stats.ds_log
229            if ds.avg_packet_loss_pct is not None:
230                fields.append({
231                    'name': '📡 Packet Loss',
232                    'value': f"**{ds.avg_packet_loss_pct}%**",
233                    'inline': True,
234                })
235            if ds.avg_trip_time_ms is not None:
236                fields.append({
237                    'name': '⏱ Latency',
238                    'value': f"avg **{ds.avg_trip_time_ms}ms** / max **{ds.max_trip_time_ms}ms**",
239                    'inline': True,
240                })
241
242        # --- OPR / rank ---
243        if event_stats:
244            es = event_stats
245            opr_parts = []
246            if es.opr is not None:
247                opr_parts.append(f"OPR **{es.opr:.1f}**")
248            if es.dpr is not None:
249                opr_parts.append(f"DPR **{es.dpr:.1f}**")
250            if es.ccwm is not None:
251                opr_parts.append(f"CCWM **{es.ccwm:.1f}**")
252            if opr_parts:
253                fields.append({
254                    'name': '📊 Event Stats',
255                    'value': ' · '.join(opr_parts),
256                    'inline': True,
257                })
258            if es.rank is not None:
259                record = ''
260                if es.wins is not None:
261                    record = f" ({es.wins}W-{es.losses}L-{es.ties}T"
262                    if es.ranking_points is not None:
263                        record += f", {es.ranking_points:.0f} RP"
264                    record += ")"
265                fields.append({
266                    'name': '🏆 Rank',
267                    'value': f"**#{es.rank}**{record}",
268                    'inline': True,
269                })
270
271        # --- Code / controller hash ---
272        if self._repo_root:
273            git_hash = _git_short_hash(self._repo_root)
274            if git_hash:
275                is_clean = _git_is_clean(self._repo_root)
276                dirty = '' if is_clean else ' ⚠ dirty'
277                fields.append({
278                    'name': '🔧 Robot Code',
279                    'value': f"`{git_hash}`{dirty}",
280                    'inline': True,
281                })
282
283            if stats.controller_config_hash:
284                local_hash = _controller_yaml_hash(self._repo_root)
285                if local_hash is not None:
286                    match_icon = '✅' if local_hash == stats.controller_config_hash else '⚠ mismatch'
287                    fields.append({
288                        'name': '🎮 Controller Config',
289                        'value': f"`{stats.controller_config_hash}` {match_icon}",
290                        'inline': True,
291                    })
292                else:
293                    fields.append({
294                        'name': '🎮 Controller Config',
295                        'value': f"`{stats.controller_config_hash}`",
296                        'inline': True,
297                    })
298
299        # --- Links ---
300        links = []
301        if match_result and match_result.match_key:
302            links.append(
303                f"[Blue Alliance](https://www.thebluealliance.com/match/{match_result.match_key})"
304            )
305        if match_result and match_result.event_key:
306            year = match_result.event_key[:4]
307            code = match_result.event_key[4:]
308            links.append(
309                f"[FRC Events](https://frc-events.firstinspires.org/{year}/{code})"
310            )
311        if match_result and match_result.video_url:
312            links.append(f"[Match Video]({match_result.video_url})")
313        if links:
314            fields.append({
315                'name': '🔗 Links',
316                'value': ' · '.join(links),
317                'inline': False,
318            })
319
320        # --- Quote ---
321        fields.append({
322            'name': '\u200b',   # zero-width space separator
323            'value': format_quote(),
324            'inline': False,
325        })
326
327        embed = {
328            'title': title,
329            'color': color,
330            'fields': fields,
331            'footer': {
332                'text': f"Team 3200 Raptacon · {datetime.now():%Y-%m-%d %H:%M:%S}",
333            },
334        }
335        # Clickable title → TBA event page
336        if match_result and match_result.event_key:
337            embed['url'] = (
338                f"https://www.thebluealliance.com/event/{match_result.event_key}"
339            )
340
341        return embed
342
343    # ------------------------------------------------------------------
344    # HTTP
345    # ------------------------------------------------------------------
346
347    def _post(self, payload: dict) -> None:
348        data = json.dumps(payload).encode('utf-8')
349        req = urllib.request.Request(
350            self._webhook_url,
351            data=data,
352            headers={
353                'Content-Type': 'application/json',
354                'User-Agent': 'DiscordBot (https://github.com/Raptacon/Robot-2026, 1.0)',
355            },
356            method='POST',
357        )
358        try:
359            with urllib.request.urlopen(req, timeout=10) as resp:
360                if resp.status not in (200, 204):
361                    logger.warning(f"Discord webhook returned {resp.status}")
362        except urllib.error.HTTPError as e:
363            body = e.read().decode(errors='replace')[:200]
364            logger.warning(f"Discord webhook HTTP error {e.code}: {body}")
365        except urllib.error.URLError as e:
366            logger.warning(f"Discord webhook unreachable: {e.reason}")
367
368    def _post_multipart(self, payload: dict,
369                        files: list[tuple[str, bytes, str]]) -> None:
370        """POST with multipart/form-data to attach files alongside JSON payload.
371
372        Args:
373            payload: JSON payload (sent as 'payload_json' part).
374            files: List of (filename, data_bytes, content_type) tuples.
375        """
376        import uuid
377        boundary = uuid.uuid4().hex
378
379        parts = []
380        # JSON payload part
381        parts.append(
382            f'--{boundary}\r\n'
383            f'Content-Disposition: form-data; name="payload_json"\r\n'
384            f'Content-Type: application/json\r\n\r\n'
385            f'{json.dumps(payload)}\r\n'
386        )
387        # File parts
388        for i, (filename, data, content_type) in enumerate(files):
389            header = (
390                f'--{boundary}\r\n'
391                f'Content-Disposition: form-data; name="files[{i}]"; '
392                f'filename="{filename}"\r\n'
393                f'Content-Type: {content_type}\r\n\r\n'
394            )
395            parts.append(header)
396            parts.append(data)
397            parts.append(b'\r\n')
398
399        parts.append(f'--{boundary}--\r\n')
400
401        # Build body
402        body = b''
403        for part in parts:
404            if isinstance(part, str):
405                body += part.encode('utf-8')
406            else:
407                body += part
408
409        req = urllib.request.Request(
410            self._webhook_url,
411            data=body,
412            headers={
413                'Content-Type': f'multipart/form-data; boundary={boundary}',
414                'User-Agent': 'DiscordBot (https://github.com/Raptacon/Robot-2026, 1.0)',
415            },
416            method='POST',
417        )
418        try:
419            with urllib.request.urlopen(req, timeout=15) as resp:
420                if resp.status not in (200, 204):
421                    logger.warning(f"Discord webhook returned {resp.status}")
422        except urllib.error.HTTPError as e:
423            body_text = e.read().decode(errors='replace')[:200]
424            logger.warning(f"Discord webhook HTTP error {e.code}: {body_text}")
425        except urllib.error.URLError as e:
426            logger.warning(f"Discord webhook unreachable: {e.reason}")

Posts a rich embed to a Discord webhook after each match.

DiscordNotifier(webhook_url: str, repo_root: pathlib.Path | None = None)
 97    def __init__(self, webhook_url: str,
 98                 repo_root: Optional[Path] = None) -> None:
 99        self._webhook_url = webhook_url
100        self._repo_root = repo_root
webhook_url: str
102    @property
103    def webhook_url(self) -> str:
104        return self._webhook_url
def on_match_complete( self, match_dir: pathlib.Path, metadata: dict, stats: host.match_monitor.analyzer.MatchStats, match_result: host.match_monitor.match_data_client.MatchResult | None = None, event_stats: host.match_monitor.match_data_client.EventStats | None = None) -> None:
110    def on_match_complete(self, match_dir: Path, metadata: dict,
111                          stats: MatchStats,
112                          match_result: Optional['MatchResult'] = None,
113                          event_stats: Optional['EventStats'] = None) -> None:
114        if not self._webhook_url:
115            logger.debug("Discord notification skipped — no webhook URL")
116            return
117        try:
118            embed = self._build_embed(metadata, stats, match_result, event_stats)
119
120            # Generate voltage chart if timeseries data is available
121            chart_png = b''
122            if stats.voltage_timeseries:
123                try:
124                    from .chart_generator import voltage_chart
125                    chart_png = voltage_chart(
126                        stats.voltage_timeseries,
127                        brownout_times=stats.brownout_timestamps_sec,
128                        mode_transitions=stats.mode_transitions,
129                        match_duration=stats.match_duration_seconds,
130                    )
131                except Exception:
132                    logger.exception("Chart generation failed (non-fatal)")
133
134            if chart_png:
135                embed['image'] = {'url': 'attachment://voltage.png'}
136                self._post_multipart(
137                    {'embeds': [embed]},
138                    files=[('voltage.png', chart_png, 'image/png')],
139                )
140            else:
141                self._post({'embeds': [embed]})
142            logger.info("Discord notification sent")
143        except Exception:
144            logger.exception("Discord notification failed (non-fatal)")

Called after match log analysis completes.

Args: match_dir: Directory containing the match log files. metadata: Dict with event_name, match_type, match_number. stats: Extracted match statistics. match_result: Official match result from TBA/FRC Events API, or None. event_stats: OPR/rankings data for our team, or None.