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

+25 -1
View File
@@ -21,6 +21,20 @@ export const ChatMessageSchema = z.strictObject({
/** Operator-maintained triage state. Independent of Matrix's own unread count. */
export const ConversationTriageStatusSchema = z.enum(['open', 'snoozed', 'done']);
/**
* Where the reply loop currently sits. Anything other than `active` means the
* bot has handed the conversation over and replies are being written by hand.
*/
export const ConversationAutomationStatusSchema = z.enum([
'active',
'image-received',
'human-required',
'boundary',
'user-overridden',
]);
/** The policy snapshotted when the conversation started. `manual` never auto-replies. */
export const ConversationPolicyModeSchema = z.enum(['legacy', 'r4r', 'r4r-media', 'manual']);
export const ActiveChatSchema = z.strictObject({
username: z.string(),
roomName: z.string(),
@@ -39,6 +53,9 @@ export const ActiveChatSchema = z.strictObject({
originMessage: z.string().optional(),
hasReceivedImages: z.boolean().optional(),
sellerDetected: z.boolean().optional(),
/** Last confirmed Reddit profile state for the other participant. */
recipientAccountStatus: z.enum(['active', 'suspended', 'deleted']).optional(),
recipientAccountStatusCheckedAt: z.number().optional(),
owner: RedditAccountOwnerSchema.optional(),
/** True when the newest message is inbound and newer than the read watermark. */
hasUnreadInbound: z.boolean().optional(),
@@ -48,6 +65,11 @@ export const ActiveChatSchema = z.strictObject({
snoozedUntil: z.number().nullable().optional(),
/** Timestamp of the newest message the operator has read. */
lastReadMessageTs: z.number().optional(),
/** Reply-loop state. Absent when no conversation state has been recorded yet. */
automationStatus: ConversationAutomationStatusSchema.optional(),
automationPolicyMode: ConversationPolicyModeSchema.optional(),
/** Why automation handed over, when it has. */
automationHandoffReason: z.string().nullable().optional(),
});
export const ChatsResponseSchema = z.array(ActiveChatSchema);
export const ConversationsResponseSchema = ChatsResponseSchema;
@@ -93,7 +115,7 @@ export const ConversationDetailResponseSchema = ActiveChatSchema.extend({
automation: z.strictObject({
turnsUsed: z.number().int().min(0).max(MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS),
remainingTurns: z.number().int().min(0).max(MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS),
status: z.enum(['active', 'image-received', 'human-required', 'boundary', 'user-overridden']),
status: ConversationAutomationStatusSchema,
handoffReason: z.string().nullable(),
}),
syncInProgress: z.strictObject({
@@ -162,6 +184,8 @@ export const FetchAllChatsResponseSchema = z.strictObject({
export type ActiveChat = z.infer<typeof ActiveChatSchema>;
export type ChatMessage = z.infer<typeof ChatMessageSchema>;
export type ConversationTriageStatus = z.infer<typeof ConversationTriageStatusSchema>;
export type ConversationAutomationStatus = z.infer<typeof ConversationAutomationStatusSchema>;
export type ConversationPolicyMode = z.infer<typeof ConversationPolicyModeSchema>;
export type BulkMarkConversationsReadResponse = z.infer<typeof BulkMarkConversationsReadResponseSchema>;
export type ChatSyncStatus = z.infer<typeof ChatSyncStatusSchema>;
export type ConversationDetailResponse = z.infer<typeof ConversationDetailResponseSchema>;