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; postForm( url: string, form: Record, headers?: Record, ): Promise; } /** Signed-out fetch, used to tell whether a comment is publicly visible. */ export type AnonymousFetcher = (url: string) => Promise; 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; } const passthroughScheduler: RedditRequestScheduler = { schedule: async (request: () => Promise) => 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) | 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 { 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 { 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 { 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> { 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 { 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 { 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; evaluate( fn: (argument: Argument) => Promise, argument: Argument, ): Promise; } 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 => { 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 })?.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 { const found = new Map(); 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 })?.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); }