feat: flag accounts Reddit considers unestablished and re-test them
New accounts cannot start chats until Reddit considers them established. Until now the bot would burn a candidate on every invitation Reddit was always going to reject, with nothing in the dashboard explaining why the account looked idle. Two signals feed the verdict. A periodic profile check reads the account's own age, karma and email verification from about.json and compares them against configurable thresholds — that is what flags a brand new account before it wastes anything. A rejected invitation is authoritative: Reddit has told us directly, so the account is held out of sending with a backoff that doubles per rejection, and a delivered invitation clears the flag outright. An unestablished account takes no new posts (canAcceptPost), raises an attention item, reports an `unestablished` sending state, and shows its age/karma evidence on the Accounts page. An unchecked account is always allowed through, so a first run is never blocked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c43e534cf1
commit
89eb4583f8
25 files changed
+1425
-11
No files matched your search
@@ -0,0 +1,297 @@
|
||||
import type { Logger } from '../utils/logger.js';
|
||||
import { RedditRateLimitError, type RedditAccountProfile } from '../reddit/api.js';
|
||||
import type {
|
||||
AccountEstablishmentRecord,
|
||||
AccountProfileMetricsInput,
|
||||
RedditAccountEstablishmentRepository,
|
||||
} from '../database/account-repositories.js';
|
||||
import { denyAccountAuth, type AccountAuthGate } from './account-auth-context.js';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60_000;
|
||||
const INITIAL_CHECK_DELAY_MS = 30_000;
|
||||
|
||||
export interface AccountEstablishmentThresholds {
|
||||
minAccountAgeDays: number;
|
||||
minTotalKarma: number;
|
||||
minCommentKarma: number;
|
||||
requireVerifiedEmail: boolean;
|
||||
}
|
||||
|
||||
export interface AccountEstablishmentSettings extends AccountEstablishmentThresholds {
|
||||
enabled: boolean;
|
||||
profileCheckIntervalMs: number;
|
||||
probeRetryBaseMs: number;
|
||||
probeRetryMaxMs: number;
|
||||
}
|
||||
|
||||
export interface AccountProfileLookup {
|
||||
getAccountProfile(username: string): Promise<RedditAccountProfile | null>;
|
||||
}
|
||||
|
||||
/** Reddit's own codes for "this account may not create chats yet". */
|
||||
const UNESTABLISHED_REDDIT_CODES: readonly RegExp[] = [
|
||||
/new_account/i,
|
||||
/immature_account/i,
|
||||
/young_account/i,
|
||||
/account_age/i,
|
||||
/minimum_karma/i,
|
||||
];
|
||||
|
||||
const UNESTABLISHED_MESSAGE_PATTERNS: readonly RegExp[] = [
|
||||
/account (?:is )?too new/i,
|
||||
/\bnot old enough\b/i,
|
||||
/\baccount age\b/i,
|
||||
/\b(?:more|insufficient|not enough|low) karma\b/i,
|
||||
/\bkarma (?:requirement|threshold)\b/i,
|
||||
/\bverify your email\b/i,
|
||||
/\bemail (?:is )?not verified\b/i,
|
||||
/\bnot eligible to (?:send|start|create|chat)/i,
|
||||
/\bnew accounts? (?:cannot|can't|are not allowed)/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Whether a failed invitation is Reddit refusing an unestablished account
|
||||
* rather than an ordinary throttle. `rate.invitation_limit_mature_account` is
|
||||
* deliberately excluded: it is the throttle applied to accounts that *are*
|
||||
* established.
|
||||
*/
|
||||
export function isUnestablishedFailure(
|
||||
reason: string | null | undefined,
|
||||
redditCode: string | null | undefined,
|
||||
): boolean {
|
||||
if (redditCode && /mature_account/i.test(redditCode) && !/immature_account/i.test(redditCode)) {
|
||||
return false;
|
||||
}
|
||||
if (redditCode && UNESTABLISHED_REDDIT_CODES.some((pattern) => pattern.test(redditCode))) return true;
|
||||
return Boolean(reason) && UNESTABLISHED_MESSAGE_PATTERNS.some((pattern) => pattern.test(reason!));
|
||||
}
|
||||
|
||||
export interface ProfileVerdict {
|
||||
established: boolean;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Judge an account's own profile against the configured thresholds. Missing
|
||||
* values never count against the account — Reddit omits fields for reasons
|
||||
* unrelated to how established the account is.
|
||||
*/
|
||||
export function evaluateAccountProfile(
|
||||
metrics: AccountProfileMetricsInput,
|
||||
thresholds: AccountEstablishmentThresholds,
|
||||
now: number = Date.now(),
|
||||
): ProfileVerdict {
|
||||
const reasons: string[] = [];
|
||||
if (metrics.accountCreatedAt !== null) {
|
||||
const ageDays = (now - metrics.accountCreatedAt) / DAY_MS;
|
||||
if (ageDays < thresholds.minAccountAgeDays) {
|
||||
reasons.push(
|
||||
`Account is ${formatDays(ageDays)} old (needs ${formatDays(thresholds.minAccountAgeDays)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (metrics.totalKarma !== null && metrics.totalKarma < thresholds.minTotalKarma) {
|
||||
reasons.push(`Total karma is ${metrics.totalKarma} (needs ${thresholds.minTotalKarma})`);
|
||||
}
|
||||
if (metrics.commentKarma !== null && metrics.commentKarma < thresholds.minCommentKarma) {
|
||||
reasons.push(`Comment karma is ${metrics.commentKarma} (needs ${thresholds.minCommentKarma})`);
|
||||
}
|
||||
if (thresholds.requireVerifiedEmail && metrics.hasVerifiedEmail === false) {
|
||||
reasons.push('Email address is not verified');
|
||||
}
|
||||
return { established: reasons.length === 0, reasons };
|
||||
}
|
||||
|
||||
function formatDays(days: number): string {
|
||||
if (days < 1) {
|
||||
const hours = Math.max(0, Math.floor(days * 24));
|
||||
return `${hours} hour${hours === 1 ? '' : 's'}`;
|
||||
}
|
||||
const whole = Math.floor(days);
|
||||
return `${whole} day${whole === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks whether one account is established enough for Reddit to accept chat
|
||||
* invitations from it.
|
||||
*
|
||||
* Two signals feed the verdict. The profile check (age, karma, verified email)
|
||||
* runs on an interval and is advisory — it is what flags a brand new account
|
||||
* before it wastes a candidate. A rejected invitation is authoritative: Reddit
|
||||
* has told us directly, so the account is held out of sending with an
|
||||
* escalating backoff and re-tested when the backoff expires.
|
||||
*/
|
||||
export class AccountEstablishmentService {
|
||||
private intervalHandle: NodeJS.Timeout | null = null;
|
||||
private initialHandle: NodeJS.Timeout | null = null;
|
||||
private checking = false;
|
||||
|
||||
constructor(
|
||||
private readonly accountId: string,
|
||||
private readonly redditUsername: string,
|
||||
private readonly reddit: AccountProfileLookup,
|
||||
private readonly repository: RedditAccountEstablishmentRepository,
|
||||
private readonly settings: AccountEstablishmentSettings,
|
||||
private readonly logger: Logger,
|
||||
private readonly accountAuth: AccountAuthGate = denyAccountAuth,
|
||||
private readonly now: () => number = Date.now,
|
||||
) {}
|
||||
|
||||
start(): void {
|
||||
if (!this.settings.enabled || this.intervalHandle) return;
|
||||
const interval = this.settings.profileCheckIntervalMs;
|
||||
this.initialHandle = setTimeout(() => {
|
||||
this.initialHandle = null;
|
||||
void this.checkNow();
|
||||
}, Math.min(INITIAL_CHECK_DELAY_MS, interval));
|
||||
this.intervalHandle = setInterval(() => void this.checkNow(), interval);
|
||||
this.logger.info(
|
||||
`Account establishment check for u/${this.redditUsername}:`
|
||||
+ ` scheduled every ${Math.round(interval / 3_600_000)} hours`,
|
||||
);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.initialHandle) clearTimeout(this.initialHandle);
|
||||
if (this.intervalHandle) clearInterval(this.intervalHandle);
|
||||
this.initialHandle = null;
|
||||
this.intervalHandle = null;
|
||||
}
|
||||
|
||||
snapshot(): AccountEstablishmentRecord | null {
|
||||
return this.repository.get(this.accountId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account may attempt an outbound invitation. Only an explicit
|
||||
* unestablished verdict inside its backoff window holds it back — an
|
||||
* unchecked account is allowed through so a first run is never blocked.
|
||||
*/
|
||||
isSendable(): boolean {
|
||||
if (!this.settings.enabled) return true;
|
||||
const record = this.repository.get(this.accountId);
|
||||
if (!record || record.state !== 'unestablished') return true;
|
||||
return record.retryAt !== null && this.now() >= record.retryAt;
|
||||
}
|
||||
|
||||
/** Refresh the profile snapshot and re-derive the verdict from it. */
|
||||
async checkNow(): Promise<AccountEstablishmentRecord | null> {
|
||||
if (!this.settings.enabled || this.checking) return null;
|
||||
if (!this.accountAuth.isFullyAuthenticated()) return null;
|
||||
this.checking = true;
|
||||
try {
|
||||
const profile = await this.reddit.getAccountProfile(this.redditUsername);
|
||||
if (!profile) {
|
||||
this.logger.warn(`Account establishment check: u/${this.redditUsername} profile is unavailable`);
|
||||
return null;
|
||||
}
|
||||
const metrics: AccountProfileMetricsInput = {
|
||||
accountCreatedAt: profile.createdAt,
|
||||
linkKarma: profile.linkKarma,
|
||||
commentKarma: profile.commentKarma,
|
||||
totalKarma: profile.totalKarma,
|
||||
hasVerifiedEmail: profile.hasVerifiedEmail,
|
||||
};
|
||||
const previous = this.repository.get(this.accountId);
|
||||
const verdict = evaluateAccountProfile(metrics, this.settings, this.now());
|
||||
const held = this.heldByProbe(previous);
|
||||
|
||||
// A live rejection outranks the profile thresholds: keep the account held
|
||||
// until its backoff expires even if the numbers now look acceptable.
|
||||
if (held) {
|
||||
const record = this.repository.saveProfileVerdict({
|
||||
accountId: this.accountId,
|
||||
state: 'unestablished',
|
||||
reasons: [...previous!.reasons, ...verdict.reasons.filter((reason) => !previous!.reasons.includes(reason))],
|
||||
metrics,
|
||||
checkedAt: this.now(),
|
||||
retryAt: previous!.retryAt,
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
const record = this.repository.saveProfileVerdict({
|
||||
accountId: this.accountId,
|
||||
state: verdict.established ? 'established' : 'unestablished',
|
||||
reasons: verdict.reasons,
|
||||
metrics,
|
||||
checkedAt: this.now(),
|
||||
retryAt: verdict.established ? null : this.now() + this.settings.profileCheckIntervalMs,
|
||||
});
|
||||
if (!verdict.established && previous?.state !== 'unestablished') {
|
||||
this.logger.warn(
|
||||
`u/${this.redditUsername} is not established yet: ${verdict.reasons.join('; ')}`,
|
||||
);
|
||||
}
|
||||
if (verdict.established && previous && previous.state !== 'established') {
|
||||
this.logger.info(`u/${this.redditUsername} now meets the establishment thresholds`);
|
||||
}
|
||||
return record;
|
||||
} catch (error) {
|
||||
if (error instanceof RedditRateLimitError) {
|
||||
this.logger.warn(
|
||||
`Account establishment check paused for u/${this.redditUsername}: Reddit rate-limited profile lookups`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Account establishment check failed for u/${this.redditUsername}: ${message}`);
|
||||
return null;
|
||||
} finally {
|
||||
this.checking = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a failed invitation. Returns the recorded state when the failure
|
||||
* was Reddit refusing an unestablished account, and null otherwise so
|
||||
* ordinary throttles and recipient-side refusals are left alone.
|
||||
*/
|
||||
recordSendFailure(
|
||||
reason: string | null | undefined,
|
||||
redditCode: string | null | undefined = null,
|
||||
): AccountEstablishmentRecord | null {
|
||||
if (!this.settings.enabled) return null;
|
||||
if (!isUnestablishedFailure(reason, redditCode)) return null;
|
||||
const previous = this.repository.get(this.accountId);
|
||||
const failures = (previous?.probeFailureCount ?? 0) + 1;
|
||||
const backoff = Math.min(
|
||||
this.settings.probeRetryBaseMs * 2 ** (failures - 1),
|
||||
this.settings.probeRetryMaxMs,
|
||||
);
|
||||
const detail = redditCode ? `${reason ?? 'Invitation rejected'} (${redditCode})` : reason!;
|
||||
const record = this.repository.recordProbeFailure({
|
||||
accountId: this.accountId,
|
||||
reason: `Reddit rejected an invitation from this account: ${detail}`,
|
||||
retryAt: this.now() + backoff,
|
||||
at: this.now(),
|
||||
});
|
||||
this.logger.warn(
|
||||
`u/${this.redditUsername} held out of sending — Reddit rejected the invitation as an unestablished account`
|
||||
+ ` (${detail}); retrying in ${Math.round(backoff / 3_600_000)}h`,
|
||||
);
|
||||
return record;
|
||||
}
|
||||
|
||||
/** A delivered invitation proves the account is established. */
|
||||
recordSendSuccess(): AccountEstablishmentRecord | null {
|
||||
if (!this.settings.enabled) return null;
|
||||
const previous = this.repository.get(this.accountId);
|
||||
if (previous?.state === 'established' && previous.probeFailureCount === 0) return previous;
|
||||
const record = this.repository.recordProbeSuccess(this.accountId, this.now());
|
||||
if (previous?.state === 'unestablished') {
|
||||
this.logger.info(`u/${this.redditUsername} delivered an invitation — clearing the unestablished flag`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
private heldByProbe(record: AccountEstablishmentRecord | null): boolean {
|
||||
return Boolean(
|
||||
record
|
||||
&& record.state === 'unestablished'
|
||||
&& record.probeFailureCount > 0
|
||||
&& record.retryAt !== null
|
||||
&& this.now() < record.retryAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../database/repositories.js';
|
||||
import {
|
||||
AccountContentRepository,
|
||||
RedditAccountEstablishmentRepository,
|
||||
RedditAccountsRepository,
|
||||
RedditRecipientAccountStatusRepository,
|
||||
} from '../database/account-repositories.js';
|
||||
@@ -53,6 +54,7 @@ import { AiUsageRepository } from '../database/ai-usage-repository.js';
|
||||
import { MediaLibraryRepository } from '../database/media-library-repository.js';
|
||||
import { MediaLibraryExchangeService } from './media-library-exchange.js';
|
||||
import { RecipientAccountStatusService } from './recipient-account-status.js';
|
||||
import { AccountEstablishmentService } from './account-establishment.js';
|
||||
|
||||
export interface ConcreteRuntimeFactoryOptions {
|
||||
config: Config;
|
||||
@@ -225,6 +227,15 @@ async function buildRuntime(
|
||||
logger,
|
||||
accountAuth,
|
||||
);
|
||||
const establishmentService = new AccountEstablishmentService(
|
||||
session.accountId,
|
||||
session.redditUsername,
|
||||
redditApi,
|
||||
new RedditAccountEstablishmentRepository(db),
|
||||
accountConfig.accountEstablishment,
|
||||
logger,
|
||||
accountAuth,
|
||||
);
|
||||
const responseChecker = new ResponseCheckerService(
|
||||
chatPage, messagesRepo, successfulMessagesRepo, postsRepo, logger,
|
||||
accountAuth, conversationOutcomesRepo,
|
||||
@@ -277,7 +288,7 @@ async function buildRuntime(
|
||||
activityLogRepo,
|
||||
monitorService, processorService, responseChecker, conversationAutomation, chatService, mediaService,
|
||||
mediaSyncService, mediaExchange, rateLimiter,
|
||||
recipientAccountStatusService,
|
||||
recipientAccountStatusService, establishmentService,
|
||||
};
|
||||
const lifecycle = new AccountRuntimeLoop(
|
||||
resources, accountConfig, logger, isBotRunning, recipientDeduplicator,
|
||||
@@ -318,6 +329,7 @@ class AccountRuntimeLoop {
|
||||
this.controller = new AbortController();
|
||||
this.resources.mediaSyncService.start();
|
||||
this.resources.recipientAccountStatusService.start();
|
||||
this.resources.establishmentService.start();
|
||||
void this.resources.chatService.runStartupSync()
|
||||
.then(() => this.resources.chatService.enrichUsernames())
|
||||
.catch((error) => this.logger.error(`[account ${this.resources.accountAuth.getSnapshot().redditUsername}] startup sync failed:`, error));
|
||||
@@ -328,6 +340,7 @@ class AccountRuntimeLoop {
|
||||
this.controller?.abort();
|
||||
this.resources.mediaSyncService.stop();
|
||||
this.resources.recipientAccountStatusService.stop();
|
||||
this.resources.establishmentService.stop();
|
||||
await this.loop?.catch((error) => {
|
||||
if (!(error instanceof DOMException && error.name === 'AbortError')) throw error;
|
||||
});
|
||||
@@ -415,7 +428,7 @@ class AccountRuntimeLoop {
|
||||
}
|
||||
|
||||
async dispatchPreparedPost(processed: ProcessedPost, signal?: AbortSignal): Promise<PreparedDeliveryResult> {
|
||||
const { postsRepo, messagesRepo, redditApi, rateLimiter, chatService } = this.resources;
|
||||
const { postsRepo, messagesRepo, redditApi, rateLimiter, chatService, establishmentService } = this.resources;
|
||||
const { post } = processed;
|
||||
if (signal?.aborted) {
|
||||
return { status: 'retryable', reason: 'Delivery interrupted by shutdown', retryAt: Date.now() + 5_000 };
|
||||
@@ -446,6 +459,13 @@ class AccountRuntimeLoop {
|
||||
);
|
||||
if (start.status !== 'sent') {
|
||||
this.recipientDeduplicator.release(post.author);
|
||||
// The rejection reason is the only place Reddit tells us the account is
|
||||
// too new to invite anyone; the code, when there is one, rides on the
|
||||
// cooldown the chat service just recorded.
|
||||
establishmentService.recordSendFailure(
|
||||
start.reason,
|
||||
chatService.getInvitationCooldown()?.redditCode ?? null,
|
||||
);
|
||||
if (start.status === 'terminal') {
|
||||
postsRepo.setError(post.id, start.reason);
|
||||
this.resources.activityLogRepo.insert({
|
||||
@@ -457,6 +477,7 @@ class AccountRuntimeLoop {
|
||||
}
|
||||
|
||||
postsRepo.markMessageSent(post.id);
|
||||
establishmentService.recordSendSuccess();
|
||||
messagesRepo.insert({
|
||||
postId: post.id, recipient: post.author, subject: post.author,
|
||||
message: processed.generatedMessage, sentAt: Date.now(),
|
||||
|
||||
@@ -35,6 +35,7 @@ import type { ResponseCheckerService } from './response-checker.js';
|
||||
import type { RedditPost } from '../reddit/api.js';
|
||||
import type { ConversationAutomationService } from './conversation-automation.js';
|
||||
import type { RecipientAccountStatusService } from './recipient-account-status.js';
|
||||
import type { AccountEstablishmentService } from './account-establishment.js';
|
||||
import type { ProcessedPost, ProcessResult } from './processor.js';
|
||||
|
||||
/** Credentials passed to a factory. They must never be exposed by list/snapshot APIs. */
|
||||
@@ -83,6 +84,7 @@ export interface RedditAccountRuntimeResources {
|
||||
mediaService: MediaService;
|
||||
mediaSyncService: MediaSyncService;
|
||||
recipientAccountStatusService: RecipientAccountStatusService;
|
||||
establishmentService: AccountEstablishmentService;
|
||||
mediaExchange?: MediaLibraryExchangeService;
|
||||
rateLimiter: RateLimiter;
|
||||
}
|
||||
@@ -197,6 +199,9 @@ export class RedditAccountRuntime {
|
||||
if (this.state !== 'running') return false;
|
||||
if (!this.resources.accountAuth.isFullyAuthenticated()
|
||||
&& !(await this.resources.accountAuth.ensureFullyAuthenticated())) return false;
|
||||
// An account Reddit has refused invitations from would burn the candidate,
|
||||
// so it takes no new posts until its re-test window opens.
|
||||
if (!this.resources.establishmentService.isSendable()) return false;
|
||||
if (await this.resources.conversationAutomation.hasActionableReplies()) return false;
|
||||
if (this.resources.chatService.getInvitationCooldown()) return false;
|
||||
return this.resources.rateLimiter.getTimers().nextMessageAvailableAt === null;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
import type { Cookie } from 'playwright';
|
||||
import type { RedditAccountsRepository, RedditAccountPublicRecord } from '../database/account-repositories.js';
|
||||
import type {
|
||||
RedditAccountsRepository,
|
||||
RedditAccountPublicRecord,
|
||||
RedditAccountEstablishmentRepository,
|
||||
AccountEstablishmentState,
|
||||
} from '../database/account-repositories.js';
|
||||
import type { AccountAuthContext } from './account-auth-context.js';
|
||||
import type { RedditSessionImporter } from './reddit-session-importer.js';
|
||||
import type { MatrixCredentials } from './chat/matrix/types.js';
|
||||
@@ -19,6 +24,21 @@ const StoredMatrixCredentialsSchema = z.object({
|
||||
capturedAt: z.number(),
|
||||
});
|
||||
|
||||
/** How established the account looks, and the evidence behind that. */
|
||||
export interface RedditAccountEstablishmentSummary {
|
||||
state: AccountEstablishmentState;
|
||||
reasons: string[];
|
||||
accountAgeDays: number | null;
|
||||
linkKarma: number | null;
|
||||
commentKarma: number | null;
|
||||
totalKarma: number | null;
|
||||
hasVerifiedEmail: boolean | null;
|
||||
profileCheckedAt: number | null;
|
||||
lastProbeAt: number | null;
|
||||
lastProbeFailure: string | null;
|
||||
retryAt: number | null;
|
||||
}
|
||||
|
||||
export interface RedditAccountSummary {
|
||||
accountId: string;
|
||||
redditUsername: string;
|
||||
@@ -28,6 +48,8 @@ export interface RedditAccountSummary {
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastImportedAt: number;
|
||||
/** Null until the establishment check has run for this account. */
|
||||
establishment: RedditAccountEstablishmentSummary | null;
|
||||
}
|
||||
|
||||
export class RedditAccountsService {
|
||||
@@ -37,6 +59,7 @@ export class RedditAccountsService {
|
||||
private readonly auth: AccountAuthContext,
|
||||
private readonly runtimes?: RedditAccountRuntimeManager,
|
||||
private readonly verificationBrowser?: BrowserClient,
|
||||
private readonly establishment?: RedditAccountEstablishmentRepository,
|
||||
) {}
|
||||
|
||||
list(): RedditAccountSummary[] {
|
||||
@@ -91,6 +114,27 @@ export class RedditAccountsService {
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt,
|
||||
lastImportedAt: account.lastImportedAt ?? account.updatedAt,
|
||||
establishment: this.toEstablishmentSummary(account.accountId),
|
||||
};
|
||||
}
|
||||
|
||||
private toEstablishmentSummary(accountId: string): RedditAccountEstablishmentSummary | null {
|
||||
const record = this.establishment?.get(accountId);
|
||||
if (!record) return null;
|
||||
return {
|
||||
state: record.state,
|
||||
reasons: record.reasons,
|
||||
accountAgeDays: record.accountCreatedAt === null
|
||||
? null
|
||||
: Math.max(0, Math.floor((Date.now() - record.accountCreatedAt) / 86_400_000)),
|
||||
linkKarma: record.linkKarma,
|
||||
commentKarma: record.commentKarma,
|
||||
totalKarma: record.totalKarma,
|
||||
hasVerifiedEmail: record.hasVerifiedEmail,
|
||||
profileCheckedAt: record.profileCheckedAt,
|
||||
lastProbeAt: record.lastProbeAt,
|
||||
lastProbeFailure: record.lastProbeFailure,
|
||||
retryAt: record.retryAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user