Files
RedditBot/src/services/recipient-account-status.ts
T
Jamie BruntonandClaude Opus 5 ac12a3146a feat: recover missing conversations, fix media attachment, add manual inbox tab
Missing conversations: recipient Reddit accounts that are deleted or suspended
left conversations unaccounted for. A new RecipientAccountStatusService polls
Reddit for the users behind cached conversations and records their account
status durably, with progressive backfill and delta-media fixes in ChatService
so history and images are no longer dropped mid-sync.

Media: attachMedia now surfaces download failures as MediaAttachmentError so the
sync cursor can be retried instead of silently losing an image, recognizes image
bytes when Matrix returns a generic content type, and keeps separate gallery
occurrences when the same asset appears in more than one event.

Manual inbox tab: the conversations list now carries the reply-loop status, so
the inbox can separate conversations the operator is driving by hand from the
ones automation is still handling. A Manual filter pill and a per-row chip
explain why each conversation was handed over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 11:03:33 +01:00

98 lines
3.8 KiB
TypeScript

import type { Logger } from '../utils/logger.js';
import type { RedditUserAccountStatus } from '../reddit/api.js';
import { RedditRateLimitError } from '../reddit/api.js';
import type { RedditRecipientAccountStatusRepository } from '../database/account-repositories.js';
import type { ChatService } from './chat.js';
import { denyAccountAuth, type AccountAuthGate } from './account-auth-context.js';
export const RECIPIENT_ACCOUNT_CHECK_INTERVAL_MS = 4 * 60 * 60_000;
const INITIAL_CHECK_DELAY_MS = 60_000;
export interface RedditAccountStatusLookup {
getUserAccountStatus(username: string): Promise<RedditUserAccountStatus>;
}
/** Periodically records whether the Reddit users behind cached conversations still exist. */
export class RecipientAccountStatusService {
private intervalHandle: NodeJS.Timeout | null = null;
private initialHandle: NodeJS.Timeout | null = null;
private checking = false;
constructor(
private readonly chatService: Pick<ChatService, 'getCachedConversations'>,
private readonly reddit: RedditAccountStatusLookup,
private readonly repository: RedditRecipientAccountStatusRepository,
private readonly logger: Logger,
private readonly accountAuth: AccountAuthGate = denyAccountAuth,
private readonly intervalMs = RECIPIENT_ACCOUNT_CHECK_INTERVAL_MS,
private readonly now: () => number = Date.now,
) {}
start(): void {
if (this.intervalHandle) return;
this.initialHandle = setTimeout(() => {
this.initialHandle = null;
void this.checkNow();
}, Math.min(INITIAL_CHECK_DELAY_MS, this.intervalMs));
this.intervalHandle = setInterval(() => void this.checkNow(), this.intervalMs);
this.logger.info(`Recipient account check: scheduled every ${Math.round(this.intervalMs / 3_600_000)} hours`);
}
stop(): void {
if (this.initialHandle) clearTimeout(this.initialHandle);
if (this.intervalHandle) clearInterval(this.intervalHandle);
this.initialHandle = null;
this.intervalHandle = null;
}
async checkNow(): Promise<number> {
if (this.checking || !this.accountAuth.isFullyAuthenticated()) return 0;
this.checking = true;
let checked = 0;
const startedAt = this.now();
try {
const chats = await this.chatService.getCachedConversations();
const uniqueUsernames = new Map<string, string>();
for (const chat of chats) {
const username = chat.username.trim();
if (isCheckableUsername(username) && !uniqueUsernames.has(username.toLowerCase())) {
uniqueUsernames.set(username.toLowerCase(), username);
}
}
const usernames = [...uniqueUsernames.values()];
for (const username of usernames) {
const previous = this.repository.get(username);
if (previous && previous.checkedAt > startedAt - this.intervalMs) continue;
try {
const status = await this.reddit.getUserAccountStatus(username);
this.repository.upsert(username, status, this.now());
checked += 1;
if (status === 'deleted' && previous?.status !== 'deleted') {
this.logger.info(`Recipient account check: u/${username} is deleted`);
}
} catch (error) {
if (error instanceof RedditRateLimitError) {
this.logger.warn('Recipient account check paused because Reddit rate-limited profile lookups');
break;
}
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Recipient account check failed for u/${username}: ${message}`);
}
}
return checked;
} finally {
this.checking = false;
}
}
}
function isCheckableUsername(username: string): boolean {
const normalized = username.toLowerCase();
return username.length > 0
&& normalized !== 'unknown'
&& normalized !== '[deleted]'
&& !normalized.startsWith('t2_')
&& !username.startsWith('@');
}