Pause suspended Reddit accounts
This commit is contained in:
1 parent
94049ed9c4
commit
7d039265e7
21 files changed
+368
-26
No files matched your search
@@ -0,0 +1,73 @@
|
||||
import type { Logger } from '../utils/logger.js';
|
||||
import { RedditRateLimitError, type RedditAccountProfile } from '../reddit/api.js';
|
||||
import { denyAccountAuth, type AccountAuthGate } from './account-auth-context.js';
|
||||
|
||||
export const ACCOUNT_SUSPENSION_CHECK_INTERVAL_MS = 6 * 60 * 60_000;
|
||||
const INITIAL_CHECK_DELAY_MS = 30_000;
|
||||
|
||||
export interface AccountSuspensionProfileLookup {
|
||||
getAccountProfile(username: string): Promise<RedditAccountProfile | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Independently verifies that an owned Reddit account is still usable. This
|
||||
* is deliberately separate from establishment checks: disabling optional
|
||||
* establishment policy must never disable suspension protection.
|
||||
*/
|
||||
export class AccountSuspensionService {
|
||||
private intervalHandle: NodeJS.Timeout | null = null;
|
||||
private initialHandle: NodeJS.Timeout | null = null;
|
||||
private checking = false;
|
||||
|
||||
constructor(
|
||||
private readonly redditUsername: string,
|
||||
private readonly reddit: AccountSuspensionProfileLookup,
|
||||
private readonly pauseAccount: (reason: string) => void,
|
||||
private readonly logger: Logger,
|
||||
private readonly accountAuth: AccountAuthGate = denyAccountAuth,
|
||||
private readonly intervalMs = ACCOUNT_SUSPENSION_CHECK_INTERVAL_MS,
|
||||
) {}
|
||||
|
||||
start(): void {
|
||||
if (this.intervalHandle) return;
|
||||
this.initialHandle = setTimeout(() => {
|
||||
this.initialHandle = null;
|
||||
void this.checkNow();
|
||||
}, Math.min(INITIAL_CHECK_DELAY_MS, this.intervalMs));
|
||||
this.intervalHandle = setInterval(() => void this.checkNow(), this.intervalMs);
|
||||
this.logger.info(
|
||||
`Account suspension check for u/${this.redditUsername}:`
|
||||
+ ` scheduled every ${Math.round(this.intervalMs / 3_600_000)} hours`,
|
||||
);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.initialHandle) clearTimeout(this.initialHandle);
|
||||
if (this.intervalHandle) clearInterval(this.intervalHandle);
|
||||
this.initialHandle = null;
|
||||
this.intervalHandle = null;
|
||||
}
|
||||
|
||||
async checkNow(): Promise<boolean> {
|
||||
if (this.checking || !this.accountAuth.isFullyAuthenticated()) return false;
|
||||
this.checking = true;
|
||||
try {
|
||||
const profile = await this.reddit.getAccountProfile(this.redditUsername);
|
||||
if (profile?.isSuspended !== true) return false;
|
||||
const reason = 'Reddit profile marks this account as suspended';
|
||||
this.logger.error(`u/${this.redditUsername} is suspended; pausing the account`);
|
||||
this.pauseAccount(reason);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof RedditRateLimitError) {
|
||||
this.logger.warn(`Account suspension check paused for u/${this.redditUsername}: Reddit rate-limited profile lookups`);
|
||||
return false;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Account suspension check failed for u/${this.redditUsername}: ${message}`);
|
||||
return false;
|
||||
} finally {
|
||||
this.checking = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export type {
|
||||
ScrapedChatMessage,
|
||||
ScrapedRoom,
|
||||
} from './types.js';
|
||||
export { MatrixClient, MatrixAuthError } from './matrix/client.js';
|
||||
export { MatrixClient, MatrixAuthError, MatrixAccountSuspendedError } from './matrix/client.js';
|
||||
export type { MatrixCredentials } from './matrix/types.js';
|
||||
export { loadCachedCredentials, saveCredentials } from './matrix/credential-store.js';
|
||||
export {
|
||||
|
||||
@@ -14,6 +14,14 @@ export class MatrixAuthError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Reddit's Matrix proxy uses this for an account that has been suspended. */
|
||||
export class MatrixAccountSuspendedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'MatrixAccountSuspendedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when Reddit's homeserver explicitly disables a Matrix endpoint.
|
||||
* Callers can remember the missing capability instead of retrying it for
|
||||
@@ -94,6 +102,8 @@ export interface MatrixClientOptions {
|
||||
requestTimeoutMs?: number;
|
||||
/** Test seam: inject a custom fetch (e.g. one that hangs). */
|
||||
fetchImpl?: typeof fetch;
|
||||
/** Durable account-level safety hook for a definitive M_FORBIDDEN result. */
|
||||
onAccountSuspended?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,6 +117,7 @@ export class MatrixClient {
|
||||
private readonly minRequestIntervalMs: number;
|
||||
private readonly requestTimeoutMs: number;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly onAccountSuspended: (() => void) | undefined;
|
||||
|
||||
constructor(
|
||||
private creds: MatrixCredentials,
|
||||
@@ -116,6 +127,7 @@ export class MatrixClient {
|
||||
this.minRequestIntervalMs = options.minRequestIntervalMs ?? 30;
|
||||
this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
this.onAccountSuspended = options.onAccountSuspended;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,6 +220,12 @@ export class MatrixClient {
|
||||
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
const failure = await readMatrixFailure(res);
|
||||
if (isAccountSuspendedFailure(failure.errcode)) {
|
||||
this.onAccountSuspended?.();
|
||||
throw new MatrixAccountSuspendedError(
|
||||
`Matrix ${method} ${path} → ${res.status} M_FORBIDDEN (account suspended)`,
|
||||
);
|
||||
}
|
||||
if (isMatrixAuthenticationFailure(res.status, failure.errcode)) {
|
||||
throw new MatrixAuthError(
|
||||
`Matrix ${method} ${path} → ${res.status} ${failure.errcode ?? ''} (token rejected)`,
|
||||
@@ -549,6 +567,10 @@ export class MatrixClient {
|
||||
if (response === null) throw new Error('Matrix media upload timed out');
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
const failure = await readMatrixFailure(response);
|
||||
if (isAccountSuspendedFailure(failure.errcode)) {
|
||||
this.onAccountSuspended?.();
|
||||
throw new MatrixAccountSuspendedError('Matrix media upload → M_FORBIDDEN (account suspended)');
|
||||
}
|
||||
if (isMatrixAuthenticationFailure(response.status, failure.errcode)) {
|
||||
throw new MatrixAuthError(`Matrix media upload → ${response.status} (token rejected)`);
|
||||
}
|
||||
@@ -667,6 +689,10 @@ export class MatrixClient {
|
||||
}
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
const failure = await readMatrixFailure(res);
|
||||
if (isAccountSuspendedFailure(failure.errcode)) {
|
||||
this.onAccountSuspended?.();
|
||||
throw new MatrixAccountSuspendedError('Matrix media download → M_FORBIDDEN (account suspended)');
|
||||
}
|
||||
if (isMatrixAuthenticationFailure(res.status, failure.errcode)) {
|
||||
throw new MatrixAuthError(
|
||||
`Matrix media download → ${res.status} ${failure.errcode ?? ''} (token rejected)`,
|
||||
@@ -724,6 +750,10 @@ function isMatrixAuthenticationFailure(status: number, errcode?: string): boolea
|
||||
return status === 401 || errcode === 'M_UNKNOWN_TOKEN' || errcode === 'M_MISSING_TOKEN';
|
||||
}
|
||||
|
||||
function isAccountSuspendedFailure(errcode?: string): boolean {
|
||||
return errcode === 'M_FORBIDDEN';
|
||||
}
|
||||
|
||||
function isFeatureDisabledFailure(responseText: string): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(responseText) as { message?: unknown };
|
||||
|
||||
@@ -70,6 +70,7 @@ import { AccountGrowthRepository } from '../database/growth-repository.js';
|
||||
import { AccountBrowsingSimulationRepository } from '../database/browsing-simulation-repository.js';
|
||||
import { CommentSourceWatchRepository } from '../database/comment-source-watch-repository.js';
|
||||
import { AccountBrowsingSimulationService } from './account-browsing-simulation.js';
|
||||
import { AccountSuspensionService } from './account-suspension.js';
|
||||
import { allowsConversationSync, allowsSending, type BotMode } from './bot-mode.js';
|
||||
import {
|
||||
wireAccountChatEvents,
|
||||
@@ -145,8 +146,17 @@ async function buildRuntime(
|
||||
throw new Error(`Stored Reddit cookies no longer verify for u/${session.redditUsername}`);
|
||||
}
|
||||
|
||||
const matrixClient = new MatrixClient(session.matrixCredentials, logger);
|
||||
const accountsRepo = new RedditAccountsRepository(db);
|
||||
let runtime: RedditAccountRuntime | null = null;
|
||||
const accountAuth = new AccountAuthContext(logger);
|
||||
const pauseSuspendedAccount = (reason: string): void => {
|
||||
accountsRepo.markSuspended(session.accountId);
|
||||
accountAuth?.invalidate(reason);
|
||||
runtime?.pause(reason);
|
||||
};
|
||||
const matrixClient = new MatrixClient(session.matrixCredentials, logger, {
|
||||
onAccountSuspended: () => pauseSuspendedAccount('Matrix returned M_FORBIDDEN: account suspended'),
|
||||
});
|
||||
const tokenRefresher = new BrowserMatrixTokenRefresher({
|
||||
context: browserClient.getContext()!,
|
||||
expectedUserId: session.matrixUserId,
|
||||
@@ -172,7 +182,6 @@ async function buildRuntime(
|
||||
throw new Error(`Stored Matrix session no longer verifies for u/${session.redditUsername}`);
|
||||
}
|
||||
|
||||
const accountAuth = new AccountAuthContext(logger);
|
||||
accountAuth.markAuthenticated(runtimeAuthenticatedAccount(session, identity));
|
||||
accountAuth.setAuthenticator(async () => {
|
||||
const current = await browserClient.getAuthenticatedIdentity();
|
||||
@@ -274,6 +283,14 @@ async function buildRuntime(
|
||||
logger,
|
||||
accountAuth,
|
||||
);
|
||||
const suspensionService = new AccountSuspensionService(
|
||||
session.redditUsername,
|
||||
redditApi,
|
||||
pauseSuspendedAccount,
|
||||
logger,
|
||||
accountAuth,
|
||||
accountConfig.accountEstablishment.profileCheckIntervalMs,
|
||||
);
|
||||
const responseChecker = new ResponseCheckerService(
|
||||
chatPage, messagesRepo, successfulMessagesRepo, postsRepo, logger,
|
||||
accountAuth, conversationOutcomesRepo,
|
||||
@@ -392,12 +409,13 @@ async function buildRuntime(
|
||||
activityLogRepo,
|
||||
monitorService, processorService, responseChecker, conversationAutomation, chatService, mediaService,
|
||||
mediaSyncService, mediaExchange, rateLimiter,
|
||||
recipientAccountStatusService, establishmentService, growthService, browsingService,
|
||||
recipientAccountStatusService, establishmentService, suspensionService, growthService, browsingService,
|
||||
};
|
||||
const lifecycle = new AccountRuntimeLoop(
|
||||
resources, accountConfig, logger, getBotMode, recipientDeduplicator,
|
||||
);
|
||||
return new RedditAccountRuntime(session, resources, lifecycle);
|
||||
runtime = new RedditAccountRuntime(session, resources, lifecycle);
|
||||
return runtime;
|
||||
} catch (error) {
|
||||
await browserClient.close();
|
||||
throw error;
|
||||
@@ -436,6 +454,7 @@ export class AccountRuntimeLoop {
|
||||
this.controller = new AbortController();
|
||||
this.resources.recipientAccountStatusService.start();
|
||||
this.resources.establishmentService.start();
|
||||
this.resources.suspensionService?.start();
|
||||
// A comment interrupted mid-post may already be live on Reddit, so those
|
||||
// rows are retired before any new growth work begins.
|
||||
this.resources.growthService?.recoverAfterRestart();
|
||||
@@ -448,12 +467,22 @@ export class AccountRuntimeLoop {
|
||||
this.resources.mediaSyncService.stop();
|
||||
this.resources.recipientAccountStatusService.stop();
|
||||
this.resources.establishmentService.stop();
|
||||
this.resources.suspensionService?.stop();
|
||||
await this.loop?.catch((error) => {
|
||||
if (!(error instanceof DOMException && error.name === 'AbortError')) throw error;
|
||||
});
|
||||
await this.resources.browserClient.close();
|
||||
}
|
||||
|
||||
pause(_reason: string): void {
|
||||
this.controller?.abort();
|
||||
this.mediaSyncRunning = false;
|
||||
this.resources.mediaSyncService.stop();
|
||||
this.resources.recipientAccountStatusService.stop();
|
||||
this.resources.establishmentService.stop();
|
||||
this.resources.suspensionService?.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts or stops the background media sync only on a mode transition —
|
||||
* `MediaSyncService.start()` re-wraps its own `stop()` on every call, so
|
||||
|
||||
@@ -38,6 +38,7 @@ import type { RecipientAccountStatusService } from './recipient-account-status.j
|
||||
import type { AccountEstablishmentService } from './account-establishment.js';
|
||||
import type { AccountGrowthService } from './account-growth.js';
|
||||
import type { AccountBrowsingSimulationService } from './account-browsing-simulation.js';
|
||||
import type { AccountSuspensionService } from './account-suspension.js';
|
||||
import type { ProcessedPost, ProcessResult } from './processor.js';
|
||||
import type { ConversationEventRepository } from '../database/conversation-event-repository.js';
|
||||
import type { ConversationLearningRepository } from '../database/conversation-learning-repository.js';
|
||||
@@ -91,6 +92,8 @@ export interface RedditAccountRuntimeResources {
|
||||
mediaSyncService: MediaSyncService;
|
||||
recipientAccountStatusService: RecipientAccountStatusService;
|
||||
establishmentService: AccountEstablishmentService;
|
||||
/** Present in concrete runtimes; optional for narrow lifecycle test fixtures. */
|
||||
suspensionService?: AccountSuspensionService;
|
||||
/**
|
||||
* Comment-based account growth. Optional because a runtime is fully
|
||||
* functional without it, and it is disabled per account by default.
|
||||
@@ -107,6 +110,8 @@ export interface RedditAccountRuntimeLifecycle {
|
||||
start(): void | Promise<void>;
|
||||
/** Stop and await this account's schedules/loop, then close its browser. */
|
||||
stop(): void | Promise<void>;
|
||||
/** Immediately halt account work after a definitive suspension signal. */
|
||||
pause?(reason: string): void;
|
||||
/** Process a post assigned by the shared discovery scheduler. */
|
||||
dispatchPost?(post: RedditPost, signal?: AbortSignal): Promise<void>;
|
||||
preparePost?(post: RedditPost, signal?: AbortSignal): Promise<ProcessResult>;
|
||||
@@ -123,6 +128,7 @@ export type RedditAccountRuntimeState =
|
||||
| 'starting'
|
||||
| 'running'
|
||||
| 'stopping'
|
||||
| 'paused'
|
||||
| 'stopped'
|
||||
| 'failed';
|
||||
|
||||
@@ -197,6 +203,17 @@ export class RedditAccountRuntime {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspension can be discovered by work that this runtime is currently
|
||||
* awaiting. Keep this synchronous so it cannot wait on its own loop.
|
||||
*/
|
||||
pause(reason: string): void {
|
||||
if (this.state === 'stopped' || this.state === 'failed') return;
|
||||
this.failure = reason;
|
||||
this.state = 'paused';
|
||||
this.lifecycle.pause?.(reason);
|
||||
}
|
||||
|
||||
snapshot(): RedditAccountRuntimeSnapshot {
|
||||
return {
|
||||
accountId: this.session.accountId,
|
||||
@@ -294,7 +311,9 @@ export class RedditAccountRuntimeManager {
|
||||
|
||||
async startEnabled(): Promise<RedditAccountRuntimeManagerSnapshot> {
|
||||
const enabled = this.accounts.listPublic().filter(isEnabledAccount);
|
||||
await Promise.all(enabled.map((account) => this.start(account.accountId)));
|
||||
// A broken or suspended session belongs to one account. Do not prevent
|
||||
// other verified accounts from starting because of it.
|
||||
await Promise.allSettled(enabled.map((account) => this.start(account.accountId)));
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export interface RedditAccountSummary {
|
||||
matrixUserId: string;
|
||||
isRunning: boolean;
|
||||
/** `removed` is terminal until a fresh session is imported for the account. */
|
||||
status: 'authenticated' | 'login-required' | 'removed';
|
||||
status: 'authenticated' | 'login-required' | 'suspended' | 'removed';
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastImportedAt: number;
|
||||
@@ -136,16 +136,20 @@ export class RedditAccountsService {
|
||||
|
||||
private toSummary(account: RedditAccountPublicRecord): RedditAccountSummary {
|
||||
const runtime = this.runtimes?.get(account.accountId);
|
||||
const running = account.status === 'removed' ? false : runtime?.snapshot().state === 'running';
|
||||
const running = account.status === 'removed' || account.status === 'suspended'
|
||||
? false
|
||||
: runtime?.snapshot().state === 'running';
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
redditUsername: account.redditUsername,
|
||||
matrixUserId: account.matrixUserId ?? account.accountId,
|
||||
isRunning: account.status === 'removed'
|
||||
isRunning: account.status === 'removed' || account.status === 'suspended'
|
||||
? false
|
||||
: running ?? (account.accountId === this.auth.getSnapshot().accountId),
|
||||
status: account.status === 'removed'
|
||||
? 'removed'
|
||||
: account.status === 'suspended'
|
||||
? 'suspended'
|
||||
: account.status === 'verified' && (running ?? account.accountId === this.auth.getSnapshot().accountId)
|
||||
? 'authenticated'
|
||||
: 'login-required',
|
||||
|
||||
Reference in new issue
Block a user