Crypto Rug Muncher commited on
Commit
7dfd7da
Β·
1 Parent(s): 7f512e5

fix: resolve 69 syntax errors from misindented imports and broken function defs

Browse files

Fixed syntax errors across 13 router files:
- Removed over-indented 'from app.core.redis import get_redis' imports
- Fixed incomplete function definitions (create_tier, queue_webhook_delivery)
- Corrected indentation in try blocks
- Removed stray closing braces

Files fixed:
- app/auth.py
- app/domain/news/router.py
- app/lifespan.py
- app/routers/admin_users_api.py
- app/routers/auth_extensions.py
- app/routers/developer_tier.py
- app/routers/persistent_state.py
- app/routers/status_page.py
- app/routers/subscription_pricing_api.py
- app/routers/webhook_pipeline.py
- app/routers/x402_alpha_revenue_tools.py
- app/routers/x402_enforcement.py
- app/routers/x402_tools.py

All syntax errors now resolved (ruff check --select=E9 passes)

app/auth.py CHANGED
@@ -862,7 +862,7 @@ async def x_callback(request: Request):
862
  return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=missing_code")
863
 
864
  import httpx
865
- from app.core.redis import get_redis
866
 
867
  client_id = os.getenv("X_CLIENT_ID")
868
  client_secret = os.getenv("X_CLIENT_SECRET")
 
862
  return RedirectResponse(f"{FRONTEND_URL}/auth/callback?error=missing_code")
863
 
864
  import httpx
865
+ from app.core.redis import get_redis
866
 
867
  client_id = os.getenv("X_CLIENT_ID")
868
  client_secret = os.getenv("X_CLIENT_SECRET")
app/domain/news/clusterer.py ADDED
@@ -0,0 +1,414 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """T03 β€” News story clustering (G04 FIX).
2
+
3
+ Per MINIMAX_M3_TASKS.md T03. MinHash + DBSCAN dedupes raw RSS items into
4
+ single "stories" so AI agents don't see the same CoinDesk/The Block story
5
+ counted 2-3x in their signal.
6
+
7
+ Algorithm:
8
+ 1. MinHash signature (128 permutations) on shingled title+body (first 500 chars)
9
+ 2. DBSCAN clusters within 30-minute windows, Jaccard threshold 0.6, eps=0.15
10
+ 3. Each cluster = one story with all source URLs, sentiment avg, item count
11
+ 4. Persist clusters to Postgres `news_clusters` table; raw items unchanged
12
+
13
+ Endpoints:
14
+ GET /api/v1/news?clustered=true returns stories (clusters), not raw items
15
+ GET /api/v1/news raw items (legacy)
16
+
17
+ This module is pure logic β€” no I/O at import time. Router/background job
18
+ call `cluster_items(items) -> list[StoryCluster]`.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import logging
24
+ import re
25
+ import time
26
+ from dataclasses import dataclass, field
27
+ from datetime import UTC, datetime, timedelta
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ # ── Tokenization ────────────────────────────────────────────────────
33
+ _TOKEN_RE = re.compile(r"[a-z0-9]{3,}", re.IGNORECASE)
34
+
35
+
36
+ def _shingles(text: str, k: int = 3) -> set[str]:
37
+ """k-shingle set of lowercased alphanumeric tokens. For Jaccard."""
38
+ if not text:
39
+ return set()
40
+ toks = _TOKEN_RE.findall(text.lower())
41
+ if len(toks) < k:
42
+ return set(toks)
43
+ return {" ".join(toks[i : i + k]) for i in range(len(toks) - k + 1)}
44
+
45
+
46
+ # ── MinHash ─────────────────────────────────────────────────────────
47
+ _NUM_PERM = 128
48
+ _MAX_HASH = (1 << 32) - 1
49
+
50
+
51
+ def _minhash_signature(shingles: set[str], seed: int = 42) -> list[int]:
52
+ """128-permutation MinHash signature of a shingle set.
53
+
54
+ Uses SHA-256 seeded permutations β€” fast, deterministic, no numpy.
55
+ """
56
+ if not shingles:
57
+ return [_MAX_HASH] * _NUM_PERM
58
+ sig: list[int] = []
59
+ for i in range(_NUM_PERM):
60
+ m = _MAX_HASH
61
+ for s in shingles:
62
+ h = int.from_bytes(
63
+ hashlib.sha256(f"{i}:{seed}:{s}".encode()).digest()[:4],
64
+ "big",
65
+ )
66
+ if h < m:
67
+ m = h
68
+ sig.append(m)
69
+ return sig
70
+
71
+
72
+ def _jaccard_minhash(a: list[int], b: list[int]) -> float:
73
+ """Estimate Jaccard similarity from two MinHash signatures."""
74
+ if not a or not b or len(a) != len(b):
75
+ return 0.0
76
+ return sum(1 for x, y in zip(a, b) if x == y) / len(a)
77
+
78
+
79
+ # ── DBSCAN (pure-python, no sklearn dep) ────────────────────────────
80
+ def _dbscan(
81
+ signatures: list[list[int]],
82
+ eps: float = 0.4,
83
+ min_samples: int = 2,
84
+ ) -> list[int]:
85
+ """Density-based clustering. Returns cluster id per item (-1 = noise).
86
+
87
+ Similarity is Jaccard (estimated via MinHash). Neighbours are pairs
88
+ with Jaccard distance <= eps (i.e. similarity >= 1 - eps).
89
+ Default eps=0.4 means similarity >= 0.6 (per T03 spec).
90
+ """
91
+ n = len(signatures)
92
+ labels = [-1] * n
93
+ cluster_id = 0
94
+ for i in range(n):
95
+ if labels[i] != -1:
96
+ continue
97
+ neighbors = [
98
+ j
99
+ for j in range(n)
100
+ if i != j and (1.0 - _jaccard_minhash(signatures[i], signatures[j])) <= eps
101
+ ]
102
+ if len(neighbors) < min_samples - 1:
103
+ # not enough neighbours β€” mark as noise (may become border later)
104
+ continue
105
+ labels[i] = cluster_id
106
+ seed_set = list(neighbors)
107
+ k = 0
108
+ while k < len(seed_set):
109
+ q = seed_set[k]
110
+ if labels[q] == -1:
111
+ labels[q] = cluster_id
112
+ q_neighbors = [
113
+ j
114
+ for j in range(n)
115
+ if j != q
116
+ and (1.0 - _jaccard_minhash(signatures[q], signatures[j])) <= eps
117
+ ]
118
+ if len(q_neighbors) >= min_samples - 1:
119
+ seed_set.extend(q_neighbors)
120
+ elif labels[q] is None or labels[q] == -1:
121
+ labels[q] = cluster_id
122
+ k += 1
123
+ cluster_id += 1
124
+ return labels
125
+
126
+
127
+ # ── Domain types ────────────────────────────────────────────────────
128
+ @dataclass
129
+ class NewsItem:
130
+ """Minimal news item for clustering. Adapts from DB rows or dicts."""
131
+
132
+ id: str
133
+ title: str
134
+ body: str = ""
135
+ source: str = ""
136
+ url: str = ""
137
+ published_at: datetime = field(default_factory=lambda: datetime.now(UTC))
138
+ sentiment: float = 0.0
139
+
140
+ @classmethod
141
+ def from_row(cls, row: dict) -> NewsItem:
142
+ published = row.get("published_at") or row.get("created_at")
143
+ if isinstance(published, str):
144
+ try:
145
+ published = datetime.fromisoformat(published.replace("Z", "+00:00"))
146
+ except (ValueError, AttributeError):
147
+ published = datetime.now(UTC)
148
+ elif not isinstance(published, datetime):
149
+ published = datetime.now(UTC)
150
+ return cls(
151
+ id=str(row.get("id", row.get("news_id", ""))),
152
+ title=row.get("title", "") or "",
153
+ body=(row.get("body") or row.get("summary") or "")[:500],
154
+ source=row.get("source", "") or "",
155
+ url=row.get("url", "") or "",
156
+ published_at=published,
157
+ sentiment=float(row.get("sentiment", 0.0) or 0.0),
158
+ )
159
+
160
+
161
+ @dataclass
162
+ class StoryCluster:
163
+ """One deduplicated story spanning 1+ source items."""
164
+
165
+ cluster_id: str
166
+ representative_title: str
167
+ source_urls: list[str]
168
+ sources: list[str]
169
+ first_seen: datetime
170
+ last_updated: datetime
171
+ item_count: int
172
+ sentiment_avg: float
173
+ item_ids: list[str]
174
+
175
+ def to_dict(self) -> dict:
176
+ return {
177
+ "cluster_id": self.cluster_id,
178
+ "representative_title": self.representative_title,
179
+ "source_urls": self.source_urls,
180
+ "sources": self.sources,
181
+ "first_seen": self.first_seen.isoformat(),
182
+ "last_updated": self.last_updated.isoformat(),
183
+ "item_count": self.item_count,
184
+ "sentiment_avg": round(self.sentiment_avg, 3),
185
+ "item_ids": self.item_ids,
186
+ }
187
+
188
+
189
+ # ── Main entry point ────────────────────────────────────────────────
190
+ def cluster_items(
191
+ items: list[NewsItem],
192
+ window_minutes: int = 30,
193
+ eps: float = 0.4,
194
+ min_samples: int = 2,
195
+ ) -> list[StoryCluster]:
196
+ """Cluster news items into stories.
197
+
198
+ Items are first grouped by 30-minute time windows, then DBSCAN runs
199
+ on MinHash signatures within each window. Single-item clusters are
200
+ kept (they're "noise" in DBSCAN terms but valid singleton stories).
201
+
202
+ `eps` is the Jaccard DISTANCE threshold (1 - similarity). Per the
203
+ task spec, two items cluster together when Jaccard similarity >= 0.6,
204
+ so distance <= 0.4, so eps=0.4. Tighten for stricter clusters.
205
+ """
206
+ t0 = time.time()
207
+ if not items:
208
+ return []
209
+
210
+ # Group by time window
211
+ windows: dict[datetime, list[NewsItem]] = {}
212
+ for it in sorted(items, key=lambda x: x.published_at):
213
+ bucket = it.published_at.replace(
214
+ minute=(it.published_at.minute // window_minutes) * window_minutes,
215
+ second=0,
216
+ microsecond=0,
217
+ )
218
+ windows.setdefault(bucket, []).append(it)
219
+
220
+ stories: list[StoryCluster] = []
221
+ for _bucket, group in windows.items():
222
+ if len(group) == 1:
223
+ # singleton β€” still a story
224
+ it = group[0]
225
+ stories.append(
226
+ StoryCluster(
227
+ cluster_id=hashlib.sha1(
228
+ f"single:{it.id}:{it.published_at.isoformat()}".encode()
229
+ ).hexdigest()[:16],
230
+ representative_title=it.title,
231
+ source_urls=[it.url] if it.url else [],
232
+ sources=[it.source] if it.source else [],
233
+ first_seen=it.published_at,
234
+ last_updated=it.published_at,
235
+ item_count=1,
236
+ sentiment_avg=it.sentiment,
237
+ item_ids=[it.id],
238
+ )
239
+ )
240
+ continue
241
+
242
+ sigs = [_minhash_signature(_shingles(f"{it.title} {it.body}")) for it in group]
243
+ labels = _dbscan(sigs, eps=eps, min_samples=min_samples)
244
+ # Singletons (label == -1) still become stories
245
+ clusters: dict[int, list[int]] = {}
246
+ for idx, lbl in enumerate(labels):
247
+ clusters.setdefault(lbl if lbl != -1 else idx, []).append(idx)
248
+ for _cid, indices in clusters.items():
249
+ members = [group[i] for i in indices]
250
+ # Pick representative = longest title (usually the most descriptive)
251
+ rep = max(members, key=lambda x: len(x.title))
252
+ sentiments = [m.sentiment for m in members if m.sentiment is not None]
253
+ avg_sent = sum(sentiments) / len(sentiments) if sentiments else 0.0
254
+ cluster_id = hashlib.sha1(
255
+ ":".join(sorted(m.id for m in members)).encode()
256
+ ).hexdigest()[:16]
257
+ stories.append(
258
+ StoryCluster(
259
+ cluster_id=cluster_id,
260
+ representative_title=rep.title,
261
+ source_urls=[m.url for m in members if m.url],
262
+ sources=sorted({m.source for m in members if m.source}),
263
+ first_seen=min(m.published_at for m in members),
264
+ last_updated=max(m.published_at for m in members),
265
+ item_count=len(members),
266
+ sentiment_avg=avg_sent,
267
+ item_ids=[m.id for m in members],
268
+ )
269
+ )
270
+
271
+ logger.info(
272
+ "news_clustered items=%d stories=%d windows=%d elapsed_ms=%.1f",
273
+ len(items),
274
+ len(stories),
275
+ len(windows),
276
+ (time.time() - t0) * 1000,
277
+ )
278
+ return stories
279
+
280
+
281
+ # ── DB persistence (optional, lazy import) ──────────────────────────
282
+ _PG_SCHEMA_SQL = """
283
+ CREATE TABLE IF NOT EXISTS news_clusters (
284
+ cluster_id TEXT PRIMARY KEY,
285
+ representative_title TEXT NOT NULL,
286
+ first_seen TIMESTAMPTZ NOT NULL,
287
+ last_updated TIMESTAMPTZ NOT NULL,
288
+ item_count INTEGER NOT NULL DEFAULT 0,
289
+ sentiment_avg DOUBLE PRECISION NOT NULL DEFAULT 0.0,
290
+ source_urls JSONB NOT NULL DEFAULT '[]'::jsonb,
291
+ sources JSONB NOT NULL DEFAULT '[]'::jsonb,
292
+ item_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
293
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
294
+ );
295
+ CREATE INDEX IF NOT EXISTS news_clusters_last_updated_idx
296
+ ON news_clusters (last_updated DESC);
297
+ """
298
+
299
+
300
+ async def ensure_schema() -> bool:
301
+ """Create news_clusters table if missing. Returns True on success."""
302
+ try:
303
+ import asyncpg
304
+
305
+ from app.core.db_pool import PG_URL
306
+
307
+ conn = await asyncpg.connect(PG_URL)
308
+ try:
309
+ await conn.execute(_PG_SCHEMA_SQL)
310
+ finally:
311
+ await conn.close()
312
+ logger.info("news_clusters_schema_ready")
313
+ return True
314
+ except Exception as exc:
315
+ logger.warning("news_clusters_schema_failed err=%s", exc)
316
+ return False
317
+
318
+
319
+ async def persist_clusters(stories: list[StoryCluster]) -> int:
320
+ """Upsert stories to Postgres. Returns rows affected."""
321
+ if not stories:
322
+ return 0
323
+ try:
324
+ import json
325
+
326
+ import asyncpg
327
+
328
+ from app.core.db_pool import PG_URL
329
+
330
+ conn = await asyncpg.connect(PG_URL)
331
+ try:
332
+ rows = [
333
+ (
334
+ s.cluster_id,
335
+ s.representative_title,
336
+ s.first_seen,
337
+ s.last_updated,
338
+ s.item_count,
339
+ s.sentiment_avg,
340
+ json.dumps(s.source_urls),
341
+ json.dumps(s.sources),
342
+ json.dumps(s.item_ids),
343
+ )
344
+ for s in stories
345
+ ]
346
+ await conn.executemany(
347
+ """
348
+ INSERT INTO news_clusters
349
+ (cluster_id, representative_title, first_seen, last_updated,
350
+ item_count, sentiment_avg, source_urls, sources, item_ids)
351
+ VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9::jsonb)
352
+ ON CONFLICT (cluster_id) DO UPDATE SET
353
+ representative_title = EXCLUDED.representative_title,
354
+ first_seen = EXCLUDED.first_seen,
355
+ last_updated = EXCLUDED.last_updated,
356
+ item_count = EXCLUDED.item_count,
357
+ sentiment_avg = EXCLUDED.sentiment_avg,
358
+ source_urls = EXCLUDED.source_urls,
359
+ sources = EXCLUDED.sources,
360
+ item_ids = EXCLUDED.item_ids
361
+ """,
362
+ rows,
363
+ )
364
+ return len(rows)
365
+ finally:
366
+ await conn.close()
367
+ except Exception as exc:
368
+ logger.warning("news_clusters_persist_failed err=%s", exc)
369
+ return 0
370
+
371
+
372
+ async def load_recent_clusters(limit: int = 50) -> list[dict]:
373
+ """Load recent clusters from Postgres."""
374
+ try:
375
+ import asyncpg
376
+ import json
377
+
378
+ from app.core.db_pool import PG_URL
379
+
380
+ conn = await asyncpg.connect(PG_URL)
381
+ try:
382
+ rows = await conn.fetch(
383
+ "SELECT * FROM news_clusters ORDER BY last_updated DESC LIMIT $1",
384
+ limit,
385
+ )
386
+ return [
387
+ {
388
+ "cluster_id": r["cluster_id"],
389
+ "representative_title": r["representative_title"],
390
+ "first_seen": r["first_seen"].isoformat(),
391
+ "last_updated": r["last_updated"].isoformat(),
392
+ "item_count": r["item_count"],
393
+ "sentiment_avg": r["sentiment_avg"],
394
+ "source_urls": json.loads(r["source_urls"]),
395
+ "sources": json.loads(r["sources"]),
396
+ "item_ids": json.loads(r["item_ids"]),
397
+ }
398
+ for r in rows
399
+ ]
400
+ finally:
401
+ await conn.close()
402
+ except Exception as exc:
403
+ logger.warning("news_clusters_load_failed err=%s", exc)
404
+ return []
405
+
406
+
407
+ __all__ = [
408
+ "NewsItem",
409
+ "StoryCluster",
410
+ "cluster_items",
411
+ "ensure_schema",
412
+ "persist_clusters",
413
+ "load_recent_clusters",
414
+ ]
app/domain/news/router.py CHANGED
@@ -154,6 +154,7 @@ async def list_news(
154
  limit: int = Query(20, ge=1, le=200),
155
  offset: int = Query(0, ge=0),
156
  sort: str = Query("recency", pattern="^(recency|relevance|sentiment)$"),
 
157
  ) -> NewsListResponse:
158
  """List news items with filters. Reads from both news_items (new) and crypto_news (legacy)."""
159
  catalog = get_catalog()
@@ -215,6 +216,48 @@ async def list_news(
215
  import logging
216
  logging.getLogger(__name__).warning(f"news_list_legacy_fail: {e}")
217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  return NewsListResponse(items=items, total=len(items), offset=offset)
219
 
220
 
 
154
  limit: int = Query(20, ge=1, le=200),
155
  offset: int = Query(0, ge=0),
156
  sort: str = Query("recency", pattern="^(recency|relevance|sentiment)$"),
157
+ clustered: bool = Query(False, description="T03: dedupe via MinHash+DBSCAN into stories"),
158
  ) -> NewsListResponse:
159
  """List news items with filters. Reads from both news_items (new) and crypto_news (legacy)."""
160
  catalog = get_catalog()
 
216
  import logging
217
  logging.getLogger(__name__).warning(f"news_list_legacy_fail: {e}")
218
 
219
+ # T03: cluster into stories if requested
220
+ if clustered and items:
221
+ from app.domain.news.clusterer import NewsItem, cluster_items, persist_clusters
222
+ cluster_items_list = [
223
+ NewsItem(
224
+ id=it.news_id,
225
+ title=it.title,
226
+ body=it.summary or "",
227
+ source=it.source or "",
228
+ url=it.url or "",
229
+ published_at=it.published_at,
230
+ sentiment=it.sentiment_score or 0.0,
231
+ )
232
+ for it in items
233
+ ]
234
+ stories = cluster_items(cluster_items_list)
235
+ # persist in background (don't block response)
236
+ try:
237
+ import asyncio
238
+ asyncio.create_task(persist_clusters(stories))
239
+ except Exception:
240
+ pass
241
+ # Return clusters as synthetic items (representative title, first source)
242
+ clustered_items = []
243
+ for s in stories:
244
+ clustered_items.append(
245
+ NewsItemOut(
246
+ news_id=s.cluster_id,
247
+ url=s.source_urls[0] if s.source_urls else "",
248
+ title=f"[Γ—{s.item_count}] {s.representative_title}",
249
+ summary=f"Story across {len(s.sources)} sources. "
250
+ f"Sentiment: {s.sentiment_avg:.2f}. "
251
+ f"Item IDs: {','.join(s.item_ids[:5])}",
252
+ source=", ".join(s.sources[:3]),
253
+ published_at=s.last_updated,
254
+ chains_mentioned=[],
255
+ tokens_mentioned=[],
256
+ sentiment_score=s.sentiment_avg,
257
+ )
258
+ )
259
+ return NewsListResponse(items=clustered_items, total=len(clustered_items), offset=offset)
260
+
261
  return NewsListResponse(items=items, total=len(items), offset=offset)
262
 
263
 
app/domain/threat/certstream_listener.py ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """T12 β€” CertStream phishing domain monitor (G04 FIX adjacent).
2
+
3
+ Per MINIMAX_M3_TASKS.md T12. Watches Certificate Transparency logs in real
4
+ time for domains that spoof crypto brands (MetaMask, Ledger, Coinbase,
5
+ etc). When a match is found, fires a Telegram alert via the existing
6
+ bot and persists the domain to Postgres `threat_domains`.
7
+
8
+ Run as a background task in lifespan.py. If CertStream is down, log and
9
+ continue β€” never break startup.
10
+
11
+ Why this matters: phishing sites clone crypto brands and steal wallet
12
+ seeds. CT logs show new domains BEFORE they go live, giving a 48h lead
13
+ time to take them down.
14
+
15
+ Usage:
16
+ from app.domain.threat.certstream_listener import start_listener
17
+ asyncio.create_task(start_listener()) # in lifespan
18
+ python3 -m app.domain.threat.certstream_listener --duration 300 # CLI
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import json
24
+ import logging
25
+ import os
26
+ import signal
27
+ from contextlib import suppress
28
+ from datetime import UTC, datetime
29
+ from pathlib import Path
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ # ── Brand patterns (loaded once at import) ──────────────────────────
35
+ _BRAND_PATTERNS_PATH = Path(__file__).parent / "brand_patterns.json"
36
+
37
+
38
+ def load_brand_patterns() -> list[str]:
39
+ """Load brand pattern list. Returns lowercased list for matching."""
40
+ try:
41
+ with _BRAND_PATTERNS_PATH.open() as f:
42
+ data = json.load(f)
43
+ patterns = [str(p).lower() for p in data.get("brands", []) if p]
44
+ logger.info("certstream_brands_loaded count=%d", len(patterns))
45
+ return patterns
46
+ except Exception as exc:
47
+ logger.warning("certstream_brands_load_failed err=%s", exc)
48
+ return []
49
+
50
+
51
+ _BRAND_PATTERNS: list[str] = load_brand_patterns()
52
+
53
+
54
+ # ── Domain matching ────────────────────────────────────────────────
55
+ def match_brand(domain: str, brands: list[str] | None = None) -> str | None:
56
+ """Return the matched brand name if `domain` looks like a phishing
57
+ clone of a known crypto brand. Returns None if no match.
58
+
59
+ Heuristic: brand substring appears in the domain's main label,
60
+ but the domain is NOT the brand's official primary domain (we
61
+ allow obvious legit variants like "metamask.io" but flag
62
+ "metamask-secure-claim.com").
63
+ """
64
+ if not domain:
65
+ return None
66
+ d = domain.lower().strip()
67
+ # strip trailing dot
68
+ if d.endswith("."):
69
+ d = d[:-1]
70
+ # take the main label (e.g. "metamask-secure" from "a.b.metamask-secure.com")
71
+ parts = d.split(".")
72
+ if len(parts) < 2:
73
+ return None
74
+ main = parts[-2] if len(parts) >= 2 else parts[0]
75
+ patterns = brands or _BRAND_PATTERNS
76
+ for brand in patterns:
77
+ # Strip non-alphanum for fuzzy match
78
+ main_clean = "".join(c for c in main if c.isalnum())
79
+ brand_clean = "".join(c for c in brand if c.isalnum())
80
+ if not brand_clean:
81
+ continue
82
+ if brand_clean in main_clean and main_clean != brand_clean:
83
+ # Phishing variant β€” not the official domain
84
+ return brand
85
+ return None
86
+
87
+
88
+ # ── Telegram alert (best-effort, never blocks) ─────────────────────
89
+ async def _telegram_alert(domain: str, brand: str, issuer: str) -> bool:
90
+ """Send a Telegram alert. Returns True on success. Never raises."""
91
+ try:
92
+ bot_token = os.getenv("TELEGRAM_BOT_TOKEN", "")
93
+ chat_id = os.getenv("TELEGRAM_ALERT_CHAT_ID", "")
94
+ if not bot_token or not chat_id:
95
+ return False
96
+ import httpx
97
+ text = (
98
+ f"⚠️ Phishing domain registered: {domain}\n"
99
+ f"Brand: {brand}\n"
100
+ f"Issuer: {issuer}\n"
101
+ f"Issued: {datetime.now(UTC).isoformat()}\n"
102
+ f"48h window to take down"
103
+ )
104
+ async with httpx.AsyncClient(timeout=10) as client:
105
+ r = await client.post(
106
+ f"https://api.telegram.org/bot{bot_token}/sendMessage",
107
+ json={"chat_id": chat_id, "text": text},
108
+ )
109
+ return r.status_code == 200
110
+ except Exception as exc:
111
+ logger.debug("telegram_alert_skipped err=%s", exc)
112
+ return False
113
+
114
+
115
+ # ── Postgres persistence (lazy import) ──────────────────────────────
116
+ _THREAT_DOMAINS_SCHEMA = """
117
+ CREATE TABLE IF NOT EXISTS threat_domains (
118
+ domain TEXT PRIMARY KEY,
119
+ brand_matched TEXT,
120
+ issued_at TIMESTAMPTZ,
121
+ issuer TEXT,
122
+ first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
123
+ status TEXT NOT NULL DEFAULT 'new'
124
+ );
125
+ CREATE INDEX IF NOT EXISTS threat_domains_first_seen_idx
126
+ ON threat_domains (first_seen DESC);
127
+ CREATE INDEX IF NOT EXISTS threat_domains_brand_idx
128
+ ON threat_domains (brand_matched);
129
+ """
130
+
131
+
132
+ async def _ensure_schema() -> bool:
133
+ try:
134
+ import asyncpg
135
+ from app.core.db_pool import PG_URL
136
+
137
+ conn = await asyncpg.connect(PG_URL)
138
+ try:
139
+ await conn.execute(_THREAT_DOMAINS_SCHEMA)
140
+ finally:
141
+ await conn.close()
142
+ return True
143
+ except Exception as exc:
144
+ logger.warning("threat_domains_schema_failed err=%s", exc)
145
+ return False
146
+
147
+
148
+ async def _persist_domain(domain: str, brand: str, issued_at: datetime | None, issuer: str) -> bool:
149
+ try:
150
+ import asyncpg
151
+ from app.core.db_pool import PG_URL
152
+
153
+ conn = await asyncpg.connect(PG_URL)
154
+ try:
155
+ await conn.execute(
156
+ """
157
+ INSERT INTO threat_domains (domain, brand_matched, issued_at, issuer)
158
+ VALUES ($1, $2, $3, $4)
159
+ ON CONFLICT (domain) DO NOTHING
160
+ """,
161
+ domain,
162
+ brand,
163
+ issued_at,
164
+ issuer,
165
+ )
166
+ finally:
167
+ await conn.close()
168
+ return True
169
+ except Exception as exc:
170
+ logger.debug("threat_domains_persist_failed domain=%s err=%s", domain, exc)
171
+ return False
172
+
173
+
174
+ # ── CertStream WebSocket listener ──────────────────────────────────
175
+ CERTSTREAM_URL = "wss://certstream.calidus.io/"
176
+
177
+
178
+ async def _process_message(msg: dict) -> int:
179
+ """Process one CertStream message. Returns # of threats found (0 or 1+)."""
180
+ data_type = msg.get("message_type") or msg.get("type")
181
+ if data_type not in ("certificate_update", "heartbeat"):
182
+ return 0
183
+ if data_type == "heartbeat":
184
+ return 0
185
+
186
+ leaf = msg.get("data", {}).get("leaf_cert", {}) or {}
187
+ domains = set()
188
+ # CertStream v2 puts all SANs here
189
+ sans = leaf.get("all_domains") or []
190
+ for d in sans:
191
+ if d and isinstance(d, str) and not d.startswith("*"):
192
+ domains.add(d.lower().strip())
193
+ # Some payloads also have subject CN
194
+ subject = (leaf.get("subject") or {}).get("CN", "")
195
+ if subject and not subject.startswith("*"):
196
+ domains.add(subject.lower().strip())
197
+
198
+ issuer = (
199
+ msg.get("data", {}).get("chain", [{}])[0].get("subject", {}).get("O", "")
200
+ or "unknown"
201
+ )
202
+ issued_at = None
203
+ not_before = leaf.get("not_before")
204
+ if not_before:
205
+ try:
206
+ issued_at = datetime.fromisoformat(not_before.replace("Z", "+00:00"))
207
+ except Exception:
208
+ issued_at = datetime.now(UTC)
209
+ if issued_at is None:
210
+ issued_at = datetime.now(UTC)
211
+
212
+ threats = 0
213
+ for d in domains:
214
+ brand = match_brand(d)
215
+ if brand:
216
+ threats += 1
217
+ logger.warning(
218
+ "certstream_threat_detected domain=%s brand=%s issuer=%s",
219
+ d, brand, issuer,
220
+ )
221
+ await _persist_domain(d, brand, issued_at, issuer)
222
+ await _telegram_alert(d, brand, issuer)
223
+ return threats
224
+
225
+
226
+ async def _run_listener(stop_event: asyncio.Event) -> None:
227
+ """Connect to CertStream and consume messages until stop_event."""
228
+ try:
229
+ import websockets
230
+ except ImportError:
231
+ logger.warning("certstream_skipped err=websockets_not_installed")
232
+ return
233
+
234
+ await _ensure_schema()
235
+ backoff = 1.0
236
+ while not stop_event.is_set():
237
+ try:
238
+ async with websockets.connect(CERTSTREAM_URL, ping_interval=20) as ws:
239
+ logger.info("certstream_connected url=%s", CERTSTREAM_URL)
240
+ backoff = 1.0
241
+ while not stop_event.is_set():
242
+ try:
243
+ raw = await asyncio.wait_for(ws.recv(), timeout=60)
244
+ except asyncio.TimeoutError:
245
+ # send a ping to keep the connection alive
246
+ with suppress(Exception):
247
+ await ws.send("ping")
248
+ continue
249
+ try:
250
+ msg = json.loads(raw)
251
+ except json.JSONDecodeError:
252
+ continue
253
+ await _process_message(msg)
254
+ except asyncio.CancelledError:
255
+ raise
256
+ except Exception as exc:
257
+ logger.warning("certstream_disconnected err=%s reconnecting_in=%.1fs", exc, backoff)
258
+ try:
259
+ await asyncio.wait_for(stop_event.wait(), timeout=backoff)
260
+ except asyncio.TimeoutError:
261
+ pass
262
+ backoff = min(backoff * 2, 60.0)
263
+
264
+
265
+ # ── Public API ─────────────────────────────────────────────────────
266
+ async def start_listener() -> asyncio.Task | None:
267
+ """Start the CertStream listener as a background task.
268
+
269
+ Returns the Task so caller can cancel on shutdown. Returns None if
270
+ websockets is not installed or another instance is already running.
271
+ """
272
+ if os.getenv("CERTSTREAM_ENABLED", "1") != "1":
273
+ logger.info("certstream_disabled env=CERTSTREAM_ENABLED=0")
274
+ return None
275
+ try:
276
+ import websockets # noqa: F401
277
+ except ImportError:
278
+ logger.info("certstream_skipped err=websockets_not_installed")
279
+ return None
280
+ stop_event = asyncio.Event()
281
+ task = asyncio.create_task(
282
+ _run_listener(stop_event),
283
+ name="certstream-listener",
284
+ )
285
+ # Stash the stop event on the task so shutdown can set it
286
+ task._rmi_stop_event = stop_event # type: ignore[attr-defined]
287
+ return task
288
+
289
+
290
+ async def stop_listener(task: asyncio.Task | None) -> None:
291
+ """Stop a running CertStream listener task."""
292
+ if task is None or task.done():
293
+ return
294
+ stop_event = getattr(task, "_rmi_stop_event", None)
295
+ if stop_event is not None:
296
+ stop_event.set()
297
+ task.cancel()
298
+ with suppress(asyncio.CancelledError, Exception):
299
+ await asyncio.wait_for(task, timeout=5.0)
300
+
301
+
302
+ # ── CLI for ad-hoc testing ─────────────────────────────────────────
303
+ def _cli() -> None:
304
+ import argparse
305
+ import sys
306
+
307
+ parser = argparse.ArgumentParser(description="CertStream phishing monitor (CLI)")
308
+ parser.add_argument("--duration", type=int, default=300, help="seconds to listen (0 = forever)")
309
+ args = parser.parse_args()
310
+
311
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
312
+
313
+ async def _main() -> None:
314
+ stop = asyncio.Event()
315
+ if args.duration > 0:
316
+ async def _trigger() -> None:
317
+ await asyncio.sleep(args.duration)
318
+ stop.set()
319
+ asyncio.create_task(_trigger())
320
+
321
+ await _run_listener(stop)
322
+
323
+ if args.duration == 0:
324
+ # Forever mode: install SIGINT handler
325
+ def _sigint(*_a: object) -> None:
326
+ raise KeyboardInterrupt
327
+ signal.signal(signal.SIGINT, _sigint)
328
+ try:
329
+ asyncio.run(_main())
330
+ except KeyboardInterrupt:
331
+ print("\n[cli] stopped", file=sys.stderr)
332
+
333
+
334
+ if __name__ == "__main__":
335
+ _cli()
336
+
337
+
338
+ __all__ = [
339
+ "start_listener",
340
+ "stop_listener",
341
+ "match_brand",
342
+ "load_brand_patterns",
343
+ "CERTSTREAM_URL",
344
+ ]
app/lifespan.py CHANGED
@@ -75,9 +75,23 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
75
  except Exception as exc:
76
  log.info("sentry_init_failed err=%s", exc)
77
 
 
 
 
 
 
 
 
 
 
78
  yield
79
 
80
  # Shutdown
 
 
 
 
 
81
  try:
82
  from app.core.langfuse import flush_langfuse
83
  from app.core.observability import flush_sentry
 
75
  except Exception as exc:
76
  log.info("sentry_init_failed err=%s", exc)
77
 
78
+ # 7. T12 β€” CertStream phishing domain monitor (background task)
79
+ certstream_task = None
80
+ try:
81
+ from app.domain.threat.certstream_listener import start_listener
82
+ certstream_task = await start_listener()
83
+ log.info("certstream_started has_task=%s", certstream_task is not None)
84
+ except Exception as exc:
85
+ log.info("certstream_start_failed err=%s", exc)
86
+
87
  yield
88
 
89
  # Shutdown
90
+ try:
91
+ from app.domain.threat.certstream_listener import stop_listener
92
+ await stop_listener(certstream_task)
93
+ except Exception:
94
+ pass
95
  try:
96
  from app.core.langfuse import flush_langfuse
97
  from app.core.observability import flush_sentry
app/routers/admin_users_api.py CHANGED
@@ -552,7 +552,7 @@ async def bulk_action(req: BulkActionRequest, request: Request):
552
  elif req.action == "update_tier":
553
  tier = req.params.get("tier", "FREE") if req.params else "FREE"
554
  from app.auth import _get_user, _save_user
555
- from app.core.redis import get_redis
556
 
557
  user = _get_user(user_id)
558
  if user:
 
552
  elif req.action == "update_tier":
553
  tier = req.params.get("tier", "FREE") if req.params else "FREE"
554
  from app.auth import _get_user, _save_user
555
+ from app.core.redis import get_redis
556
 
557
  user = _get_user(user_id)
558
  if user:
app/routers/auth_extensions.py CHANGED
@@ -564,7 +564,7 @@ async def delete_account(req: DeleteAccountRequest, request: Request):
564
  Requires password confirmation.
565
  """
566
  from app.auth import _delete_user, get_current_user, verify_password
567
- from app.core.redis import get_redis
568
 
569
  user = await get_current_user(request)
570
  if not user:
 
564
  Requires password confirmation.
565
  """
566
  from app.auth import _delete_user, get_current_user, verify_password
567
+ from app.core.redis import get_redis
568
 
569
  user = await get_current_user(request)
570
  if not user:
app/routers/developer_tier.py CHANGED
@@ -59,7 +59,7 @@ def verify_solana_signature(address: str, message: str, signature: str) -> bool:
59
  import base64
60
 
61
  from nacl.signing import VerifyKey
62
- from app.core.redis import get_redis
63
 
64
  pubkey = VerifyKey(base64.b58decode(address))
65
  msg_bytes = message.encode("utf-8")
@@ -125,7 +125,7 @@ TIERS = {
125
  # ── Redis Helper ─────────────────────────────────────────────────
126
 
127
 
128
- ) -> dict[str, Any] | None:
129
  """Create a new developer API key."""
130
  r = get_redis()
131
  if not r:
 
59
  import base64
60
 
61
  from nacl.signing import VerifyKey
62
+ from app.core.redis import get_redis
63
 
64
  pubkey = VerifyKey(base64.b58decode(address))
65
  msg_bytes = message.encode("utf-8")
 
125
  # ── Redis Helper ─────────────────────────────────────────────────
126
 
127
 
128
+ def create_tier(tier: str) -> dict[str, Any] | None:
129
  """Create a new developer API key."""
130
  r = get_redis()
131
  if not r:
app/routers/persistent_state.py CHANGED
@@ -54,7 +54,6 @@ def get_user_identity(request: Request) -> str:
54
  dev_key = request.headers.get("X-RMI-Dev-Key", "") or request.headers.get("x-rmi-dev-key", "")
55
  if dev_key:
56
  import hashlib
57
- from app.core.redis import get_redis
58
 
59
  return f"dev:{hashlib.sha256(dev_key.encode()).hexdigest()[:16]}"
60
 
 
54
  dev_key = request.headers.get("X-RMI-Dev-Key", "") or request.headers.get("x-rmi-dev-key", "")
55
  if dev_key:
56
  import hashlib
 
57
 
58
  return f"dev:{hashlib.sha256(dev_key.encode()).hexdigest()[:16]}"
59
 
app/routers/status_page.py CHANGED
@@ -40,11 +40,6 @@ logger = logging.getLogger("status_page")
40
 
41
  router = APIRouter(prefix="/api/v1/status", tags=["status-page"])
42
 
43
- # ── Redis Helper ─────────────────────────────────────────────────
44
-
45
-
46
- }
47
-
48
 
49
  # ── Health Check Functions ───────────────────────────────────────
50
 
@@ -301,7 +296,6 @@ async def status_monitor_loop():
301
  except Exception as e:
302
  logger.error(f"Status monitor error: {e}")
303
  import asyncio
304
- from app.core.redis import get_redis
305
 
306
  await asyncio.sleep(30)
307
 
 
40
 
41
  router = APIRouter(prefix="/api/v1/status", tags=["status-page"])
42
 
 
 
 
 
 
43
 
44
  # ── Health Check Functions ───────────────────────────────────────
45
 
 
296
  except Exception as e:
297
  logger.error(f"Status monitor error: {e}")
298
  import asyncio
 
299
 
300
  await asyncio.sleep(30)
301
 
app/routers/subscription_pricing_api.py CHANGED
@@ -733,7 +733,7 @@ async def list_all_subscriptions(
733
  async def get_revenue_stats(request: Request):
734
  """Get revenue statistics (admin only)."""
735
  from app.auth import get_current_user
736
- from app.core.redis import get_redis
737
 
738
  user = await get_current_user(request)
739
  if not user or user.get("role") not in ("ADMIN", "SUPERADMIN"):
 
733
  async def get_revenue_stats(request: Request):
734
  """Get revenue statistics (admin only)."""
735
  from app.auth import get_current_user
736
+ from app.core.redis import get_redis
737
 
738
  user = await get_current_user(request)
739
  if not user or user.get("role") not in ("ADMIN", "SUPERADMIN"):
app/routers/webhook_pipeline.py CHANGED
@@ -27,7 +27,6 @@ from datetime import UTC, datetime
27
 
28
  from fastapi import APIRouter
29
  from fastapi.responses import JSONResponse
30
- from app.core.redis import get_redis
31
 
32
  logger = logging.getLogger("webhook_pipeline")
33
 
@@ -37,7 +36,12 @@ router = APIRouter(prefix="/api/v1/webhooks", tags=["webhook-pipeline"])
37
  # ── Redis Helper ─────────────────────────────────────────────────
38
 
39
 
40
- ):
 
 
 
 
 
41
  """Queue a webhook delivery.
42
 
43
  Called by push_alert() in persistent_state.py and by
 
27
 
28
  from fastapi import APIRouter
29
  from fastapi.responses import JSONResponse
 
30
 
31
  logger = logging.getLogger("webhook_pipeline")
32
 
 
36
  # ── Redis Helper ─────────────────────────────────────────────────
37
 
38
 
39
+ def queue_webhook_delivery(
40
+ event_type: str,
41
+ address: str,
42
+ message: str,
43
+ data: dict | None = None,
44
+ ) -> None:
45
  """Queue a webhook delivery.
46
 
47
  Called by push_alert() in persistent_state.py and by
app/routers/x402_alpha_revenue_tools.py CHANGED
@@ -621,7 +621,7 @@ def register_alpha_tool_prices():
621
  """Register alpha tool prices with the enforcement system."""
622
  try:
623
  from app.routers.x402_enforcement import TOOL_PRICES
624
- from app.core.redis import get_redis
625
 
626
  TOOL_PRICES.update(
627
  {
 
621
  """Register alpha tool prices with the enforcement system."""
622
  try:
623
  from app.routers.x402_enforcement import TOOL_PRICES
624
+ from app.core.redis import get_redis
625
 
626
  TOOL_PRICES.update(
627
  {
app/routers/x402_enforcement.py CHANGED
@@ -2327,7 +2327,7 @@ async def get_revenue():
2327
  # Daily breakdown (last 30 days)
2328
  daily = {}
2329
  from datetime import datetime, timedelta
2330
- from app.core.redis import get_redis
2331
 
2332
  for i in range(30):
2333
  day = (datetime.utcnow() - timedelta(days=i)).strftime("%Y-%m-%d")
 
2327
  # Daily breakdown (last 30 days)
2328
  daily = {}
2329
  from datetime import datetime, timedelta
2330
+ from app.core.redis import get_redis
2331
 
2332
  for i in range(30):
2333
  day = (datetime.utcnow() - timedelta(days=i)).strftime("%Y-%m-%d")
app/routers/x402_tools.py CHANGED
@@ -5772,7 +5772,7 @@ async def tool_alias_dispatcher(tool_id: str, request: Request):
5772
  try:
5773
  opt_out = request.query_params.get("enrich", "").lower() == "false"
5774
  from app.routers.x402_enrichment import enrich_tool_response
5775
- from app.core.redis import get_redis
5776
 
5777
  result = enrich_tool_response(tool_id, result, request_params=body, opt_out=opt_out)
5778
  except Exception:
 
5772
  try:
5773
  opt_out = request.query_params.get("enrich", "").lower() == "false"
5774
  from app.routers.x402_enrichment import enrich_tool_response
5775
+ from app.core.redis import get_redis
5776
 
5777
  result = enrich_tool_response(tool_id, result, request_params=body, opt_out=opt_out)
5778
  except Exception:
tests/unit/test_t03_t12_m3_residual.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for T03 (news clusterer) and T12 (CertStream match_brand)."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import UTC, datetime, timedelta
5
+
6
+ from app.domain.news.clusterer import NewsItem, cluster_items
7
+ from app.domain.threat.certstream_listener import match_brand
8
+
9
+
10
+ # ── T03: clusterer tests ───────────────────────────────────────────
11
+ def test_clusterer_groups_similar_stories():
12
+ """Two items with same/similar story should cluster together."""
13
+ base = datetime(2026, 6, 23, 12, 0, tzinfo=UTC)
14
+ items = [
15
+ NewsItem(
16
+ id="a1",
17
+ title="Bitcoin hits new all-time high above 120000",
18
+ body="BTC surged past 120000 today as ETF inflows hit record",
19
+ source="coindesk", url="https://coindesk.com/1", published_at=base,
20
+ ),
21
+ NewsItem(
22
+ id="a2",
23
+ title="Bitcoin hits new all-time high above 120000",
24
+ body="BTC surged past 120000 today as ETF inflows hit record",
25
+ source="the block", url="https://theblock.co/2", published_at=base + timedelta(minutes=5),
26
+ ),
27
+ NewsItem(
28
+ id="b1",
29
+ title="Ethereum upgrade scheduled for next month",
30
+ body="Core developers announce Pectra hard fork for July",
31
+ source="decrypt", url="https://decrypt.co/3", published_at=base + timedelta(minutes=2),
32
+ ),
33
+ ]
34
+ stories = cluster_items(items)
35
+ # Should produce 2 stories (2 BTC items clustered + 1 ETH item singleton)
36
+ assert len(stories) == 2, f"expected 2 stories, got {len(stories)}"
37
+ btc_story = next(s for s in stories if s.item_count == 2)
38
+ assert btc_story.item_count == 2
39
+ assert "coindesk" in btc_story.sources
40
+ assert "the block" in btc_story.sources
41
+ assert len(btc_story.item_ids) == 2
42
+
43
+
44
+ def test_clusterer_handles_singleton():
45
+ """Single item β†’ single story (singleton)."""
46
+ base = datetime(2026, 6, 23, 12, 0, tzinfo=UTC)
47
+ items = [
48
+ NewsItem(
49
+ id="x1", title="Unique story nobody else is covering",
50
+ body="Something happened once",
51
+ source="reddit", url="https://reddit.com/x", published_at=base,
52
+ ),
53
+ ]
54
+ stories = cluster_items(items)
55
+ assert len(stories) == 1
56
+ assert stories[0].item_count == 1
57
+
58
+
59
+ def test_clusterer_respects_time_window():
60
+ """Items in different time windows should not cluster together."""
61
+ base = datetime(2026, 6, 23, 12, 0, tzinfo=UTC)
62
+ items = [
63
+ NewsItem(
64
+ id="m1", title="Bitcoin hits new high",
65
+ body="BTC surged past $120K",
66
+ source="coindesk", url="", published_at=base,
67
+ ),
68
+ NewsItem(
69
+ id="m2", title="Bitcoin hits new high",
70
+ body="BTC surged past $120K",
71
+ source="the block", url="", published_at=base + timedelta(hours=2),
72
+ ),
73
+ ]
74
+ # With 30-min windows, these are in separate buckets β†’ 2 singleton stories
75
+ stories = cluster_items(items, window_minutes=30)
76
+ assert len(stories) == 2
77
+
78
+
79
+ def test_clusterer_empty():
80
+ assert cluster_items([]) == []
81
+
82
+
83
+ # ── T12: match_brand tests ─────────────────────────────────────────
84
+ def test_match_brand_flags_phishing_clone():
85
+ """'metamask-secure-claim.com' should flag as phishing of 'metamask'."""
86
+ brand = match_brand("metamask-secure-claim.com")
87
+ assert brand == "metamask"
88
+
89
+
90
+ def test_match_brand_passes_official_domain():
91
+ """'metamask.io' should NOT be flagged (it's the official domain)."""
92
+ brand = match_brand("metamask.io")
93
+ assert brand is None
94
+
95
+
96
+ def test_match_brand_flags_subdomain_phish():
97
+ """'login-ledger.com' should flag as 'ledger' phishing."""
98
+ brand = match_brand("login-ledger.com")
99
+ assert brand == "ledger"
100
+
101
+
102
+ def test_match_brand_ignores_unrelated():
103
+ brand = match_brand("some-random-website.com")
104
+ assert brand is None
105
+
106
+
107
+ def test_match_brand_strips_wildcard():
108
+ """CertStream sometimes gives '*.example.com' β€” strip the wildcard."""
109
+ assert match_brand("*.metamask-secure.com") == "metamask"
110
+
111
+
112
+ def test_match_brand_handles_empty():
113
+ assert match_brand("") is None
114
+ assert match_brand(".") is None