|
| 1 | +"""Operator notifications — pings to the maintainer's Discord server. |
| 2 | +
|
| 3 | +NOT the user-facing webhooks product: this is the internal channel that |
| 4 | +tells the operator a visitor submitted the contact form or reported a |
| 5 | +URL. It happens to deliver over Discord webhook URLs, which is why it |
| 6 | +used to live in ``infrastructure/webhook/`` — that name is reserved for |
| 7 | +the real webhooks system. |
| 8 | +
|
| 9 | +``OpsNotifier`` is semantic: callers state WHAT happened; the |
| 10 | +implementation owns channel routing and every Discord specific (embed |
| 11 | +structure, colors, footer). Send failures return ``False`` and never |
| 12 | +raise — callers decide whether a failed ping is fatal. |
| 13 | +""" |
| 14 | + |
| 15 | +from __future__ import annotations |
| 16 | + |
| 17 | +from datetime import datetime, timezone |
| 18 | +from typing import Any, Protocol |
| 19 | + |
| 20 | +from infrastructure.http_client import HttpClient |
| 21 | +from infrastructure.logging import get_logger |
| 22 | + |
| 23 | +log = get_logger(__name__) |
| 24 | + |
| 25 | +_FOOTER = { |
| 26 | + "text": "spoo-me", |
| 27 | + "icon_url": "https://spoo.me/static/images/favicon.png", |
| 28 | +} |
| 29 | +_CONTACT_COLOR = 9103397 |
| 30 | +_REPORT_COLOR = 14177041 |
| 31 | + |
| 32 | +# Summary embed: list at most this many targets, then "… and N more". |
| 33 | +_SUMMARY_MAX_LISTED = 10 |
| 34 | +_SUMMARY_LINE_MAX = 80 |
| 35 | + |
| 36 | + |
| 37 | +class OpsNotifier(Protocol): |
| 38 | + async def contact_message(self, email: str, message: str) -> bool: ... |
| 39 | + |
| 40 | + async def url_report( |
| 41 | + self, short_code: str, reason: str, ip_address: str, app_url: str |
| 42 | + ) -> bool: ... |
| 43 | + |
| 44 | + async def report_summary( |
| 45 | + self, |
| 46 | + *, |
| 47 | + submission_id: str, |
| 48 | + source: str, |
| 49 | + authenticated: bool, |
| 50 | + accepted: list[tuple[str, str]], |
| 51 | + rejected_count: int, |
| 52 | + reporter_email: str | None, |
| 53 | + reporter_org: str | None, |
| 54 | + ip: str, |
| 55 | + now: datetime, |
| 56 | + ) -> bool: ... |
| 57 | + |
| 58 | + |
| 59 | +class DiscordOpsNotifier: |
| 60 | + """Discord implementation — routes each notification to its channel |
| 61 | + (contact vs reports) and builds the embeds. |
| 62 | +
|
| 63 | + Embed shapes are pinned by the integration tests (test_contact / |
| 64 | + test_reports run this class over a capturing HTTP fake): change a |
| 65 | + field here and a test breaks. |
| 66 | + """ |
| 67 | + |
| 68 | + def __init__( |
| 69 | + self, contact_url: str, report_url: str, http_client: HttpClient |
| 70 | + ) -> None: |
| 71 | + self._contact_url = contact_url |
| 72 | + self._report_url = report_url |
| 73 | + self._http = http_client |
| 74 | + |
| 75 | + # ── OpsNotifier ─────────────────────────────────────────────────────────── |
| 76 | + |
| 77 | + async def contact_message(self, email: str, message: str) -> bool: |
| 78 | + payload = { |
| 79 | + "embeds": [ |
| 80 | + { |
| 81 | + "title": "New Contact Message ✉️", |
| 82 | + "color": _CONTACT_COLOR, |
| 83 | + "fields": [ |
| 84 | + {"name": "Email", "value": f"```{email}```"}, |
| 85 | + {"name": "Message", "value": f"```{message}```"}, |
| 86 | + ], |
| 87 | + "timestamp": datetime.now(timezone.utc).isoformat(), |
| 88 | + "footer": _FOOTER, |
| 89 | + } |
| 90 | + ] |
| 91 | + } |
| 92 | + return await self._deliver(self._contact_url, payload, kind="contact_message") |
| 93 | + |
| 94 | + async def url_report( |
| 95 | + self, short_code: str, reason: str, ip_address: str, app_url: str |
| 96 | + ) -> bool: |
| 97 | + payload = { |
| 98 | + "embeds": [ |
| 99 | + { |
| 100 | + "title": f"URL Report for `{short_code}`", |
| 101 | + "color": _REPORT_COLOR, |
| 102 | + "url": f"{app_url}stats/{short_code}", |
| 103 | + "fields": [ |
| 104 | + {"name": "Short Code", "value": f"```{short_code}```"}, |
| 105 | + {"name": "Reason", "value": f"```{reason}```"}, |
| 106 | + {"name": "IP Address", "value": f"```{ip_address}```"}, |
| 107 | + ], |
| 108 | + "timestamp": datetime.now(timezone.utc).isoformat(), |
| 109 | + "footer": _FOOTER, |
| 110 | + } |
| 111 | + ] |
| 112 | + } |
| 113 | + return await self._deliver(self._report_url, payload, kind="url_report") |
| 114 | + |
| 115 | + async def report_summary( |
| 116 | + self, |
| 117 | + *, |
| 118 | + submission_id: str, |
| 119 | + source: str, |
| 120 | + authenticated: bool, |
| 121 | + accepted: list[tuple[str, str]], |
| 122 | + rejected_count: int, |
| 123 | + reporter_email: str | None, |
| 124 | + reporter_org: str | None, |
| 125 | + ip: str, |
| 126 | + now: datetime, |
| 127 | + ) -> bool: |
| 128 | + """ONE embed per submission — counts, source, up to |
| 129 | + ``_SUMMARY_MAX_LISTED`` targets with reasons, submission id. |
| 130 | +
|
| 131 | + ``accepted`` carries ``(display_target, reason)`` pairs; ``now`` |
| 132 | + is the submission timestamp already stamped on the audit record, |
| 133 | + so the embed and the record can never disagree. |
| 134 | + """ |
| 135 | + fields: list[dict[str, Any]] = [ |
| 136 | + {"name": "Submission ID", "value": f"```{submission_id}```"}, |
| 137 | + { |
| 138 | + "name": "Source", |
| 139 | + "value": ( |
| 140 | + f"```{source} · " |
| 141 | + f"{'authenticated' if authenticated else 'anonymous'}```" |
| 142 | + ), |
| 143 | + }, |
| 144 | + { |
| 145 | + "name": "Accepted / Rejected", |
| 146 | + "value": f"```{len(accepted)} / {rejected_count}```", |
| 147 | + }, |
| 148 | + ] |
| 149 | + |
| 150 | + if accepted: |
| 151 | + lines = [] |
| 152 | + for display, reason in accepted[:_SUMMARY_MAX_LISTED]: |
| 153 | + line = f"{display} — {reason}" |
| 154 | + if len(line) > _SUMMARY_LINE_MAX: |
| 155 | + line = line[: _SUMMARY_LINE_MAX - 1] + "…" |
| 156 | + lines.append(line) |
| 157 | + overflow = len(accepted) - _SUMMARY_MAX_LISTED |
| 158 | + if overflow > 0: |
| 159 | + lines.append(f"… and {overflow} more") |
| 160 | + fields.append( |
| 161 | + {"name": "Reported Links", "value": "```" + "\n".join(lines) + "```"} |
| 162 | + ) |
| 163 | + |
| 164 | + if reporter_email or reporter_org: |
| 165 | + fields.append( |
| 166 | + { |
| 167 | + "name": "Reporter", |
| 168 | + "value": f"```{reporter_email or '—'} · {reporter_org or '—'}```", |
| 169 | + } |
| 170 | + ) |
| 171 | + |
| 172 | + fields.append({"name": "IP Address", "value": f"```{ip}```"}) |
| 173 | + |
| 174 | + payload = { |
| 175 | + "embeds": [ |
| 176 | + { |
| 177 | + "title": "New URL Report Submission", |
| 178 | + "color": _REPORT_COLOR, |
| 179 | + "fields": fields, |
| 180 | + "timestamp": now.isoformat(), |
| 181 | + "footer": _FOOTER, |
| 182 | + } |
| 183 | + ] |
| 184 | + } |
| 185 | + return await self._deliver(self._report_url, payload, kind="report_summary") |
| 186 | + |
| 187 | + # ── Delivery ────────────────────────────────────────────────────────────── |
| 188 | + |
| 189 | + async def _deliver(self, url: str, payload: dict[str, Any], *, kind: str) -> bool: |
| 190 | + if not url: |
| 191 | + log.warning("ops_notify_not_configured", kind=kind) |
| 192 | + return False |
| 193 | + try: |
| 194 | + response = await self._http.post(url, json=payload) |
| 195 | + if response.status_code in (200, 204): |
| 196 | + return True |
| 197 | + log.warning( |
| 198 | + "ops_notify_failed", |
| 199 | + kind=kind, |
| 200 | + status_code=response.status_code, |
| 201 | + response_text=response.text[:200], |
| 202 | + ) |
| 203 | + return False |
| 204 | + except Exception as e: |
| 205 | + log.error( |
| 206 | + "ops_notify_request_failed", |
| 207 | + kind=kind, |
| 208 | + error=str(e), |
| 209 | + error_type=type(e).__name__, |
| 210 | + ) |
| 211 | + return False |
0 commit comments