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

+49
View File
@@ -201,6 +201,14 @@ export interface AccountMediaRecord {
createdAt: number;
}
export type RedditRecipientAccountStatus = 'active' | 'suspended' | 'deleted';
export interface RedditRecipientAccountStatusRecord {
username: string;
status: RedditRecipientAccountStatus;
checkedAt: number;
}
type ConversationRow = {
account_id: string;
conversation_id: string;
@@ -325,6 +333,16 @@ export class AccountContentRepository {
return row ? mapMedia(row) : null;
}
getMediaByOriginalUrl(accountId: string, originalUrl: string): AccountMediaRecord | null {
const row = this.db.prepare(`
SELECT * FROM reddit_media
WHERE account_id = ? AND original_url = ?
ORDER BY timestamp, media_id
LIMIT 1
`).get(accountId, originalUrl) as MediaRow | undefined;
return row ? mapMedia(row) : null;
}
listMedia(accountId: string): AccountMediaRecord[] {
const rows = this.db.prepare(`
SELECT * FROM reddit_media WHERE account_id = ?
@@ -340,6 +358,37 @@ export class AccountContentRepository {
}
}
type RecipientAccountStatusRow = {
username: string;
status: RedditRecipientAccountStatus;
checked_at: number;
};
/** Process-global Reddit profile availability, shared by conversations across owning accounts. */
export class RedditRecipientAccountStatusRepository {
constructor(private readonly db: Database.Database) {}
get(username: string): RedditRecipientAccountStatusRecord | null {
const row = this.db.prepare(`
SELECT username, status, checked_at
FROM reddit_recipient_account_status
WHERE username = ? COLLATE NOCASE
`).get(username) as RecipientAccountStatusRow | undefined;
return row ? { username: row.username, status: row.status, checkedAt: row.checked_at } : null;
}
upsert(username: string, status: RedditRecipientAccountStatus, checkedAt = Date.now()): void {
requireText(username, 'username');
this.db.prepare(`
INSERT INTO reddit_recipient_account_status (username, status, checked_at)
VALUES (?, ?, ?)
ON CONFLICT(username) DO UPDATE SET
status = excluded.status,
checked_at = excluded.checked_at
`).run(username, status, checkedAt);
}
}
function mapConversation(row: ConversationRow): AccountConversationRecord {
return {
accountId: row.account_id,
+31
View File
@@ -425,6 +425,37 @@ export function runMigrations(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_conversation_read_state_status
ON conversation_read_state(account_id, status, snoozed_until);
`),
() => db.exec(`
CREATE TABLE reddit_media_occurrences (
account_id TEXT NOT NULL,
media_id TEXT NOT NULL,
chat_username TEXT,
chat_room_id TEXT,
sender TEXT NOT NULL,
timestamp INTEGER NOT NULL,
original_url TEXT NOT NULL,
file_name TEXT NOT NULL,
relative_path TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (account_id, media_id),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
);
INSERT INTO reddit_media_occurrences SELECT * FROM reddit_media;
DROP TABLE reddit_media;
ALTER TABLE reddit_media_occurrences RENAME TO reddit_media;
CREATE INDEX idx_reddit_media_timestamp ON reddit_media(account_id, timestamp DESC);
CREATE INDEX idx_reddit_media_room ON reddit_media(account_id, chat_room_id);
CREATE INDEX idx_reddit_media_original_url ON reddit_media(account_id, original_url);
`),
() => db.exec(`
CREATE TABLE IF NOT EXISTS reddit_recipient_account_status (
username TEXT PRIMARY KEY COLLATE NOCASE,
status TEXT NOT NULL CHECK(status IN ('active', 'suspended', 'deleted')),
checked_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_reddit_recipient_account_status_due
ON reddit_recipient_account_status(checked_at);
`),
];
let currentVersion = db.pragma('user_version', { simple: true }) as number;