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,