feat: the extension adds Discord channels to an artist you pick, and its tests gate the XPI (milestone 429)
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 24s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m22s
CI and images / build-agent (push) Successful in 5s
CI and images / sign-extension (push) Successful in 3m13s
CI and images / build-web (push) Successful in 1m42s
CI and images / smoke-web (push) Successful in 54s
CI and images / promote (push) Successful in 1s

Server (#4420)
- extension_service gains a Discord pattern (server or channel, jump links,
  ptb/canary; not DMs or threads), mirrored in platforms.js and pinned by
  the shared artist-url-samples.json.
- probe on a Discord URL matches the source by ids under any artist, reports
  a whole-server source as covering the channel, suggests the artist who owns
  another source on the same server, and names server/channel via the stored
  token (best-effort, bounded, no rate-limit waits).
- quick-add takes artist_id / artist_name; Discord URLs are stored canonical.

Extension (#4421, #4422)
- Content script on discord.com; SPA navigation by URL polling (the old
  pushState patch ran in the isolated world and never fired); stale probes
  are dropped.
- Discord chip opens an Add panel: this channel or the whole server, and the
  suggested artist / a search / a new name.
- Popup: sources show artist, platform and state; a Discord token export is
  verified by FC and the result shown. Token capture covers ptb/canary.
- Pure logic in lib/chip.js and lib/popup-format.js, with specs.

CI (#4423)
- extension.yml's lane (web-ext lint, vitest, XPI contents) moves into
  build.yml as extension-test and joins the needs of sign-extension,
  build-web and build-agent. As a separate workflow it gated nothing: a red
  extension suite still signed and shipped the XPI (rule 177).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
2026-09-24 23:31:38 -04:00
co-authored by Claude Opus 5.5
parent 97045279a3
commit 4c75dd0f88
23 changed files with 1176 additions and 209 deletions
+20 -2
View File
@@ -19,6 +19,7 @@ from ..models import AppSetting
from ..services.extension_service import (
ExtensionService,
InvalidUrlError,
UnknownArtistError,
UnknownPlatformError,
)
from ..services.source_service import KNOWN_PLATFORMS
@@ -87,10 +88,14 @@ async def probe_source():
url = (request.args.get("url") or "").strip()
if not url:
return _bad("invalid_body", detail="url query parameter is required")
from .credentials import _get_crypto
async with get_session() as session:
if not await _ext_key_required(session):
return _bad("unauthorized", status=401)
result = await ExtensionService(session).probe(url)
# crypto lets a Discord probe name the server and channel with the
# stored token; every other platform ignores it.
result = await ExtensionService(session, _get_crypto()).probe(url)
return jsonify(result)
@@ -102,6 +107,15 @@ async def quick_add_source():
url = body.get("url")
if not isinstance(url, str) or not url.strip():
return _bad("invalid_body", detail="url is required")
# Optional: connect the new source to an existing artist (artist_id) or to
# the artist of that name (artist_name). A Discord channel names no
# creator, so the extension's Add panel always sends one of them.
artist_id = body.get("artist_id")
if artist_id is not None and (isinstance(artist_id, bool) or not isinstance(artist_id, int)):
return _bad("invalid_body", detail="artist_id must be an integer")
artist_name = body.get("artist_name")
if artist_name is not None and not isinstance(artist_name, str):
return _bad("invalid_body", detail="artist_name must be a string")
from .credentials import _get_crypto
@@ -111,7 +125,11 @@ async def quick_add_source():
try:
# crypto lets an add resolve the artist's display name via the
# stored credential (else it falls back to the URL handle). #130.
result = await ExtensionService(session, _get_crypto()).quick_add_source(url)
result = await ExtensionService(session, _get_crypto()).quick_add_source(
url, artist_id=artist_id, artist_name=artist_name,
)
except UnknownArtistError as exc:
return _bad("not_found", detail=str(exc), status=404)
except UnknownPlatformError as exc:
return _bad(
"unknown_platform",
+18
View File
@@ -510,6 +510,24 @@ class DiscordClient:
# -- verify ------------------------------------------------------------
def describe(self, server_id: str | None, channel_id: str | None) -> dict:
"""The display names behind a server/channel pair, for the browser
extension's Add panel. Best-effort per name: one that can't be read
comes back None, and the other is still returned."""
out: dict = {"server": None, "channel": None, "parent": None}
if server_id:
try:
out["server"] = (self._get(f"/guilds/{server_id}") or {}).get("name") or None
except DiscordAPIError:
pass
if channel_id:
try:
meta = self._parse_channel(self._get(f"/channels/{channel_id}"))
out["channel"] = meta.get("channel") or None
except (DiscordAPIError, AttributeError):
pass
return out
def verify_auth(self, url: str) -> tuple[bool | None, str]:
"""Is the token valid, and can it see what the source names?"""
try:
+188 -10
View File
@@ -21,6 +21,9 @@ from .source_service import BACKFILL_MAX_CHUNKS
log = logging.getLogger(__name__)
# The probe runs while the chip is drawing; names that take longer are skipped.
_NAME_LOOKUP_SECONDS = 6.0
class UnknownPlatformError(Exception):
"""URL didn't match any platform pattern."""
@@ -30,6 +33,10 @@ class InvalidUrlError(Exception):
"""URL was empty or missing a scheme."""
class UnknownArtistError(Exception):
"""quick-add named an `artist_id` that does not exist."""
# Mirrored byte-for-byte from extension/lib/platforms.js
# PLATFORM_ARTIST_PATTERNS. Keep these two copies in sync by hand —
# reviewers catch drift.
@@ -55,8 +62,39 @@ _PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
r"^https?://(?:www\.)?hentai-foundry\.com/user/(?P<slug>[^/?#]+)",
re.IGNORECASE,
)),
# A Discord URL names a server or a channel, never a creator, so the slug is
# `<server>` or `<server>/<channel>` and the artist is chosen, not derived.
# A trailing message id (a jump link) still names its channel. DMs (`@me`)
# are not sources; thread links (`/threads/`) are left to the manual form.
("discord", re.compile(
r"^https?://(?:www\.|ptb\.|canary\.)?discord\.com/channels/"
r"(?P<slug>\d+(?:/\d+)?)(?:/\d+)?/?(?:[?#].*)?$",
re.IGNORECASE,
)),
]
DISCORD = "discord"
def canonical_source_url(platform: str, url: str, slug: str) -> str:
"""The URL a new source is stored under. Discord's is rebuilt from the ids
— the form the manual Add form and the ingester use — so a jump link, a
ptb/canary host or a trailing slash never makes a second source for the
same channel. Every other platform keeps the URL as given."""
if platform == DISCORD:
return f"https://discord.com/channels/{slug}"
return url
def _discord_ids(url: str) -> tuple[str | None, str | None] | None:
"""`(server_id, channel_id)` of a stored Discord source URL, None if it
does not parse (a DM or thread link, or an old malformed row)."""
from .discord_client import DiscordAPIError, parse_source_url
try:
return parse_source_url(url)
except DiscordAPIError:
return None
class ExtensionService:
def __init__(self, session: AsyncSession, crypto=None) -> None:
@@ -65,30 +103,63 @@ class ExtensionService:
# add-time. None → skip resolution, fall back to the handle.
self._crypto = crypto
async def quick_add_source(self, url: str) -> dict:
async def quick_add_source(
self,
url: str,
*,
artist_id: int | None = None,
artist_name: str | None = None,
) -> dict:
"""Add `url` as a source. `artist_id` connects it to an existing
artist, `artist_name` to that artist (created if new); with neither,
the artist is resolved from the platform as before."""
platform, raw_slug = self._derive(url)
url = canonical_source_url(platform, url, raw_slug)
# Identity by SOURCE handle (#130): an existing (platform, url) source
# keeps its artist on re-add — even if that artist was since renamed (its
# frozen slug no longer matches the current name). Only a genuinely new
# source resolves/creates an artist.
existing = (await self.session.execute(
select(Source).where(Source.platform == platform, Source.url == url)
)).scalar_one_or_none()
# frozen slug no longer matches the current name), and even when the
# add named a different artist. Only a genuinely new source
# resolves/creates an artist.
existing = await self._existing_source(platform, url)
if existing is not None:
artist = (await self.session.execute(
select(Artist).where(Artist.id == existing.artist_id)
)).scalar_one()
return self._shape(existing, artist, created_source=False, created_artist=False)
# New source → name the artist properly by resolving the real display
# name from the platform (falls back to the URL handle).
name = await self._resolve_artist_name(platform, raw_slug, url)
artist, created_artist = await self._find_or_create_artist(name)
if artist_id is not None:
artist = (await self.session.execute(
select(Artist).where(Artist.id == artist_id)
)).scalar_one_or_none()
if artist is None:
raise UnknownArtistError(f"no artist with id {artist_id}")
created_artist = False
else:
name = (artist_name or "").strip()
if not name:
# Name the artist properly by resolving the real display name
# from the platform (falls back to the URL handle).
name = await self._resolve_artist_name(platform, raw_slug, url)
artist, created_artist = await self._find_or_create_artist(name)
source, created_source = await self._find_or_create_source(
artist_id=artist.id, platform=platform, url=url,
)
return self._shape(source, artist, created_source, created_artist)
async def _existing_source(self, platform: str, url: str) -> Source | None:
"""The source this URL already is, whichever artist owns it. Discord
compares ids, not strings, so a row stored before canonicalisation (a
ptb host, a trailing slash) is still found."""
if platform != DISCORD:
return (await self.session.execute(
select(Source).where(Source.platform == platform, Source.url == url)
)).scalars().first()
want = _discord_ids(url)
rows = (await self.session.execute(
select(Source).where(Source.platform == DISCORD).order_by(Source.id)
)).scalars().all()
return next((s for s in rows if _discord_ids(s.url) == want), None)
@staticmethod
def _shape(source, artist, created_source: bool, created_artist: bool) -> dict:
return {
@@ -117,6 +188,11 @@ class ExtensionService:
platforms (and any failure — no credential, network error) fall back to
the URL handle, which is already readable.
The resolvers are sync, so they run in an executor."""
if platform == DISCORD:
# The server's name: what the operator knows the community as.
server_id = raw_slug.split("/", 1)[0]
names = await self._discord_names(server_id, None)
return names.get("server") or f"Discord {server_id}"
if self._crypto is None or platform not in ("patreon", "subscribestar"):
return raw_slug
import asyncio
@@ -166,6 +242,8 @@ class ExtensionService:
platform, raw_slug = self._derive(url)
except (UnknownPlatformError, InvalidUrlError):
return {"state": "unknown_platform"}
if platform == DISCORD:
return await self._probe_discord(raw_slug)
slug = slugify(raw_slug)
artist = (await self.session.execute(
@@ -205,6 +283,106 @@ class ExtensionService:
},
}
async def _probe_discord(self, raw_slug: str) -> dict:
"""probe for a Discord server or channel. The states mean what they
mean elsewhere, but the artist is never read off the URL:
- source_match: this channel is a source — or the whole server is
(`covered_by_server`), which already walks every channel;
- artist_match: another source on this server belongs to an artist,
the one this channel most likely belongs to too (a suggestion the
Add panel preselects, not a decision);
- new: nothing on this server yet.
`discord` carries the ids, both canonical URLs and the display names,
read with the stored token; a name that can't be read is None."""
server_id, _, channel_id = raw_slug.partition("/")
channel_id = channel_id or None
rows = (await self.session.execute(
select(Source, Artist)
.join(Artist, Artist.id == Source.artist_id)
.where(Source.platform == DISCORD)
.order_by(Source.id)
)).all()
exact = server_whole = on_server = None
for source, artist in rows:
ids = _discord_ids(source.url)
if ids is None or ids[0] != server_id:
continue
if ids[1] == channel_id and exact is None:
exact = (source, artist)
elif ids[1] is None and server_whole is None:
server_whole = (source, artist)
if on_server is None:
on_server = (source, artist)
names = await self._discord_names(server_id, channel_id)
base = f"https://discord.com/channels/{server_id}"
result: dict = {
"platform": DISCORD,
"slug": raw_slug,
"discord": {
"server_id": server_id,
"channel_id": channel_id,
"server_name": names.get("server"),
"channel_name": names.get("channel"),
"server_url": base,
"channel_url": f"{base}/{channel_id}" if channel_id else None,
},
}
hit = exact or server_whole
if hit is not None:
source, artist = hit
result.update(
state="source_match",
artist=self._artist_payload(artist),
source=self._source_payload(source),
covered_by_server=exact is None,
)
elif on_server is not None:
result.update(state="artist_match", artist=self._artist_payload(on_server[1]))
else:
result["state"] = "new"
return result
async def _discord_names(self, server_id: str | None, channel_id: str | None) -> dict:
"""Server/channel display names via the stored Discord token. Never
raises and never waits out a rate limit: it runs while the operator
looks at a page, so a slow or missing answer just means no names."""
if self._crypto is None:
return {}
import asyncio
from .credential_service import CredentialService
from .discord_client import DiscordClient
try:
token = await CredentialService(self.session, self._crypto).get_token(DISCORD)
if not token:
return {}
client = DiscordClient(token, max_retries=0)
loop = asyncio.get_running_loop()
return await asyncio.wait_for(
loop.run_in_executor(None, client.describe, server_id, channel_id),
timeout=_NAME_LOOKUP_SECONDS,
)
except Exception as exc: # names are decoration — never fail the call
log.info("Discord name lookup failed: %s", exc)
return {}
@staticmethod
def _artist_payload(artist) -> dict:
return {"id": artist.id, "name": artist.name, "slug": artist.slug}
@staticmethod
def _source_payload(source) -> dict:
return {
"id": source.id,
"artist_id": source.artist_id,
"platform": source.platform,
"url": source.url,
"enabled": source.enabled,
}
def _derive(self, url: str) -> tuple[str, str]:
if not isinstance(url, str) or not url.strip():
raise InvalidUrlError("url is empty")