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>
98 lines
3.8 KiB
TypeScript
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('@');
|
|
}
|