Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions APIs.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,29 @@ and uploaded as a native Discord attachment so the embed renders immediately.
The Link button retains the resolved raw media URL. No browser automation,
prefetching, speculative request, or background crawl is used.

## 12. PornPics butt-plug category feed

The Real source for `/buttplug` uses the public category feed at:

`https://www.pornpics.com/butt-plug/`

Each execution selects a cryptographically random item index from `0` through
`999`. For indices `0..19`, the already-rendered category HTML is requested once
and only image elements inside `/galleries/...` links are considered. For
indices `20..999`, one request is sent to the same endpoint with
`offset={selectedIndex}` and the first JSON array item is used. The response
fields are `g_url`, `t_url`, `t_url_460`, `gid`, and `mid`.

The adapter never requests `g_url` or downloads media bytes. It validates the
exact `cdni.pornpics.com` media hostname and promotes the thumbnail path from
`/300/` or `/460/` to `/1280/` while retaining the shard directories and hashed
filename. The resulting high-resolution CDN URL is placed directly in the
Discord embed; the Link button points to the validated gallery URL. Category
responses are bounded to 2 MiB, redirects remain restricted to PornPics hosts,
and malformed JSON, foreign hosts, insecure URLs, and unknown media paths are
rejected. No browser automation, gallery crawl, CDN binary fetch, proxy, or
transcoding step is used.

## Command-to-provider preservation

Every command source is explicit in its own module. The provider parity tests
Expand All @@ -256,6 +279,11 @@ Porngifs.com, NekoBot `pgif`, and Porngifs.tv before fallback is applied.
Its primary/fallback order and one-feed extraction contracts are covered by
dedicated executable tests.

The Real `/buttplug` PornPics source is also an intentional active extension.
Its archived Anime N-SFW source remains intact, and dedicated tests cover the
new option grouping, exact offset range, feed-only extraction, and 1280px URL
promotion.

The experimental endpoint studies in `archive/legacy/studies/` are preserved
as research documents. They were not active command implementations in the old
runtime and are therefore not silently promoted into production providers.
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ Discord API reference, but it is intentionally ignored and never committed.
re-uploads the media as a native Discord attachment. Its transport remains
hostname-verified and may tolerate certificate expiry only; its Link button
opens the validated Osaka media URL directly.
- PornPics supplies the Real `/buttplug` option from its category feed. The
adapter selects across the 1,000-item category index, reads only gallery-card
thumbnails, promotes `/300/` or `/460/` CDN paths to `/1280/`, and never opens
an individual gallery page.
- Free accounts receive 60 successful media requests per rolling minute and
1,000 per UTC day. Premium accounts remove the minute ceiling and receive
5,000 per UTC day. Failed provider/Discord executions do not consume media
Expand Down Expand Up @@ -64,10 +68,11 @@ Utility commands:
button that updates the existing message from the latest usage snapshot.
Premium membership currently comes from a deployment allowlist.

The original options are preserved:
Command options:

- `/anal`, `/ass`, `/blowjob`, `/boobs`, `/feet`, and `/thigh`: optional
`style` choice (`Anime` or `Real`).
- `/anal`, `/ass`, `/blowjob`, `/boobs`, `/buttplug`, `/feet`, and `/thigh`:
optional `style` choice (`Anime` or `Real`). `/buttplug` adds this grouping to
its archived Anime-only behavior.
- `/solo`: optional `gender` choice (`female` or `male`).
- `/threesome`: optional `type` choice (`fff`, `ffm`, or `mmf`).
- `/help`: optional autocompleted `command` value.
Expand Down
6 changes: 6 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ extract media directly from that feed. They never load gallery or album pages.
Downloaded image bytes are re-uploaded as native Discord attachments while the
Link button keeps the validated raw source URL.

The Real `/buttplug` provider follows the same feed-only constraint. It selects
one exact index across PornPics' 1,000-item category window, reads either the
initial gallery cards or the offset JSON feed, and promotes the selected cover
path to the CDN's `/1280/` variant. It does not open the gallery or fetch the
CDN binary; Discord receives the validated direct media URL.

Each provider also has an independent consecutive-failure circuit. It opens
for a randomized 30–120 seconds after three failures. Once the interval
elapses, only one genuine user request becomes a half-open probe; concurrent
Expand Down
10 changes: 7 additions & 3 deletions docs/AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ and REST rate limits.
guild/bot-DM/private-channel contexts.
- The active `/cosplay` extension adds two feed-only scraper transports without
modifying the byte-for-byte legacy archive or its parity baseline.
- The active Real `/buttplug` extension adds one PornPics category-feed
transport. The archived N-SFW mapping remains the Anime group and continues
to be verified against the parity baseline.
- Original endpoint categories, query parameters, provider headers, style
groups, and command choice values are covered by executable parity tests.
- The hard-coded NekoBot value became a secret-manager environment variable.
Expand Down Expand Up @@ -91,9 +94,10 @@ The second verification pass executes every archived media command with
mocked Discord builders and provider transports. Explicit option groups and
the archived random source ranges are exercised, and the reachable provider
calls are compared directly with each modern command declaration. Separate
provider contract tests cover all 11 archived transports plus both active
cosplay feeds, including query objects, headers, response fields, random ranges,
DNS/SNI routing, retry counts, feed parsing, and attachment behavior.
provider contract tests cover all 11 archived transports, both active cosplay
feeds, and the active PornPics category feed, including query objects, headers,
response fields, random ranges, DNS/SNI routing, retry counts, feed parsing,
resolution promotion, and attachment/direct-URL behavior.

This pass also hardened interaction handling against duplicate Gateway
dispatches, ambiguous callback retries, expired tokens, legacy refresh IDs,
Expand Down
8 changes: 7 additions & 1 deletion docs/COMMAND_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ compares them with the corresponding modern command declaration.
| `/blowjob` | Anime: Purrbot `blowjob/gif`, Waifu.pics `blowjob`, N-SFW `blowjob`, Waifu.im `oral`; Real: NekoBot `blowjob` |
| `/boobs` | Anime: Waifu.im `oppai`, NekoBot `hboobs`; Real: Oboobs, NekoBot `boobs` |
| `/breeding` | N-SFW `breeding` |
| `/buttplug` | N-SFW `buttplug` |
| `/buttplug` | Anime (archived): N-SFW `buttplug`; Real (active extension): PornPics butt-plug category feed |
| `/cages` | N-SFW `cages` |
| `/cosplay` | New active command: Hentai-cosplay-xxx.com feed, then Ahottie feed only as fallback |
| `/cum` | Purrbot `cum/gif` |
Expand Down Expand Up @@ -59,6 +59,10 @@ intentionally not an application command.
extension and is excluded from the byte-for-byte legacy execution comparison;
separate tests enforce its ordered fallback and scraper contracts.

The archived `/buttplug` execution remains compared directly with the modern
Anime group. Its Real PornPics group is an intentional additive source with no
archived counterpart and is covered by separate command/provider tests.

## Provider transport verification

The tests also exercise the provider layer independently of command mapping:
Expand All @@ -77,6 +81,8 @@ The tests also exercise the provider layer independently of command mapping:
headers, ID range, size floor, and 15-attempt limit;
- Porngifs.tv AJAX parameters, headers, page range, HTML extraction, CDN host
validation, size floor, and native content type;
- PornPics exact 1,000-item index range, single-feed-request behavior,
gallery-card-only extraction, and `/300|460/` to `/1280/` promotion;
- Purrbot, Waifu.pics, and Nekos v4 response shapes.

Run `npm run validate` to repeat the complete audit.
2 changes: 2 additions & 0 deletions src/brain/providers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { fetchNekosV4 } from './nekos-v4.js';
import { fetchOBoobs, fetchOButts } from './obru.js';
import { fetchPorngifs } from './porngifs.js';
import { fetchPorngifsTv } from './porngifs-tv.js';
import { fetchPornPics } from './pornpics.js';
import { fetchPurrbot } from './purrbot.js';
import { fetchSexCom } from './sexcom.js';
import { fetchWaifuIm } from './waifu-im.js';
Expand All @@ -22,6 +23,7 @@ const providers = Object.freeze({
obutts: fetchOButts,
porngifs: fetchPorngifs,
porngifstv: fetchPorngifsTv,
pornpics: fetchPornPics,
purrbot: fetchPurrbot,
sexcom: fetchSexCom,
waifuim: fetchWaifuIm,
Expand Down
1 change: 1 addition & 0 deletions src/brain/providers/manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const ProviderEndpoint = Object.freeze({
PORNGIFS_MEDIA: 'https://cdn.porngifs.com/img/{id}',
PORNGIFS_IP_MEDIA: 'https://{resolvedIP}/img/{id}',
PORNGIFS_TV: 'https://porngifs.tv/?action=ajax&mode=async&function=get_block&block_id=list_videos_most_recent_videos&sort_by=post_date&from={page}',
PORNPICS_BUTT_PLUG: 'https://www.pornpics.com/butt-plug/',
HENTAI_COSPLAY_FEED: 'https://hentai-cosplay-xxx.com/search/page/{page}/',
AHOTTIE_COSPLAY_FEED: 'https://ahottie.top/tags/Cosplay?page={page}',
});
Expand Down
193 changes: 193 additions & 0 deletions src/brain/providers/pornpics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { ProviderEndpoint } from './manifest.js';
import { mediaResult } from './media-result.js';
import { MediaProviderError, providerError } from './errors.js';
import { randomInteger } from '../../lib/random.js';

const PROVIDER = 'pornpics.com';
const FEED_HOST = 'pornpics.com';
const MEDIA_HOST = 'cdni.pornpics.com';
const INITIAL_GALLERY_COUNT = 20;
const GALLERY_LIMIT = 1_000;
const MAXIMUM_FEED_BYTES = 2 * 1024 * 1024;

const HTML_HEADERS = Object.freeze({
Accept: 'text/html,application/xhtml+xml',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0 Safari/537.36',
});

const JSON_HEADERS = Object.freeze({
Accept: 'application/json',
'User-Agent': HTML_HEADERS['User-Agent'],
'X-Requested-With': 'XMLHttpRequest',
});

const defaultDependencies = Object.freeze({ randomInteger });

function codePoint(value, radix) {
const parsed = Number.parseInt(value, radix);
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 0x10ffff) return '';
return String.fromCodePoint(parsed);
}

function decodeHtml(value) {
return String(value ?? '')
.replace(/&#x([0-9a-f]+);?/gi, (_match, number) => codePoint(number, 16))
.replace(/&#(\d+);?/g, (_match, number) => codePoint(number, 10))
.replace(/&(amp|quot|apos|lt|gt);/gi, (_match, name) => ({
amp: '&',
quot: '"',
apos: "'",
lt: '<',
gt: '>',
})[name.toLowerCase()]);
}

function attribute(tag, name) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = String(tag).match(new RegExp(
`\\s${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`,
'i',
));
return decodeHtml(match?.[1] ?? match?.[2] ?? match?.[3] ?? '');
}

function httpsUrl(value, baseUrl) {
try {
const normalized = decodeHtml(value).replace(/\\\//g, '/').trim();
const url = new URL(normalized.startsWith('//') ? `https:${normalized}` : normalized, baseUrl);
if (url.protocol !== 'https:' || url.username || url.password || url.port) return null;
url.hash = '';
return url;
} catch {
return null;
}
}

function galleryUrl(value, baseUrl = ProviderEndpoint.PORNPICS_BUTT_PLUG) {
const url = httpsUrl(value, baseUrl);
if (!url || !['pornpics.com', 'www.pornpics.com'].includes(url.hostname)) return null;
return url.pathname.startsWith('/galleries/') ? url.toString() : null;
}

export function pornPicsHighResolutionUrl(value) {
const url = httpsUrl(value, ProviderEndpoint.PORNPICS_BUTT_PLUG);
if (!url || url.hostname !== MEDIA_HOST) return null;
const match = url.pathname.match(/^\/(?:300|460|1280)(\/[^?#]+\.(?:jpe?g|webp))$/i);
if (!match) return null;
url.pathname = `/1280${match[1]}`;
return url.toString();
}

function imageElementCandidates(block) {
const candidates = [];
for (const match of String(block).matchAll(/<img\b[^>]*>/gi)) {
const tag = match[0];
for (const name of ['data-original', 'data-full', 'data-src', 'data-lazy-src', 'src']) {
const value = attribute(tag, name);
if (value) candidates.push(value);
}
const srcset = attribute(tag, 'data-srcset') || attribute(tag, 'srcset');
for (const entry of srcset.split(',')) {
const value = entry.trim().split(/\s+/, 1)[0];
if (value) candidates.push(value);
}
}
return candidates;
}

export function extractPornPicsGalleryThumbnails(html, pageUrl = ProviderEndpoint.PORNPICS_BUTT_PLUG) {
const items = [];
const seen = new Set();
for (const match of String(html).matchAll(/<a\b[^>]*>[\s\S]*?<\/a>/gi)) {
const block = match[0];
const openingTag = block.match(/^<a\b[^>]*>/i)?.[0] ?? '';
const watchUrl = galleryUrl(attribute(openingTag, 'href'), pageUrl);
if (!watchUrl || seen.has(watchUrl)) continue;
const url = imageElementCandidates(block)
.map((value) => pornPicsHighResolutionUrl(value))
.find(Boolean);
if (!url) continue;
seen.add(watchUrl);
items.push({ url, watchUrl });
}
return items;
}

function itemFromOffsetPayload(payload) {
if (!Array.isArray(payload) || !payload[0] || typeof payload[0] !== 'object') {
throw new MediaProviderError('PornPics offset feed did not contain a gallery.', {
code: 'INVALID_RESPONSE',
});
}
const item = payload[0];
const url = pornPicsHighResolutionUrl(item.t_url_460 || item.t_url);
if (!url) {
throw new MediaProviderError('PornPics offset feed did not contain a valid gallery thumbnail.', {
code: 'INVALID_RESPONSE',
});
}
return {
id: item.gid ?? item.mid ?? null,
url,
watchUrl: galleryUrl(item.g_url) ?? ProviderEndpoint.PORNPICS_BUTT_PLUG,
};
}

function parseOffsetPayload(body) {
try {
return JSON.parse(body);
} catch (cause) {
throw new MediaProviderError('PornPics offset feed returned invalid JSON.', {
code: 'INVALID_RESPONSE',
cause,
});
}
}

function requestOptions(headers, query) {
return {
headers,
allowedHosts: [FEED_HOST],
maximumBytes: MAXIMUM_FEED_BYTES,
...(query ? { query } : {}),
};
}

export async function fetchPornPics(_source, context, dependencies = defaultDependencies) {
try {
const randomIndex = dependencies.randomInteger(0, GALLERY_LIMIT - 1);
let item;
if (randomIndex < INITIAL_GALLERY_COUNT) {
const html = await context.http.text(
ProviderEndpoint.PORNPICS_BUTT_PLUG,
requestOptions(HTML_HEADERS),
);
item = extractPornPicsGalleryThumbnails(html)[randomIndex];
if (!item) {
throw new MediaProviderError('PornPics category page did not contain the selected gallery thumbnail.', {
code: 'INVALID_RESPONSE',
});
}
} else {
const body = await context.http.text(
ProviderEndpoint.PORNPICS_BUTT_PLUG,
requestOptions(JSON_HEADERS, { offset: randomIndex }),
);
item = itemFromOffsetPayload(parseOffsetPayload(body));
}
return mediaResult({
provider: PROVIDER,
id: item.id === null || item.id === undefined ? String(randomIndex) : String(item.id),
url: item.url,
watchUrl: item.watchUrl,
});
} catch (error) {
throw providerError(error, PROVIDER);
}
}

export {
GALLERY_LIMIT,
INITIAL_GALLERY_COUNT,
MAXIMUM_FEED_BYTES,
};
14 changes: 11 additions & 3 deletions src/commands/buttplug.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { defineMediaCommand } from '../brain/media-command.js';
import { styleOption } from '../brain/command-schema.js';

export default defineMediaCommand({
name: 'buttplug',
description: 'Delivers a random Buttplug image',
title: 'NSFW Buttplug Image',
sources: [
{ provider: 'abd', endpoint: 'https://api.n-sfw.com/nsfw/buttplug' },
],
options: [styleOption],
optionName: 'style',
groups: {
Anime: [
{ provider: 'abd', endpoint: 'https://api.n-sfw.com/nsfw/buttplug' },
],
Real: [
{ provider: 'pornpics' },
],
},
});
4 changes: 2 additions & 2 deletions test/commands.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ test('premium accepts one optional raw Discord user option', () => {
}]);
});

test('legacy command options and choice values are preserved', () => {
for (const name of ['anal', 'ass', 'blowjob', 'boobs', 'feet', 'thigh']) {
test('style-selecting commands expose the Anime and Real choices', () => {
for (const name of ['anal', 'ass', 'blowjob', 'boobs', 'buttplug', 'feet', 'thigh']) {
const option = commands.get(name).data.options[0];
assert.equal(option.name, 'style');
assert.deepEqual(option.choices, [
Expand Down
7 changes: 7 additions & 0 deletions test/legacy-runtime-parity.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,13 @@ test('every reachable archived media provider call exists in the matching modern
const archivedUnion = [];
const modernGroups = modern.media.groups ?? { all: modern.media.sources ?? [] };

if (modern.data.name === 'buttplug') {
const archived = await executeLegacyAcrossRandomRange(legacy, {});
const anime = sortedUnique(modernGroups.Anime.map(normalizeModernSource));
assert.deepEqual(archived, anime, 'buttplug:Anime');
continue;
}

for (const scenario of scenarioGroups(modern.data.name)) {
const archived = await executeLegacyAcrossRandomRange(
legacy,
Expand Down
Loading