feat: the extension picks a Discord source's posters from who posts in the channel, stored by id (#4488)
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / extension-test (push) Successful in 18s
CI and images / frontend-build (push) Successful in 26s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m24s
CI and images / build-agent (push) Successful in 5s
CI and images / sign-extension (push) Successful in 6m13s
CI and images / build-web (push) Successful in 1m38s
CI and images / smoke-web (push) Successful in 54s
CI and images / promote (push) Successful in 2s
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / extension-test (push) Successful in 18s
CI and images / frontend-build (push) Successful in 26s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m24s
CI and images / build-agent (push) Successful in 5s
CI and images / sign-extension (push) Successful in 6m13s
CI and images / build-web (push) Successful in 1m38s
CI and images / smoke-web (push) Successful in 54s
CI and images / promote (push) Successful in 2s
On a Discord channel page the Add panel lists who posted in the newest 200 messages, the creator the server is named for (or owned by) ticked. On a channel FabledCurator already follows, the chip opens the same list with Save and Open artist. The list is kept as user ids, so a rename never quietly stops it matching; the name each was picked under rides beside it for display, and the Subscriptions dialog shows it. - DiscordClient.recent_posters + rank_posters (owner / name-matches-server; bots never suggested; it suggests, the operator ticks). - GET/POST /api/extension/discord/posters; quick-add takes discord_authors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 || '') };
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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.
|
||||
|
||||
+13
-1
@@ -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}.
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,6 +52,9 @@
|
||||
placeholder="Todding" hint="Comma-separated. Empty takes everyone's."
|
||||
persistent-hint class="mt-2"
|
||||
/>
|
||||
<div v-if="platform === 'discord' && authorNames.length" class="text-caption mt-2">
|
||||
{{ authorNames.join(', ') }}
|
||||
</div>
|
||||
<p class="text-caption mt-2" style="opacity: 0.75">
|
||||
More per-platform fields land here over time. Use Advanced JSON for everything else.
|
||||
</p>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user