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
@@ -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 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 {
|
||||
constructor(public readonly retryAfterMs: number) {
|
||||
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[]> {
|
||||
try {
|
||||
const cookieHeader = await this.getCookieHeader();
|
||||
|
||||
Reference in new issue
Block a user