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