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>
This commit is contained in:
1 parent
725b5fc4e6
commit
ac12a3146a
35 files changed
+1164
-129
No files matched your search
@@ -0,0 +1,97 @@
|
||||
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('@');
|
||||
}
|
||||
Reference in new issue
Block a user