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}")
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.
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.