Skip to content

Commit 297b936

Browse files
authored
Merge pull request #269 from spoo-me/refactor/ops-notify
refactor: move operator Discord pings out of infrastructure/webhook
2 parents ec280b5 + 4e52e12 commit 297b936

12 files changed

Lines changed: 481 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: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
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

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)