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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user