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
+128
-41
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user