Skip to content

Commit e602758

Browse files
committed
refactor: rename operator Discord pings out of infrastructure/webhook
infrastructure/webhook was never the webhooks product — it is the operator-notification transport for contact/report pings. Rebuild it as infrastructure/ops_notify with a semantic OpsNotifier protocol; the Discord impl now owns channel routing and all embed formatting. Frees the webhook vocabulary for the upcoming webhooks system.
1 parent ffd5417 commit e602758

12 files changed

Lines changed: 480 additions & 432 deletions

File tree

dependencies/wiring.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020
from infrastructure.cloudflare_client import CloudflareClient
2121
from infrastructure.cloudflare_kv import CloudflareKVClient
2222
from infrastructure.logging import get_logger
23+
from infrastructure.ops_notify import DiscordOpsNotifier
2324
from infrastructure.storage.r2 import R2StorageClient
24-
from infrastructure.webhook.discord import DiscordWebhookProvider
2525
from repositories.api_key_repository import ApiKeyRepository
2626
from repositories.app_grant_repository import AppGrantRepository
2727
from repositories.blocked_domain_repository import BlockedDomainRepository
@@ -127,8 +127,11 @@ def wire_services(app: FastAPI, settings: AppSettings, redis_client) -> None:
127127
negative_ttl_seconds=settings.redis.feature_flag_negative_ttl_seconds,
128128
)
129129
captcha = HCaptchaProvider(settings.hcaptcha_secret, http_client)
130-
contact_webhook = DiscordWebhookProvider(settings.contact_webhook, http_client)
131-
report_webhook = DiscordWebhookProvider(settings.url_report_webhook, http_client)
130+
# One notifier, two channels — env vars keep their shipped names
131+
# (CONTACT_WEBHOOK / URL_REPORT_WEBHOOK, they ARE Discord webhook URLs).
132+
ops_notifier = DiscordOpsNotifier(
133+
settings.contact_webhook, settings.url_report_webhook, http_client
134+
)
132135

133136
# Edge KV client, shared by the og write-through and the bulk ops'
134137
# edge flush. None when the edge cache isn't configured (self-host) —
@@ -247,15 +250,15 @@ def wire_services(app: FastAPI, settings: AppSettings, redis_client) -> None:
247250
max_date_range_days=settings.max_date_range_days,
248251
)
249252
# Report intake shares the resolver (existence checks answer from the
250-
# same generation the redirect serves) and the report webhook + captcha
253+
# same generation the redirect serves) and the ops notifier + captcha
251254
# already built above for ContactService.
252255
app.state.report_intake_service = ReportIntakeService(
253256
ReportRepository(db["reports"]),
254257
ReportSubmissionRepository(db["report_submissions"]),
255258
public_link_resolver,
256259
url_repo,
257260
captcha,
258-
report_webhook,
261+
ops_notifier,
259262
system_default_domain=settings.system_default_domain,
260263
)
261264
app.state.export_service = ExportService(
@@ -313,8 +316,7 @@ def wire_services(app: FastAPI, settings: AppSettings, redis_client) -> None:
313316
key_secret=settings.secret_key,
314317
)
315318
app.state.contact_service = ContactService(
316-
contact_webhook,
317-
report_webhook,
319+
ops_notifier,
318320
captcha,
319321
)
320322

infrastructure/ops_notify.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
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 structures are preserved exactly from the previous per-service
64+
builders (originally ``utils/contact_utils.py``).
65+
"""
66+
67+
def __init__(
68+
self, contact_url: str, report_url: str, http_client: HttpClient
69+
) -> None:
70+
self._contact_url = contact_url
71+
self._report_url = report_url
72+
self._http = http_client
73+
74+
# ── OpsNotifier ───────────────────────────────────────────────────────────
75+
76+
async def contact_message(self, email: str, message: str) -> bool:
77+
payload = {
78+
"embeds": [
79+
{
80+
"title": "New Contact Message ✉️",
81+
"color": _CONTACT_COLOR,
82+
"fields": [
83+
{"name": "Email", "value": f"```{email}```"},
84+
{"name": "Message", "value": f"```{message}```"},
85+
],
86+
"timestamp": datetime.now(timezone.utc).isoformat(),
87+
"footer": _FOOTER,
88+
}
89+
]
90+
}
91+
return await self._deliver(self._contact_url, payload, kind="contact_message")
92+
93+
async def url_report(
94+
self, short_code: str, reason: str, ip_address: str, app_url: str
95+
) -> bool:
96+
payload = {
97+
"embeds": [
98+
{
99+
"title": f"URL Report for `{short_code}`",
100+
"color": _REPORT_COLOR,
101+
"url": f"{app_url}stats/{short_code}",
102+
"fields": [
103+
{"name": "Short Code", "value": f"```{short_code}```"},
104+
{"name": "Reason", "value": f"```{reason}```"},
105+
{"name": "IP Address", "value": f"```{ip_address}```"},
106+
],
107+
"timestamp": datetime.now(timezone.utc).isoformat(),
108+
"footer": _FOOTER,
109+
}
110+
]
111+
}
112+
return await self._deliver(self._report_url, payload, kind="url_report")
113+
114+
async def report_summary(
115+
self,
116+
*,
117+
submission_id: str,
118+
source: str,
119+
authenticated: bool,
120+
accepted: list[tuple[str, str]],
121+
rejected_count: int,
122+
reporter_email: str | None,
123+
reporter_org: str | None,
124+
ip: str,
125+
now: datetime,
126+
) -> bool:
127+
"""ONE embed per submission — counts, source, up to
128+
``_SUMMARY_MAX_LISTED`` targets with reasons, submission id.
129+
130+
``accepted`` carries ``(display_target, reason)`` pairs; ``now``
131+
is the submission timestamp already stamped on the audit record,
132+
so the embed and the record can never disagree.
133+
"""
134+
fields: list[dict[str, Any]] = [
135+
{"name": "Submission ID", "value": f"```{submission_id}```"},
136+
{
137+
"name": "Source",
138+
"value": (
139+
f"```{source} · "
140+
f"{'authenticated' if authenticated else 'anonymous'}```"
141+
),
142+
},
143+
{
144+
"name": "Accepted / Rejected",
145+
"value": f"```{len(accepted)} / {rejected_count}```",
146+
},
147+
]
148+
149+
if accepted:
150+
lines = []
151+
for display, reason in accepted[:_SUMMARY_MAX_LISTED]:
152+
line = f"{display}{reason}"
153+
if len(line) > _SUMMARY_LINE_MAX:
154+
line = line[: _SUMMARY_LINE_MAX - 1] + "…"
155+
lines.append(line)
156+
overflow = len(accepted) - _SUMMARY_MAX_LISTED
157+
if overflow > 0:
158+
lines.append(f"… and {overflow} more")
159+
fields.append(
160+
{"name": "Reported Links", "value": "```" + "\n".join(lines) + "```"}
161+
)
162+
163+
if reporter_email or reporter_org:
164+
fields.append(
165+
{
166+
"name": "Reporter",
167+
"value": f"```{reporter_email or '—'} · {reporter_org or '—'}```",
168+
}
169+
)
170+
171+
fields.append({"name": "IP Address", "value": f"```{ip}```"})
172+
173+
payload = {
174+
"embeds": [
175+
{
176+
"title": "New URL Report Submission",
177+
"color": _REPORT_COLOR,
178+
"fields": fields,
179+
"timestamp": now.isoformat(),
180+
"footer": _FOOTER,
181+
}
182+
]
183+
}
184+
return await self._deliver(self._report_url, payload, kind="report_summary")
185+
186+
# ── Delivery ──────────────────────────────────────────────────────────────
187+
188+
async def _deliver(self, url: str, payload: dict[str, Any], *, kind: str) -> bool:
189+
if not url:
190+
log.warning("ops_notify_not_configured", kind=kind)
191+
return False
192+
try:
193+
response = await self._http.post(url, json=payload)
194+
if response.status_code in (200, 204):
195+
return True
196+
log.warning(
197+
"ops_notify_failed",
198+
kind=kind,
199+
status_code=response.status_code,
200+
response_text=response.text[:200],
201+
)
202+
return False
203+
except Exception as e:
204+
log.error(
205+
"ops_notify_request_failed",
206+
kind=kind,
207+
error=str(e),
208+
error_type=type(e).__name__,
209+
)
210+
return False

infrastructure/webhook/__init__.py

Lines changed: 0 additions & 1 deletion
This file was deleted.

infrastructure/webhook/discord.py

Lines changed: 0 additions & 42 deletions
This file was deleted.

infrastructure/webhook/protocol.py

Lines changed: 0 additions & 7 deletions
This file was deleted.

0 commit comments

Comments
 (0)