Files
RedditBot/src/reddit/comment-client.ts
T

603 lines
24 KiB
TypeScript

import type { Logger } from '../utils/logger.js';
import type { RedditRequestScheduler } from './request-coordinator.js';
/**
* Every authenticated call is made against this one origin so the page issuing
* it is same-origin: a cross-origin fetch would not send the session cookies,
* and a page still on `about:blank` cannot reach Reddit at all.
*/
export const REDDIT_WRITE_ORIGIN = 'https://old.reddit.com';
/** The public view, only ever fetched signed out from Node, so CORS never applies. */
const WWW_REDDIT = 'https://www.reddit.com';
/**
* The outcome of one attempted comment. Every branch exists because Reddit
* reports it distinctly and it needs different handling: retry, drop the
* thread, drop the subreddit, refresh the session, or stop the account.
*/
export type CommentPostResult =
/** The comment exists on Reddit. Irreversible. */
| { status: 'posted'; fullname: string; permalink: string | null }
/** Reddit asked us to slow down. `scope` says whether it is the whole account or one community. */
| { status: 'rate-limited'; scope: 'account' | 'subreddit'; retryAfterMs: number; reason: string }
/** The thread will never accept a comment; abandon this candidate. */
| { status: 'thread-closed'; reason: string }
/** This account may not comment in this community; stop polling it. */
| { status: 'not-allowed'; reason: string }
/** The session is no longer signed in; the account needs a fresh import. */
| { status: 'session-invalid'; reason: string }
/** Reddit is challenging the account. Treated as a hard stop, never retried. */
| { status: 'blocked'; reason: string }
/** Anything else: transient, safe to retry later. */
| { status: 'failed'; reason: string };
/** Result of a single upvote. A failed/uncertain request is never retried. */
export type VoteResult =
| { status: 'submitted' }
| { status: 'rate-limited'; retryAfterMs: number; reason: string }
| { status: 'session-invalid'; reason: string }
| { status: 'blocked'; reason: string }
| { status: 'failed'; reason: string };
/** Why a modhash lookup failed, so the caller can pick a proportionate response. */
type ModhashResult =
| { ok: true; modhash: string }
| { ok: false; kind: 'unreachable' | 'signed-out'; reason: string };
export interface RedditCommentState {
fullname: string;
score: number | null;
/** Removed by a moderator, automod, or deleted. */
removed: boolean;
/** Reddit did not return the comment at all. */
missing: boolean;
}
export interface HttpTextResponse {
status: number;
body: string;
}
/**
* Minimal HTTP seam. The production implementation runs inside the account's
* own Playwright context so Reddit sees the same session, TLS fingerprint and
* cookies as a human using that profile; tests supply a fake.
*/
export interface RedditWriteTransport {
get(url: string): Promise<HttpTextResponse>;
postForm(
url: string,
form: Record<string, string>,
headers?: Record<string, string>,
): Promise<HttpTextResponse>;
}
/** Signed-out fetch, used to tell whether a comment is publicly visible. */
export type AnonymousFetcher = (url: string) => Promise<HttpTextResponse>;
export interface RedditCommentClientDependencies {
scheduler?: RedditRequestScheduler;
anonymousFetch?: AnonymousFetcher;
now?: () => number;
/**
* Names and domains of the cookies visible to Reddit, never their values.
* Included in the log when Reddit reports a signed-out session, because the
* usual cause is cookies scoped to a host that this origin cannot see.
*/
describeCookies?: () => Promise<string>;
}
const passthroughScheduler: RedditRequestScheduler = {
schedule: async <T>(request: () => Promise<T>) => await request(),
extendCooldown: () => undefined,
};
/** Reddit error codes that mean the thread itself is finished. */
const THREAD_CLOSED_CODES = new Set([
'THREAD_LOCKED',
'TOO_OLD',
'DELETED_COMMENT',
'DELETED_LINK',
'DELETED_THING',
]);
/** Codes that mean this account is not welcome in this community. */
const NOT_ALLOWED_CODES = new Set([
'SUBREDDIT_NOTALLOWED',
'BANNED_FROM_SUBREDDIT',
'USER_BLOCKED',
'NOT_WHITELISTED_BY_EPIO_LICENSE',
]);
/**
* Posts comments as one Reddit account and reports what happened to them.
*
* Uses the old.reddit form endpoint rather than the current web app's private
* GraphQL calls: it is stable, it returns machine-readable error codes, and it
* needs nothing beyond the cookies already imported for the account.
*/
export class RedditCommentClient {
private modhash: string | null = null;
private readonly scheduler: RedditRequestScheduler;
private readonly anonymousFetch: AnonymousFetcher | null;
private readonly now: () => number;
private readonly describeCookies: (() => Promise<string>) | null;
constructor(
private readonly transport: RedditWriteTransport,
private readonly logger: Logger,
dependencies: RedditCommentClientDependencies = {},
) {
this.scheduler = dependencies.scheduler ?? passthroughScheduler;
this.anonymousFetch = dependencies.anonymousFetch ?? null;
this.now = dependencies.now ?? Date.now;
this.describeCookies = dependencies.describeCookies ?? null;
}
/**
* Adds one top-level comment to a thread.
*
* `threadFullname` must be a `t3_` post fullname. A `posted` result is
* irreversible, so callers must record it before doing anything else.
*/
async postComment(
threadFullname: string,
subreddit: string,
text: string,
): Promise<CommentPostResult> {
if (!/^t3_[a-z0-9]+$/i.test(threadFullname)) {
return { status: 'failed', reason: `"${threadFullname}" is not a post fullname` };
}
if (!text.trim()) {
return { status: 'failed', reason: 'Refusing to post an empty comment' };
}
const modhash = await this.ensureModhash();
if (!modhash.ok) {
// Only Reddit actually reporting a signed-out session may invalidate the
// account's auth; anything else is transient and simply retried.
return modhash.kind === 'signed-out'
? { status: 'session-invalid', reason: modhash.reason }
: { status: 'failed', reason: modhash.reason };
}
let response: HttpTextResponse;
try {
response = await this.scheduler.schedule(() => this.transport.postForm(
`${REDDIT_WRITE_ORIGIN}/api/comment`,
{ thing_id: threadFullname, text, api_type: 'json', uh: modhash.modhash },
{ 'X-Modhash': modhash.modhash, 'Content-Type': 'application/x-www-form-urlencoded' },
));
} catch (error) {
return { status: 'failed', reason: errorMessage(error) };
}
if (response.status === 403) {
// Reddit's anti-abuse interstitial is an HTML 403. It says nothing about
// whether we are signed in, so it must not invalidate the account.
if (!looksLikeJson(response.body)) {
return { status: 'failed', reason: blockPageReason(response) };
}
// A JSON 403 is ambiguous between a dead session and a community ban, so
// drop the cached modhash and let the next attempt re-derive identity.
this.modhash = null;
return { status: 'session-invalid', reason: 'Reddit rejected the comment with HTTP 403' };
}
if (response.status === 429) {
return {
status: 'rate-limited',
scope: 'account',
retryAfterMs: 10 * 60_000,
reason: 'Reddit returned HTTP 429 for the comment',
};
}
if (response.status >= 500) {
return { status: 'failed', reason: `Reddit returned HTTP ${response.status}` };
}
const payload = parseJson(response.body);
if (!payload) {
return { status: 'failed', reason: `Reddit returned an unreadable comment response (HTTP ${response.status})` };
}
const errors = readErrors(payload);
if (errors.length > 0) return this.classifyError(errors[0], subreddit);
const created = readCreatedComment(payload);
if (!created) {
return {
status: 'failed',
reason: 'Reddit accepted the comment but returned no comment identity',
};
}
this.logger.info(`Posted growth comment ${created.fullname} to r/${subreddit}`);
return { status: 'posted', fullname: created.fullname, permalink: created.permalink };
}
/** Submit one upvote through the same authenticated browser session as comments. */
async upvote(thingFullname: string): Promise<VoteResult> {
if (!/^t[13]_[a-z0-9]+$/i.test(thingFullname)) {
return { status: 'failed', reason: `"${thingFullname}" is not a post or comment fullname` };
}
const modhash = await this.ensureModhash();
if (!modhash.ok) return modhash.kind === 'signed-out'
? { status: 'session-invalid', reason: modhash.reason }
: { status: 'failed', reason: modhash.reason };
let response: HttpTextResponse;
try {
response = await this.scheduler.schedule(() => this.transport.postForm(
`${REDDIT_WRITE_ORIGIN}/api/vote`,
{ id: thingFullname, dir: '1', api_type: 'json', uh: modhash.modhash },
{ 'X-Modhash': modhash.modhash, 'Content-Type': 'application/x-www-form-urlencoded' },
));
} catch (error) {
return { status: 'failed', reason: errorMessage(error) };
}
if (response.status === 429) {
const retryAfterMs = 10 * 60_000;
this.scheduler.extendCooldown(retryAfterMs);
return { status: 'rate-limited', retryAfterMs, reason: 'Reddit returned HTTP 429 for the vote' };
}
if (response.status === 403) {
if (!looksLikeJson(response.body)) return { status: 'blocked', reason: blockPageReason(response) };
this.modhash = null;
return { status: 'session-invalid', reason: 'Reddit rejected the vote with HTTP 403' };
}
if (response.status >= 500) return { status: 'failed', reason: `Reddit returned HTTP ${response.status}` };
const payload = parseJson(response.body);
if (!payload) return { status: 'failed', reason: `Reddit returned an unreadable vote response (HTTP ${response.status})` };
const errors = readErrors(payload);
if (errors.length > 0) {
const classified = this.classifyError(errors[0], 'the selected community');
if (classified.status === 'rate-limited') return { status: 'rate-limited', retryAfterMs: classified.retryAfterMs, reason: classified.reason };
if (classified.status === 'session-invalid' || classified.status === 'blocked') return classified;
return { status: 'failed', reason: 'reason' in classified ? classified.reason : 'Reddit rejected the vote' };
}
this.logger.info(`Submitted Reddit upvote for ${thingFullname}`);
return { status: 'submitted' };
}
/**
* Current score and removal state for previously posted comments, from the
* account's own authenticated view. Batched because Reddit accepts up to 100
* fullnames per request and a sweep should cost one request, not one each.
*/
async fetchCommentStates(fullnames: readonly string[]): Promise<RedditCommentState[]> {
const ids = [...new Set(fullnames)].filter((id) => /^t1_[a-z0-9]+$/i.test(id));
if (ids.length === 0) return [];
if (ids.length > 100) throw new Error('Reddit accepts at most 100 fullnames per info lookup');
const url = `${REDDIT_WRITE_ORIGIN}/api/info.json?id=${ids.join(',')}&raw_json=1`;
const response = await this.scheduler.schedule(() => this.transport.get(url));
if (response.status !== 200) {
throw new Error(`Reddit returned HTTP ${response.status} for comment info`);
}
const found = readListing(parseJson(response.body));
return ids.map((fullname) => {
const entry = found.get(fullname.toLowerCase());
if (!entry) return { fullname, score: null, removed: false, missing: true };
return { fullname, score: entry.score, removed: entry.removed, missing: false };
});
}
/**
* Which of these comments a signed-out visitor can see.
*
* This is the shadowban and silent-removal test: a comment the account itself
* can see but the public cannot has achieved nothing, and continuing to
* comment from that account only deepens the hole.
*/
async fetchPubliclyVisible(fullnames: readonly string[]): Promise<Set<string>> {
const ids = [...new Set(fullnames)].filter((id) => /^t1_[a-z0-9]+$/i.test(id));
if (ids.length === 0 || !this.anonymousFetch) return new Set(ids);
if (ids.length > 100) throw new Error('Reddit accepts at most 100 fullnames per info lookup');
const url = `${WWW_REDDIT}/api/info.json?id=${ids.join(',')}&raw_json=1`;
const response = await this.scheduler.schedule(() => this.anonymousFetch!(url));
if (response.status !== 200) {
// Never conclude "shadowbanned" from a failed probe; that would pause a
// healthy account on a network blip.
throw new Error(`Anonymous Reddit lookup returned HTTP ${response.status}`);
}
const found = readListing(parseJson(response.body));
return new Set(ids.filter((id) => {
const entry = found.get(id.toLowerCase());
return entry !== undefined && !entry.removed;
}));
}
/** Forces the next request to re-derive the session's modhash. */
invalidateSession(): void {
this.modhash = null;
}
/**
* Resolves this session's modhash, distinguishing "we could not reach Reddit"
* from "Reddit says this session is not signed in".
*
* The distinction matters beyond growth: a `session-invalid` result
* invalidates the account's shared auth context, which would disrupt outreach
* and chat for that account. A network or browser failure must never do that.
*/
private async ensureModhash(): Promise<ModhashResult> {
if (this.modhash) return { ok: true, modhash: this.modhash };
let response: HttpTextResponse;
try {
response = await this.scheduler.schedule(
() => this.transport.get(`${REDDIT_WRITE_ORIGIN}/api/me.json`),
);
} catch (error) {
this.logger.warn('Could not reach Reddit to read the session modhash:', error);
return {
ok: false,
kind: 'unreachable',
reason: `Could not reach Reddit to read the session modhash: ${errorMessage(error)}`,
};
}
// A non-JSON body is Reddit refusing to serve us at all — an anti-abuse
// interstitial or error page. Reading that as "signed out" would invalidate
// a perfectly good session and disrupt outreach, so it stays retryable.
if (!looksLikeJson(response.body)) {
this.logger.warn(`Growth: ${blockPageReason(response)}`);
return { ok: false, kind: 'unreachable', reason: blockPageReason(response) };
}
if (response.status === 401 || response.status === 403) {
return {
ok: false,
kind: 'signed-out',
reason: `Reddit returned HTTP ${response.status} for the session identity check`,
};
}
if (response.status !== 200) {
this.logger.warn(`Reddit returned HTTP ${response.status} for the session identity check`);
return {
ok: false,
kind: 'unreachable',
reason: `Reddit returned HTTP ${response.status} for the session identity check`,
};
}
const payload = parseJson(response.body);
const data = payload && typeof payload === 'object'
? (payload as { data?: { modhash?: unknown } }).data
: undefined;
const modhash = typeof data?.modhash === 'string' ? data.modhash : '';
if (!modhash) {
// Reddit answered with real JSON and no modhash, so it genuinely does not
// recognise this session. The cookie summary is the fastest way to see
// why — most often they are scoped to a host this origin cannot read.
const cookies = await this.describeSessionCookies();
this.logger.warn(
`Growth: Reddit does not recognise this session on ${REDDIT_WRITE_ORIGIN}. Cookies visible: ${cookies}`,
);
return {
ok: false,
kind: 'signed-out',
reason: `Reddit did not return a modhash for this session (cookies visible: ${cookies})`,
};
}
this.modhash = modhash;
return { ok: true, modhash };
}
private async describeSessionCookies(): Promise<string> {
if (!this.describeCookies) return 'not inspected';
try {
return await this.describeCookies();
} catch (error) {
return `could not be read (${errorMessage(error)})`;
}
}
private classifyError(
error: { code: string; message: string },
subreddit: string,
): CommentPostResult {
const code = error.code.toUpperCase();
const reason = error.message.trim() || code;
if (code === 'RATELIMIT') {
const retryAfterMs = parseRetryAfterMs(error.message) ?? 10 * 60_000;
this.scheduler.extendCooldown(retryAfterMs);
return { status: 'rate-limited', scope: 'account', retryAfterMs, reason };
}
if (code === 'SUBREDDIT_RATELIMIT') {
return {
status: 'rate-limited',
scope: 'subreddit',
retryAfterMs: parseRetryAfterMs(error.message) ?? 60 * 60_000,
reason,
};
}
if (code === 'QUOTA_FILLED') {
return { status: 'rate-limited', scope: 'account', retryAfterMs: 6 * 60 * 60_000, reason };
}
if (THREAD_CLOSED_CODES.has(code)) {
return { status: 'thread-closed', reason };
}
if (NOT_ALLOWED_CODES.has(code)) {
return { status: 'not-allowed', reason: `${reason} (r/${subreddit})` };
}
if (code === 'USER_REQUIRED') {
this.modhash = null;
return { status: 'session-invalid', reason };
}
if (code === 'BAD_CAPTCHA' || code === 'CAPTCHA_REQUIRED') {
return {
status: 'blocked',
reason: `Reddit is challenging this account with a captcha (${reason})`,
};
}
return { status: 'failed', reason: `${code}: ${reason}` };
}
}
/**
* Runs the HTTP calls inside a page of the account's own browser context, so
* the request carries that profile's cookies and looks like the signed-in user.
*/
export interface EvaluatingPage {
url(): string;
goto(url: string, options?: { waitUntil?: 'domcontentloaded'; timeout?: number }): Promise<unknown>;
evaluate<Result, Argument>(
fn: (argument: Argument) => Promise<Result>,
argument: Argument,
): Promise<Result>;
}
export function isRedditWriteOrigin(value: string): boolean {
try {
return new URL(value).origin === REDDIT_WRITE_ORIGIN;
} catch {
return false;
}
}
export function createBrowserWriteTransport(page: EvaluatingPage): RedditWriteTransport {
/**
* Parks the page on the Reddit origin before it fetches anything.
*
* A freshly opened page sits on `about:blank`, whose opaque origin cannot
* reach Reddit at all — the fetch fails outright with "Failed to fetch". Any
* other origin would make these calls cross-origin, so the session cookies
* would be withheld even if CORS allowed the request. Being on the same
* origin is what makes them authenticated.
*/
const ensureOrigin = async (): Promise<void> => {
if (isRedditWriteOrigin(page.url())) return;
await page.goto(REDDIT_WRITE_ORIGIN, { waitUntil: 'domcontentloaded', timeout: 30_000 });
};
return {
// The page already carries the profile's cookies and user agent, so the
// request needs no explicit identity of its own.
get: async (url) => {
await ensureOrigin();
return page.evaluate(async ({ target }) => {
const response = await fetch(target, {
credentials: 'include',
headers: { Accept: 'application/json' },
});
return { status: response.status, body: await response.text() };
}, { target: url });
},
postForm: async (url, form, headers) => {
await ensureOrigin();
return page.evaluate(async ({ target, fields, extraHeaders }) => {
const body = new URLSearchParams(fields).toString();
const response = await fetch(target, {
method: 'POST',
credentials: 'include',
headers: { Accept: 'application/json', ...extraHeaders },
body,
});
return { status: response.status, body: await response.text() };
}, { target: url, fields: form, extraHeaders: headers ?? {} });
},
};
}
/** Signed-out fetcher used for the public-visibility probe. */
export function createAnonymousFetcher(userAgent: string): AnonymousFetcher {
return async (url) => {
const response = await fetch(url, {
headers: { 'User-Agent': userAgent, Accept: 'application/json' },
});
return { status: response.status, body: await response.text() };
};
}
/**
* Reddit phrases comment throttling as prose ("try again in 9 minutes"), so the
* wait has to be read out of the message rather than a header.
*/
export function parseRetryAfterMs(message: string): number | null {
const match = /(\d+(?:\.\d+)?)\s*(second|minute|hour)s?/i.exec(message);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value) || value < 0) return null;
const unit = match[2].toLowerCase();
const multiplier = unit === 'hour' ? 3_600_000 : unit === 'minute' ? 60_000 : 1_000;
return Math.round(value * multiplier);
}
function readErrors(payload: unknown): Array<{ code: string; message: string }> {
const json = (payload as { json?: { errors?: unknown } })?.json;
const errors = json?.errors;
if (!Array.isArray(errors)) return [];
return errors.flatMap((entry) => {
if (Array.isArray(entry)) {
const [code, message] = entry;
if (typeof code !== 'string') return [];
return [{ code, message: typeof message === 'string' ? message : '' }];
}
if (typeof entry === 'string') return [{ code: entry, message: '' }];
return [];
});
}
function readCreatedComment(payload: unknown): { fullname: string; permalink: string | null } | null {
const things = (payload as { json?: { data?: { things?: unknown } } })?.json?.data?.things;
if (!Array.isArray(things)) return null;
for (const thing of things) {
const data = (thing as { data?: Record<string, unknown> })?.data;
if (!data) continue;
const name = typeof data.name === 'string' && data.name
? data.name
: typeof data.id === 'string' && data.id
? (data.id.startsWith('t1_') ? data.id : `t1_${data.id}`)
: null;
if (!name) continue;
const permalink = typeof data.permalink === 'string' ? data.permalink : null;
return { fullname: name, permalink };
}
return null;
}
function readListing(payload: unknown): Map<string, { score: number | null; removed: boolean }> {
const found = new Map<string, { score: number | null; removed: boolean }>();
const children = (payload as { data?: { children?: unknown } })?.data?.children;
if (!Array.isArray(children)) return found;
for (const child of children) {
const data = (child as { data?: Record<string, unknown> })?.data;
const name = typeof data?.name === 'string' ? data.name : null;
if (!name) continue;
const body = typeof data?.body === 'string' ? data.body : '';
const removed = body === '[removed]'
|| body === '[deleted]'
|| (data?.banned_by !== null && data?.banned_by !== undefined)
|| data?.removed === true;
found.set(name.toLowerCase(), {
score: typeof data?.score === 'number' ? data.score : null,
removed,
});
}
return found;
}
/** Reddit serves JSON for real answers and HTML when it is blocking or erroring. */
function looksLikeJson(body: string): boolean {
const start = body.trimStart();
return start.startsWith('{') || start.startsWith('[');
}
function blockPageReason(response: HttpTextResponse): string {
return `Reddit served a non-JSON HTTP ${response.status} response `
+ `(${response.body.length} bytes), which is an anti-abuse block page rather than a session verdict`;
}
function parseJson(body: string): unknown {
try {
return JSON.parse(body) as unknown;
} catch {
return null;
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}