diff --git a/backend/app/api/extension.py b/backend/app/api/extension.py index 56b3078..5fbe7f5 100644 --- a/backend/app/api/extension.py +++ b/backend/app/api/extension.py @@ -19,8 +19,10 @@ from ..models import AppSetting from ..services.extension_service import ( ExtensionService, InvalidUrlError, + PosterLookupError, UnknownArtistError, UnknownPlatformError, + UnknownSourceError, ) from ..services.source_service import KNOWN_PLATFORMS from ._responses import error_response as _bad @@ -121,6 +123,10 @@ async def quick_add_source(): # Patreon is canon: adding a Patreon source to an existing artist can take # the creator's Patreon display name (name only; the slug never moves). use_platform_name = body.get("use_platform_name") is True + # #4488: a Discord add can carry the posters picked in the panel. + discord_authors = body.get("discord_authors") + if discord_authors is not None and not _valid_authors(discord_authors): + return _bad("invalid_body", detail="discord_authors must be a list of {id, name}") from .credentials import _get_crypto @@ -133,6 +139,7 @@ async def quick_add_source(): result = await ExtensionService(session, _get_crypto()).quick_add_source( url, artist_id=artist_id, artist_name=artist_name, use_platform_name=use_platform_name, + discord_authors=discord_authors, ) except UnknownArtistError as exc: return _bad("not_found", detail=str(exc), status=404) @@ -147,6 +154,59 @@ async def quick_add_source(): return jsonify(result), (201 if result["created_source"] else 200) +def _valid_authors(value) -> bool: + """[{id, name}]: the id is what the list keys on, the name only shows it.""" + return isinstance(value, list) and all( + isinstance(a, dict) and isinstance(a.get("id"), str | int) + and not isinstance(a.get("id"), bool) + and (a.get("name") is None or isinstance(a.get("name"), str)) + for a in value + ) + + +@extension_bp.route("/discord/posters", methods=["GET"]) +async def discord_posters(): + """Who posts in the Discord channel `url` shows, lately, with the likely + creator marked (#4488). Reads the channel with the stored token; writes + nothing.""" + 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) + try: + result = await ExtensionService(session, _get_crypto()).discord_posters(url) + except (InvalidUrlError, UnknownPlatformError) as exc: + return _bad("invalid_url", detail=str(exc)) + except PosterLookupError as exc: + return _bad("posters_unavailable", detail=str(exc), status=409) + return jsonify(result) + + +@extension_bp.route("/discord/posters", methods=["POST"]) +async def set_discord_posters(): + """Set the poster list of the source following `url`'s channel.""" + body = await request.get_json(silent=True) + if not isinstance(body, dict) or not isinstance(body.get("url"), str): + return _bad("invalid_body", detail="url is required") + authors = body.get("authors") + if not _valid_authors(authors): + return _bad("invalid_body", detail="authors must be a list of {id, name}") + async with get_session() as session: + if not await _ext_key_required(session): + return _bad("unauthorized", status=401) + try: + result = await ExtensionService(session).set_discord_posters(body["url"], authors) + except (InvalidUrlError, UnknownPlatformError, PosterLookupError) as exc: + return _bad("invalid_url", detail=str(exc)) + except UnknownSourceError as exc: + return _bad("not_found", detail=str(exc), status=404) + return jsonify(result) + + def _read_manifest_sync() -> dict | None: """All the filesystem-touching work for /api/extension/manifest, in a sync helper so the async route can dispatch it via diff --git a/backend/app/services/discord_client.py b/backend/app/services/discord_client.py index d2f4f77..e068178 100644 --- a/backend/app/services/discord_client.py +++ b/backend/app/services/discord_client.py @@ -75,6 +75,9 @@ _THREADS_BATCH = 25 # it is honoured when present; 60s is the fallback and the cap. _MAX_429_RETRIES = 4 _429_WAIT_SECONDS = 60.0 +# How far back the poster picker looks: two pages. Enough to see who posts in +# a channel, few enough to answer while the operator waits (#4488). +_POSTER_SCAN = 200 # https://discord.com/developers/docs/resources/message#message-object-message-types # DEFAULT, REPLY, CHAT_INPUT_COMMAND — the ones that carry user content. @@ -204,6 +207,55 @@ def has_visual_attachment(message: dict) -> bool: ) +_SQUASH = re.compile(r"[\W_]+", re.UNICODE) +# Words a server name wraps around its creator's: "Todding's Server", +# "The Official Todding Discord". +_SERVER_WORDS = frozenset({"server", "discord", "the", "official", "community", "s"}) + + +def _squash(text: str | None) -> str: + return _SQUASH.sub("", (text or "").lower()) + + +def _server_tokens(server_name: str | None) -> list[str]: + name = (server_name or "").lower().replace("'s", " ").replace("\u2019s", " ") + words = [w for w in _SQUASH.split(name) if w and w not in _SERVER_WORDS] + tokens = [w for w in words if len(w) >= 3] + joined = "".join(words) + if len(joined) >= 3 and joined not in tokens: + tokens.append(joined) + return tokens + + +def rank_posters(server_name: str | None, owner_id, posters: list[dict]) -> list[dict]: + """Mark who is probably the creator a server is named for, and order the + list for picking: suggested first, then by images posted, then messages. + + Two signals, each a reason the picker shows beside the name: + - they own the server; + - their username or display name matches the server's name, once + "'s Server", "Official", "Discord" and punctuation are taken off + ("Todding's Server" and "todding"). + Neither signal alone is proof, which is why this suggests and never + decides: the operator ticks the list. Bots are never suggested.""" + tokens = _server_tokens(server_name) + owner = str(owner_id) if owner_id else None + ranked = [] + for p in posters: + reasons = [] + if owner and p.get("id") == owner: + reasons.append("owns the server") + names = [n for n in (_squash(p.get("username")), _squash(p.get("global_name"))) if len(n) >= 3] + if any(t in n or n in t for t in tokens for n in names): + reasons.append("name matches the server") + ranked.append({**p, "reasons": reasons, "suggested": False}) + best = max((len(r["reasons"]) for r in ranked if not r.get("bot")), default=0) + for r in ranked: + r["suggested"] = best > 0 and not r.get("bot") and len(r["reasons"]) == best + ranked.sort(key=lambda r: (not r["suggested"], -r.get("images", 0), -r.get("messages", 0))) + return ranked + + @dataclass class MediaItem: """One file of a Discord message. `filename`/`extension` are gallery-dl's @@ -611,6 +663,61 @@ class DiscordClient: pass return out + def recent_posters( + self, server_id: str | None, channel_id: str, *, max_messages: int = _POSTER_SCAN, + ) -> dict: + """Who has posted lately in a channel, for picking a source's poster + list (#4488): the newest `max_messages` messages, tallied per author. + Deliberately shallow — it answers "who posts here", not "who ever did". + Every poster comes back with their stable id, which is what the list + stores: a username can change on a whim, an id can't.""" + server: dict = {} + if server_id: + try: + server = self._get(f"/guilds/{server_id}") or {} + except DiscordAPIError: + server = {} + tally: dict[str, dict] = {} + before = None + seen = 0 + while seen < max_messages: + page = self._get( + f"/channels/{channel_id}/messages", + {"limit": min(_MESSAGES_BATCH, max_messages - seen), "before": before}, + ) + if not isinstance(page, list) or not page: + break + for message in page: + seen += 1 + if message.get("type") not in MESSAGE_TYPES: + continue + author = message.get("author") or {} + aid = str(author.get("id") or "") + if not aid: + continue + row = tally.setdefault(aid, { + "id": aid, + "username": author.get("username"), + "global_name": author.get("global_name"), + "bot": bool(author.get("bot")), + "messages": 0, + "images": 0, + }) + row["messages"] += 1 + if has_visual_attachment(message): + row["images"] += 1 + if len(page) < _MESSAGES_BATCH: + break + before = str(page[-1]["id"]) + return { + "server": server.get("name") or None, + "owner_id": str(server["owner_id"]) if server.get("owner_id") else None, + "scanned": seen, + "posters": rank_posters( + server.get("name"), server.get("owner_id"), list(tally.values()), + ), + } + def verify_auth(self, url: str) -> tuple[bool | None, str]: """Is the token valid, and can it see what the source names?""" try: diff --git a/backend/app/services/extension_service.py b/backend/app/services/extension_service.py index 1c2168a..7c041bb 100644 --- a/backend/app/services/extension_service.py +++ b/backend/app/services/extension_service.py @@ -23,6 +23,12 @@ log = logging.getLogger(__name__) # The probe runs while the chip is drawing; names that take longer are skipped. _NAME_LOOKUP_SECONDS = 6.0 +# The poster picker waits on two pages of a channel's messages (#4488). +_POSTER_LOOKUP_SECONDS = 20.0 + +# A source's poster list (discord_ingester.AUTHORS_KEY) holds user ids; this +# keeps the name each id was picked under, for showing the list to a person. +AUTHOR_LABELS_KEY = "discord_author_labels" class UnknownPlatformError(Exception): @@ -37,6 +43,15 @@ class UnknownArtistError(Exception): """quick-add named an `artist_id` that does not exist.""" +class PosterLookupError(Exception): + """The Discord poster picker couldn't read the channel (no token, no + access, not a channel page, or Discord was slow).""" + + +class UnknownSourceError(Exception): + """No source follows this Discord channel or its server.""" + + # Mirrored byte-for-byte from extension/lib/platforms.js # PLATFORM_ARTIST_PATTERNS. Keep these two copies in sync by hand — # reviewers catch drift. @@ -110,6 +125,7 @@ class ExtensionService: artist_id: int | None = None, artist_name: str | None = None, use_platform_name: bool = False, + discord_authors: list[dict] | 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, @@ -119,7 +135,10 @@ class ExtensionService: name is canon: a Patreon source added to an existing artist renames that artist to the creator's Patreon display name. Name only — the slug, and every path keyed off it, never moves (#130). Ignored on every - other platform, and when the name can't be read.""" + other platform, and when the name can't be read. + + `discord_authors` ([{id, name}], #4488) sets a Discord source's poster + list in the same step, on a new source or one already there.""" platform, raw_slug = self._derive(url) url = canonical_source_url(platform, url, raw_slug) renamed_from = None @@ -133,6 +152,8 @@ class ExtensionService: artist = (await self.session.execute( select(Artist).where(Artist.id == existing.artist_id) )).scalar_one() + if platform == DISCORD and discord_authors is not None: + await self._apply_posters(existing, discord_authors) return self._shape(existing, artist, created_source=False, created_artist=False) if artist_id is not None: @@ -154,11 +175,98 @@ class ExtensionService: source, created_source = await self._find_or_create_source( artist_id=artist.id, platform=platform, url=url, ) + if platform == DISCORD and discord_authors is not None: + await self._apply_posters(source, discord_authors) shaped = self._shape(source, artist, created_source, created_artist) if renamed_from is not None: shaped["renamed_from"] = renamed_from return shaped + # -- Discord poster picker (#4488) ------------------------------------------ + + def _discord_page(self, url: str) -> tuple[str, str]: + platform, raw_slug = self._derive(url) + if platform != DISCORD: + raise InvalidUrlError("not a Discord page") + server_id, _, channel_id = raw_slug.partition("/") + if not channel_id: + raise PosterLookupError("Open a channel to see who posts in it.") + return server_id, channel_id + + async def _source_for_page(self, server_id: str, channel_id: str) -> Source | None: + """The source this channel's posts land on: the channel's own, else a + whole-server source that walks it.""" + base = f"https://discord.com/channels/{server_id}" + return ( + await self._existing_source(DISCORD, f"{base}/{channel_id}") + or await self._existing_source(DISCORD, base) + ) + + async def discord_posters(self, url: str) -> dict: + """Who posts in the channel `url` shows, newest `_POSTER_SCAN` + messages deep, with the likely creator marked — and, when a source + already follows it, the poster list that source has now.""" + server_id, channel_id = self._discord_page(url) + import asyncio + + from .credential_service import CredentialService + from .discord_client import DiscordAPIError, DiscordClient + token = None + if self._crypto is not None: + token = await CredentialService(self.session, self._crypto).get_token(DISCORD) + if not token: + raise PosterLookupError("No Discord token is saved in FabledCurator.") + client = DiscordClient(token, max_retries=0) + loop = asyncio.get_running_loop() + try: + found = await asyncio.wait_for( + loop.run_in_executor(None, client.recent_posters, server_id, channel_id), + timeout=_POSTER_LOOKUP_SECONDS, + ) + except TimeoutError as exc: + raise PosterLookupError("Discord took too long to answer; try again.") from exc + except DiscordAPIError as exc: + raise PosterLookupError(f"Couldn't read this channel: {exc}") from exc + source = await self._source_for_page(server_id, channel_id) + co = (source.config_overrides or {}) if source is not None else {} + from .discord_ingester import AUTHORS_KEY + return { + **found, + "source_id": source.id if source is not None else None, + "selected": list(co.get(AUTHORS_KEY) or []), + # So someone on the list who hasn't posted lately still shows by + # name, and can be unticked. + "selected_labels": dict(co.get(AUTHOR_LABELS_KEY) or {}), + } + + async def set_discord_posters(self, url: str, authors: list[dict]) -> dict: + """Store the picked posters on the source that follows `url`'s channel.""" + server_id, channel_id = self._discord_page(url) + source = await self._source_for_page(server_id, channel_id) + if source is None: + raise UnknownSourceError("No source follows this channel yet.") + return await self._apply_posters(source, authors) + + async def _apply_posters(self, source: Source, authors: list[dict]) -> dict: + """Ids are what the list keys on — a username can change on a whim, + an id can't — and the name each was picked under rides beside it for + showing the list to a person. An empty pick clears the list, which + takes everyone's posts again.""" + from .discord_ingester import AUTHORS_KEY + picked = [a for a in authors if str(a.get("id") or "").strip()] + co = dict(source.config_overrides or {}) + if picked: + co[AUTHORS_KEY] = [str(a["id"]).strip() for a in picked] + co[AUTHOR_LABELS_KEY] = { + str(a["id"]).strip(): str(a.get("name") or a["id"]) for a in picked + } + else: + co.pop(AUTHORS_KEY, None) + co.pop(AUTHOR_LABELS_KEY, None) + source.config_overrides = co or None + await self.session.commit() + return {"source_id": source.id, "discord_authors": co.get(AUTHORS_KEY, [])} + async def _adopt_patreon_name(self, artist, raw_slug: str, url: str) -> str | None: """Rename `artist` to the Patreon display name; the old name when it changed, else None. Unreadable name → no rename, never the handle.""" diff --git a/extension/background/background.js b/extension/background/background.js index f41d117..74b62d0 100644 --- a/extension/background/background.js +++ b/extension/background/background.js @@ -276,11 +276,26 @@ browser.runtime.onMessage.addListener(async (msg) => { artistId: msg.artistId ?? null, artistName: msg.artistName ?? null, usePlatformName: msg.usePlatformName === true, + discordAuthors: Array.isArray(msg.discordAuthors) ? msg.discordAuthors : null, }); } catch (e) { return { error: e.message }; } + case 'DISCORD_POSTERS': + try { + return await api.getDiscordPosters(msg.url); + } catch (e) { + return { error: e.message }; + } + + case 'SET_DISCORD_POSTERS': + try { + return await api.setDiscordPosters(msg.url, msg.authors || []); + } catch (e) { + return { error: e.message }; + } + case 'SEARCH_ARTISTS': try { return { artists: await api.searchArtists(msg.q || '') }; diff --git a/extension/content/content-script.css b/extension/content/content-script.css index 86eaa64..03c2906 100644 --- a/extension/content/content-script.css +++ b/extension/content/content-script.css @@ -100,3 +100,7 @@ /* The panel's own [hidden] — its rows are display:flex, which beats the UA's. */ .fc-panel [hidden] { display: none !important; } .fc-panel__rename { margin-top: 8px; font-size: 13px; } +/* Poster picker (#4488): who posts in the channel, the likely creator ticked. */ +.fc-panel__posters { max-height: 220px; overflow-y: auto; } +.fc-panel__poster { flex-wrap: wrap; } +.fc-panel__poster-meta { flex-basis: 100%; padding-left: 21px; font-size: 11px; color: rgb(170, 166, 156); } diff --git a/extension/content/content-script.js b/extension/content/content-script.js index 6f3bacf..7c0b17c 100644 --- a/extension/content/content-script.js +++ b/extension/content/content-script.js @@ -81,6 +81,13 @@ const probe = currentProbe; if (probe?.state === 'source_match') { + // A followed Discord channel opens its poster list (#4488); the artist + // page is one click further, in that panel. + if (probe.platform === 'discord' && probe.discord?.channel_id) { + if (document.getElementById('fc-add-panel')) closePanel(); + else openPostersPanel(btn, probe); + return; + } await openArtist(btn, probe.artist?.slug); return; } @@ -158,6 +165,120 @@ document.getElementById('fc-add-panel')?.remove(); } + // ---- Poster picker (#4488) ---- + // Who posted in this channel lately, read by FabledCurator with its Discord + // token, the creator the server is named for (or owned by) ticked. The + // source keeps their ids: a username can change on a whim, an id can't, so + // the list never quietly stops matching. Names are other people's text — + // createElement only. + + function posterSection() { + const list = el('div', { class: 'fc-panel__posters' }, [ + el('div', { class: 'fc-panel__hint', text: 'Reading who posts here…' }), + ]); + const node = el('div', {}, [ + el('div', { class: 'fc-panel__label', text: 'Only posts by' }), + list, + ]); + const state = { data: null, picked: new Set() }; + + function render() { + const posters = withSelected(state.data); + if (!posters.length) { + list.replaceChildren(el('div', { class: 'fc-panel__hint', text: 'No one has posted here lately.' })); + return; + } + const rows = posters.map((p) => { + const id = String(p.id); + const box = el('input', { type: 'checkbox', checked: state.picked.has(id) }); + box.addEventListener('change', () => { + if (box.checked) state.picked.add(id); + else state.picked.delete(id); + hint.textContent = pickHint(); + }); + const bits = [`${p.images} image${p.images === 1 ? '' : 's'}`, `${p.messages} message${p.messages === 1 ? '' : 's'}`]; + if (p.reasons?.length) bits.unshift(p.reasons.join(', ')); + return el('label', { class: 'fc-panel__radio fc-panel__poster' }, [ + box, + el('span', { text: posterLabel(p) }), + el('span', { class: 'fc-panel__poster-meta', text: bits.join(' · ') }), + ]); + }); + const hint = el('div', { class: 'fc-panel__hint', text: pickHint() }); + list.replaceChildren(...rows, hint); + } + + function pickHint() { + return state.picked.size + ? 'Only the ticked people’s posts are taken.' + : 'Nothing ticked: everyone’s posts are taken.'; + } + + async function load() { + let r; + try { + r = await browser.runtime.sendMessage({ type: 'DISCORD_POSTERS', url: window.location.href }); + } catch (e) { + r = { error: e.message }; + } + if (r?.error) { + list.replaceChildren(el('div', { class: 'fc-panel__hint', text: `Couldn’t list posters — ${r.error}` })); + return; + } + state.data = r; + state.picked = initialPosterPick(r); + render(); + } + + // null until the list has loaded: an add made before then leaves the + // source's list as it is instead of clearing it. + const request = () => (state.data ? postersRequest(state.data.posters, state.picked) : null); + return { node, load, request }; + } + + function openPostersPanel(btn, probe) { + closePanel(); + const d = probe.discord || {}; + const posters = posterSection(); + const saveBtn = el('button', { class: 'fc-panel__btn fc-panel__btn--primary', text: 'Save' }); + const artistBtn = el('button', { class: 'fc-panel__btn', text: `Open ${probe.artist?.name || 'artist'}` }); + const cancelBtn = el('button', { class: 'fc-panel__btn', text: 'Close' }); + const panel = el('div', { id: 'fc-add-panel', class: 'fc-panel' }, [ + el('div', { class: 'fc-panel__title', text: 'Discord source' }), + el('div', { class: 'fc-panel__sub', text: d.channel_id ? channelLabel(d) : serverLabel(d) }), + posters.node, + el('div', { class: 'fc-panel__actions' }, [artistBtn, cancelBtn, saveBtn]), + ]); + panel.addEventListener('keydown', (e) => { + e.stopPropagation(); + if (e.key === 'Escape') closePanel(); + }); + cancelBtn.addEventListener('click', closePanel); + artistBtn.addEventListener('click', () => { closePanel(); openArtist(btn, probe.artist?.slug); }); + saveBtn.addEventListener('click', async () => { + const authors = posters.request(); + if (!authors) return; + saveBtn.disabled = true; + try { + const r = await browser.runtime.sendMessage({ + type: 'SET_DISCORD_POSTERS', url: window.location.href, authors, + }); + if (r?.error) { + showToast(`Error: ${r.error}`, 'error'); + return; + } + showToast(authors.length ? `Only posts by ${authors.map((a) => a.name || a.id).join(', ')}` : 'Taking everyone’s posts', 'success'); + closePanel(); + } catch (e) { + showToast(`Error: ${e.message}`, 'error'); + } finally { + saveBtn.disabled = false; + } + }); + document.body.appendChild(panel); + posters.load(); + } + async function openAddPanel(btn, probe) { closePanel(); const platformName = PLATFORMS[probe.platform]?.name || probe.platform; @@ -184,6 +305,8 @@ const d = probe.discord || {}; const discord = probe.platform === 'discord'; const choice = panelDefaults(probe, window.location.href); + // #4488: on a channel page, pick whose posts the new source takes. + const posters = discord && d.channel_id ? posterSection() : null; const scopeRow = (value, label, disabled) => { const input = el('input', { @@ -234,6 +357,7 @@ el('div', { class: 'fc-panel__label', text: 'Artist' }), el('div', { class: 'fc-panel__combo' }, [nameInput, results]), renameRow, + ...(posters ? [posters.node] : []), hint, el('div', { class: 'fc-panel__actions' }, [cancelBtn, addBtn]), ]); @@ -392,6 +516,8 @@ addBtn.addEventListener('click', async () => { const req = addRequest(choice); if (!req) return; + const authors = posters?.request(); + if (authors) req.discordAuthors = authors; const btn = document.getElementById('fc-add-source-btn'); addBtn.disabled = true; const ok = await add(btn || addBtn, req); @@ -402,6 +528,7 @@ document.body.appendChild(panel); refresh(); nameInput.focus(); + posters?.load(); nameInput.select(); // Search what the field opens with — the server's name, usually — so an // artist it already matches is picked before the operator types anything. diff --git a/extension/lib/api.js b/extension/lib/api.js index aa9fe0b..e5c457d 100644 --- a/extension/lib/api.js +++ b/extension/lib/api.js @@ -106,8 +106,11 @@ class FabledCuratorAPI { // artist from the URL. A Discord channel always sends one. // usePlatformName: a Patreon source joining an existing artist renames it // to the Patreon display name (Patreon is canon; name only, never the slug). - quickAddSource(url, { artistId = null, artistName = null, usePlatformName = false } = {}) { + // discordAuthors ([{id, name}], #4488): a Discord source's poster list, set + // in the same step. + quickAddSource(url, { artistId = null, artistName = null, usePlatformName = false, discordAuthors = null } = {}) { const body = { url }; + if (discordAuthors) body.discord_authors = discordAuthors; if (artistId != null) body.artist_id = artistId; else if (artistName) body.artist_name = artistName; if (usePlatformName) body.use_platform_name = true; @@ -123,6 +126,15 @@ class FabledCuratorAPI { const qs = new URLSearchParams(params).toString(); return this.request('GET', `/extension/probe?${qs}`); } + // #4488: who posts in the Discord channel `url` shows, lately, with the + // likely creator marked; and setting the list on the source that follows it. + getDiscordPosters(url) { + const qs = new URLSearchParams({ url }).toString(); + return this.request('GET', `/extension/discord/posters?${qs}`); + } + setDiscordPosters(url, authors) { + return this.request('POST', '/extension/discord/posters', { url, authors }); + } // Latest published extension version on this instance — drives the in-app // update prompt. Public endpoint (no key needed, but request() sends it // harmlessly). Returns {version, xpi_url, latest_url, sha256}. diff --git a/extension/lib/chip.js b/extension/lib/chip.js index 1a7f0f3..8fb625a 100644 --- a/extension/lib/chip.js +++ b/extension/lib/chip.js @@ -144,3 +144,49 @@ function inlineCompletion(typed, results) { if (!t) return null; return (results || []).find((a) => a.name.length > t.length && a.name.toLowerCase().startsWith(t)) || null; } + +// ---- Discord poster picker (#4488) ---- +// /api/extension/discord/posters lists who posted in the channel lately, with +// the creator the server is named for (or owned by) marked `suggested`. The +// picker ticks posters; the source keeps their ids, since a username can +// change on a whim and an id can't. + +/** "Todding (@todding)" — the display name, then the handle when it differs. */ +function posterLabel(p) { + const shown = p?.global_name || p?.username || p?.id || ''; + const handle = p?.username && p.username !== shown ? ` (@${p.username})` : ''; + return `${shown}${handle}`; +} + +/** What the picker starts with: the source's current list when it has one, + * else the suggested creator(s). A set of ids. */ +function initialPosterPick(data) { + const current = (data?.selected || []).map(String); + if (current.length) return new Set(current); + return new Set((data?.posters || []).filter((p) => p.suggested).map((p) => String(p.id))); +} + +/** The checklist's rows: who posted lately, then anyone already on the + * source's list who hasn't — by the name they were picked under — so they + * can still be unticked. */ +function withSelected(data) { + const posters = [...(data?.posters || [])]; + const shown = new Set(posters.map((p) => String(p.id))); + const labels = data?.selected_labels || {}; + for (const id of (data?.selected || []).map(String)) { + if (shown.has(id)) continue; + posters.push({ id, global_name: labels[id] || null, username: null, + messages: 0, images: 0, reasons: ['on the list, not posting lately'] }); + } + return posters; +} + +/** The picked posters as the API takes them: [{id, name}], in list order. + * An id already on the source but no longer posting lately is kept. */ +function postersRequest(posters, picked) { + const byId = new Map((posters || []).map((p) => [String(p.id), p])); + return [...picked].map((id) => ({ + id, + name: byId.has(id) ? (byId.get(id).global_name || byId.get(id).username || id) : null, + })); +} diff --git a/extension/test/chip.spec.js b/extension/test/chip.spec.js index 2edfa43..473b651 100644 --- a/extension/test/chip.spec.js +++ b/extension/test/chip.spec.js @@ -198,3 +198,46 @@ describe('Add panel on Patreon and SubscribeStar', () => { expect(c.artistName).toBe('Tamada') }) }) + +const { posterLabel, initialPosterPick, postersRequest, withSelected } = loadLib('chip.js', [ + 'posterLabel', 'initialPosterPick', 'postersRequest', 'withSelected', +]) + +describe('the poster checklist rows', () => { + it('adds someone on the list who has not posted lately, by their saved name', () => { + const rows = withSelected({ + posters: [{ id: '7', username: 'todding', global_name: 'Todding' }], + selected: ['7', '42'], + selected_labels: { 42: 'Old Name' }, + }) + expect(rows.map((r) => r.id)).toEqual(['7', '42']) + expect(posterLabel(rows[1])).toBe('Old Name') + expect(rows[1].reasons).toEqual(['on the list, not posting lately']) + }) +}) + +describe('the Discord poster picker (#4488)', () => { + const posters = [ + { id: '7', username: 'todding', global_name: 'Todding', suggested: true }, + { id: '8', username: 'jakeboii', global_name: 'Jake Boii', suggested: false }, + { id: '9', username: 'plain', global_name: null, suggested: false }, + ] + + it('labels a poster by display name, then handle', () => { + expect(posterLabel(posters[0])).toBe('Todding (@todding)') + expect(posterLabel(posters[2])).toBe('plain') + }) + + it('starts from the source list when it has one, else the suggestion', () => { + expect([...initialPosterPick({ posters, selected: [] })]).toEqual(['7']) + expect([...initialPosterPick({ posters, selected: ['8'] })]).toEqual(['8']) + expect([...initialPosterPick({ posters: [] })]).toEqual([]) + }) + + it('sends ids with the names they were picked under, keeping unknown ids', () => { + expect(postersRequest(posters, new Set(['7', '42']))).toEqual([ + { id: '7', name: 'Todding' }, + { id: '42', name: null }, + ]) + }) +}) diff --git a/frontend/src/components/subscriptions/SourceFormDialog.vue b/frontend/src/components/subscriptions/SourceFormDialog.vue index f14b161..2ebecbc 100644 --- a/frontend/src/components/subscriptions/SourceFormDialog.vue +++ b/frontend/src/components/subscriptions/SourceFormDialog.vue @@ -52,6 +52,9 @@ placeholder="Todding" hint="Comma-separated. Empty takes everyone's." persistent-hint class="mt-2" /> +
+ {{ authorNames.join(', ') }} +

More per-platform fields land here over time. Use Advanced JSON for everything else.

@@ -112,6 +115,15 @@ const structuredAuthors = ref('') const otherConfig = ref({}) const STRUCTURED_KEYS = ['videos', 'since', 'discord_authors'] +// The extension's poster picker stores ids (#4488) and the name each was +// picked under, so a list of numbers still says who it means. +const authorNames = computed(() => { + const labels = otherConfig.value.discord_author_labels || {} + return splitAuthors(structuredAuthors.value) + .map(a => (labels[a] ? `${labels[a]} (${a})` : null)) + .filter(Boolean) +}) + function splitAuthors(txt) { return (txt || '').split(',').map(s => s.trim()).filter(Boolean) } diff --git a/tests/test_discord_client.py b/tests/test_discord_client.py index d71bb2d..499f2fc 100644 --- a/tests/test_discord_client.py +++ b/tests/test_discord_client.py @@ -424,3 +424,60 @@ def test_the_adapter_authenticates_with_the_token_and_keys_by_identity(tmp_path) _msg(9, attachments=[{"id": "300", "url": "https://cdn/a/1.png"}]) ) assert _ledger_key(media) == "9:300" + + +# -- the poster picker (#4488) ------------------------------------------------- + +def _person(pid, username, global_name=None, **extra): + return {"id": pid, "username": username, "global_name": global_name, + "messages": 1, "images": 0, **extra} + + +def test_the_creator_a_server_is_named_for_is_suggested(): + ranked = dc.rank_posters("Todding's Server", "99", [ + _person("8", "jakeboii", "Jake Boii", images=3), + _person("7", "todding", "Todding", images=1), + ]) + assert ranked[0]["id"] == "7" and ranked[0]["suggested"] is True + assert ranked[0]["reasons"] == ["name matches the server"] + assert ranked[1]["suggested"] is False + + +def test_owning_the_server_and_matching_its_name_outranks_either_alone(): + ranked = dc.rank_posters("The Official Todding Discord", "7", [ + _person("5", "toddingfan"), + _person("7", "t0dd", "Todding"), + ]) + assert [r["id"] for r in ranked if r["suggested"]] == ["7"] + assert ranked[0]["reasons"] == ["owns the server", "name matches the server"] + + +def test_no_signal_suggests_no_one_and_bots_never(): + assert not any(r["suggested"] for r in dc.rank_posters("Art Club", None, [ + _person("1", "alice"), _person("2", "bob"), + ])) + ranked = dc.rank_posters("Todding's Server", None, [_person("3", "toddingbot", bot=True)]) + assert ranked[0]["suggested"] is False + + +def test_recent_posters_tallies_a_shallow_window_by_id(): + creator = {"id": "7", "username": "todding", "global_name": "Todding"} + other = {"id": "8", "username": "jakeboii", "global_name": "Jake Boii"} + art = [{"url": "https://cdn/a/1.png"}] + page = [_msg(i, author=creator, attachments=art) for i in range(300, 240, -1)] + page += [_msg(i, author=other) for i in range(240, 200, -1)] + routes = { + "/guilds/1": _ok({"id": "1", "name": "Todding's Server", "owner_id": "7"}), + ("/channels/2/messages", None): _ok(page), + ("/channels/2/messages", "201"): _ok([_msg(150, author=other)]), + } + client = _client(routes) + got = client.recent_posters("1", "2", max_messages=150) + assert got["server"] == "Todding's Server" and got["owner_id"] == "7" + assert got["scanned"] == 101 + first = got["posters"][0] + assert (first["id"], first["messages"], first["images"], first["suggested"]) == ("7", 60, 60, True) + assert got["posters"][1]["messages"] == 41 + # It asked for no more than the window: 100, then the 50 left of 150. + limits = [p.get("limit") for e, p in client._session.calls if e.endswith("/messages")] + assert limits == [100, 50]