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
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { AccountEstablishmentSummary } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||||
import {
|
import {
|
||||||
useImportRedditSessionMutation,
|
useImportRedditSessionMutation,
|
||||||
@@ -11,6 +12,68 @@ function errorMessage(error: Error | null): string {
|
|||||||
return error?.message ?? 'Something went wrong.';
|
return error?.message ?? 'Something went wrong.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function karmaLabel(value: number | null): string {
|
||||||
|
return value === null ? 'unknown' : value.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ageLabel(days: number | null): string {
|
||||||
|
if (days === null) return 'unknown';
|
||||||
|
if (days < 1) return 'less than a day';
|
||||||
|
return `${days.toLocaleString()} day${days === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryLabel(retryAt: number | null): string {
|
||||||
|
if (retryAt === null) return 'Will be re-tested on the next profile check.';
|
||||||
|
const minutes = Math.round((retryAt - Date.now()) / 60_000);
|
||||||
|
if (minutes <= 0) return 'Due for a re-test now.';
|
||||||
|
if (minutes < 60) return `Next re-test in ${minutes} minute${minutes === 1 ? '' : 's'}.`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
return `Next re-test in about ${hours} hour${hours === 1 ? '' : 's'}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A new account cannot start chats until Reddit considers it established. The
|
||||||
|
* bot holds it out of sending and re-tests it, so this panel exists to explain
|
||||||
|
* why an account looks idle.
|
||||||
|
*/
|
||||||
|
function EstablishmentDetail({ establishment }: { establishment: AccountEstablishmentSummary }) {
|
||||||
|
const isUnestablished = establishment.state === 'unestablished';
|
||||||
|
return (
|
||||||
|
<div className="account-establishment">
|
||||||
|
{isUnestablished ? (
|
||||||
|
<>
|
||||||
|
<p className="account-establishment-reason">
|
||||||
|
{establishment.reasons.length > 0
|
||||||
|
? establishment.reasons.join('; ')
|
||||||
|
: 'Reddit will not accept chat invitations from this account yet.'}
|
||||||
|
</p>
|
||||||
|
<p className="field-hint">Outbound sending is on hold. {retryLabel(establishment.retryAt)}</p>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{establishment.lastProbeFailure && !isUnestablished ? (
|
||||||
|
<p className="field-hint">Last rejection: {establishment.lastProbeFailure}</p>
|
||||||
|
) : null}
|
||||||
|
<dl className="account-establishment-metrics">
|
||||||
|
<div><dt>Age</dt><dd>{ageLabel(establishment.accountAgeDays)}</dd></div>
|
||||||
|
<div><dt>Post karma</dt><dd>{karmaLabel(establishment.linkKarma)}</dd></div>
|
||||||
|
<div><dt>Comment karma</dt><dd>{karmaLabel(establishment.commentKarma)}</dd></div>
|
||||||
|
<div><dt>Total karma</dt><dd>{karmaLabel(establishment.totalKarma)}</dd></div>
|
||||||
|
<div>
|
||||||
|
<dt>Verified email</dt>
|
||||||
|
<dd>{establishment.hasVerifiedEmail === null ? 'unknown' : establishment.hasVerifiedEmail ? 'Yes' : 'No'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function establishmentPill(establishment: AccountEstablishmentSummary | null | undefined) {
|
||||||
|
if (!establishment || establishment.state === 'unknown') return null;
|
||||||
|
return establishment.state === 'unestablished'
|
||||||
|
? <span className="pill warning">Not established</span>
|
||||||
|
: <span className="pill success">Established</span>;
|
||||||
|
}
|
||||||
|
|
||||||
function sessionLabel(status: ReturnType<typeof useLoginStatusQuery>['data']): string {
|
function sessionLabel(status: ReturnType<typeof useLoginStatusQuery>['data']): string {
|
||||||
if (!status) return 'Unknown';
|
if (!status) return 'Unknown';
|
||||||
if (!status.ready) return 'Browser unavailable';
|
if (!status.ready) return 'Browser unavailable';
|
||||||
@@ -57,6 +120,7 @@ export function AccountsPage() {
|
|||||||
: null;
|
: null;
|
||||||
const accounts = accountsQuery.data ?? [];
|
const accounts = accountsQuery.data ?? [];
|
||||||
const pausedCount = accounts.filter((account) => !account.isRunning).length;
|
const pausedCount = accounts.filter((account) => !account.isRunning).length;
|
||||||
|
const unestablished = accounts.filter((account) => account.establishment?.state === 'unestablished');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page accounts-page">
|
<div className="page accounts-page">
|
||||||
@@ -82,6 +146,20 @@ export function AccountsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{unestablished.length > 0 ? (
|
||||||
|
<div className="state-banner is-warning" role="status">
|
||||||
|
<strong>
|
||||||
|
{unestablished.length === 1
|
||||||
|
? `u/${unestablished[0].redditUsername} is not established yet.`
|
||||||
|
: `${unestablished.length} accounts are not established yet.`}
|
||||||
|
</strong>
|
||||||
|
<span>
|
||||||
|
Reddit will not accept chat invitations from a new account, so outbound sending is on hold
|
||||||
|
for it. The bot re-tests periodically and resumes on its own.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{statusQuery.isPending ? (
|
{statusQuery.isPending ? (
|
||||||
<div aria-label="Loading access" className="page-state" role="status">
|
<div aria-label="Loading access" className="page-state" role="status">
|
||||||
<span className="spinner" />Loading Reddit access…
|
<span className="spinner" />Loading Reddit access…
|
||||||
@@ -127,9 +205,15 @@ export function AccountsPage() {
|
|||||||
<strong><RedditUsernameLink username={account.redditUsername} /></strong>
|
<strong><RedditUsernameLink username={account.redditUsername} /></strong>
|
||||||
<span>{account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}</span>
|
<span>{account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={account.isRunning ? 'pill success' : 'pill warning'}>
|
<div className="account-row-pills">
|
||||||
{account.isRunning ? 'Running' : 'Paused'}
|
{establishmentPill(account.establishment)}
|
||||||
</span>
|
<span className={account.isRunning ? 'pill success' : 'pill warning'}>
|
||||||
|
{account.isRunning ? 'Running' : 'Paused'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{account.establishment && account.establishment.state !== 'unknown' ? (
|
||||||
|
<EstablishmentDetail establishment={account.establishment} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{accountsQuery.isSuccess && accounts.length === 0 ? (
|
{accountsQuery.isSuccess && accounts.length === 0 ? (
|
||||||
|
|||||||
@@ -8,3 +8,13 @@
|
|||||||
.account-row > div { display: grid; gap: .2rem; min-width: 0; }
|
.account-row > div { display: grid; gap: .2rem; min-width: 0; }
|
||||||
.account-row span { color: var(--muted); font-size: .82rem; }
|
.account-row span { color: var(--muted); font-size: .82rem; }
|
||||||
.account-row .pill { font-size: .7rem; }
|
.account-row .pill { font-size: .7rem; }
|
||||||
|
.account-row > .account-row-pills { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; }
|
||||||
|
|
||||||
|
/* Establishment evidence sits on its own row beneath the account summary. */
|
||||||
|
.account-establishment { flex-basis: 100%; display: grid; gap: .4rem; padding-top: .7rem; border-top: 1px solid var(--border); }
|
||||||
|
.account-establishment .account-establishment-reason { margin: 0; font-size: .82rem; }
|
||||||
|
.account-establishment .field-hint { margin: 0; }
|
||||||
|
.account-establishment-metrics { display: flex; flex-wrap: wrap; gap: .35rem 1.25rem; margin: 0; }
|
||||||
|
.account-establishment-metrics > div { display: grid; gap: .1rem; }
|
||||||
|
.account-establishment-metrics dt { color: var(--muted); font-size: .7rem; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
.account-establishment-metrics dd { margin: 0; font-size: .82rem; font-variant-numeric: tabular-nums; }
|
||||||
@@ -18,6 +18,8 @@ function actionHint(kind: AttentionItem['kind']): string {
|
|||||||
switch (kind) {
|
switch (kind) {
|
||||||
case 'account-login-required':
|
case 'account-login-required':
|
||||||
return 'No linked conversation — check Settings.';
|
return 'No linked conversation — check Settings.';
|
||||||
|
case 'account-unestablished':
|
||||||
|
return 'No linked conversation — check Accounts.';
|
||||||
case 'runtime-failed':
|
case 'runtime-failed':
|
||||||
case 'invitation-cooldown':
|
case 'invitation-cooldown':
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ const sendingStateCopy: Record<AccountStatus['sendingState'], { label: string; t
|
|||||||
'matrix-cooldown': { label: 'Matrix cooldown', tone: 'warning', detail: 'Only this account is paused by Reddit chat throttling.' },
|
'matrix-cooldown': { label: 'Matrix cooldown', tone: 'warning', detail: 'Only this account is paused by Reddit chat throttling.' },
|
||||||
'replies-prioritized': { label: 'Replies first', tone: 'neutral', detail: 'Inbound replies are being handled before new outreach.' },
|
'replies-prioritized': { label: 'Replies first', tone: 'neutral', detail: 'Inbound replies are being handled before new outreach.' },
|
||||||
pacing: { label: 'Pacing', tone: 'neutral', detail: 'Waiting for the account sending window.' },
|
pacing: { label: 'Pacing', tone: 'neutral', detail: 'Waiting for the account sending window.' },
|
||||||
|
unestablished: { label: 'Not established', tone: 'warning', detail: 'Reddit will not accept chat invitations from this account yet.' },
|
||||||
};
|
};
|
||||||
|
|
||||||
function AccountStatusPanel({ accounts }: { accounts: readonly AccountStatus[] }) {
|
function AccountStatusPanel({ accounts }: { accounts: readonly AccountStatus[] }) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const AttentionKindSchema = z.enum([
|
|||||||
'account-login-required',
|
'account-login-required',
|
||||||
'runtime-failed',
|
'runtime-failed',
|
||||||
'invitation-cooldown',
|
'invitation-cooldown',
|
||||||
|
'account-unestablished',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const AttentionItemSchema = z.strictObject({
|
export const AttentionItemSchema = z.strictObject({
|
||||||
|
|||||||
@@ -6,6 +6,24 @@ export const RedditSessionStateSchema = z.union([
|
|||||||
z.literal('login-required'),
|
z.literal('login-required'),
|
||||||
z.literal('unreachable'),
|
z.literal('unreachable'),
|
||||||
]);
|
]);
|
||||||
|
export const AccountEstablishmentStateSchema = z.union([
|
||||||
|
z.literal('unknown'),
|
||||||
|
z.literal('established'),
|
||||||
|
z.literal('unestablished'),
|
||||||
|
]);
|
||||||
|
export const AccountEstablishmentSummarySchema = z.strictObject({
|
||||||
|
state: AccountEstablishmentStateSchema,
|
||||||
|
reasons: z.array(z.string()),
|
||||||
|
accountAgeDays: z.number().nullable(),
|
||||||
|
linkKarma: z.number().nullable(),
|
||||||
|
commentKarma: z.number().nullable(),
|
||||||
|
totalKarma: z.number().nullable(),
|
||||||
|
hasVerifiedEmail: z.boolean().nullable(),
|
||||||
|
profileCheckedAt: z.number().nullable(),
|
||||||
|
lastProbeAt: z.number().nullable(),
|
||||||
|
lastProbeFailure: z.string().nullable(),
|
||||||
|
retryAt: z.number().nullable(),
|
||||||
|
});
|
||||||
export const RedditAccountSummarySchema = z.strictObject({
|
export const RedditAccountSummarySchema = z.strictObject({
|
||||||
accountId: z.string().min(1),
|
accountId: z.string().min(1),
|
||||||
redditUsername: z.string().min(1),
|
redditUsername: z.string().min(1),
|
||||||
@@ -15,6 +33,8 @@ export const RedditAccountSummarySchema = z.strictObject({
|
|||||||
createdAt: z.number(),
|
createdAt: z.number(),
|
||||||
updatedAt: z.number(),
|
updatedAt: z.number(),
|
||||||
lastImportedAt: z.number(),
|
lastImportedAt: z.number(),
|
||||||
|
/** Absent on responses from a build that predates establishment tracking. */
|
||||||
|
establishment: AccountEstablishmentSummarySchema.nullable().optional(),
|
||||||
});
|
});
|
||||||
export const RedditAccountOwnerSchema = RedditAccountSummarySchema.pick({
|
export const RedditAccountOwnerSchema = RedditAccountSummarySchema.pick({
|
||||||
accountId: true,
|
accountId: true,
|
||||||
@@ -71,4 +91,6 @@ export type RedditSessionState = z.infer<typeof RedditSessionStateSchema>;
|
|||||||
export type LoginStatusResponse = z.infer<typeof LoginStatusResponseSchema>;
|
export type LoginStatusResponse = z.infer<typeof LoginStatusResponseSchema>;
|
||||||
export type RedditCookieImportRequest = z.infer<typeof RedditCookieImportRequestSchema>;
|
export type RedditCookieImportRequest = z.infer<typeof RedditCookieImportRequestSchema>;
|
||||||
export type RedditAccountSummary = z.infer<typeof RedditAccountSummarySchema>;
|
export type RedditAccountSummary = z.infer<typeof RedditAccountSummarySchema>;
|
||||||
|
export type AccountEstablishmentState = z.infer<typeof AccountEstablishmentStateSchema>;
|
||||||
|
export type AccountEstablishmentSummary = z.infer<typeof AccountEstablishmentSummarySchema>;
|
||||||
export type RedditAccountOwner = z.infer<typeof RedditAccountOwnerSchema>;
|
export type RedditAccountOwner = z.infer<typeof RedditAccountOwnerSchema>;
|
||||||
@@ -10,6 +10,7 @@ export const AccountSendingStateSchema = z.enum([
|
|||||||
'matrix-cooldown',
|
'matrix-cooldown',
|
||||||
'replies-prioritized',
|
'replies-prioritized',
|
||||||
'pacing',
|
'pacing',
|
||||||
|
'unestablished',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const AccountStatusSchema = z.strictObject({
|
export const AccountStatusSchema = z.strictObject({
|
||||||
|
|||||||
+6
-1
@@ -43,7 +43,11 @@ import { PushNotifier } from './services/push-notifier.js';
|
|||||||
import { RedditSessionImporter } from './services/reddit-session-importer.js';
|
import { RedditSessionImporter } from './services/reddit-session-importer.js';
|
||||||
import { AccountAuthContext, authenticateCurrentAccount } from './services/account-auth-context.js';
|
import { AccountAuthContext, authenticateCurrentAccount } from './services/account-auth-context.js';
|
||||||
import { ConfigStore } from './config/persistence.js';
|
import { ConfigStore } from './config/persistence.js';
|
||||||
import { RedditAccountsRepository, AccountContentRepository } from './database/account-repositories.js';
|
import {
|
||||||
|
RedditAccountsRepository,
|
||||||
|
AccountContentRepository,
|
||||||
|
RedditAccountEstablishmentRepository,
|
||||||
|
} from './database/account-repositories.js';
|
||||||
import { RedditAccountsService } from './services/reddit-accounts.js';
|
import { RedditAccountsService } from './services/reddit-accounts.js';
|
||||||
import type { Cookie } from 'playwright';
|
import type { Cookie } from 'playwright';
|
||||||
import { ChatCache } from './services/chat/cache.js';
|
import { ChatCache } from './services/chat/cache.js';
|
||||||
@@ -371,6 +375,7 @@ export async function bootstrap(
|
|||||||
accountAuth,
|
accountAuth,
|
||||||
accountRuntimeManager,
|
accountRuntimeManager,
|
||||||
browserClient,
|
browserClient,
|
||||||
|
new RedditAccountEstablishmentRepository(db),
|
||||||
);
|
);
|
||||||
if (config.web.enabled) {
|
if (config.web.enabled) {
|
||||||
webServer = new WebServer(
|
webServer = new WebServer(
|
||||||
|
|||||||
@@ -164,6 +164,36 @@ export const ConfigSchema = z.object({
|
|||||||
killOnSellerKeyword: true,
|
killOnSellerKeyword: true,
|
||||||
aiReplyScreening: { enabled: false, killConfidence: 0.8, flagConfidence: 0.6 },
|
aiReplyScreening: { enabled: false, killConfidence: 0.8, flagConfidence: 0.6 },
|
||||||
}),
|
}),
|
||||||
|
/**
|
||||||
|
* New Reddit accounts cannot start chats until Reddit considers them
|
||||||
|
* established. Rather than burning candidates on invitations Reddit will
|
||||||
|
* reject, an account is flagged and held out of outbound sending, then
|
||||||
|
* re-tested on an interval.
|
||||||
|
*/
|
||||||
|
accountEstablishment: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
/** How often an account's own age/karma profile is refreshed. */
|
||||||
|
profileCheckIntervalMs: z.number().int().min(60_000).default(6 * 60 * 60_000),
|
||||||
|
minAccountAgeDays: z.number().min(0).default(7),
|
||||||
|
minTotalKarma: z.number().int().min(0).default(20),
|
||||||
|
minCommentKarma: z.number().int().min(0).default(5),
|
||||||
|
requireVerifiedEmail: z.boolean().default(false),
|
||||||
|
/**
|
||||||
|
* How long to hold an account out of sending after Reddit rejects an
|
||||||
|
* invitation for being too new. Doubles per consecutive rejection.
|
||||||
|
*/
|
||||||
|
probeRetryBaseMs: z.number().int().min(60_000).default(4 * 60 * 60_000),
|
||||||
|
probeRetryMaxMs: z.number().int().min(60_000).default(48 * 60 * 60_000),
|
||||||
|
}).default({
|
||||||
|
enabled: true,
|
||||||
|
profileCheckIntervalMs: 6 * 60 * 60_000,
|
||||||
|
minAccountAgeDays: 7,
|
||||||
|
minTotalKarma: 20,
|
||||||
|
minCommentKarma: 5,
|
||||||
|
requireVerifiedEmail: false,
|
||||||
|
probeRetryBaseMs: 4 * 60 * 60_000,
|
||||||
|
probeRetryMaxMs: 48 * 60 * 60_000,
|
||||||
|
}),
|
||||||
pushNotifications: z.object({
|
pushNotifications: z.object({
|
||||||
enabled: z.boolean().default(false),
|
enabled: z.boolean().default(false),
|
||||||
/** Base URL of the ntfy server, e.g. https://ntfy.example.com. */
|
/** Base URL of the ntfy server, e.g. https://ntfy.example.com. */
|
||||||
|
|||||||
@@ -389,6 +389,211 @@ export class RedditRecipientAccountStatusRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AccountEstablishmentState = 'unknown' | 'established' | 'unestablished';
|
||||||
|
|
||||||
|
export interface AccountEstablishmentRecord {
|
||||||
|
accountId: string;
|
||||||
|
state: AccountEstablishmentState;
|
||||||
|
/** Why the account is not established yet. Empty when it is. */
|
||||||
|
reasons: string[];
|
||||||
|
accountCreatedAt: number | null;
|
||||||
|
linkKarma: number | null;
|
||||||
|
commentKarma: number | null;
|
||||||
|
totalKarma: number | null;
|
||||||
|
hasVerifiedEmail: boolean | null;
|
||||||
|
profileCheckedAt: number | null;
|
||||||
|
lastProbeAt: number | null;
|
||||||
|
lastProbeFailure: string | null;
|
||||||
|
probeFailureCount: number;
|
||||||
|
/** Earliest time the account may attempt an outbound invitation again. */
|
||||||
|
retryAt: number | null;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountProfileMetricsInput {
|
||||||
|
accountCreatedAt: number | null;
|
||||||
|
linkKarma: number | null;
|
||||||
|
commentKarma: number | null;
|
||||||
|
totalKarma: number | null;
|
||||||
|
hasVerifiedEmail: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EstablishmentRow = {
|
||||||
|
account_id: string;
|
||||||
|
state: AccountEstablishmentState;
|
||||||
|
reasons_json: string;
|
||||||
|
account_created_at: number | null;
|
||||||
|
link_karma: number | null;
|
||||||
|
comment_karma: number | null;
|
||||||
|
total_karma: number | null;
|
||||||
|
has_verified_email: number | null;
|
||||||
|
profile_checked_at: number | null;
|
||||||
|
last_probe_at: number | null;
|
||||||
|
last_probe_failure: string | null;
|
||||||
|
probe_failure_count: number;
|
||||||
|
retry_at: number | null;
|
||||||
|
updated_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether each of our own Reddit accounts is established enough for Reddit to
|
||||||
|
* accept chat invitations from it, and the profile evidence behind that call.
|
||||||
|
*/
|
||||||
|
export class RedditAccountEstablishmentRepository {
|
||||||
|
constructor(private readonly db: Database.Database) {}
|
||||||
|
|
||||||
|
get(accountId: string): AccountEstablishmentRecord | null {
|
||||||
|
const row = this.db.prepare('SELECT * FROM reddit_account_establishment WHERE account_id = ?')
|
||||||
|
.get(accountId) as EstablishmentRow | undefined;
|
||||||
|
return row ? mapEstablishment(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): AccountEstablishmentRecord[] {
|
||||||
|
const rows = this.db.prepare('SELECT * FROM reddit_account_establishment ORDER BY account_id')
|
||||||
|
.all() as EstablishmentRow[];
|
||||||
|
return rows.map(mapEstablishment);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store a profile snapshot and the verdict derived from it. */
|
||||||
|
saveProfileVerdict(input: {
|
||||||
|
accountId: string;
|
||||||
|
state: AccountEstablishmentState;
|
||||||
|
reasons: string[];
|
||||||
|
metrics: AccountProfileMetricsInput;
|
||||||
|
checkedAt?: number;
|
||||||
|
retryAt?: number | null;
|
||||||
|
}): AccountEstablishmentRecord {
|
||||||
|
requireText(input.accountId, 'accountId');
|
||||||
|
const now = input.checkedAt ?? Date.now();
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT INTO reddit_account_establishment (
|
||||||
|
account_id, state, reasons_json, account_created_at, link_karma, comment_karma,
|
||||||
|
total_karma, has_verified_email, profile_checked_at, probe_failure_count, retry_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
@accountId, @state, @reasonsJson, @accountCreatedAt, @linkKarma, @commentKarma,
|
||||||
|
@totalKarma, @hasVerifiedEmail, @now, 0, @retryAt, @now
|
||||||
|
)
|
||||||
|
ON CONFLICT(account_id) DO UPDATE SET
|
||||||
|
state = excluded.state,
|
||||||
|
reasons_json = excluded.reasons_json,
|
||||||
|
account_created_at = excluded.account_created_at,
|
||||||
|
link_karma = excluded.link_karma,
|
||||||
|
comment_karma = excluded.comment_karma,
|
||||||
|
total_karma = excluded.total_karma,
|
||||||
|
has_verified_email = excluded.has_verified_email,
|
||||||
|
profile_checked_at = excluded.profile_checked_at,
|
||||||
|
retry_at = excluded.retry_at,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`).run({
|
||||||
|
accountId: input.accountId,
|
||||||
|
state: input.state,
|
||||||
|
reasonsJson: JSON.stringify(input.reasons),
|
||||||
|
accountCreatedAt: input.metrics.accountCreatedAt,
|
||||||
|
linkKarma: input.metrics.linkKarma,
|
||||||
|
commentKarma: input.metrics.commentKarma,
|
||||||
|
totalKarma: input.metrics.totalKarma,
|
||||||
|
hasVerifiedEmail: boolToInt(input.metrics.hasVerifiedEmail),
|
||||||
|
retryAt: input.retryAt ?? null,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
return this.getRequired(input.accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record that Reddit rejected an outbound invitation for being too new. */
|
||||||
|
recordProbeFailure(input: {
|
||||||
|
accountId: string;
|
||||||
|
reason: string;
|
||||||
|
retryAt: number;
|
||||||
|
at?: number;
|
||||||
|
}): AccountEstablishmentRecord {
|
||||||
|
requireText(input.accountId, 'accountId');
|
||||||
|
const now = input.at ?? Date.now();
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT INTO reddit_account_establishment (
|
||||||
|
account_id, state, reasons_json, last_probe_at, last_probe_failure,
|
||||||
|
probe_failure_count, retry_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
@accountId, 'unestablished', @reasonsJson, @now, @reason, 1, @retryAt, @now
|
||||||
|
)
|
||||||
|
ON CONFLICT(account_id) DO UPDATE SET
|
||||||
|
state = 'unestablished',
|
||||||
|
reasons_json = excluded.reasons_json,
|
||||||
|
last_probe_at = excluded.last_probe_at,
|
||||||
|
last_probe_failure = excluded.last_probe_failure,
|
||||||
|
probe_failure_count = reddit_account_establishment.probe_failure_count + 1,
|
||||||
|
retry_at = excluded.retry_at,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`).run({
|
||||||
|
accountId: input.accountId,
|
||||||
|
reasonsJson: JSON.stringify([input.reason]),
|
||||||
|
reason: input.reason,
|
||||||
|
retryAt: input.retryAt,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
return this.getRequired(input.accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A delivered invitation is the only positive proof an account is
|
||||||
|
* established, so it clears the flag and the failure backoff outright.
|
||||||
|
*/
|
||||||
|
recordProbeSuccess(accountId: string, at: number = Date.now()): AccountEstablishmentRecord {
|
||||||
|
requireText(accountId, 'accountId');
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT INTO reddit_account_establishment (
|
||||||
|
account_id, state, reasons_json, last_probe_at, probe_failure_count, retry_at, updated_at
|
||||||
|
) VALUES (@accountId, 'established', '[]', @at, 0, NULL, @at)
|
||||||
|
ON CONFLICT(account_id) DO UPDATE SET
|
||||||
|
state = 'established',
|
||||||
|
reasons_json = '[]',
|
||||||
|
last_probe_at = excluded.last_probe_at,
|
||||||
|
last_probe_failure = NULL,
|
||||||
|
probe_failure_count = 0,
|
||||||
|
retry_at = NULL,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`).run({ accountId, at });
|
||||||
|
return this.getRequired(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRequired(accountId: string): AccountEstablishmentRecord {
|
||||||
|
const record = this.get(accountId);
|
||||||
|
if (!record) throw new Error(`Establishment state for ${accountId} was not persisted`);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapEstablishment(row: EstablishmentRow): AccountEstablishmentRecord {
|
||||||
|
return {
|
||||||
|
accountId: row.account_id,
|
||||||
|
state: row.state,
|
||||||
|
reasons: parseReasons(row.reasons_json),
|
||||||
|
accountCreatedAt: row.account_created_at,
|
||||||
|
linkKarma: row.link_karma,
|
||||||
|
commentKarma: row.comment_karma,
|
||||||
|
totalKarma: row.total_karma,
|
||||||
|
hasVerifiedEmail: row.has_verified_email === null ? null : row.has_verified_email === 1,
|
||||||
|
profileCheckedAt: row.profile_checked_at,
|
||||||
|
lastProbeAt: row.last_probe_at,
|
||||||
|
lastProbeFailure: row.last_probe_failure,
|
||||||
|
probeFailureCount: row.probe_failure_count,
|
||||||
|
retryAt: row.retry_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReasons(json: string): string[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json) as unknown;
|
||||||
|
return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function boolToInt(value: boolean | null): number | null {
|
||||||
|
return value === null ? null : value ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
function mapConversation(row: ConversationRow): AccountConversationRecord {
|
function mapConversation(row: ConversationRow): AccountConversationRecord {
|
||||||
return {
|
return {
|
||||||
accountId: row.account_id,
|
accountId: row.account_id,
|
||||||
|
|||||||
@@ -456,6 +456,27 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_reddit_recipient_account_status_due
|
CREATE INDEX IF NOT EXISTS idx_reddit_recipient_account_status_due
|
||||||
ON reddit_recipient_account_status(checked_at);
|
ON reddit_recipient_account_status(checked_at);
|
||||||
`),
|
`),
|
||||||
|
() => db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS reddit_account_establishment (
|
||||||
|
account_id TEXT PRIMARY KEY,
|
||||||
|
state TEXT NOT NULL CHECK(state IN ('unknown', 'established', 'unestablished')),
|
||||||
|
reasons_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
account_created_at INTEGER,
|
||||||
|
link_karma INTEGER,
|
||||||
|
comment_karma INTEGER,
|
||||||
|
total_karma INTEGER,
|
||||||
|
has_verified_email INTEGER,
|
||||||
|
profile_checked_at INTEGER,
|
||||||
|
last_probe_at INTEGER,
|
||||||
|
last_probe_failure TEXT,
|
||||||
|
probe_failure_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
retry_at INTEGER,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reddit_account_establishment_state
|
||||||
|
ON reddit_account_establishment(state, retry_at);
|
||||||
|
`),
|
||||||
];
|
];
|
||||||
|
|
||||||
let currentVersion = db.pragma('user_version', { simple: true }) as number;
|
let currentVersion = db.pragma('user_version', { simple: true }) as number;
|
||||||
|
|||||||
@@ -16,8 +16,34 @@ const RedditUserAboutResponseSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subset of `/user/<name>/about.json` that says how established an account
|
||||||
|
* is. `has_verified_email` is only returned for the requesting user's own
|
||||||
|
* profile, so it stays nullable for anyone else.
|
||||||
|
*/
|
||||||
|
const RedditAccountProfileResponseSchema = z.object({
|
||||||
|
data: z.object({
|
||||||
|
created_utc: z.number().optional(),
|
||||||
|
link_karma: z.number().optional(),
|
||||||
|
comment_karma: z.number().optional(),
|
||||||
|
total_karma: z.number().optional(),
|
||||||
|
has_verified_email: z.boolean().nullish(),
|
||||||
|
is_suspended: z.boolean().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export type RedditUserAccountStatus = 'active' | 'suspended' | 'deleted';
|
export type RedditUserAccountStatus = 'active' | 'suspended' | 'deleted';
|
||||||
|
|
||||||
|
export interface RedditAccountProfile {
|
||||||
|
/** Account creation time in epoch milliseconds. */
|
||||||
|
createdAt: number | null;
|
||||||
|
linkKarma: number | null;
|
||||||
|
commentKarma: number | null;
|
||||||
|
totalKarma: number | null;
|
||||||
|
hasVerifiedEmail: boolean | null;
|
||||||
|
isSuspended: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class RedditRateLimitError extends Error {
|
export class RedditRateLimitError extends Error {
|
||||||
constructor(public readonly retryAfterMs: number) {
|
constructor(public readonly retryAfterMs: number) {
|
||||||
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
|
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
|
||||||
@@ -273,6 +299,42 @@ export class RedditApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Age, karma and email-verification for one account. Used to judge whether
|
||||||
|
* one of our own accounts is established enough for Reddit to let it start
|
||||||
|
* chats. Returns null when the profile no longer exists.
|
||||||
|
*/
|
||||||
|
async getAccountProfile(username: string): Promise<RedditAccountProfile | null> {
|
||||||
|
const cookieHeader = await this.getCookieHeader();
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (cookieHeader) headers.Cookie = cookieHeader;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.scheduleRequest(() => this.client.get(
|
||||||
|
`/user/${encodeURIComponent(username)}/about.json`,
|
||||||
|
{ params: { raw_json: 1 }, headers },
|
||||||
|
));
|
||||||
|
const { data } = RedditAccountProfileResponseSchema.parse(response.data);
|
||||||
|
const totalKarma = data.total_karma
|
||||||
|
?? (data.link_karma === undefined && data.comment_karma === undefined
|
||||||
|
? undefined
|
||||||
|
: (data.link_karma ?? 0) + (data.comment_karma ?? 0));
|
||||||
|
return {
|
||||||
|
createdAt: data.created_utc === undefined ? null : Math.round(data.created_utc * 1000),
|
||||||
|
linkKarma: data.link_karma ?? null,
|
||||||
|
commentKarma: data.comment_karma ?? null,
|
||||||
|
totalKarma: totalKarma ?? null,
|
||||||
|
hasVerifiedEmail: data.has_verified_email ?? null,
|
||||||
|
isSuspended: data.is_suspended === true,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 404) return null;
|
||||||
|
const rateLimitError = this.toRateLimitError(error);
|
||||||
|
if (rateLimitError) throw rateLimitError;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getNewPosts(subreddit: string, limit = 25, type: 'image' | 'chat' = 'image'): Promise<RedditPost[]> {
|
async getNewPosts(subreddit: string, limit = 25, type: 'image' | 'chat' = 'image'): Promise<RedditPost[]> {
|
||||||
try {
|
try {
|
||||||
const cookieHeader = await this.getCookieHeader();
|
const cookieHeader = await this.getCookieHeader();
|
||||||
|
|||||||
@@ -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';
|
} from '../database/repositories.js';
|
||||||
import {
|
import {
|
||||||
AccountContentRepository,
|
AccountContentRepository,
|
||||||
|
RedditAccountEstablishmentRepository,
|
||||||
RedditAccountsRepository,
|
RedditAccountsRepository,
|
||||||
RedditRecipientAccountStatusRepository,
|
RedditRecipientAccountStatusRepository,
|
||||||
} from '../database/account-repositories.js';
|
} 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 { MediaLibraryRepository } from '../database/media-library-repository.js';
|
||||||
import { MediaLibraryExchangeService } from './media-library-exchange.js';
|
import { MediaLibraryExchangeService } from './media-library-exchange.js';
|
||||||
import { RecipientAccountStatusService } from './recipient-account-status.js';
|
import { RecipientAccountStatusService } from './recipient-account-status.js';
|
||||||
|
import { AccountEstablishmentService } from './account-establishment.js';
|
||||||
|
|
||||||
export interface ConcreteRuntimeFactoryOptions {
|
export interface ConcreteRuntimeFactoryOptions {
|
||||||
config: Config;
|
config: Config;
|
||||||
@@ -225,6 +227,15 @@ async function buildRuntime(
|
|||||||
logger,
|
logger,
|
||||||
accountAuth,
|
accountAuth,
|
||||||
);
|
);
|
||||||
|
const establishmentService = new AccountEstablishmentService(
|
||||||
|
session.accountId,
|
||||||
|
session.redditUsername,
|
||||||
|
redditApi,
|
||||||
|
new RedditAccountEstablishmentRepository(db),
|
||||||
|
accountConfig.accountEstablishment,
|
||||||
|
logger,
|
||||||
|
accountAuth,
|
||||||
|
);
|
||||||
const responseChecker = new ResponseCheckerService(
|
const responseChecker = new ResponseCheckerService(
|
||||||
chatPage, messagesRepo, successfulMessagesRepo, postsRepo, logger,
|
chatPage, messagesRepo, successfulMessagesRepo, postsRepo, logger,
|
||||||
accountAuth, conversationOutcomesRepo,
|
accountAuth, conversationOutcomesRepo,
|
||||||
@@ -277,7 +288,7 @@ async function buildRuntime(
|
|||||||
activityLogRepo,
|
activityLogRepo,
|
||||||
monitorService, processorService, responseChecker, conversationAutomation, chatService, mediaService,
|
monitorService, processorService, responseChecker, conversationAutomation, chatService, mediaService,
|
||||||
mediaSyncService, mediaExchange, rateLimiter,
|
mediaSyncService, mediaExchange, rateLimiter,
|
||||||
recipientAccountStatusService,
|
recipientAccountStatusService, establishmentService,
|
||||||
};
|
};
|
||||||
const lifecycle = new AccountRuntimeLoop(
|
const lifecycle = new AccountRuntimeLoop(
|
||||||
resources, accountConfig, logger, isBotRunning, recipientDeduplicator,
|
resources, accountConfig, logger, isBotRunning, recipientDeduplicator,
|
||||||
@@ -318,6 +329,7 @@ class AccountRuntimeLoop {
|
|||||||
this.controller = new AbortController();
|
this.controller = new AbortController();
|
||||||
this.resources.mediaSyncService.start();
|
this.resources.mediaSyncService.start();
|
||||||
this.resources.recipientAccountStatusService.start();
|
this.resources.recipientAccountStatusService.start();
|
||||||
|
this.resources.establishmentService.start();
|
||||||
void this.resources.chatService.runStartupSync()
|
void this.resources.chatService.runStartupSync()
|
||||||
.then(() => this.resources.chatService.enrichUsernames())
|
.then(() => this.resources.chatService.enrichUsernames())
|
||||||
.catch((error) => this.logger.error(`[account ${this.resources.accountAuth.getSnapshot().redditUsername}] startup sync failed:`, error));
|
.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.controller?.abort();
|
||||||
this.resources.mediaSyncService.stop();
|
this.resources.mediaSyncService.stop();
|
||||||
this.resources.recipientAccountStatusService.stop();
|
this.resources.recipientAccountStatusService.stop();
|
||||||
|
this.resources.establishmentService.stop();
|
||||||
await this.loop?.catch((error) => {
|
await this.loop?.catch((error) => {
|
||||||
if (!(error instanceof DOMException && error.name === 'AbortError')) throw error;
|
if (!(error instanceof DOMException && error.name === 'AbortError')) throw error;
|
||||||
});
|
});
|
||||||
@@ -415,7 +428,7 @@ class AccountRuntimeLoop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async dispatchPreparedPost(processed: ProcessedPost, signal?: AbortSignal): Promise<PreparedDeliveryResult> {
|
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;
|
const { post } = processed;
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
return { status: 'retryable', reason: 'Delivery interrupted by shutdown', retryAt: Date.now() + 5_000 };
|
return { status: 'retryable', reason: 'Delivery interrupted by shutdown', retryAt: Date.now() + 5_000 };
|
||||||
@@ -446,6 +459,13 @@ class AccountRuntimeLoop {
|
|||||||
);
|
);
|
||||||
if (start.status !== 'sent') {
|
if (start.status !== 'sent') {
|
||||||
this.recipientDeduplicator.release(post.author);
|
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') {
|
if (start.status === 'terminal') {
|
||||||
postsRepo.setError(post.id, start.reason);
|
postsRepo.setError(post.id, start.reason);
|
||||||
this.resources.activityLogRepo.insert({
|
this.resources.activityLogRepo.insert({
|
||||||
@@ -457,6 +477,7 @@ class AccountRuntimeLoop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
postsRepo.markMessageSent(post.id);
|
postsRepo.markMessageSent(post.id);
|
||||||
|
establishmentService.recordSendSuccess();
|
||||||
messagesRepo.insert({
|
messagesRepo.insert({
|
||||||
postId: post.id, recipient: post.author, subject: post.author,
|
postId: post.id, recipient: post.author, subject: post.author,
|
||||||
message: processed.generatedMessage, sentAt: Date.now(),
|
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 { RedditPost } from '../reddit/api.js';
|
||||||
import type { ConversationAutomationService } from './conversation-automation.js';
|
import type { ConversationAutomationService } from './conversation-automation.js';
|
||||||
import type { RecipientAccountStatusService } from './recipient-account-status.js';
|
import type { RecipientAccountStatusService } from './recipient-account-status.js';
|
||||||
|
import type { AccountEstablishmentService } from './account-establishment.js';
|
||||||
import type { ProcessedPost, ProcessResult } from './processor.js';
|
import type { ProcessedPost, ProcessResult } from './processor.js';
|
||||||
|
|
||||||
/** Credentials passed to a factory. They must never be exposed by list/snapshot APIs. */
|
/** Credentials passed to a factory. They must never be exposed by list/snapshot APIs. */
|
||||||
@@ -83,6 +84,7 @@ export interface RedditAccountRuntimeResources {
|
|||||||
mediaService: MediaService;
|
mediaService: MediaService;
|
||||||
mediaSyncService: MediaSyncService;
|
mediaSyncService: MediaSyncService;
|
||||||
recipientAccountStatusService: RecipientAccountStatusService;
|
recipientAccountStatusService: RecipientAccountStatusService;
|
||||||
|
establishmentService: AccountEstablishmentService;
|
||||||
mediaExchange?: MediaLibraryExchangeService;
|
mediaExchange?: MediaLibraryExchangeService;
|
||||||
rateLimiter: RateLimiter;
|
rateLimiter: RateLimiter;
|
||||||
}
|
}
|
||||||
@@ -197,6 +199,9 @@ export class RedditAccountRuntime {
|
|||||||
if (this.state !== 'running') return false;
|
if (this.state !== 'running') return false;
|
||||||
if (!this.resources.accountAuth.isFullyAuthenticated()
|
if (!this.resources.accountAuth.isFullyAuthenticated()
|
||||||
&& !(await this.resources.accountAuth.ensureFullyAuthenticated())) return false;
|
&& !(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 (await this.resources.conversationAutomation.hasActionableReplies()) return false;
|
||||||
if (this.resources.chatService.getInvitationCooldown()) return false;
|
if (this.resources.chatService.getInvitationCooldown()) return false;
|
||||||
return this.resources.rateLimiter.getTimers().nextMessageAvailableAt === null;
|
return this.resources.rateLimiter.getTimers().nextMessageAvailableAt === null;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { Cookie } from 'playwright';
|
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 { AccountAuthContext } from './account-auth-context.js';
|
||||||
import type { RedditSessionImporter } from './reddit-session-importer.js';
|
import type { RedditSessionImporter } from './reddit-session-importer.js';
|
||||||
import type { MatrixCredentials } from './chat/matrix/types.js';
|
import type { MatrixCredentials } from './chat/matrix/types.js';
|
||||||
@@ -19,6 +24,21 @@ const StoredMatrixCredentialsSchema = z.object({
|
|||||||
capturedAt: z.number(),
|
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 {
|
export interface RedditAccountSummary {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
redditUsername: string;
|
redditUsername: string;
|
||||||
@@ -28,6 +48,8 @@ export interface RedditAccountSummary {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
lastImportedAt: number;
|
lastImportedAt: number;
|
||||||
|
/** Null until the establishment check has run for this account. */
|
||||||
|
establishment: RedditAccountEstablishmentSummary | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RedditAccountsService {
|
export class RedditAccountsService {
|
||||||
@@ -37,6 +59,7 @@ export class RedditAccountsService {
|
|||||||
private readonly auth: AccountAuthContext,
|
private readonly auth: AccountAuthContext,
|
||||||
private readonly runtimes?: RedditAccountRuntimeManager,
|
private readonly runtimes?: RedditAccountRuntimeManager,
|
||||||
private readonly verificationBrowser?: BrowserClient,
|
private readonly verificationBrowser?: BrowserClient,
|
||||||
|
private readonly establishment?: RedditAccountEstablishmentRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
list(): RedditAccountSummary[] {
|
list(): RedditAccountSummary[] {
|
||||||
@@ -91,6 +114,27 @@ export class RedditAccountsService {
|
|||||||
createdAt: account.createdAt,
|
createdAt: account.createdAt,
|
||||||
updatedAt: account.updatedAt,
|
updatedAt: account.updatedAt,
|
||||||
lastImportedAt: account.lastImportedAt ?? 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ import type { WebContext } from '../context.js';
|
|||||||
|
|
||||||
interface AttentionItem {
|
interface AttentionItem {
|
||||||
id: string;
|
id: string;
|
||||||
kind: 'human-required' | 'boundary' | 'failed-delivery' | 'account-login-required' | 'runtime-failed' | 'invitation-cooldown';
|
kind: 'human-required' | 'boundary' | 'failed-delivery' | 'account-login-required' | 'runtime-failed' | 'invitation-cooldown' | 'account-unestablished';
|
||||||
severity: 'warning' | 'critical';
|
severity: 'warning' | 'critical';
|
||||||
title: string;
|
title: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
@@ -20,6 +20,22 @@ export function createAttentionRouter(ctx: WebContext): Router {
|
|||||||
const items: AttentionItem[] = [];
|
const items: AttentionItem[] = [];
|
||||||
const accounts = ctx.services.redditAccounts?.list() ?? [];
|
const accounts = ctx.services.redditAccounts?.list() ?? [];
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
|
const owner = { accountId: account.accountId, redditUsername: account.redditUsername };
|
||||||
|
if (account.establishment?.state === 'unestablished') {
|
||||||
|
const { reasons, retryAt } = account.establishment;
|
||||||
|
items.push({
|
||||||
|
id: `establishment:${account.accountId}`,
|
||||||
|
kind: 'account-unestablished',
|
||||||
|
severity: 'warning',
|
||||||
|
title: `u/${account.redditUsername} is not established yet`,
|
||||||
|
detail: `${reasons.length > 0 ? reasons.join('; ') : 'Reddit will not accept chat invitations from this account yet.'}`
|
||||||
|
+ ` Outbound sending is on hold${retryAt ? ` until ${new Date(retryAt).toISOString()}` : ''}.`,
|
||||||
|
occurredAt: account.establishment.profileCheckedAt
|
||||||
|
?? account.establishment.lastProbeAt
|
||||||
|
?? account.updatedAt,
|
||||||
|
owner,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (account.status !== 'login-required') continue;
|
if (account.status !== 'login-required') continue;
|
||||||
items.push({
|
items.push({
|
||||||
id: `account:${account.accountId}`,
|
id: `account:${account.accountId}`,
|
||||||
@@ -28,7 +44,7 @@ export function createAttentionRouter(ctx: WebContext): Router {
|
|||||||
title: `u/${account.redditUsername} needs a new session`,
|
title: `u/${account.redditUsername} needs a new session`,
|
||||||
detail: 'Import a fresh extension session before this account can resume.',
|
detail: 'Import a fresh extension session before this account can resume.',
|
||||||
occurredAt: account.updatedAt,
|
occurredAt: account.updatedAt,
|
||||||
owner: { accountId: account.accountId, redditUsername: account.redditUsername },
|
owner,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ export function createStatsRouter(ctx: WebContext): Router {
|
|||||||
? 'login-required'
|
? 'login-required'
|
||||||
: runtimeState !== 'running'
|
: runtimeState !== 'running'
|
||||||
? 'runtime-stopped'
|
? 'runtime-stopped'
|
||||||
|
: account.establishment?.state === 'unestablished'
|
||||||
|
&& !(account.establishment.retryAt !== null && account.establishment.retryAt <= Date.now())
|
||||||
|
? 'unestablished'
|
||||||
: accountCooldown
|
: accountCooldown
|
||||||
? 'matrix-cooldown'
|
? 'matrix-cooldown'
|
||||||
: !ctx.getBotRunning()
|
: !ctx.getBotRunning()
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ export interface BotStats {
|
|||||||
redditUsername: string;
|
redditUsername: string;
|
||||||
runtimeState: 'created' | 'starting' | 'running' | 'stopping' | 'stopped' | 'failed';
|
runtimeState: 'created' | 'starting' | 'running' | 'stopping' | 'stopped' | 'failed';
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
sendingState: 'ready' | 'bot-stopped' | 'runtime-stopped' | 'runtime-failed' | 'login-required' | 'matrix-cooldown' | 'replies-prioritized' | 'pacing';
|
sendingState: 'ready' | 'bot-stopped' | 'runtime-stopped' | 'runtime-failed' | 'login-required' | 'matrix-cooldown' | 'replies-prioritized' | 'pacing' | 'unestablished';
|
||||||
messagesThisHour: number;
|
messagesThisHour: number;
|
||||||
maxMessagesPerHour: number;
|
maxMessagesPerHour: number;
|
||||||
nextMessageAvailableAt: number | null;
|
nextMessageAvailableAt: number | null;
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import {
|
||||||
|
RedditAccountEstablishmentRepository,
|
||||||
|
RedditAccountsRepository,
|
||||||
|
} from '../src/database/account-repositories.js';
|
||||||
|
import { runMigrations } from '../src/database/client.js';
|
||||||
|
import {
|
||||||
|
AccountEstablishmentService,
|
||||||
|
evaluateAccountProfile,
|
||||||
|
isUnestablishedFailure,
|
||||||
|
type AccountEstablishmentSettings,
|
||||||
|
} from '../src/services/account-establishment.js';
|
||||||
|
import { RedditAccountsService } from '../src/services/reddit-accounts.js';
|
||||||
|
import type { RedditAccountProfile } from '../src/reddit/api.js';
|
||||||
|
import { RedditRateLimitError } from '../src/reddit/api.js';
|
||||||
|
import { createMockLogger } from './mockLogger.js';
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60_000;
|
||||||
|
const NOW = 1_700_000_000_000;
|
||||||
|
const ACCOUNT_ID = '@t2_new:reddit.com';
|
||||||
|
|
||||||
|
const settings: AccountEstablishmentSettings = {
|
||||||
|
enabled: true,
|
||||||
|
profileCheckIntervalMs: 6 * 60 * 60_000,
|
||||||
|
minAccountAgeDays: 7,
|
||||||
|
minTotalKarma: 20,
|
||||||
|
minCommentKarma: 5,
|
||||||
|
requireVerifiedEmail: false,
|
||||||
|
probeRetryBaseMs: 4 * 60 * 60_000,
|
||||||
|
probeRetryMaxMs: 48 * 60 * 60_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
function profile(overrides: Partial<RedditAccountProfile> = {}): RedditAccountProfile {
|
||||||
|
return {
|
||||||
|
createdAt: NOW - 30 * DAY_MS,
|
||||||
|
linkKarma: 100,
|
||||||
|
commentKarma: 50,
|
||||||
|
totalKarma: 150,
|
||||||
|
hasVerifiedEmail: true,
|
||||||
|
isSuspended: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('unestablished failure classification', () => {
|
||||||
|
it('recognises the Reddit codes and messages that mean the account is too new', () => {
|
||||||
|
expect(isUnestablishedFailure(null, 'rate.invitation_limit_new_account')).toBe(true);
|
||||||
|
expect(isUnestablishedFailure(null, 'rate.invitation_limit_immature_account')).toBe(true);
|
||||||
|
expect(isUnestablishedFailure('Your account is too new to start chats', null)).toBe(true);
|
||||||
|
expect(isUnestablishedFailure('You need more karma to message this user', null)).toBe(true);
|
||||||
|
expect(isUnestablishedFailure('Please verify your email address first', null)).toBe(true);
|
||||||
|
expect(isUnestablishedFailure('New accounts cannot start chats', null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves ordinary throttles and recipient-side refusals alone', () => {
|
||||||
|
// This code is the throttle applied to accounts that *are* established.
|
||||||
|
expect(isUnestablishedFailure(
|
||||||
|
'Reddit is throttling new chat invitations; replies in existing chats can continue.',
|
||||||
|
'rate.invitation_limit_mature_account',
|
||||||
|
)).toBe(false);
|
||||||
|
expect(isUnestablishedFailure('Recipient does not accept chat requests', null)).toBe(false);
|
||||||
|
expect(isUnestablishedFailure('Reddit user is unavailable', null)).toBe(false);
|
||||||
|
expect(isUnestablishedFailure('Reddit chat invitation failed temporarily', 'rate.room_creation_limit')).toBe(false);
|
||||||
|
expect(isUnestablishedFailure(null, null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('profile evaluation', () => {
|
||||||
|
it('reports every threshold the account falls short of', () => {
|
||||||
|
const verdict = evaluateAccountProfile({
|
||||||
|
accountCreatedAt: NOW - 2 * DAY_MS,
|
||||||
|
linkKarma: 1,
|
||||||
|
commentKarma: 1,
|
||||||
|
totalKarma: 2,
|
||||||
|
hasVerifiedEmail: false,
|
||||||
|
}, { ...settings, requireVerifiedEmail: true }, NOW);
|
||||||
|
|
||||||
|
expect(verdict.established).toBe(false);
|
||||||
|
expect(verdict.reasons).toEqual([
|
||||||
|
'Account is 2 days old (needs 7 days)',
|
||||||
|
'Total karma is 2 (needs 20)',
|
||||||
|
'Comment karma is 1 (needs 5)',
|
||||||
|
'Email address is not verified',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes an account that clears every threshold', () => {
|
||||||
|
const verdict = evaluateAccountProfile({
|
||||||
|
accountCreatedAt: NOW - 30 * DAY_MS,
|
||||||
|
linkKarma: 100,
|
||||||
|
commentKarma: 50,
|
||||||
|
totalKarma: 150,
|
||||||
|
hasVerifiedEmail: true,
|
||||||
|
}, settings, NOW);
|
||||||
|
|
||||||
|
expect(verdict).toEqual({ established: true, reasons: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never counts a missing value against the account', () => {
|
||||||
|
const verdict = evaluateAccountProfile({
|
||||||
|
accountCreatedAt: null,
|
||||||
|
linkKarma: null,
|
||||||
|
commentKarma: null,
|
||||||
|
totalKarma: null,
|
||||||
|
hasVerifiedEmail: null,
|
||||||
|
}, { ...settings, requireVerifiedEmail: true }, NOW);
|
||||||
|
|
||||||
|
expect(verdict).toEqual({ established: true, reasons: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AccountEstablishmentService', () => {
|
||||||
|
let db: Database.Database;
|
||||||
|
let repository: RedditAccountEstablishmentRepository;
|
||||||
|
let now: number;
|
||||||
|
|
||||||
|
function createService(
|
||||||
|
getAccountProfile: (username: string) => Promise<RedditAccountProfile | null>,
|
||||||
|
overrides: Partial<AccountEstablishmentSettings> = {},
|
||||||
|
) {
|
||||||
|
return new AccountEstablishmentService(
|
||||||
|
ACCOUNT_ID,
|
||||||
|
'new-account',
|
||||||
|
{ getAccountProfile },
|
||||||
|
repository,
|
||||||
|
{ ...settings, ...overrides },
|
||||||
|
createMockLogger(),
|
||||||
|
{ isFullyAuthenticated: () => true, invalidate: () => undefined },
|
||||||
|
() => now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
now = NOW;
|
||||||
|
db = new Database(':memory:');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
runMigrations(db);
|
||||||
|
new RedditAccountsRepository(db).upsertVerified({
|
||||||
|
accountId: ACCOUNT_ID,
|
||||||
|
redditUsername: 'new-account',
|
||||||
|
matrixUserId: ACCOUNT_ID,
|
||||||
|
cookiesJson: '[]',
|
||||||
|
matrixCredentialsJson: '{}',
|
||||||
|
now: NOW,
|
||||||
|
});
|
||||||
|
repository = new RedditAccountEstablishmentRepository(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => db.close());
|
||||||
|
|
||||||
|
it('allows sending until something says otherwise', () => {
|
||||||
|
const service = createService(async () => profile());
|
||||||
|
|
||||||
|
expect(service.isSendable()).toBe(true);
|
||||||
|
expect(service.snapshot()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags a brand new account from its profile and stores the evidence', async () => {
|
||||||
|
const service = createService(async () => profile({
|
||||||
|
createdAt: NOW - DAY_MS,
|
||||||
|
linkKarma: 0,
|
||||||
|
commentKarma: 0,
|
||||||
|
totalKarma: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const record = await service.checkNow();
|
||||||
|
|
||||||
|
expect(record).toMatchObject({
|
||||||
|
state: 'unestablished',
|
||||||
|
accountCreatedAt: NOW - DAY_MS,
|
||||||
|
linkKarma: 0,
|
||||||
|
commentKarma: 0,
|
||||||
|
totalKarma: 0,
|
||||||
|
hasVerifiedEmail: true,
|
||||||
|
profileCheckedAt: NOW,
|
||||||
|
probeFailureCount: 0,
|
||||||
|
});
|
||||||
|
expect(record!.reasons).toContain('Account is 1 day old (needs 7 days)');
|
||||||
|
expect(service.isSendable()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the flag once the account has aged and earned karma', async () => {
|
||||||
|
let current = profile({ createdAt: NOW - DAY_MS, linkKarma: 0, commentKarma: 0, totalKarma: 0 });
|
||||||
|
const service = createService(async () => current);
|
||||||
|
|
||||||
|
await service.checkNow();
|
||||||
|
expect(service.isSendable()).toBe(false);
|
||||||
|
|
||||||
|
now = NOW + 10 * DAY_MS;
|
||||||
|
current = profile({ createdAt: NOW - DAY_MS, commentKarma: 30, totalKarma: 60 });
|
||||||
|
const record = await service.checkNow();
|
||||||
|
|
||||||
|
expect(record).toMatchObject({ state: 'established', reasons: [] });
|
||||||
|
expect(service.isSendable()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds the account out of sending when Reddit rejects an invitation as too new', () => {
|
||||||
|
const service = createService(async () => profile());
|
||||||
|
|
||||||
|
const record = service.recordSendFailure(
|
||||||
|
'Reddit chat invitation failed temporarily',
|
||||||
|
'rate.invitation_limit_new_account',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(record).toMatchObject({ state: 'unestablished', probeFailureCount: 1 });
|
||||||
|
expect(record!.retryAt).toBe(NOW + settings.probeRetryBaseMs);
|
||||||
|
expect(record!.lastProbeFailure).toContain('rate.invitation_limit_new_account');
|
||||||
|
expect(service.isSendable()).toBe(false);
|
||||||
|
|
||||||
|
now = NOW + settings.probeRetryBaseMs;
|
||||||
|
expect(service.isSendable()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores failures that are not about how established the account is', () => {
|
||||||
|
const service = createService(async () => profile());
|
||||||
|
|
||||||
|
expect(service.recordSendFailure('Recipient does not accept chat requests', null)).toBeNull();
|
||||||
|
expect(service.snapshot()).toBeNull();
|
||||||
|
expect(service.isSendable()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backs off further on each repeated rejection, up to the configured ceiling', () => {
|
||||||
|
const service = createService(async () => profile(), { probeRetryMaxMs: 6 * 60 * 60_000 });
|
||||||
|
|
||||||
|
expect(service.recordSendFailure('account is too new')!.retryAt).toBe(NOW + 4 * 60 * 60_000);
|
||||||
|
expect(service.recordSendFailure('account is too new')!.retryAt).toBe(NOW + 6 * 60 * 60_000);
|
||||||
|
expect(service.recordSendFailure('account is too new')).toMatchObject({
|
||||||
|
probeFailureCount: 3,
|
||||||
|
retryAt: NOW + 6 * 60 * 60_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a delivered invitation as proof the account is established', () => {
|
||||||
|
const service = createService(async () => profile());
|
||||||
|
service.recordSendFailure('account is too new');
|
||||||
|
|
||||||
|
const record = service.recordSendSuccess();
|
||||||
|
|
||||||
|
expect(record).toMatchObject({
|
||||||
|
state: 'established',
|
||||||
|
reasons: [],
|
||||||
|
probeFailureCount: 0,
|
||||||
|
retryAt: null,
|
||||||
|
lastProbeFailure: null,
|
||||||
|
});
|
||||||
|
expect(service.isSendable()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a rejected account held even when its profile now looks acceptable', async () => {
|
||||||
|
const service = createService(async () => profile());
|
||||||
|
service.recordSendFailure('account is too new');
|
||||||
|
|
||||||
|
now = NOW + 60_000;
|
||||||
|
const record = await service.checkNow();
|
||||||
|
|
||||||
|
expect(record).toMatchObject({ state: 'unestablished', retryAt: NOW + settings.probeRetryBaseMs });
|
||||||
|
expect(record!.totalKarma).toBe(150);
|
||||||
|
expect(service.isSendable()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the stored verdict untouched when Reddit rate-limits the profile lookup', async () => {
|
||||||
|
const service = createService(async () => { throw new RedditRateLimitError(60_000); });
|
||||||
|
|
||||||
|
expect(await service.checkNow()).toBeNull();
|
||||||
|
expect(service.snapshot()).toBeNull();
|
||||||
|
expect(service.isSendable()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing at all when establishment tracking is disabled', async () => {
|
||||||
|
const getAccountProfile = vi.fn(async () => profile({ createdAt: NOW }));
|
||||||
|
const service = createService(getAccountProfile, { enabled: false });
|
||||||
|
|
||||||
|
service.start();
|
||||||
|
expect(await service.checkNow()).toBeNull();
|
||||||
|
expect(service.recordSendFailure('account is too new')).toBeNull();
|
||||||
|
expect(getAccountProfile).not.toHaveBeenCalled();
|
||||||
|
expect(service.isSendable()).toBe(true);
|
||||||
|
service.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes the stored evidence through the account listing the dashboard reads', async () => {
|
||||||
|
const accounts = new RedditAccountsService(
|
||||||
|
new RedditAccountsRepository(db),
|
||||||
|
{} as never,
|
||||||
|
{ getSnapshot: () => ({ accountId: ACCOUNT_ID }) } as never,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
repository,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(accounts.list()[0].establishment).toBeNull();
|
||||||
|
|
||||||
|
// Age is reported relative to the wall clock the dashboard renders in.
|
||||||
|
await createService(async () => profile({
|
||||||
|
createdAt: Date.now() - 3 * DAY_MS,
|
||||||
|
linkKarma: 1,
|
||||||
|
commentKarma: 2,
|
||||||
|
totalKarma: 3,
|
||||||
|
hasVerifiedEmail: false,
|
||||||
|
})).checkNow();
|
||||||
|
|
||||||
|
expect(accounts.list()[0].establishment).toMatchObject({
|
||||||
|
state: 'unestablished',
|
||||||
|
accountAgeDays: 3,
|
||||||
|
linkKarma: 1,
|
||||||
|
commentKarma: 2,
|
||||||
|
totalKarma: 3,
|
||||||
|
hasVerifiedEmail: false,
|
||||||
|
profileCheckedAt: NOW,
|
||||||
|
retryAt: NOW + settings.profileCheckIntervalMs,
|
||||||
|
});
|
||||||
|
expect(accounts.list()[0].establishment!.reasons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips the check while the account session is not authenticated', async () => {
|
||||||
|
const getAccountProfile = vi.fn(async () => profile());
|
||||||
|
const service = new AccountEstablishmentService(
|
||||||
|
ACCOUNT_ID,
|
||||||
|
'new-account',
|
||||||
|
{ getAccountProfile },
|
||||||
|
repository,
|
||||||
|
settings,
|
||||||
|
createMockLogger(),
|
||||||
|
{ isFullyAuthenticated: () => false, invalidate: () => undefined },
|
||||||
|
() => now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await service.checkNow()).toBeNull();
|
||||||
|
expect(getAccountProfile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,20 @@ const account = {
|
|||||||
lastImportedAt: 2,
|
lastImportedAt: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const establishment = {
|
||||||
|
state: 'unestablished' as const,
|
||||||
|
reasons: ['Account is 2 days old (needs 7 days)', 'Total karma is 4 (needs 20)'],
|
||||||
|
accountAgeDays: 2,
|
||||||
|
linkKarma: 1,
|
||||||
|
commentKarma: 3,
|
||||||
|
totalKarma: 4,
|
||||||
|
hasVerifiedEmail: false,
|
||||||
|
profileCheckedAt: 1_700_000_000_000,
|
||||||
|
lastProbeAt: null,
|
||||||
|
lastProbeFailure: null,
|
||||||
|
retryAt: Date.now() + 4 * 60 * 60_000,
|
||||||
|
};
|
||||||
|
|
||||||
const server = setupServer(
|
const server = setupServer(
|
||||||
http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: false, status: 'login-required' })),
|
http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: false, status: 'login-required' })),
|
||||||
http.get('/api/accounts', () => HttpResponse.json([])),
|
http.get('/api/accounts', () => HttpResponse.json([])),
|
||||||
@@ -81,6 +95,56 @@ describe('accounts page', () => {
|
|||||||
expect(screen.getByText('Waiting for a refreshed session')).toBeInTheDocument();
|
expect(screen.getByText('Waiting for a refreshed session')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('explains why an unestablished account is not sending, with the profile evidence', async () => {
|
||||||
|
server.use(http.get('/api/accounts', () => HttpResponse.json([{ ...account, establishment }])));
|
||||||
|
renderAccounts();
|
||||||
|
|
||||||
|
expect(await screen.findByText('u/example-account is not established yet.')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Reddit will not accept chat invitations from a new account/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Not established')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(
|
||||||
|
'Account is 2 days old (needs 7 days); Total karma is 4 (needs 20)',
|
||||||
|
)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Next re-test in about 4 hours/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
const metrics = screen.getByText('Comment karma').closest('dl')!;
|
||||||
|
expect(within(metrics).getByText('2 days')).toBeInTheDocument();
|
||||||
|
expect(within(metrics).getByText('3')).toBeInTheDocument();
|
||||||
|
expect(within(metrics).getByText('No')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks an account that has cleared the thresholds as established', async () => {
|
||||||
|
server.use(http.get('/api/accounts', () => HttpResponse.json([{
|
||||||
|
...account,
|
||||||
|
establishment: {
|
||||||
|
...establishment,
|
||||||
|
state: 'established',
|
||||||
|
reasons: [],
|
||||||
|
accountAgeDays: 400,
|
||||||
|
commentKarma: 900,
|
||||||
|
totalKarma: 1_200,
|
||||||
|
hasVerifiedEmail: true,
|
||||||
|
retryAt: null,
|
||||||
|
},
|
||||||
|
}])));
|
||||||
|
renderAccounts();
|
||||||
|
|
||||||
|
expect(await screen.findByText('Established')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/not established yet/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Outbound sending is on hold/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1,200')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no establishment detail before the first check has run', async () => {
|
||||||
|
server.use(http.get('/api/accounts', () => HttpResponse.json([{ ...account, establishment: null }])));
|
||||||
|
renderAccounts();
|
||||||
|
|
||||||
|
expect(await screen.findByRole('link', { name: 'u/example-account' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Not established')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Established')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Comment karma')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('says plainly when no accounts have been added', async () => {
|
it('says plainly when no accounts have been added', async () => {
|
||||||
renderAccounts();
|
renderAccounts();
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ describe('Database Client', () => {
|
|||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||||
.all() as { name: string }[];
|
.all() as { name: string }[];
|
||||||
// Existing domain/config tables plus account, conversation, sync and media ownership stores.
|
// Existing domain/config tables plus account, conversation, sync and media ownership stores.
|
||||||
expect(tables.length).toBe(22);
|
expect(tables.length).toBe(23);
|
||||||
expect(tables.map((table) => table.name)).toEqual(expect.arrayContaining([
|
expect(tables.map((table) => table.name)).toEqual(expect.arrayContaining([
|
||||||
'candidate_queue',
|
'candidate_queue',
|
||||||
'runtime_state',
|
'runtime_state',
|
||||||
@@ -115,6 +115,7 @@ describe('Database Client', () => {
|
|||||||
'media_library_sends',
|
'media_library_sends',
|
||||||
'conversation_read_state',
|
'conversation_read_state',
|
||||||
'reddit_recipient_account_status',
|
'reddit_recipient_account_status',
|
||||||
|
'reddit_account_establishment',
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -240,6 +240,7 @@ describe('account runtime isolation', () => {
|
|||||||
conversationAutomation: { hasActionableReplies },
|
conversationAutomation: { hasActionableReplies },
|
||||||
chatService: { getInvitationCooldown: () => null },
|
chatService: { getInvitationCooldown: () => null },
|
||||||
rateLimiter: { getTimers: () => ({ nextMessageAvailableAt: null }) },
|
rateLimiter: { getTimers: () => ({ nextMessageAvailableAt: null }) },
|
||||||
|
establishmentService: { isSendable: () => true },
|
||||||
} as unknown as RedditAccountRuntimeResources,
|
} as unknown as RedditAccountRuntimeResources,
|
||||||
{ start: vi.fn(), stop: vi.fn() },
|
{ start: vi.fn(), stop: vi.fn() },
|
||||||
);
|
);
|
||||||
@@ -250,6 +251,37 @@ describe('account runtime isolation', () => {
|
|||||||
expect(await runtime.canAcceptPost()).toBe(true);
|
expect(await runtime.canAcceptPost()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not accept new outreach while the account is not established yet', async () => {
|
||||||
|
const stored = account('too_new');
|
||||||
|
const isSendable = vi.fn().mockReturnValue(false);
|
||||||
|
const runtime = new RedditAccountRuntime(
|
||||||
|
{
|
||||||
|
accountId: stored.accountId,
|
||||||
|
redditUsername: stored.redditUsername,
|
||||||
|
matrixUserId: stored.matrixUserId!,
|
||||||
|
cookies: [],
|
||||||
|
matrixCredentials: JSON.parse(stored.matrixCredentialsJson),
|
||||||
|
updatedAt: stored.updatedAt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountAuth: {
|
||||||
|
isFullyAuthenticated: () => true,
|
||||||
|
ensureFullyAuthenticated: async () => true,
|
||||||
|
},
|
||||||
|
conversationAutomation: { hasActionableReplies: vi.fn().mockResolvedValue(false) },
|
||||||
|
chatService: { getInvitationCooldown: () => null },
|
||||||
|
rateLimiter: { getTimers: () => ({ nextMessageAvailableAt: null }) },
|
||||||
|
establishmentService: { isSendable },
|
||||||
|
} as unknown as RedditAccountRuntimeResources,
|
||||||
|
{ start: vi.fn(), stop: vi.fn() },
|
||||||
|
);
|
||||||
|
await runtime.start();
|
||||||
|
|
||||||
|
expect(await runtime.canAcceptPost()).toBe(false);
|
||||||
|
isSendable.mockReturnValue(true);
|
||||||
|
expect(await runtime.canAcceptPost()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('uses the Reddit fullname for auth while retaining the Matrix runtime owner', () => {
|
it('uses the Reddit fullname for auth while retaining the Matrix runtime owner', () => {
|
||||||
expect(runtimeAuthenticatedAccount(
|
expect(runtimeAuthenticatedAccount(
|
||||||
{ matrixUserId: '@t2_1u57iqxxbv:reddit.com' },
|
{ matrixUserId: '@t2_1u57iqxxbv:reddit.com' },
|
||||||
|
|||||||
@@ -622,6 +622,65 @@ describe('RedditApiClient', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getAccountProfile', () => {
|
||||||
|
it('reads age, karma and email verification for one of our own accounts', async () => {
|
||||||
|
const mockAxios = { get: vi.fn() };
|
||||||
|
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
|
||||||
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||||
|
|
||||||
|
mockAxios.get.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
data: {
|
||||||
|
created_utc: 1_700_000_000,
|
||||||
|
link_karma: 12,
|
||||||
|
comment_karma: 30,
|
||||||
|
total_karma: 42,
|
||||||
|
has_verified_email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client2.getAccountProfile('new-account')).resolves.toEqual({
|
||||||
|
createdAt: 1_700_000_000_000,
|
||||||
|
linkKarma: 12,
|
||||||
|
commentKarma: 30,
|
||||||
|
totalKarma: 42,
|
||||||
|
hasVerifiedEmail: true,
|
||||||
|
isSuspended: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives total karma when Reddit omits it and leaves absent fields null', async () => {
|
||||||
|
const mockAxios = { get: vi.fn() };
|
||||||
|
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
|
||||||
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||||
|
|
||||||
|
mockAxios.get.mockResolvedValueOnce({ data: { data: { link_karma: 3, comment_karma: 4 } } });
|
||||||
|
|
||||||
|
await expect(client2.getAccountProfile('new-account')).resolves.toEqual({
|
||||||
|
createdAt: null,
|
||||||
|
linkKarma: 3,
|
||||||
|
commentKarma: 4,
|
||||||
|
totalKarma: 7,
|
||||||
|
hasVerifiedEmail: null,
|
||||||
|
isSuspended: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a missing profile as null and still propagates real failures', async () => {
|
||||||
|
const mockAxios = { get: vi.fn() };
|
||||||
|
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
|
||||||
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||||
|
|
||||||
|
mockAxios.get.mockRejectedValueOnce({ isAxiosError: true, response: { status: 404 } });
|
||||||
|
await expect(client2.getAccountProfile('gone')).resolves.toBeNull();
|
||||||
|
|
||||||
|
const temporary = { isAxiosError: true, response: { status: 503 } };
|
||||||
|
mockAxios.get.mockRejectedValueOnce(temporary);
|
||||||
|
await expect(client2.getAccountProfile('new-account')).rejects.toBe(temporary);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getNewPosts', () => {
|
describe('getNewPosts', () => {
|
||||||
it('throws a permanent-disable signal for a plain 404 subreddit', async () => {
|
it('throws a permanent-disable signal for a plain 404 subreddit', async () => {
|
||||||
const error = { isAxiosError: true, response: { status: 404 } };
|
const error = { isAxiosError: true, response: { status: 404 } };
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { AttentionResponseSchema } from '../../packages/api-contracts/src/index.js';
|
||||||
|
import type { WebContext } from '../../src/web/context.js';
|
||||||
|
import { createAttentionRouter } from '../../src/web/routes/attention.js';
|
||||||
|
|
||||||
|
const servers: Array<ReturnType<express.Express['listen']>> = [];
|
||||||
|
|
||||||
|
async function get(context: WebContext, path: string): Promise<Response> {
|
||||||
|
const app = express();
|
||||||
|
app.use('/api', createAttentionRouter(context));
|
||||||
|
const server = app.listen(0);
|
||||||
|
servers.push(server);
|
||||||
|
await new Promise<void>((resolve) => server.once('listening', resolve));
|
||||||
|
const { port } = server.address() as AddressInfo;
|
||||||
|
return fetch(`http://127.0.0.1:${port}${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
|
||||||
|
});
|
||||||
|
|
||||||
|
function contextWith(accounts: unknown[]): WebContext {
|
||||||
|
return {
|
||||||
|
services: {
|
||||||
|
redditAccounts: { list: () => accounts },
|
||||||
|
accountRuntimes: { list: () => [], get: () => null },
|
||||||
|
},
|
||||||
|
} as unknown as WebContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOW = 1_700_000_000_000;
|
||||||
|
const account = {
|
||||||
|
accountId: '@t2_new:reddit.com',
|
||||||
|
redditUsername: 'new-account',
|
||||||
|
status: 'authenticated',
|
||||||
|
updatedAt: NOW,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('attention route establishment items', () => {
|
||||||
|
it('raises an unestablished account with its reasons and hold window', async () => {
|
||||||
|
const response = await get(contextWith([{
|
||||||
|
...account,
|
||||||
|
establishment: {
|
||||||
|
state: 'unestablished',
|
||||||
|
reasons: ['Account is 2 days old (needs 7 days)'],
|
||||||
|
retryAt: NOW + 4 * 60 * 60_000,
|
||||||
|
profileCheckedAt: NOW,
|
||||||
|
lastProbeAt: null,
|
||||||
|
},
|
||||||
|
}]), '/api/attention');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = AttentionResponseSchema.parse(await response.json());
|
||||||
|
expect(body.items).toHaveLength(1);
|
||||||
|
expect(body.items[0]).toMatchObject({
|
||||||
|
id: 'establishment:@t2_new:reddit.com',
|
||||||
|
kind: 'account-unestablished',
|
||||||
|
severity: 'warning',
|
||||||
|
title: 'u/new-account is not established yet',
|
||||||
|
occurredAt: NOW,
|
||||||
|
owner: { accountId: account.accountId, redditUsername: 'new-account' },
|
||||||
|
});
|
||||||
|
expect(body.items[0].detail).toContain('Account is 2 days old (needs 7 days)');
|
||||||
|
expect(body.items[0].detail).toContain('Outbound sending is on hold');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays quiet for established accounts and for accounts never checked', async () => {
|
||||||
|
const response = await get(contextWith([
|
||||||
|
{ ...account, establishment: { state: 'established', reasons: [], retryAt: null, profileCheckedAt: NOW, lastProbeAt: null } },
|
||||||
|
{ ...account, accountId: '@t2_other:reddit.com', redditUsername: 'other', establishment: null },
|
||||||
|
]), '/api/attention');
|
||||||
|
|
||||||
|
const body = AttentionResponseSchema.parse(await response.json());
|
||||||
|
expect(body.items).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an unestablished account that also needs a new session as two separate items', async () => {
|
||||||
|
const response = await get(contextWith([{
|
||||||
|
...account,
|
||||||
|
status: 'login-required',
|
||||||
|
establishment: {
|
||||||
|
state: 'unestablished',
|
||||||
|
reasons: [],
|
||||||
|
retryAt: null,
|
||||||
|
profileCheckedAt: NOW,
|
||||||
|
lastProbeAt: null,
|
||||||
|
},
|
||||||
|
}]), '/api/attention');
|
||||||
|
|
||||||
|
const body = AttentionResponseSchema.parse(await response.json());
|
||||||
|
expect(body.items.map((item) => item.kind).sort())
|
||||||
|
.toEqual(['account-login-required', 'account-unestablished']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user