Add durable candidate funnel
This commit is contained in:
1 parent
5abac31c28
commit
df35648920
20 files changed
+1128
-142
No files matched your search
@@ -53,6 +53,11 @@ export interface UsernameResolver {
|
||||
resolveUsernameToT2?(username: string): Promise<string | null>;
|
||||
}
|
||||
|
||||
export type ConversationStartResult =
|
||||
| { status: 'sent'; chat: ActiveChat }
|
||||
| { status: 'terminal'; reason: string }
|
||||
| { status: 'retryable'; reason: string; retryAt: number };
|
||||
|
||||
interface BuildChatOpts {
|
||||
knownUsernames: Map<string, string>;
|
||||
includeTimelineMessages?: boolean;
|
||||
@@ -620,21 +625,37 @@ export class ChatService {
|
||||
message: string,
|
||||
knownT2Id?: string,
|
||||
): Promise<ActiveChat | null> {
|
||||
if (!this.accountAuth?.isFullyAuthenticated()) return null;
|
||||
const result = await this.startConversationDetailed(username, message, knownT2Id);
|
||||
return result.status === 'sent' ? result.chat : null;
|
||||
}
|
||||
|
||||
/** Start a DM while preserving terminal versus retryable failure semantics. */
|
||||
async startConversationDetailed(
|
||||
username: string,
|
||||
message: string,
|
||||
knownT2Id?: string,
|
||||
): Promise<ConversationStartResult> {
|
||||
if (!this.accountAuth?.isFullyAuthenticated()) {
|
||||
return {
|
||||
status: 'retryable',
|
||||
reason: 'Account authentication is temporarily unavailable',
|
||||
retryAt: Date.now() + 60_000,
|
||||
};
|
||||
}
|
||||
const active = this.getInvitationCooldown();
|
||||
if (active) {
|
||||
const secondsLeft = Math.ceil((active.until - Date.now()) / 1000);
|
||||
this.logger.warn(
|
||||
`[chat] skipping startConversation(${username}) — invitation cooldown active for ${secondsLeft}s (${active.redditCode ?? 'rate-limited'})`,
|
||||
);
|
||||
return null;
|
||||
return { status: 'retryable', reason: active.reason, retryAt: active.until };
|
||||
}
|
||||
return this.enqueue(async () => {
|
||||
try {
|
||||
const t2Id = await this.resolveUsernameToT2(username, knownT2Id);
|
||||
if (!t2Id) {
|
||||
this.logger.error(`[chat] cannot start conversation: unknown user "${username}"`);
|
||||
return null;
|
||||
return { status: 'terminal', reason: 'Reddit user no longer exists' };
|
||||
}
|
||||
const mxid = MatrixClient.t2ToMxid(t2Id);
|
||||
const { roomId, eventId } = await this.withAuth(() =>
|
||||
@@ -666,14 +687,30 @@ export class ChatService {
|
||||
const merged = [chat, ...existing.filter((c) => c.roomId !== roomId)];
|
||||
this.cache.set(merged);
|
||||
await this.cache.save(merged);
|
||||
return chat;
|
||||
return { status: 'sent', chat };
|
||||
} catch (error) {
|
||||
if (error instanceof MatrixRateLimitError) {
|
||||
this.setInvitationCooldown(error);
|
||||
return null;
|
||||
const cooldown = this.getInvitationCooldown();
|
||||
return {
|
||||
status: 'retryable',
|
||||
reason: cooldown?.reason ?? 'Reddit temporarily rate-limited chat invitations',
|
||||
retryAt: cooldown?.until ?? Date.now() + error.retryAfterMs + 5_000,
|
||||
};
|
||||
}
|
||||
this.logger.error('[chat] startConversation failed:', error);
|
||||
return null;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (/do not accept chat requests/i.test(message)) {
|
||||
return { status: 'terminal', reason: 'Recipient does not accept chat requests' };
|
||||
}
|
||||
if (/unknown user|not found|M_NOT_FOUND/i.test(message)) {
|
||||
return { status: 'terminal', reason: 'Reddit user is unavailable' };
|
||||
}
|
||||
return {
|
||||
status: 'retryable',
|
||||
reason: 'Reddit chat invitation failed temporarily',
|
||||
retryAt: Date.now() + 60_000,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user