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:
Jamie BruntonandClaude Opus 5 committed 2026-08-10 11:03:33 +01:00
1 parent 725b5fc4e6
commit ac12a3146a
35 files changed
+1164 -129

No files matched your search

+128 -41
View File
@@ -2,6 +2,7 @@ import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import { z } from 'zod';
import sharp from 'sharp';
import type { Logger } from '../utils/logger.js';
import type { ActiveChat, ChatMedia, ChatMessage } from './chat.js';
import type { AccountContentRepository } from '../database/account-repositories.js';
@@ -28,6 +29,15 @@ export interface MediaRecord {
createdAt: number;
}
export class MediaAttachmentError extends Error {
constructor(public readonly failedDownloads: number) {
super(`Failed to retrieve ${failedDownloads} conversation media item(s)`);
this.name = 'MediaAttachmentError';
}
}
class MediaDownloadError extends Error {}
const MediaIndexSchema = z.object({
version: z.number(),
savedAt: z.number(),
@@ -72,9 +82,16 @@ export class MediaService {
const databaseBacked = Boolean(this.databaseStore && this.getAccountId());
if (!databaseBacked) await this.loadIndex();
const updated: ChatMessage[] = [];
let failedDownloads = 0;
for (const message of messages) {
const media = await this.downloadMessageMedia(chat, message);
let media: ChatMedia[] = [];
try {
media = await this.downloadMessageMedia(chat, message);
} catch (error) {
if (!(error instanceof MediaDownloadError)) throw error;
failedDownloads++;
}
updated.push({
eventId: message.eventId,
sender: message.sender,
@@ -86,6 +103,7 @@ export class MediaService {
}
if (!databaseBacked) await this.saveIndex();
if (failedDownloads > 0) throw new MediaAttachmentError(failedDownloads);
return updated;
}
@@ -155,7 +173,13 @@ export class MediaService {
// Try the full-resolution URL first, fall back to original
const fullUrl = this.resolveFullMediaUrl(url);
const accountId = this.getAccountId();
const id = crypto.createHash('sha1').update(`${accountId ?? 'legacy'}\0${fullUrl}`).digest('hex');
const ownerKey = accountId ?? 'legacy';
const assetId = crypto.createHash('sha1').update(`${ownerKey}\0${fullUrl}`).digest('hex');
const occurrenceKey = message.eventId
?? `${chat.roomId ?? ''}\0${message.timestamp}\0${message.sender}`;
const id = crypto.createHash('sha1')
.update(`${ownerKey}\0${occurrenceKey}\0${fullUrl}`)
.digest('hex');
if (this.databaseStore && accountId) {
const existingRecord = this.databaseStore.getMedia(accountId, id);
if (existingRecord) {
@@ -166,6 +190,23 @@ export class MediaService {
this.databaseStore.deleteMedia(accountId, id);
}
}
const reusable = this.databaseStore.getMediaByOriginalUrl(accountId, fullUrl);
if (reusable) {
try {
await fs.access(path.join(this.mediaRoot, reusable.relativePath));
const record = this.buildRecord(
id, chat, message, fullUrl, reusable.fileName, reusable.relativePath, accountId,
);
this.databaseStore.upsertMedia(this.toStoredRecord(record, accountId));
if (reusable.mediaId === assetId && assetId !== id) {
this.databaseStore.deleteMedia(accountId, assetId);
}
return record;
} catch {
this.databaseStore.deleteMedia(accountId, reusable.mediaId);
}
}
}
const existing = this.index?.items.find((item) => item.id === id);
if (existing) {
@@ -181,23 +222,45 @@ export class MediaService {
}
}
const reusable = this.index?.items.find((item) => item.originalUrl === fullUrl);
if (reusable) {
try {
await fs.access(path.join(this.mediaRoot, reusable.relativePath));
const record = this.buildRecord(
id, chat, message, fullUrl, reusable.fileName, reusable.relativePath, accountId,
);
this.index!.items.push(record);
if (reusable.id === assetId && assetId !== id) {
this.index!.items = this.index!.items.filter((item) => item.id !== assetId);
}
return record;
} catch {
this.index!.items = this.index!.items.filter((item) => item.id !== reusable.id);
}
}
let download = await this.downloader.fetch(fullUrl);
if (!download && fullUrl !== url) {
this.logger.debug(`Full-res download failed, trying original URL: ${url}`);
let extension = download
? await this.pickExtension(url, download.contentType, download.buffer)
: null;
if ((!download || !extension) && fullUrl !== url) {
this.logger.debug(`Full-res media was unavailable or invalid, trying original URL: ${url}`);
download = await this.downloader.fetch(url);
extension = download
? await this.pickExtension(url, download.contentType, download.buffer)
: null;
}
if (!download) {
this.logger.warn(`Failed to download chat media from ${fullUrl}`);
return null;
throw new MediaDownloadError('Conversation media download failed');
}
const extension = this.pickExtension(url, download.contentType);
if (!extension) {
this.logger.debug(`Skipping non-image media from ${url}`);
return null;
}
const fileName = `${id}${extension}`;
const fileName = `${assetId}${extension}`;
const accountDirectory = accountId
? crypto.createHash('sha256').update(accountId).digest('hex').slice(0, 16)
: '';
@@ -207,32 +270,10 @@ export class MediaService {
await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, download.buffer);
const record: MediaRecord = {
accountId: accountId ?? undefined,
id,
chatUsername: chat.username || null,
chatRoomId: chat.roomId || null,
sender: message.sender,
timestamp: message.timestamp,
originalUrl: fullUrl,
fileName,
relativePath,
createdAt: Date.now(),
};
const record = this.buildRecord(id, chat, message, fullUrl, fileName, relativePath, accountId);
if (this.databaseStore && accountId) {
this.databaseStore.upsertMedia({
accountId,
mediaId: id,
chatUsername: record.chatUsername,
chatRoomId: record.chatRoomId,
sender: record.sender,
timestamp: record.timestamp,
originalUrl: record.originalUrl,
fileName: record.fileName,
relativePath: record.relativePath,
createdAt: record.createdAt,
});
this.databaseStore.upsertMedia(this.toStoredRecord(record, accountId));
return record;
}
@@ -246,26 +287,72 @@ export class MediaService {
return record;
}
private pickExtension(url: string, contentType: string | null): string | null {
private buildRecord(
id: string,
chat: ActiveChat,
message: ScrapedChatMessage,
originalUrl: string,
fileName: string,
relativePath: string,
accountId: string | null,
): MediaRecord {
return {
accountId: accountId ?? undefined,
id,
chatUsername: chat.username || null,
chatRoomId: chat.roomId || null,
sender: message.sender,
timestamp: message.timestamp,
originalUrl,
fileName,
relativePath,
createdAt: Date.now(),
};
}
private toStoredRecord(record: MediaRecord, accountId: string) {
return {
accountId,
mediaId: record.id,
chatUsername: record.chatUsername,
chatRoomId: record.chatRoomId,
sender: record.sender,
timestamp: record.timestamp,
originalUrl: record.originalUrl,
fileName: record.fileName,
relativePath: record.relativePath,
createdAt: record.createdAt,
};
}
private async pickExtension(url: string, contentType: string | null, buffer: Buffer): Promise<string | null> {
const supportedExtensions = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif']);
let urlExt = '';
try {
urlExt = path.extname(new URL(url).pathname || '').toLowerCase();
} catch {
urlExt = path.extname(url.split('?')[0] || '').toLowerCase();
}
if (urlExt && urlExt.length <= 5) {
if (supportedExtensions.has(urlExt)) {
return urlExt;
}
if (!contentType) {
return null;
}
if (contentType?.includes('image/jpeg')) return '.jpg';
if (contentType?.includes('image/png')) return '.png';
if (contentType?.includes('image/gif')) return '.gif';
if (contentType?.includes('image/webp')) return '.webp';
if (contentType?.includes('image/avif')) return '.avif';
if (contentType.includes('image/jpeg')) return '.jpg';
if (contentType.includes('image/png')) return '.png';
if (contentType.includes('image/gif')) return '.gif';
if (contentType.includes('image/webp')) return '.webp';
if (contentType.includes('image/avif')) return '.avif';
try {
const format = (await sharp(buffer).metadata()).format;
if (format === 'jpeg') return '.jpg';
if (format === 'png') return '.png';
if (format === 'gif') return '.gif';
if (format === 'webp') return '.webp';
if (format === 'heif') return '.avif';
} catch {
// Unsupported/corrupt payloads are not gallery images.
}
return null;
}