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
@@ -1,5 +1,6 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import { z } from 'zod';
|
||||
import type { Config } from '../config/index.js';
|
||||
import type { Logger } from '../utils/logger.js';
|
||||
import type { RedditRequestScheduler } from './request-coordinator.js';
|
||||
@@ -9,6 +10,14 @@ const uncoordinatedRequestScheduler: RedditRequestScheduler = {
|
||||
extendCooldown: () => undefined,
|
||||
};
|
||||
|
||||
const RedditUserAboutResponseSchema = z.object({
|
||||
data: z.object({
|
||||
is_suspended: z.boolean().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type RedditUserAccountStatus = 'active' | 'suspended' | 'deleted';
|
||||
|
||||
export class RedditRateLimitError extends Error {
|
||||
constructor(public readonly retryAfterMs: number) {
|
||||
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
|
||||
@@ -197,6 +206,27 @@ export class RedditApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** A 404 is Reddit's definitive signal that the username no longer has an account. */
|
||||
async getUserAccountStatus(username: string): Promise<RedditUserAccountStatus> {
|
||||
const cookieHeader = await this.getCookieHeader();
|
||||
const headers: Record<string, string> = {};
|
||||
if (cookieHeader) headers.Cookie = cookieHeader;
|
||||
|
||||
try {
|
||||
const response = await this.scheduleRequest(() => this.client.get(
|
||||
`/user/${encodeURIComponent(username)}/about.json`,
|
||||
{ params: { raw_json: 1 }, headers },
|
||||
));
|
||||
const parsed = RedditUserAboutResponseSchema.parse(response.data);
|
||||
return parsed.data.is_suspended === true ? 'suspended' : 'active';
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) return 'deleted';
|
||||
const rateLimitError = this.toRateLimitError(error);
|
||||
if (rateLimitError) throw rateLimitError;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getNewPosts(subreddit: string, limit = 25, type: 'image' | 'chat' = 'image'): Promise<RedditPost[]> {
|
||||
try {
|
||||
const cookieHeader = await this.getCookieHeader();
|
||||
|
||||
Reference in new issue
Block a user