fix: never let a guessed threshold hold an account out of sending
The profile check was allowed to set state 'unestablished', which made isSendable() false, which made canAcceptPost() false — so a flagged account never attempted an invitation and the functional probe could never run. The one signal with real authority was gated behind the one that is guessing, and because each check pushed retryAt forward by another interval, accounts that send perfectly well were held indefinitely. Reddit does not publish the thresholds its chat gate applies, so ours are a guess and are now advisory only. Sending is held solely by a live rejection, tracked as sendingHeld on the account summary and keyed off by the attention feed, the sending state and the Accounts page. Reason strings now say "below the configured floor of N" rather than "needs N", and the Accounts page distinguishes "Sending on hold" from "Below thresholds", stating outright that the latter is our own number and the account is still sending. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
89eb4583f8
commit
3a40651930
9 files changed
+164
-71
No files matched your search
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user