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

+3
View File
@@ -22,11 +22,14 @@ export interface MatrixEventContent {
body?: string;
/** mxc:// URI for image/file messages */
url?: string;
/** Unencrypted attachment thumbnail; Matrix clients use both locations. */
thumbnail_url?: string;
info?: {
mimetype?: string;
w?: number;
h?: number;
size?: number;
thumbnail_url?: string;
};
[k: string]: unknown;
}
+56 -27
View File
@@ -138,7 +138,7 @@ export type NewReplyListener = (info: {
export class ChatService {
private cache: ChatCache;
private queue: Promise<unknown> = Promise.resolve();
private deltaSyncInFlight: Promise<void> | null = null;
private deltaSyncInFlight: Promise<number> | null = null;
private readonly t2ToUsername = new Map<string, string>();
/** Rooms whose /joined_members lookup has already failed — don't retry. */
private readonly unresolvableRooms = new Set<string>();
@@ -318,7 +318,7 @@ export class ChatService {
let failure: unknown;
try {
this.updateProgress({ phase: 'starting', current: 0, total: 0 });
const merged = await this.syncOnce();
const { chats: merged } = await this.syncOnce();
const cutoff = Date.now() - ACTIVE_WINDOW_MS;
const active = merged.filter((c) => c.lastMessageTime >= cutoff);
this.cache.set(active);
@@ -340,7 +340,7 @@ export class ChatService {
* cache. The `limit` arg is preserved for API compatibility but Matrix
* already returns only rooms with new events, so it's a no-op.
*/
refreshTopChats(_limit = 5): Promise<void> {
refreshTopChats(_limit = 5): Promise<number> {
if (this.deltaSyncInFlight) return this.deltaSyncInFlight;
const operation = this.enqueue(async () => {
@@ -348,13 +348,14 @@ export class ChatService {
let failure: unknown;
try {
this.updateProgress({ phase: 'starting', current: 0, total: 0 });
await this.syncOnce();
this.logger.info('[chat] incremental sync complete');
const { changedRooms } = await this.syncOnce();
this.logger.info(`[chat] incremental sync complete (${changedRooms} changed rooms)`);
this.updateProgress({
phase: 'done',
current: this.cache.get().length,
total: this.cache.get().length,
});
return changedRooms;
} catch (error) {
failure = error;
this.logger.error('[chat] refreshTopChats failed:', error);
@@ -376,7 +377,7 @@ export class ChatService {
* one's full history. Slow but rare; intended for "rebuild the cache from
* scratch" or initial provisioning.
*/
async fetchAllMessages(): Promise<ActiveChat[]> {
async fetchAllMessages(options: { requireComplete?: boolean } = {}): Promise<ActiveChat[]> {
return this.enqueue(async () => {
this.beginSync('full');
let failure: unknown;
@@ -436,6 +437,7 @@ export class ChatService {
await this.repairDirectRoomMappings(chats);
let done = 0;
let failedRooms = 0;
for (const chat of chats) {
this.updateProgress({
phase: 'loading-details',
@@ -447,6 +449,7 @@ export class ChatService {
await this.loadDetails(chat);
} catch (err) {
if (err instanceof MatrixAuthenticationUnavailableError) throw err;
failedRooms++;
this.logger.error(`[chat] backfill: failed details for ${chat.roomName}`, err);
}
done++;
@@ -459,6 +462,15 @@ export class ChatService {
});
chats.sort((a, b) => b.lastMessageTime - a.lastMessageTime);
this.cache.set(chats);
if (failedRooms > 0) {
await this.cache.save(chats);
const error = new Error(
`Full chat backfill incomplete: ${failedRooms} of ${chats.length} conversations failed`,
);
this.logger.error(`[chat] ${error.message}`);
if (options.requireComplete) throw error;
return chats;
}
this.cache.markFullSync();
await this.cache.save(chats);
@@ -470,7 +482,7 @@ export class ChatService {
} catch (error) {
failure = error;
this.logger.error('[chat] fetchAllMessages failed:', error);
if (error instanceof MatrixAuthenticationUnavailableError) throw error;
if (error instanceof MatrixAuthenticationUnavailableError || options.requireComplete) throw error;
return this.cache.get();
} finally {
this.endSync(failure);
@@ -842,11 +854,10 @@ export class ChatService {
// ------------ internals ------------
/** Single delta /sync that updates cache + nextBatch and returns the union. */
private async syncOnce(): Promise<ActiveChat[]> {
private async syncOnce(): Promise<{ chats: ActiveChat[]; changedRooms: number }> {
const sync = await this.withAuth(() =>
this.matrix.sync({ since: this.cache.getMarkers().nextBatch ?? undefined, timeoutMs: 0 }),
);
this.cache.setNextBatch(sync.next_batch);
const joined = sync.rooms?.join ?? {};
const existing = new Map(this.cache.get().map((c) => [c.roomId!, c] as const));
@@ -859,22 +870,26 @@ export class ChatService {
}
for (const [roomId, room] of Object.entries(joined)) {
const scraped = this.scrapeMessageEvents(room?.timeline?.events ?? []);
if (scraped.length === 0) continue;
const chat = existing.get(roomId);
if (!chat) continue;
// A limited timeline means Matrix omitted events between the saved
// cursor and this response. Walk the room history before advancing the
// cursor so media in that gap cannot become permanently unreachable.
if (room?.timeline?.limited) {
this.logger.info(`[chat] delta timeline limited for ${roomId}; backfilling room history`);
await this.loadDetails(chat);
continue;
}
const scraped = this.scrapeMessageEvents(room?.timeline?.events ?? []);
if (scraped.length === 0) continue;
let newMessages: ChatMessage[] = scraped.map(
({ mediaUrls: _ignored, ...message }) => message,
);
if (this.mediaService && scraped.some((message) => message.mediaUrls.length > 0)) {
try {
newMessages = await this.mediaService.attachMedia(chat, scraped);
} catch (err) {
this.logger.warn(
`[chat] delta media attach failed for ${roomId}: ${(err as Error).message}`,
);
}
newMessages = await this.mediaService.attachMedia(chat, scraped);
}
this.mergeNewMessages(chat, newMessages);
this.recordConversationPerformance(chat);
@@ -884,13 +899,14 @@ export class ChatService {
(a, b) => b.lastMessageTime - a.lastMessageTime,
);
this.cache.set(merged);
this.cache.setNextBatch(sync.next_batch);
this.cache.markDeltaSync();
await this.cache.save(merged);
// Resolve any t2_-prefixed usernames the delta just introduced, so the
// dashboard doesn't show raw Matrix-style IDs for new rooms.
await this.enrichUsernames();
await this.repairDirectRoomMappings(merged);
return merged;
return { chats: merged, changedRooms: Object.keys(joined).length };
}
/**
@@ -937,18 +953,26 @@ export class ChatService {
.filter((e) => e.type === 'm.room.message')
.map((e) => {
const body = (e.content.body as string | undefined) ?? '';
const mxc = typeof e.content.url === 'string' ? (e.content.url as string) : null;
const mediaUrls = this.extractMediaUrls(e);
return {
eventId: e.event_id,
sender: e.sender === this.matrix.userId ? 'Me' : this.shortenSender(e.sender),
text: body || (mxc ? '[Image]' : ''),
text: body || (mediaUrls.length > 0 ? '[Image]' : ''),
timestamp: e.origin_server_ts,
isSelf: e.sender === this.matrix.userId,
mediaUrls: mxc ? [mxc] : [],
mediaUrls,
};
});
}
private extractMediaUrls(event: MatrixTimelineEvent): string[] {
if (typeof event.content.url === 'string' && event.content.url.length > 0) {
return [event.content.url];
}
const thumbnail = event.content.thumbnail_url ?? event.content.info?.thumbnail_url;
return typeof thumbnail === 'string' && thumbnail.length > 0 ? [thumbnail] : [];
}
private mergeNewMessages(chat: ActiveChat, newMessages: ChatMessage[]): void {
if (newMessages.length === 0) return;
chat.messages = this.normalizeMessages([...(chat.messages ?? []), ...newMessages]);
@@ -1042,12 +1066,17 @@ export class ChatService {
const events: MatrixTimelineEvent[] = [];
let from: string | undefined = undefined;
for (let i = 0; i < 50; i++) {
const seenPaginationTokens = new Set<string>();
while (true) {
const res = await this.withAuth(() =>
this.matrix.getRoomMessages(chat.roomId!, { from, limit: 100, dir: 'b' }),
);
events.push(...res.chunk);
if (!res.end || res.end === from || res.chunk.length === 0) break;
if (!res.end || res.chunk.length === 0) break;
if (res.end === from || seenPaginationTokens.has(res.end)) {
throw new Error(`Matrix history pagination stalled for ${chat.roomId}`);
}
seenPaginationTokens.add(res.end);
from = res.end;
}
@@ -1056,14 +1085,14 @@ export class ChatService {
const scraped: ScrapedChatMessage[] = messages.map((e) => {
const body = (e.content.body as string | undefined) ?? '';
const mxc = typeof e.content.url === 'string' ? (e.content.url as string) : null;
const mediaUrls = this.extractMediaUrls(e);
return {
eventId: e.event_id,
sender: e.sender === this.matrix.userId ? 'Me' : this.shortenSender(e.sender),
text: body || (mxc ? '[Image]' : ''),
text: body || (mediaUrls.length > 0 ? '[Image]' : ''),
timestamp: e.origin_server_ts,
isSelf: e.sender === this.matrix.userId,
mediaUrls: mxc ? [mxc] : [],
mediaUrls,
};
});