diff --git a/apps/dashboard/src/features/accounts/accounts-page.tsx b/apps/dashboard/src/features/accounts/accounts-page.tsx index 0e6178b..3722abd 100644 --- a/apps/dashboard/src/features/accounts/accounts-page.tsx +++ b/apps/dashboard/src/features/accounts/accounts-page.tsx @@ -32,25 +32,36 @@ function retryLabel(retryAt: number | null): string { } /** - * 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. + * Two different situations share this panel: Reddit actively refusing + * invitations from the account (sending is on hold), and the account merely + * sitting below our own configured thresholds (nothing is blocked). Conflating + * them reads as a broken account when it is only a soft signal. */ function EstablishmentDetail({ establishment }: { establishment: AccountEstablishmentSummary }) { - const isUnestablished = establishment.state === 'unestablished'; + const belowThresholds = establishment.state === 'unestablished' && !establishment.sendingHeld; return (
- {isUnestablished ? ( + {establishment.sendingHeld ? ( <>

- {establishment.reasons.length > 0 - ? establishment.reasons.join('; ') - : 'Reddit will not accept chat invitations from this account yet.'} + {establishment.lastProbeFailure + ?? 'Reddit rejected an invitation from this account as unestablished.'}

Outbound sending is on hold. {retryLabel(establishment.retryAt)}

) : null} - {establishment.lastProbeFailure && !isUnestablished ? ( + {belowThresholds ? ( + <> +

+ {establishment.reasons.join('; ')} +

+

+ Still sending normally — these are our own configured thresholds, not limits Reddit + publishes. The account is only held back if Reddit actually rejects an invitation. +

+ + ) : null} + {establishment.lastProbeFailure && !establishment.sendingHeld ? (

Last rejection: {establishment.lastProbeFailure}

) : null}
@@ -69,9 +80,9 @@ function EstablishmentDetail({ establishment }: { establishment: AccountEstablis function establishmentPill(establishment: AccountEstablishmentSummary | null | undefined) { if (!establishment || establishment.state === 'unknown') return null; - return establishment.state === 'unestablished' - ? Not established - : Established; + if (establishment.sendingHeld) return Sending on hold; + if (establishment.state === 'unestablished') return Below thresholds; + return Established; } function sessionLabel(status: ReturnType['data']): string { @@ -120,7 +131,7 @@ export function AccountsPage() { : null; const accounts = accountsQuery.data ?? []; const pausedCount = accounts.filter((account) => !account.isRunning).length; - const unestablished = accounts.filter((account) => account.establishment?.state === 'unestablished'); + const heldAccounts = accounts.filter((account) => account.establishment?.sendingHeld); return (
@@ -146,16 +157,16 @@ export function AccountsPage() {
) : null} - {unestablished.length > 0 ? ( + {heldAccounts.length > 0 ? (
- {unestablished.length === 1 - ? `u/${unestablished[0].redditUsername} is not established yet.` - : `${unestablished.length} accounts are not established yet.`} + {heldAccounts.length === 1 + ? `u/${heldAccounts[0].redditUsername} cannot start chats yet.` + : `${heldAccounts.length} accounts cannot start chats yet.`} - 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. + Reddit rejected an invitation from a new account, so outbound sending is on hold for it. + The bot re-tests periodically and resumes on its own.
) : null} diff --git a/packages/api-contracts/src/session.ts b/packages/api-contracts/src/session.ts index 638429c..10e0c43 100644 --- a/packages/api-contracts/src/session.ts +++ b/packages/api-contracts/src/session.ts @@ -13,6 +13,8 @@ export const AccountEstablishmentStateSchema = z.union([ ]); export const AccountEstablishmentSummarySchema = z.strictObject({ state: AccountEstablishmentStateSchema, + /** True only when Reddit rejected an invitation; profile shortfalls do not hold sending. */ + sendingHeld: z.boolean(), reasons: z.array(z.string()), accountAgeDays: z.number().nullable(), linkKarma: z.number().nullable(), diff --git a/src/services/account-establishment.ts b/src/services/account-establishment.ts index e4d23d3..1d34571 100644 --- a/src/services/account-establishment.ts +++ b/src/services/account-establishment.ts @@ -86,15 +86,16 @@ export function evaluateAccountProfile( const ageDays = (now - metrics.accountCreatedAt) / DAY_MS; if (ageDays < thresholds.minAccountAgeDays) { reasons.push( - `Account is ${formatDays(ageDays)} old (needs ${formatDays(thresholds.minAccountAgeDays)})`, + `Account is ${formatDays(ageDays)} old` + + ` (below the configured floor of ${formatDays(thresholds.minAccountAgeDays)})`, ); } } if (metrics.totalKarma !== null && metrics.totalKarma < thresholds.minTotalKarma) { - reasons.push(`Total karma is ${metrics.totalKarma} (needs ${thresholds.minTotalKarma})`); + reasons.push(`Total karma is ${metrics.totalKarma} (below the configured floor of ${thresholds.minTotalKarma})`); } if (metrics.commentKarma !== null && metrics.commentKarma < thresholds.minCommentKarma) { - reasons.push(`Comment karma is ${metrics.commentKarma} (needs ${thresholds.minCommentKarma})`); + reasons.push(`Comment karma is ${metrics.commentKarma} (below the configured floor of ${thresholds.minCommentKarma})`); } if (thresholds.requireVerifiedEmail && metrics.hasVerifiedEmail === false) { reasons.push('Email address is not verified'); @@ -115,11 +116,15 @@ function formatDays(days: number): string { * 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. + * Only one of the two signals may stop an account from sending. A rejected + * invitation is authoritative — Reddit has told us directly — so it holds the + * account with an escalating backoff and re-tests when the backoff expires. + * + * The profile check (age, karma, verified email) is advisory only. Reddit does + * not publish the thresholds its chat gate actually applies, so ours are a + * guess; letting a guess hold an account back would also stop it ever + * attempting the invitation that would prove the guess wrong. It reports + * shortfalls and nothing more. */ export class AccountEstablishmentService { private intervalHandle: NodeJS.Timeout | null = null; @@ -163,15 +168,14 @@ export class AccountEstablishmentService { } /** - * 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. + * Whether the account may attempt an outbound invitation. Only a live Reddit + * rejection inside its backoff window holds it back. A profile shortfall + * never does: the account has to be allowed to try, or the authoritative + * signal can never arrive. */ 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; + return !this.heldByProbe(this.repository.get(this.accountId)); } /** Refresh the profile snapshot and re-derive the verdict from it. */ @@ -210,17 +214,20 @@ export class AccountEstablishmentService { return record; } + // retryAt belongs to the probe backoff alone. A profile shortfall has no + // hold to expire, so it leaves the field null. 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, + retryAt: null, }); if (!verdict.established && previous?.state !== 'unestablished') { - this.logger.warn( - `u/${this.redditUsername} is not established yet: ${verdict.reasons.join('; ')}`, + this.logger.info( + `u/${this.redditUsername} is below the configured establishment thresholds` + + ` but is still allowed to send: ${verdict.reasons.join('; ')}`, ); } if (verdict.established && previous && previous.state !== 'established') { diff --git a/src/services/reddit-accounts.ts b/src/services/reddit-accounts.ts index 4cb45aa..2553954 100644 --- a/src/services/reddit-accounts.ts +++ b/src/services/reddit-accounts.ts @@ -27,6 +27,12 @@ const StoredMatrixCredentialsSchema = z.object({ /** How established the account looks, and the evidence behind that. */ export interface RedditAccountEstablishmentSummary { state: AccountEstablishmentState; + /** + * Whether outbound sending is actually on hold. True only when Reddit itself + * rejected an invitation; falling below the configured profile thresholds is + * advisory and never stops the account sending. + */ + sendingHeld: boolean; reasons: string[]; accountAgeDays: number | null; linkKarma: number | null; @@ -123,6 +129,9 @@ export class RedditAccountsService { if (!record) return null; return { state: record.state, + sendingHeld: record.probeFailureCount > 0 + && record.retryAt !== null + && Date.now() < record.retryAt, reasons: record.reasons, accountAgeDays: record.accountCreatedAt === null ? null diff --git a/src/web/routes/attention.ts b/src/web/routes/attention.ts index 6695940..5385411 100644 --- a/src/web/routes/attention.ts +++ b/src/web/routes/attention.ts @@ -21,17 +21,19 @@ export function createAttentionRouter(ctx: WebContext): Router { const accounts = ctx.services.redditAccounts?.list() ?? []; for (const account of accounts) { const owner = { accountId: account.accountId, redditUsername: account.redditUsername }; - if (account.establishment?.state === 'unestablished') { - const { reasons, retryAt } = account.establishment; + // Only a live Reddit rejection is actionable. Falling below our own + // profile thresholds is advisory and shown on the Accounts page instead. + if (account.establishment?.sendingHeld) { + const { lastProbeFailure, 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.'}` + title: `u/${account.redditUsername} cannot start chats yet`, + detail: `${lastProbeFailure ?? 'Reddit rejected an invitation from this account as unestablished.'}` + ` Outbound sending is on hold${retryAt ? ` until ${new Date(retryAt).toISOString()}` : ''}.`, - occurredAt: account.establishment.profileCheckedAt - ?? account.establishment.lastProbeAt + occurredAt: account.establishment.lastProbeAt + ?? account.establishment.profileCheckedAt ?? account.updatedAt, owner, }); diff --git a/src/web/routes/stats.ts b/src/web/routes/stats.ts index 75f14e0..84a0516 100644 --- a/src/web/routes/stats.ts +++ b/src/web/routes/stats.ts @@ -59,8 +59,7 @@ export function createStatsRouter(ctx: WebContext): Router { ? 'login-required' : runtimeState !== 'running' ? 'runtime-stopped' - : account.establishment?.state === 'unestablished' - && !(account.establishment.retryAt !== null && account.establishment.retryAt <= Date.now()) + : account.establishment?.sendingHeld ? 'unestablished' : accountCooldown ? 'matrix-cooldown' diff --git a/tests/account-establishment.test.ts b/tests/account-establishment.test.ts index ff50a15..635ab44 100644 --- a/tests/account-establishment.test.ts +++ b/tests/account-establishment.test.ts @@ -78,9 +78,9 @@ describe('profile evaluation', () => { 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)', + 'Account is 2 days old (below the configured floor of 7 days)', + 'Total karma is 2 (below the configured floor of 20)', + 'Comment karma is 1 (below the configured floor of 5)', 'Email address is not verified', ]); }); @@ -176,16 +176,18 @@ describe('AccountEstablishmentService', () => { profileCheckedAt: NOW, probeFailureCount: 0, }); - expect(record!.reasons).toContain('Account is 1 day old (needs 7 days)'); - expect(service.isSendable()).toBe(false); + expect(record!.reasons).toContain('Account is 1 day old (below the configured floor of 7 days)'); + // A guessed threshold must never hold the account back: it has to be + // allowed to try, or Reddit's authoritative answer can never arrive. + expect(record!.retryAt).toBeNull(); + expect(service.isSendable()).toBe(true); }); 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); + expect(await service.checkNow()).toMatchObject({ state: 'unestablished' }); now = NOW + 10 * DAY_MS; current = profile({ createdAt: NOW - DAY_MS, commentKarma: 30, totalKarma: 60 }); @@ -302,17 +304,40 @@ describe('AccountEstablishmentService', () => { expect(accounts.list()[0].establishment).toMatchObject({ state: 'unestablished', + // Below our thresholds, but nothing Reddit has refused — so not held. + sendingHeld: false, accountAgeDays: 3, linkKarma: 1, commentKarma: 2, totalKarma: 3, hasVerifiedEmail: false, profileCheckedAt: NOW, - retryAt: NOW + settings.profileCheckIntervalMs, + retryAt: null, }); expect(accounts.list()[0].establishment!.reasons.length).toBeGreaterThan(0); }); + it('marks sending as held only while a Reddit rejection is still in force', () => { + const accounts = new RedditAccountsService( + new RedditAccountsRepository(db), + {} as never, + { getSnapshot: () => ({ accountId: ACCOUNT_ID }) } as never, + undefined, + undefined, + repository, + ); + // The listing derives the hold against the wall clock the dashboard renders + // in, so this account's rejection is recorded there too. + now = Date.now(); + const service = createService(async () => profile()); + + service.recordSendFailure('account is too new'); + expect(accounts.list()[0].establishment).toMatchObject({ state: 'unestablished', sendingHeld: true }); + + service.recordSendSuccess(); + expect(accounts.list()[0].establishment).toMatchObject({ state: 'established', sendingHeld: false }); + }); + it('skips the check while the account session is not authenticated', async () => { const getAccountProfile = vi.fn(async () => profile()); const service = new AccountEstablishmentService( diff --git a/tests/dashboard/accounts.test.tsx b/tests/dashboard/accounts.test.tsx index 9604ac3..d19e1f6 100644 --- a/tests/dashboard/accounts.test.tsx +++ b/tests/dashboard/accounts.test.tsx @@ -23,15 +23,16 @@ const account = { const establishment = { state: 'unestablished' as const, - reasons: ['Account is 2 days old (needs 7 days)', 'Total karma is 4 (needs 20)'], + sendingHeld: true, + reasons: ['Account is 2 days old (below the configured floor of 7 days)'], accountAgeDays: 2, linkKarma: 1, commentKarma: 3, totalKarma: 4, hasVerifiedEmail: false, profileCheckedAt: 1_700_000_000_000, - lastProbeAt: null, - lastProbeFailure: null, + lastProbeAt: 1_700_000_000_000, + lastProbeFailure: 'Reddit rejected an invitation from this account: account is too new (rate.invitation_limit_new_account)', retryAt: Date.now() + 4 * 60 * 60_000, }; @@ -95,16 +96,14 @@ describe('accounts page', () => { expect(screen.getByText('Waiting for a refreshed session')).toBeInTheDocument(); }); - it('explains why an unestablished account is not sending, with the profile evidence', async () => { + it('explains a held account with what Reddit actually said, plus 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(await screen.findByText('u/example-account cannot start chats yet.')).toBeInTheDocument(); + expect(screen.getByText(/Reddit rejected an invitation from a new account/)).toBeInTheDocument(); + expect(screen.getByText('Sending on hold')).toBeInTheDocument(); + expect(screen.getByText(/rate\.invitation_limit_new_account/)).toBeInTheDocument(); expect(screen.getByText(/Next re-test in about 4 hours/)).toBeInTheDocument(); const metrics = screen.getByText('Comment karma').closest('dl')!; @@ -113,12 +112,31 @@ describe('accounts page', () => { expect(within(metrics).getByText('No')).toBeInTheDocument(); }); + it('does not claim an account is blocked when it is only below our own thresholds', async () => { + server.use(http.get('/api/accounts', () => HttpResponse.json([{ + ...account, + establishment: { ...establishment, sendingHeld: false, lastProbeFailure: null, retryAt: null }, + }]))); + renderAccounts(); + + expect(await screen.findByText('Below thresholds')).toBeInTheDocument(); + expect(screen.getByText(/Still sending normally/)).toBeInTheDocument(); + expect(screen.getByText(/not limits Reddit\s+publishes/)).toBeInTheDocument(); + expect(screen.getByText('Account is 2 days old (below the configured floor of 7 days)')).toBeInTheDocument(); + // Nothing may imply sending has stopped. + expect(screen.queryByText(/cannot start chats yet/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Outbound sending is on hold/)).not.toBeInTheDocument(); + expect(screen.queryByText('Sending on hold')).not.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', + sendingHeld: false, + lastProbeFailure: null, reasons: [], accountAgeDays: 400, commentKarma: 900, @@ -130,7 +148,7 @@ describe('accounts page', () => { renderAccounts(); expect(await screen.findByText('Established')).toBeInTheDocument(); - expect(screen.queryByText(/not established yet/)).not.toBeInTheDocument(); + expect(screen.queryByText(/cannot start chats yet/)).not.toBeInTheDocument(); expect(screen.queryByText(/Outbound sending is on hold/)).not.toBeInTheDocument(); expect(screen.getByText('1,200')).toBeInTheDocument(); }); @@ -140,7 +158,8 @@ describe('accounts page', () => { renderAccounts(); expect(await screen.findByRole('link', { name: 'u/example-account' })).toBeInTheDocument(); - expect(screen.queryByText('Not established')).not.toBeInTheDocument(); + expect(screen.queryByText('Sending on hold')).not.toBeInTheDocument(); + expect(screen.queryByText('Below thresholds')).not.toBeInTheDocument(); expect(screen.queryByText('Established')).not.toBeInTheDocument(); expect(screen.queryByText('Comment karma')).not.toBeInTheDocument(); }); diff --git a/tests/web/attention-route.test.ts b/tests/web/attention-route.test.ts index 3297201..89cefaf 100644 --- a/tests/web/attention-route.test.ts +++ b/tests/web/attention-route.test.ts @@ -39,15 +39,17 @@ const account = { }; describe('attention route establishment items', () => { - it('raises an unestablished account with its reasons and hold window', async () => { + it('raises an account Reddit has refused, with what Reddit said and the hold window', async () => { const response = await get(contextWith([{ ...account, establishment: { state: 'unestablished', - reasons: ['Account is 2 days old (needs 7 days)'], + sendingHeld: true, + reasons: ['Reddit rejected an invitation from this account: account is too new'], + lastProbeFailure: 'Reddit rejected an invitation from this account: account is too new', retryAt: NOW + 4 * 60 * 60_000, profileCheckedAt: NOW, - lastProbeAt: null, + lastProbeAt: NOW, }, }]), '/api/attention'); @@ -58,18 +60,33 @@ describe('attention route establishment items', () => { id: 'establishment:@t2_new:reddit.com', kind: 'account-unestablished', severity: 'warning', - title: 'u/new-account is not established yet', + title: 'u/new-account cannot start chats 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('account is too new'); expect(body.items[0].detail).toContain('Outbound sending is on hold'); }); - it('stays quiet for established accounts and for accounts never checked', async () => { + it('stays quiet for an account that is only below our own thresholds', async () => { const response = await get(contextWith([ - { ...account, establishment: { state: 'established', reasons: [], retryAt: null, profileCheckedAt: NOW, lastProbeAt: null } }, + { ...account, establishment: { state: 'established', sendingHeld: false, reasons: [], lastProbeFailure: null, retryAt: null, profileCheckedAt: NOW, lastProbeAt: null } }, { ...account, accountId: '@t2_other:reddit.com', redditUsername: 'other', establishment: null }, + { + ...account, + accountId: '@t2_soft:reddit.com', + redditUsername: 'soft', + // Below the configured floor but never refused by Reddit — advisory only. + establishment: { + state: 'unestablished', + sendingHeld: false, + reasons: ['Comment karma is 3 (below the configured floor of 5)'], + lastProbeFailure: null, + retryAt: null, + profileCheckedAt: NOW, + lastProbeAt: null, + }, + }, ]), '/api/attention'); const body = AttentionResponseSchema.parse(await response.json()); @@ -82,7 +99,9 @@ describe('attention route establishment items', () => { status: 'login-required', establishment: { state: 'unestablished', + sendingHeld: true, reasons: [], + lastProbeFailure: null, retryAt: null, profileCheckedAt: NOW, lastProbeAt: null,