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

+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dashboard",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dashboard:dev"],
"port": 5173
}
]
}
@@ -573,6 +573,9 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
<span className="conversation-detail-badges">
<AccountOwnerBadge owner={conversation.owner} />
{conversation.sellerDetected ? <span className="conversation-seller-marker">Seller</span> : null}
{conversation.recipientAccountStatus === 'deleted' ? (
<span className="conversation-badge is-done">Account deleted</span>
) : null}
{isUnread(conversation) ? <span className="conversation-badge">Unread</span> : null}
{status === 'snoozed' ? <span className="conversation-badge is-snoozed">Snoozed</span> : null}
{status === 'done' ? <span className="conversation-badge is-done">Done</span> : null}
@@ -5,15 +5,17 @@ import type { ActiveChat } from '../../../../../packages/api-contracts/src/index
* application read state rather than Matrix's unread count.
*
* `all` deliberately excludes resolved and snoozed conversations — the default
* view is "what is still on my plate", and the two terminal buckets have their
* own pills so nothing becomes unreachable.
* view is "what is still on my plate", and non-actionable conversations have
* dedicated pills so nothing becomes unreachable.
*/
export type ConversationFilter =
| 'all'
| 'needs-reply'
| 'unread'
| 'awaiting-reply'
| 'manual'
| 'received-images'
| 'account-deleted'
| 'snoozed'
| 'done';
@@ -41,7 +43,9 @@ export const CONVERSATION_FILTERS: ReadonlyArray<{ value: ConversationFilter; la
{ value: 'needs-reply', label: 'Needs reply' },
{ value: 'unread', label: 'Unread' },
{ value: 'awaiting-reply', label: 'Waiting on them' },
{ value: 'manual', label: 'Manual' },
{ value: 'received-images', label: 'Images' },
{ value: 'account-deleted', label: 'Account deleted' },
{ value: 'snoozed', label: 'Snoozed' },
{ value: 'done', label: 'Done' },
];
@@ -76,16 +80,54 @@ export function isUnread(conversation: ActiveChat): boolean {
}
export function needsReply(conversation: ActiveChat): boolean {
if (conversation.recipientAccountStatus === 'deleted') return false;
if (conversation.needsReply !== undefined) return conversation.needsReply;
return !conversation.isFromUs && triageStatus(conversation) === 'open';
}
/** We have replied and are waiting on them. */
export function isWaitingOnThem(conversation: ActiveChat): boolean {
return conversation.isFromUs && triageStatus(conversation) === 'open';
return conversation.recipientAccountStatus !== 'deleted'
&& conversation.isFromUs
&& triageStatus(conversation) === 'open';
}
/**
* True when the reply loop is no longer sending on its own, so every further
* message has to be written by an operator: either the subreddit's policy was
* manual from the start, or automation handed the conversation over (a manual
* reply was sent, an image arrived, a boundary or turn limit was hit).
*
* Conversations with no recorded automation state are not manual — they have
* simply never been through the reply loop.
*/
export function isManualFlow(conversation: ActiveChat): boolean {
if (conversation.automationPolicyMode === 'manual') return true;
return conversation.automationStatus !== undefined && conversation.automationStatus !== 'active';
}
/** Short label for why a conversation is being handled by hand. */
export function manualFlowLabel(conversation: ActiveChat): string | null {
if (!isManualFlow(conversation)) return null;
if (conversation.automationPolicyMode === 'manual' && conversation.automationStatus === 'active') {
return 'Manual by policy';
}
switch (conversation.automationStatus) {
case 'user-overridden':
return 'Manual — you replied';
case 'image-received':
return 'Manual — image received';
case 'boundary':
return 'Manual — boundary';
case 'human-required':
return 'Manual — handed over';
default:
return 'Manual';
}
}
export function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
if (conversation.recipientAccountStatus === 'deleted') return filter === 'account-deleted';
const status = triageStatus(conversation);
switch (filter) {
case 'needs-reply':
@@ -94,8 +136,12 @@ export function matchesFilter(conversation: ActiveChat, filter: ConversationFilt
return status === 'open' && isUnread(conversation);
case 'awaiting-reply':
return isWaitingOnThem(conversation);
case 'manual':
return status === 'open' && isManualFlow(conversation);
case 'received-images':
return status === 'open' && conversation.hasReceivedImages === true;
case 'account-deleted':
return false;
case 'snoozed':
return status === 'snoozed';
case 'done':
@@ -22,6 +22,7 @@ import {
accountOptions,
filterCounts,
isUnread,
manualFlowLabel,
needsReply,
partitionConversations,
triageStatus,
@@ -53,17 +54,41 @@ interface ConversationRowProps {
registerRef(element: HTMLButtonElement | null): void;
}
/**
* A chip's visible label, plus the longer form used for screen readers and the
* hover tooltip when the label alone does not say why the chip is there.
*/
interface ConversationChip {
label: string;
hint?: string;
tooltip?: string;
}
/** Independent state chips. A conversation can be several of these at once. */
function conversationChips(conversation: ActiveChat, now: number): string[] {
const chips: string[] = [];
if (isUnread(conversation)) chips.push('Unread');
if (needsReply(conversation)) chips.push('Needs reply');
else if (conversation.isFromUs && triageStatus(conversation) === 'open') chips.push('Waiting on them');
function conversationChips(conversation: ActiveChat, now: number): ConversationChip[] {
const chips: ConversationChip[] = [];
if (conversation.recipientAccountStatus === 'deleted') {
chips.push({ label: 'Account deleted' });
return chips;
}
if (isUnread(conversation)) chips.push({ label: 'Unread' });
if (needsReply(conversation)) chips.push({ label: 'Needs reply' });
else if (conversation.isFromUs && triageStatus(conversation) === 'open') chips.push({ label: 'Waiting on them' });
const manualLabel = manualFlowLabel(conversation);
if (manualLabel) {
chips.push({
label: 'Manual',
hint: manualLabel,
tooltip: conversation.automationHandoffReason
? `${manualLabel} — ${conversation.automationHandoffReason}`
: manualLabel,
});
}
const status = triageStatus(conversation);
if (status === 'snoozed') chips.push('Snoozed');
if (status === 'done') chips.push('Done');
if (status === 'snoozed') chips.push({ label: 'Snoozed' });
if (status === 'done') chips.push({ label: 'Done' });
if (needsReply(conversation) && conversationAge(conversation.lastMessageTime, now) !== 'fresh') {
chips.push('Overdue');
chips.push({ label: 'Overdue' });
}
return chips;
}
@@ -82,7 +107,7 @@ function ConversationRow({
const descriptors = [
redditUsernameLabel(conversation.username),
...chips,
...chips.map((chip) => chip.hint ?? chip.label),
relativeTime,
...(conversation.sellerDetected ? ['Seller'] : []),
...(conversation.hasReceivedImages ? ['Image received'] : []),
@@ -122,8 +147,12 @@ function ConversationRow({
</button>
<span className="conversation-list-chips">
{chips.map((chip) => (
<span className={`conversation-chip is-${chip.toLowerCase().replaceAll(' ', '-')}`} key={chip}>
{chip}
<span
className={`conversation-chip is-${chip.label.toLowerCase().replaceAll(' ', '-')}`}
key={chip.label}
title={chip.tooltip}
>
{chip.label}
</span>
))}
{showOwner ? <AccountOwnerBadge owner={conversation.owner} /> : null}
@@ -86,8 +86,9 @@
.conversation-chip { padding: .1rem .4rem; border: 1px solid var(--border); border-radius: 999px; background: var(--surface-raised); color: var(--muted); font-size: .68rem; font-weight: 750; }
.conversation-chip.is-unread { color: var(--accent); border-color: #28593d; background: var(--accent-dark); }
.conversation-chip.is-needs-reply { color: #cfe8ff; border-color: #315d85; background: #18324d; }
.conversation-chip.is-manual { color: #e7d8ff; border-color: #5a4285; background: #2c1f47; }
.conversation-chip.is-overdue { color: var(--warning-text); border-color: var(--warning-border); background: var(--warning-bg); }
.conversation-chip.is-snoozed, .conversation-chip.is-done { opacity: .85; }
.conversation-chip.is-snoozed, .conversation-chip.is-done, .conversation-chip.is-account-deleted { opacity: .85; }
.conversation-list-footer { display: flex; flex-wrap: wrap; justify-content: space-between; gap: .5rem; padding: .5rem .75rem .75rem; margin: 0; color: var(--muted); font-size: .72rem; }
.conversation-shortcut-hint { margin-left: auto; opacity: .8; }
+9 -1
View File
@@ -96,7 +96,15 @@ Legacy conversations may make at most one optional autonomous media request, inc
`ChatService` communicates with Reddit's Matrix homeserver, maintains an account-scoped SQLite-backed cache, resolves `t2_` identities to Reddit usernames, and emits sync progress/new-reply callbacks. Startup chooses full or delta sync from persisted cursor state. Operators can explicitly run tracked sync jobs through the versioned API.
`MediaService` resolves Matrix media, downloads it into the persistent data directory, and writes an account-scoped media index. `MediaSyncService` schedules incremental work only while the account is authenticated. The media listing reads persisted indexes for every stored account, including accounts whose runtimes are stopped or require login, and exposes received items only: records attributed to the owning Reddit/Matrix account are excluded, including historical self-sender forms. Private media is served behind the same authorization boundary as the dashboard and APIs.
Each authenticated account runtime also checks the Reddit profiles represented in its cached conversations every four hours. Profile results are shared process-wide by case-insensitive username, so a recently checked recipient is not looked up again by another account runtime. A valid profile is recorded as active, Reddit's explicit suspension marker is recorded separately, and only an HTTP 404 is recorded as deleted; authentication, rate-limit, network, server, and malformed-response failures do not change the previous state. The dashboard removes deleted recipients from actionable conversation queues and exposes them through the dedicated **Account deleted** bucket.
`MediaService` resolves Matrix media, downloads it into the persistent data directory, and writes an account-scoped media index. Each Matrix message occurrence gets its own gallery record, while repeated occurrences of the same remote asset reuse one private file. Generic download content types are verified from the image bytes before an item is rejected as unsupported.
`MediaSyncService` schedules incremental work only while the account is authenticated. Incremental sync consumes the rooms changed since the durable Matrix cursor; the reported count is the number of rooms Matrix actually returned, not an assumed inbox limit. The cursor advances only after media attachment and cache persistence succeed, so a transient media failure is retried. When Matrix marks a room timeline as limited, chat sync paginates that room's history before advancing the cursor to close the omitted-event gap.
Full media sync walks every joined room and paginates until Matrix reports the end of history. Per-room history or media failures leave the full-sync marker unset and make an operator-triggered full media sync fail visibly; a later full sync can retry the incomplete rooms. Startup may retain the successfully loaded partial cache for availability, but it does not claim that a full backfill completed.
The media listing reads persisted indexes for every stored account, including accounts whose runtimes are stopped or require login, and exposes received items only: records attributed to the owning Reddit/Matrix account are excluded, including historical self-sender forms. Private media is served behind the same authorization boundary as the dashboard and APIs.
The process-global exchange library is separate. Dashboard uploads are decoded and re-encoded with `sharp`, which validates the actual JPEG/PNG/WebP contents, applies orientation, and removes EXIF/location/camera metadata. OpenRouter then assigns overlapping anatomy tags plus SFW/NSFW classification. Failed or low-confidence classifications remain disabled until the operator corrects them.
+3 -1
View File
@@ -13,6 +13,7 @@ The application uses one `better-sqlite3` database, normally `data/reddit-bot.db
- `reddit_accounts`: verified Reddit/Matrix identity, encrypted-at-rest only to the degree provided by the host/volume, cookie JSON, Matrix credentials, active/status timestamps.
- `reddit_conversations`: account-scoped cached Matrix conversation payloads.
- `reddit_account_sync_state`: account-scoped sync cursor, full/delta timestamps, and invitation cooldown.
- `reddit_recipient_account_status`: process-global, case-insensitive recipient username status (`active`, `suspended`, or `deleted`) and last confirmed check time.
- `reddit_media`: account-scoped private-media metadata and relative file path.
- `media_library_assets`: process-global operator-owned image metadata, overlapping anatomy tags, SFW/NSFW classification, consent time, enablement, dimensions, hash, and private relative path.
- `media_library_sends`: account/conversation/inbound-event reservation and confirmed/failed Matrix delivery audit for a shared library asset.
@@ -52,8 +53,9 @@ The special legacy-unassigned account exists only to preserve rows created befor
5. Conversation policy is snapshotted in state JSON when automation starts for that conversation.
6. Secrets and the database path are not stored in `app_config`.
7. Private media paths in SQLite are relative to the persistent data root; HTTP access remains authenticated.
8. Received `reddit_media` and operator `media_library_assets` are separate ownership domains; no automatic copy or promotion exists.
8. Received `reddit_media` and operator `media_library_assets` are separate ownership domains; no automatic copy or promotion exists. Received-media identity represents the account-scoped Matrix message occurrence, so repeated use of one remote asset can produce multiple gallery rows that safely share the same private relative file path.
9. A library image content hash has at most one reservation per account conversation, even if the same file is later re-uploaded under a new asset ID; an inbound request also has at most one selected asset. Failed or uncertain attempts remain reserved to prevent duplicates.
10. Recipient account status changes only after a definitive Reddit profile response. Transient lookup failures retain the previous value, and the shared check timestamp prevents duplicate lookups across account runtimes during the four-hour interval.
## Repository map
+2
View File
@@ -63,6 +63,8 @@ Exchange-library uploads require an explicit consent checkbox before the raw ima
Conversation reply composers include a collapsed saved-image picker. It lists only server-approved assets for the latest inbound request, keeps thumbnails blurred until revealed, and requires selection plus a dedicated confirmation dialog before sending. The server remains authoritative and may reject a choice if the conversation changes before the rate-limited send executes.
The conversation inbox has a system-derived **Account deleted** bucket alongside the operator-maintained open, snoozed, and done queues. A deleted recipient is excluded from the default, unread, needs-reply, waiting, manual, and image buckets and is labelled in both the list and detail views. Suspended profiles are not treated as deleted.
## Feature structure
Each route is a slice under `features/<name>/` with page/route components, local styles, and feature-specific helpers. Cross-feature visual/domain components live under `components/`; application lifecycle belongs under `app/`; transport belongs under `api/`.
+3 -2
View File
@@ -36,14 +36,15 @@ This is an ownership map, not an API dump. Read the owning implementation and te
| `services/chat/cache.ts` | Account-scoped cached conversations and sync state | SQLite is durable; do not share caches across accounts. |
| `services/chat/matrix/username-resolver.ts` | Matrix/Reddit identity mapping | Preserve unresolved internal IDs without leaking them in operator text. |
| `services/chat/matrix/token-refresher.ts` | Explicit refresh-unavailable behavior | New credentials come from verified extension dumps, not token sniffing. |
| `services/recipient-account-status.ts` | Periodic cached-conversation recipient profile checks | Only definitive Reddit results replace durable state; checks are authentication-bound and must not block the reply loop. |
| `services/conversation-automation.ts` | Reply-first policy, claims, retries, send-time refresh, handoffs, turn budgets | Snapshot policy; claim before send; supersede stale queued work; reconcile pending claims; fail closed. |
| `services/conversation-state.ts` | Pure conversation parsing/state/safety helpers | Conversation text is untrusted data; keep prompt boundaries explicit. |
| `services/conversation-boundaries.ts` | Sequence-aware media requests, refusals, and full-stop detection | Short ambiguous replies become boundaries only in the matching request context. |
| `services/outbound-message-safety.ts` | Final no-proximity/no-meeting rule | This is defense in depth beyond prompts. |
| `services/conversation-outcome*.ts` | Response outcome classification/recording | Keep analytics attribution account/message scoped. |
| `services/response-checker.ts` | Inbox/performance response checks and successful examples | Non-overlapping; do not confuse chat sync with outcome maturity. |
| `services/media.ts` | Download, deduplicate, index, and list private media | Preserve account ownership and authenticated serving. |
| `services/media-sync.ts` | Periodic authenticated media sync | Stop while auth is unavailable and on runtime shutdown. |
| `services/media.ts` | Download, verify, index, and list private media occurrences while reusing asset files | Preserve account ownership, retryable download failures, and authenticated serving. |
| `services/media-sync.ts` | Periodic authenticated media sync and operator-visible completion status | Stop while auth is unavailable and on runtime shutdown; do not report partial work as success. |
| `services/media-library.ts` | Shared operator uploads, image validation/re-encoding, classification state, and private files | Require consent confirmation; received media never enters this store. |
| `services/media-library-exchange.ts` | Inbound request detection, progressive selection, reservation, and image-first exchange | Never offer proactively; exact tags do not substitute; no asset reuse per conversation. |
| `database/media-library-repository.ts` | Shared asset metadata and durable account/conversation send audit | Reserve before remote send; uncertain sends stay consumed. |
+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>;
+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;
+30
View File
@@ -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();
+3
View File
@@ -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;
}
+56 -27
View File
@@ -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,
};
});
+9 -9
View File
@@ -74,8 +74,7 @@ export class MediaSyncService {
this.logger.info('Media sync: sync already in progress, skipping trigger');
return false;
}
await this.runSync(full);
return true;
return this.runSync(full);
}
getStatus(): MediaSyncStatus {
@@ -91,12 +90,12 @@ export class MediaSyncService {
};
}
private async runSync(full = false): Promise<void> {
private async runSync(full = false): Promise<boolean> {
if (!this.accountAuth.isFullyAuthenticated()) {
this.logger.debug('Media sync: skipped because the account is not fully authenticated');
return;
return false;
}
if (this.syncing) return;
if (this.syncing) return false;
this.syncing = true;
this.lastError = null;
const start = Date.now();
@@ -104,12 +103,11 @@ export class MediaSyncService {
try {
if (full) {
this.logger.info('Media sync: running full sync (all conversations)...');
const chats = await this.chatService.fetchAllMessages();
const chats = await this.chatService.fetchAllMessages({ requireComplete: true });
this.lastSyncChats = chats.length;
} else {
this.logger.info('Media sync: running incremental sync (top 10 chats)...');
await this.chatService.refreshTopChats(10);
this.lastSyncChats = 10;
this.logger.info('Media sync: running incremental Matrix sync...');
this.lastSyncChats = await this.chatService.refreshTopChats();
}
this.lastSyncAt = Date.now();
@@ -118,10 +116,12 @@ export class MediaSyncService {
`Media sync: completed in ${Math.round(this.lastSyncDurationMs / 1000)}s` +
` (${this.lastSyncChats} chats)`
);
return true;
} catch (error) {
this.lastError = error instanceof Error ? error.message : String(error);
this.lastSyncDurationMs = Date.now() - start;
this.logger.error('Media sync: failed:', error);
return false;
} finally {
this.syncing = false;
}
+128 -41
View File
@@ -2,6 +2,7 @@ import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import { z } from 'zod';
import sharp from 'sharp';
import type { Logger } from '../utils/logger.js';
import type { ActiveChat, ChatMedia, ChatMessage } from './chat.js';
import type { AccountContentRepository } from '../database/account-repositories.js';
@@ -28,6 +29,15 @@ export interface MediaRecord {
createdAt: number;
}
export class MediaAttachmentError extends Error {
constructor(public readonly failedDownloads: number) {
super(`Failed to retrieve ${failedDownloads} conversation media item(s)`);
this.name = 'MediaAttachmentError';
}
}
class MediaDownloadError extends Error {}
const MediaIndexSchema = z.object({
version: z.number(),
savedAt: z.number(),
@@ -72,9 +82,16 @@ export class MediaService {
const databaseBacked = Boolean(this.databaseStore && this.getAccountId());
if (!databaseBacked) await this.loadIndex();
const updated: ChatMessage[] = [];
let failedDownloads = 0;
for (const message of messages) {
const media = await this.downloadMessageMedia(chat, message);
let media: ChatMedia[] = [];
try {
media = await this.downloadMessageMedia(chat, message);
} catch (error) {
if (!(error instanceof MediaDownloadError)) throw error;
failedDownloads++;
}
updated.push({
eventId: message.eventId,
sender: message.sender,
@@ -86,6 +103,7 @@ export class MediaService {
}
if (!databaseBacked) await this.saveIndex();
if (failedDownloads > 0) throw new MediaAttachmentError(failedDownloads);
return updated;
}
@@ -155,7 +173,13 @@ export class MediaService {
// Try the full-resolution URL first, fall back to original
const fullUrl = this.resolveFullMediaUrl(url);
const accountId = this.getAccountId();
const id = crypto.createHash('sha1').update(`${accountId ?? 'legacy'}\0${fullUrl}`).digest('hex');
const ownerKey = accountId ?? 'legacy';
const assetId = crypto.createHash('sha1').update(`${ownerKey}\0${fullUrl}`).digest('hex');
const occurrenceKey = message.eventId
?? `${chat.roomId ?? ''}\0${message.timestamp}\0${message.sender}`;
const id = crypto.createHash('sha1')
.update(`${ownerKey}\0${occurrenceKey}\0${fullUrl}`)
.digest('hex');
if (this.databaseStore && accountId) {
const existingRecord = this.databaseStore.getMedia(accountId, id);
if (existingRecord) {
@@ -166,6 +190,23 @@ export class MediaService {
this.databaseStore.deleteMedia(accountId, id);
}
}
const reusable = this.databaseStore.getMediaByOriginalUrl(accountId, fullUrl);
if (reusable) {
try {
await fs.access(path.join(this.mediaRoot, reusable.relativePath));
const record = this.buildRecord(
id, chat, message, fullUrl, reusable.fileName, reusable.relativePath, accountId,
);
this.databaseStore.upsertMedia(this.toStoredRecord(record, accountId));
if (reusable.mediaId === assetId && assetId !== id) {
this.databaseStore.deleteMedia(accountId, assetId);
}
return record;
} catch {
this.databaseStore.deleteMedia(accountId, reusable.mediaId);
}
}
}
const existing = this.index?.items.find((item) => item.id === id);
if (existing) {
@@ -181,23 +222,45 @@ export class MediaService {
}
}
const reusable = this.index?.items.find((item) => item.originalUrl === fullUrl);
if (reusable) {
try {
await fs.access(path.join(this.mediaRoot, reusable.relativePath));
const record = this.buildRecord(
id, chat, message, fullUrl, reusable.fileName, reusable.relativePath, accountId,
);
this.index!.items.push(record);
if (reusable.id === assetId && assetId !== id) {
this.index!.items = this.index!.items.filter((item) => item.id !== assetId);
}
return record;
} catch {
this.index!.items = this.index!.items.filter((item) => item.id !== reusable.id);
}
}
let download = await this.downloader.fetch(fullUrl);
if (!download && fullUrl !== url) {
this.logger.debug(`Full-res download failed, trying original URL: ${url}`);
let extension = download
? await this.pickExtension(url, download.contentType, download.buffer)
: null;
if ((!download || !extension) && fullUrl !== url) {
this.logger.debug(`Full-res media was unavailable or invalid, trying original URL: ${url}`);
download = await this.downloader.fetch(url);
extension = download
? await this.pickExtension(url, download.contentType, download.buffer)
: null;
}
if (!download) {
this.logger.warn(`Failed to download chat media from ${fullUrl}`);
return null;
throw new MediaDownloadError('Conversation media download failed');
}
const extension = this.pickExtension(url, download.contentType);
if (!extension) {
this.logger.debug(`Skipping non-image media from ${url}`);
return null;
}
const fileName = `${id}${extension}`;
const fileName = `${assetId}${extension}`;
const accountDirectory = accountId
? crypto.createHash('sha256').update(accountId).digest('hex').slice(0, 16)
: '';
@@ -207,32 +270,10 @@ export class MediaService {
await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, download.buffer);
const record: MediaRecord = {
accountId: accountId ?? undefined,
id,
chatUsername: chat.username || null,
chatRoomId: chat.roomId || null,
sender: message.sender,
timestamp: message.timestamp,
originalUrl: fullUrl,
fileName,
relativePath,
createdAt: Date.now(),
};
const record = this.buildRecord(id, chat, message, fullUrl, fileName, relativePath, accountId);
if (this.databaseStore && accountId) {
this.databaseStore.upsertMedia({
accountId,
mediaId: id,
chatUsername: record.chatUsername,
chatRoomId: record.chatRoomId,
sender: record.sender,
timestamp: record.timestamp,
originalUrl: record.originalUrl,
fileName: record.fileName,
relativePath: record.relativePath,
createdAt: record.createdAt,
});
this.databaseStore.upsertMedia(this.toStoredRecord(record, accountId));
return record;
}
@@ -246,26 +287,72 @@ export class MediaService {
return record;
}
private pickExtension(url: string, contentType: string | null): string | null {
private buildRecord(
id: string,
chat: ActiveChat,
message: ScrapedChatMessage,
originalUrl: string,
fileName: string,
relativePath: string,
accountId: string | null,
): MediaRecord {
return {
accountId: accountId ?? undefined,
id,
chatUsername: chat.username || null,
chatRoomId: chat.roomId || null,
sender: message.sender,
timestamp: message.timestamp,
originalUrl,
fileName,
relativePath,
createdAt: Date.now(),
};
}
private toStoredRecord(record: MediaRecord, accountId: string) {
return {
accountId,
mediaId: record.id,
chatUsername: record.chatUsername,
chatRoomId: record.chatRoomId,
sender: record.sender,
timestamp: record.timestamp,
originalUrl: record.originalUrl,
fileName: record.fileName,
relativePath: record.relativePath,
createdAt: record.createdAt,
};
}
private async pickExtension(url: string, contentType: string | null, buffer: Buffer): Promise<string | null> {
const supportedExtensions = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif']);
let urlExt = '';
try {
urlExt = path.extname(new URL(url).pathname || '').toLowerCase();
} catch {
urlExt = path.extname(url.split('?')[0] || '').toLowerCase();
}
if (urlExt && urlExt.length <= 5) {
if (supportedExtensions.has(urlExt)) {
return urlExt;
}
if (!contentType) {
return null;
}
if (contentType?.includes('image/jpeg')) return '.jpg';
if (contentType?.includes('image/png')) return '.png';
if (contentType?.includes('image/gif')) return '.gif';
if (contentType?.includes('image/webp')) return '.webp';
if (contentType?.includes('image/avif')) return '.avif';
if (contentType.includes('image/jpeg')) return '.jpg';
if (contentType.includes('image/png')) return '.png';
if (contentType.includes('image/gif')) return '.gif';
if (contentType.includes('image/webp')) return '.webp';
if (contentType.includes('image/avif')) return '.avif';
try {
const format = (await sharp(buffer).metadata()).format;
if (format === 'jpeg') return '.jpg';
if (format === 'png') return '.png';
if (format === 'gif') return '.gif';
if (format === 'webp') return '.webp';
if (format === 'heif') return '.avif';
} catch {
// Unsupported/corrupt payloads are not gallery images.
}
return null;
}
+97
View File
@@ -0,0 +1,97 @@
import type { Logger } from '../utils/logger.js';
import type { RedditUserAccountStatus } from '../reddit/api.js';
import { RedditRateLimitError } from '../reddit/api.js';
import type { RedditRecipientAccountStatusRepository } from '../database/account-repositories.js';
import type { ChatService } from './chat.js';
import { denyAccountAuth, type AccountAuthGate } from './account-auth-context.js';
export const RECIPIENT_ACCOUNT_CHECK_INTERVAL_MS = 4 * 60 * 60_000;
const INITIAL_CHECK_DELAY_MS = 60_000;
export interface RedditAccountStatusLookup {
getUserAccountStatus(username: string): Promise<RedditUserAccountStatus>;
}
/** Periodically records whether the Reddit users behind cached conversations still exist. */
export class RecipientAccountStatusService {
private intervalHandle: NodeJS.Timeout | null = null;
private initialHandle: NodeJS.Timeout | null = null;
private checking = false;
constructor(
private readonly chatService: Pick<ChatService, 'getCachedConversations'>,
private readonly reddit: RedditAccountStatusLookup,
private readonly repository: RedditRecipientAccountStatusRepository,
private readonly logger: Logger,
private readonly accountAuth: AccountAuthGate = denyAccountAuth,
private readonly intervalMs = RECIPIENT_ACCOUNT_CHECK_INTERVAL_MS,
private readonly now: () => number = Date.now,
) {}
start(): void {
if (this.intervalHandle) return;
this.initialHandle = setTimeout(() => {
this.initialHandle = null;
void this.checkNow();
}, Math.min(INITIAL_CHECK_DELAY_MS, this.intervalMs));
this.intervalHandle = setInterval(() => void this.checkNow(), this.intervalMs);
this.logger.info(`Recipient account check: scheduled every ${Math.round(this.intervalMs / 3_600_000)} hours`);
}
stop(): void {
if (this.initialHandle) clearTimeout(this.initialHandle);
if (this.intervalHandle) clearInterval(this.intervalHandle);
this.initialHandle = null;
this.intervalHandle = null;
}
async checkNow(): Promise<number> {
if (this.checking || !this.accountAuth.isFullyAuthenticated()) return 0;
this.checking = true;
let checked = 0;
const startedAt = this.now();
try {
const chats = await this.chatService.getCachedConversations();
const uniqueUsernames = new Map<string, string>();
for (const chat of chats) {
const username = chat.username.trim();
if (isCheckableUsername(username) && !uniqueUsernames.has(username.toLowerCase())) {
uniqueUsernames.set(username.toLowerCase(), username);
}
}
const usernames = [...uniqueUsernames.values()];
for (const username of usernames) {
const previous = this.repository.get(username);
if (previous && previous.checkedAt > startedAt - this.intervalMs) continue;
try {
const status = await this.reddit.getUserAccountStatus(username);
this.repository.upsert(username, status, this.now());
checked += 1;
if (status === 'deleted' && previous?.status !== 'deleted') {
this.logger.info(`Recipient account check: u/${username} is deleted`);
}
} catch (error) {
if (error instanceof RedditRateLimitError) {
this.logger.warn('Recipient account check paused because Reddit rate-limited profile lookups');
break;
}
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Recipient account check failed for u/${username}: ${message}`);
}
}
return checked;
} finally {
this.checking = false;
}
}
}
function isCheckableUsername(username: string): boolean {
const normalized = username.toLowerCase();
return username.length > 0
&& normalized !== 'unknown'
&& normalized !== '[deleted]'
&& !normalized.startsWith('t2_')
&& !username.startsWith('@');
}
+18 -2
View File
@@ -13,7 +13,11 @@ import {
ActivityLogRepository,
RecipientHistoryRepository,
} from '../database/repositories.js';
import { AccountContentRepository, RedditAccountsRepository } from '../database/account-repositories.js';
import {
AccountContentRepository,
RedditAccountsRepository,
RedditRecipientAccountStatusRepository,
} from '../database/account-repositories.js';
import { RedditApiClient, RedditRateLimitError, type RedditPost } from '../reddit/api.js';
import { RedditRequestCoordinator } from '../reddit/request-coordinator.js';
import { OpenRouterClient } from '../ai/openrouter.js';
@@ -48,6 +52,7 @@ import { ConversationAutomationService } from './conversation-automation.js';
import { AiUsageRepository } from '../database/ai-usage-repository.js';
import { MediaLibraryRepository } from '../database/media-library-repository.js';
import { MediaLibraryExchangeService } from './media-library-exchange.js';
import { RecipientAccountStatusService } from './recipient-account-status.js';
export interface ConcreteRuntimeFactoryOptions {
config: Config;
@@ -212,6 +217,14 @@ async function buildRuntime(
);
await chatService.loadCache();
const mediaSyncService = new MediaSyncService(chatService, logger, accountConfig.media.syncIntervalMs, accountAuth);
const recipientAccountStatusRepo = new RedditRecipientAccountStatusRepository(db);
const recipientAccountStatusService = new RecipientAccountStatusService(
chatService,
redditApi,
recipientAccountStatusRepo,
logger,
accountAuth,
);
const responseChecker = new ResponseCheckerService(
chatPage, messagesRepo, successfulMessagesRepo, postsRepo, logger,
accountAuth, conversationOutcomesRepo,
@@ -250,10 +263,11 @@ async function buildRuntime(
browserProfileDir, browserClient, accountAuth, matrixClient, chatCache, redditApi,
postsRepo, messagesRepo, successfulMessagesRepo, deliveryOutboxRepo,
conversationStateRepo, conversationReadStateRepo, conversationOutcomesRepo,
redditUsersRepo, accountContentRepo,
redditUsersRepo, accountContentRepo, recipientAccountStatusRepo,
activityLogRepo,
monitorService, processorService, responseChecker, conversationAutomation, chatService, mediaService,
mediaSyncService, mediaExchange, rateLimiter,
recipientAccountStatusService,
};
const lifecycle = new AccountRuntimeLoop(
resources, accountConfig, logger, isBotRunning, recipientDeduplicator,
@@ -293,6 +307,7 @@ class AccountRuntimeLoop {
if (this.loop) return;
this.controller = new AbortController();
this.resources.mediaSyncService.start();
this.resources.recipientAccountStatusService.start();
void this.resources.chatService.runStartupSync()
.then(() => this.resources.chatService.enrichUsernames())
.catch((error) => this.logger.error(`[account ${this.resources.accountAuth.getSnapshot().redditUsername}] startup sync failed:`, error));
@@ -302,6 +317,7 @@ class AccountRuntimeLoop {
async stop(): Promise<void> {
this.controller?.abort();
this.resources.mediaSyncService.stop();
this.resources.recipientAccountStatusService.stop();
await this.loop?.catch((error) => {
if (!(error instanceof DOMException && error.name === 'AbortError')) throw error;
});
+4
View File
@@ -6,6 +6,7 @@ import type {
RedditAccountPrivateRecord,
RedditAccountsRepository,
AccountContentRepository,
RedditRecipientAccountStatusRepository,
} from '../database/account-repositories.js';
import type {
ConversationOutcomesRepository,
@@ -33,6 +34,7 @@ import type { ProcessorService } from './processor.js';
import type { ResponseCheckerService } from './response-checker.js';
import type { RedditPost } from '../reddit/api.js';
import type { ConversationAutomationService } from './conversation-automation.js';
import type { RecipientAccountStatusService } from './recipient-account-status.js';
import type { ProcessedPost, ProcessResult } from './processor.js';
/** Credentials passed to a factory. They must never be exposed by list/snapshot APIs. */
@@ -70,6 +72,7 @@ export interface RedditAccountRuntimeResources {
conversationOutcomesRepo: ConversationOutcomesRepository;
redditUsersRepo: RedditUsersRepository;
accountContentRepo: AccountContentRepository;
recipientAccountStatusRepo: RedditRecipientAccountStatusRepository;
activityLogRepo: ActivityLogRepository;
monitorService: MonitorService;
@@ -79,6 +82,7 @@ export interface RedditAccountRuntimeResources {
chatService: ChatService;
mediaService: MediaService;
mediaSyncService: MediaSyncService;
recipientAccountStatusService: RecipientAccountStatusService;
mediaExchange?: MediaLibraryExchangeService;
rateLimiter: RateLimiter;
}
+38 -8
View File
@@ -8,12 +8,15 @@ import {
conversationMessageFingerprint,
mergeConversationState,
remainingAutomationTurns,
type ConversationAutomationStatus,
type ConversationPolicyMode,
} from '../../services/conversation-state.js';
import type { MediaLibraryAsset } from '../../services/media-library-types.js';
import type {
ConversationReadStateRecord,
ConversationReadStateRepository,
} from '../../database/repositories.js';
import type { RedditRecipientAccountStatusRecord } from '../../database/account-repositories.js';
const ShareConversationMediaRequestSchema = z.strictObject({ assetId: z.string().uuid() });
const MarkReadRequestSchema = z.strictObject({
@@ -30,7 +33,12 @@ const BulkMarkReadRequestSchema = z.strictObject({
conversationIds: z.array(z.string().min(1)).min(1).max(500),
});
type OwnedActiveChat = ActiveChat & { owner?: { accountId: string; redditUsername: string } };
type OwnedActiveChat = ActiveChat & {
owner?: { accountId: string; redditUsername: string };
automationStatus?: ConversationAutomationStatus;
automationPolicyMode?: ConversationPolicyMode;
automationHandoffReason?: string | null;
};
/** An owned conversation with operator triage state projected onto it. */
type TriagedActiveChat = OwnedActiveChat & {
@@ -39,6 +47,8 @@ type TriagedActiveChat = OwnedActiveChat & {
triageStatus: 'open' | 'snoozed' | 'done';
snoozedUntil: number | null;
lastReadMessageTs: number;
recipientAccountStatus?: 'active' | 'suspended' | 'deleted';
recipientAccountStatusCheckedAt?: number;
};
/**
@@ -52,16 +62,21 @@ type TriagedActiveChat = OwnedActiveChat & {
function annotateTriage(
conversation: OwnedActiveChat,
readState: ConversationReadStateRecord | undefined,
recipientStatus?: RedditRecipientAccountStatusRecord | null,
): TriagedActiveChat {
const lastReadMessageTs = readState?.lastReadMessageTs ?? 0;
const status = readState?.status ?? 'open';
return {
...conversation,
hasUnreadInbound: !conversation.isFromUs && conversation.lastMessageTime > lastReadMessageTs,
needsReply: !conversation.isFromUs && status === 'open',
needsReply: recipientStatus?.status !== 'deleted' && !conversation.isFromUs && status === 'open',
triageStatus: status,
snoozedUntil: readState?.snoozedUntil ?? null,
lastReadMessageTs,
...(recipientStatus ? {
recipientAccountStatus: recipientStatus.status,
recipientAccountStatusCheckedAt: recipientStatus.checkedAt,
} : {}),
};
}
@@ -132,14 +147,25 @@ export function createConversationsRouter(ctx: WebContext): Router {
return { messages, state, intent: chooseConversationIntent(messages, state, requestedIntent) };
}
function withSellerMarker(
/**
* Projects the durable conversation state onto a list row: the seller flag and
* the reply-loop status. The status is what tells the inbox which
* conversations the operator is now driving by hand rather than the bot.
*/
function withStateMarkers(
conversation: OwnedActiveChat,
stateRepo = ctx.services.conversationState,
): OwnedActiveChat {
if (!conversation.roomId) return conversation;
return stateRepo?.get(conversation.roomId)?.sellerDetected === true
? { ...conversation, sellerDetected: true }
: conversation;
const state = stateRepo?.get(conversation.roomId);
if (!state) return conversation;
return {
...conversation,
...(state.sellerDetected === true ? { sellerDetected: true } : {}),
automationStatus: state.automation.status,
automationPolicyMode: state.automation.policyMode,
automationHandoffReason: state.automation.handoffReason,
};
}
/**
@@ -184,17 +210,18 @@ export function createConversationsRouter(ctx: WebContext): Router {
const runtime = ctx.services.accountRuntimes!.get(snapshot.accountId)!;
const readState = currentReadState(runtime.resources.conversationReadStateRepo);
return (await runtime.resources.chatService.getCachedConversations()).map((conversation) => annotateTriage(
withSellerMarker({
withStateMarkers({
...conversation,
owner: { accountId: snapshot.accountId, redditUsername: snapshot.redditUsername },
}, runtime.resources.conversationStateRepo),
conversation.roomId ? readState.get(conversation.roomId) : undefined,
runtime.resources.recipientAccountStatusRepo?.get(conversation.username),
));
}))).flat()
: await (async () => {
const readState = currentReadState(ctx.services.conversationReadState);
return (await chatService.getCachedConversations()).map((conversation) => annotateTriage(
withSellerMarker(conversation),
withStateMarkers(conversation),
conversation.roomId ? readState.get(conversation.roomId) : undefined,
));
})();
@@ -347,6 +374,7 @@ export function createConversationsRouter(ctx: WebContext): Router {
const triage = annotateTriage(
conversation,
conversation.roomId ? readStateRepo?.get(conversation.roomId) : undefined,
resolved.runtime?.resources.recipientAccountStatusRepo?.get(conversation.username),
);
res.json({
@@ -356,6 +384,8 @@ export function createConversationsRouter(ctx: WebContext): Router {
triageStatus: triage.triageStatus,
snoozedUntil: triage.snoozedUntil,
lastReadMessageTs: triage.lastReadMessageTs,
recipientAccountStatus: triage.recipientAccountStatus,
recipientAccountStatusCheckedAt: triage.recipientAccountStatusCheckedAt,
sellerDetected: intelligence.state.sellerDetected,
...(annotatedMessages ? { messages: annotatedMessages } : {}),
owner: resolved.runtime
+24 -4
View File
@@ -100,7 +100,14 @@ export function createMediaRouter(ctx: WebContext): Router {
const results = await Promise.all(runtimes.map((snapshot) =>
ctx.services.accountRuntimes!.get(snapshot.accountId)!.resources.mediaSyncService.triggerSync(full),
));
res.json({ success: results.some(Boolean), reason: results.some(Boolean) ? undefined : 'Sync already in progress' });
const success = results.every(Boolean);
const failedStatus = runtimes
.map((snapshot) => ctx.services.accountRuntimes!.get(snapshot.accountId)!.resources.mediaSyncService.getStatus())
.find((status) => status.lastError);
res.json({
success,
reason: success ? undefined : failedStatus?.lastError ?? 'Sync already in progress',
});
return;
}
if (!ctx.services.mediaSync) {
@@ -111,7 +118,10 @@ export function createMediaRouter(ctx: WebContext): Router {
const full = req.body?.full === true;
const started = await ctx.services.mediaSync.triggerSync(full);
if (!started) {
res.json({ success: false, reason: 'Sync already in progress' });
res.json({
success: false,
reason: ctx.services.mediaSync.getStatus().lastError ?? 'Sync already in progress',
});
return;
}
ctx.broadcaster.push('info', `Media sync triggered from dashboard (${full ? 'full' : 'incremental'})`);
@@ -128,7 +138,14 @@ export function createMediaRouter(ctx: WebContext): Router {
const results = await Promise.all(runtimes.map((snapshot) =>
ctx.services.accountRuntimes!.get(snapshot.accountId)!.resources.mediaSyncService.triggerSync(true),
));
res.json({ success: results.some(Boolean), reason: results.some(Boolean) ? undefined : 'Sync already in progress' });
const success = results.every(Boolean);
const failedStatus = runtimes
.map((snapshot) => ctx.services.accountRuntimes!.get(snapshot.accountId)!.resources.mediaSyncService.getStatus())
.find((status) => status.lastError);
res.json({
success,
reason: success ? undefined : failedStatus?.lastError ?? 'Sync already in progress',
});
return;
}
if (!ctx.services.mediaSync) {
@@ -138,7 +155,10 @@ export function createMediaRouter(ctx: WebContext): Router {
try {
const started = await ctx.services.mediaSync.triggerSync(true);
if (!started) {
res.json({ success: false, reason: 'Sync already in progress' });
res.json({
success: false,
reason: ctx.services.mediaSync.getStatus().lastError ?? 'Sync already in progress',
});
return;
}
ctx.broadcaster.push('info', 'Full media sync triggered from dashboard (all conversations)');
+25
View File
@@ -6,6 +6,7 @@ import path from 'node:path';
import {
AccountContentRepository,
RedditAccountsRepository,
RedditRecipientAccountStatusRepository,
} from '../src/database/account-repositories.js';
import { LEGACY_UNASSIGNED_ACCOUNT_ID, runMigrations } from '../src/database/client.js';
import { importLegacyAccountContent } from '../src/database/legacy-content-import.js';
@@ -54,6 +55,15 @@ describe('multi-account database ownership', () => {
});
});
it('stores recipient account state case-insensitively and allows later recovery', () => {
const statuses = new RedditRecipientAccountStatusRepository(db);
statuses.upsert('Alice', 'deleted', 100);
expect(statuses.get('alice')).toEqual({ username: 'Alice', status: 'deleted', checkedAt: 100 });
statuses.upsert('ALICE', 'active', 200);
expect(statuses.get('alice')).toEqual({ username: 'Alice', status: 'active', checkedAt: 200 });
});
it('updates matrix credentials in place without touching cookies or status', () => {
addAccount('t2_alice', 'alice-bot', 100);
@@ -196,4 +206,19 @@ describe('multi-account database ownership', () => {
fs.rmSync(dataDirectory, { recursive: true, force: true });
}
});
it('stores separate media occurrences that share one downloaded file', () => {
addAccount('t2_alice', 'alice-bot', 100);
const base = {
accountId: 't2_alice', chatUsername: 'sender', chatRoomId: 'room-one', sender: 'sender',
originalUrl: 'mxc://reddit.com/shared', fileName: 'shared.jpg', relativePath: 'shared.jpg',
createdAt: 10,
};
content.upsertMedia({ ...base, mediaId: 'event-one', timestamp: 1 });
content.upsertMedia({ ...base, mediaId: 'event-two', timestamp: 2 });
expect(content.listMedia('t2_alice')).toHaveLength(2);
expect(content.getMediaByOriginalUrl('t2_alice', base.originalUrl)?.mediaId).toBe('event-one');
});
});
+1 -1
View File
@@ -75,7 +75,7 @@ describe('ChatService account authentication gate', () => {
await vi.waitFor(() => expect(matrix.sync).toHaveBeenCalledOnce());
finishSync({ next_batch: 's1' });
await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined]);
await expect(Promise.all([first, second])).resolves.toEqual([0, 0]);
expect(matrix.sync).toHaveBeenCalledOnce();
});
+77
View File
@@ -198,4 +198,81 @@ describe('ChatService delta sync — media attachment', () => {
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
await expect(svc.getActiveChats()).resolves.toBeDefined();
});
it('backfills the room when Matrix marks a delta timeline as limited', async () => {
const logger = createMockLogger();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-limited-delta-media-'));
try {
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
matrix.sync = vi.fn(async () => ({
next_batch: 's2',
rooms: { join: { '!room1:example.test': {
timeline: {
limited: true,
prev_batch: 'gap-token',
events: [],
},
} } },
})) as typeof matrix.sync;
matrix.getRoomMessages = vi.fn(async () => ({
start: 'latest',
chunk: [{
event_id: '$missed-image', type: 'm.room.message', sender: '@t2_abc:example.test',
origin_server_ts: 1_000,
content: { msgtype: 'm.image', info: { thumbnail_url: 'mxc://example.test/missed' } },
}],
}));
const media = new MediaService(buildDownloader(), logger, tmpDir);
const svc = new ChatService(
matrix, logger, undefined, media, undefined, undefined,
new ChatCache(logger, path.join(tmpDir, 'chat-cache.json')),
undefined, undefined, authenticatedAccountAuth,
);
await expect(svc.refreshTopChats()).resolves.toBe(1);
expect(matrix.getRoomMessages).toHaveBeenCalledWith(
'!room1:example.test', { from: undefined, limit: 100, dir: 'b' },
);
expect(await media.getMediaItems()).toEqual([
expect.objectContaining({ originalUrl: 'mxc://example.test/missed' }),
]);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it('does not persist the next delta cursor when a media download fails', async () => {
const logger = createMockLogger();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-media-retry-cursor-'));
const cacheFile = path.join(tmpDir, 'chat-cache.json');
try {
await fs.writeFile(cacheFile, JSON.stringify({
version: 1, savedAt: 1, chats: [], nextBatch: 's1',
lastFullSyncAt: 1, lastDeltaSyncAt: 1, cooldown: null,
}));
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
matrix.sync = vi.fn(async () => ({
next_batch: 's2',
rooms: { join: { '!room1:example.test': { timeline: { events: [{
event_id: '$image', type: 'm.room.message', sender: '@t2_abc:example.test',
origin_server_ts: 1_000, content: { msgtype: 'm.image', url: 'mxc://example.test/fails' },
}] } } } },
})) as typeof matrix.sync;
const media = new MediaService({ fetch: vi.fn().mockResolvedValue(null) }, logger, tmpDir);
const cache = new ChatCache(logger, cacheFile);
const svc = new ChatService(
matrix, logger, undefined, media, undefined, undefined, cache,
undefined, undefined, authenticatedAccountAuth,
);
await svc.loadCache();
await expect(svc.refreshTopChats()).rejects.toThrow('Failed to retrieve');
const persisted = JSON.parse(await fs.readFile(cacheFile, 'utf-8')) as { nextBatch: string };
expect(persisted.nextBatch).toBe('s1');
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
});
@@ -191,4 +191,62 @@ describe('ChatService progressive full backfill', () => {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it('rejects a required-complete backfill and leaves the full-sync marker unset when a room fails', async () => {
const logger = createMockLogger();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-incomplete-backfill-'));
try {
const matrix = new MatrixClient(credentials, logger, { minRequestIntervalMs: 0 });
matrix.sync = vi.fn(async () => ({ next_batch: 's1', rooms: { join: {} } })) as typeof matrix.sync;
matrix.getJoinedRooms = vi.fn(async () => ['!alice:reddit.com']);
matrix.getRoomMessages = vi.fn().mockRejectedValue(new Error('temporary history failure'));
const cache = new ChatCache(logger, path.join(tmpDir, 'chat-cache.json'));
const service = new ChatService(
matrix, logger, undefined, undefined, undefined, undefined, cache,
undefined, undefined, authenticatedAccountAuth,
);
await expect(service.fetchAllMessages({ requireComplete: true }))
.rejects.toThrow('1 of 1 conversations failed');
expect(cache.getMarkers().lastFullSyncAt).toBeNull();
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it('paginates beyond the former 50-page history ceiling', async () => {
const logger = createMockLogger();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-long-backfill-'));
try {
const matrix = new MatrixClient(credentials, logger, { minRequestIntervalMs: 0 });
matrix.sync = vi.fn(async () => ({ next_batch: 's1', rooms: { join: {} } })) as typeof matrix.sync;
matrix.getJoinedRooms = vi.fn(async () => ['!long:reddit.com']);
let page = 0;
matrix.getRoomMessages = vi.fn(async () => {
page++;
return {
start: `start-${page}`,
end: page < 51 ? `page-${page}` : undefined,
chunk: [{
event_id: `$event-${page}`,
type: 'm.room.message',
sender: '@t2_long:reddit.com',
origin_server_ts: page,
content: { msgtype: 'm.text', body: `message ${page}` },
}],
};
});
const service = new ChatService(
matrix, logger, undefined, undefined, undefined, undefined,
new ChatCache(logger, path.join(tmpDir, 'chat-cache.json')),
undefined, undefined, authenticatedAccountAuth,
);
const [chat] = await service.fetchAllMessages({ requireComplete: true });
expect(matrix.getRoomMessages).toHaveBeenCalledTimes(51);
expect(chat.messages).toHaveLength(51);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
});
+54 -2
View File
@@ -6,8 +6,10 @@ import {
filterCounts,
isConversationFilter,
isConversationSort,
isManualFlow,
isUnread,
isWaitingOnThem,
manualFlowLabel,
matchesFilter,
matchesSearch,
needsReply,
@@ -63,6 +65,33 @@ describe('conversation triage', () => {
it('defaults a conversation with no stored state to open', () => {
expect(triageStatus(chat({ username: 'a' }))).toBe('open');
});
it('treats every handed-over reply-loop status as a manual conversation', () => {
for (const status of ['user-overridden', 'image-received', 'boundary', 'human-required'] as const) {
expect(isManualFlow(chat({ username: 'a', automationStatus: status }))).toBe(true);
}
});
it('treats a manual policy as manual even while the reply loop is still nominally active', () => {
expect(isManualFlow(chat({
username: 'a', automationStatus: 'active', automationPolicyMode: 'manual',
}))).toBe(true);
});
it('does not call an actively automated or never-automated conversation manual', () => {
expect(isManualFlow(chat({ username: 'a', automationStatus: 'active' }))).toBe(false);
expect(isManualFlow(chat({ username: 'b' }))).toBe(false);
});
it('explains why a conversation is being handled by hand', () => {
expect(manualFlowLabel(chat({ username: 'a', automationStatus: 'user-overridden' })))
.toBe('Manual — you replied');
expect(manualFlowLabel(chat({ username: 'b', automationStatus: 'image-received' })))
.toBe('Manual — image received');
expect(manualFlowLabel(chat({ username: 'c', automationStatus: 'active', automationPolicyMode: 'manual' })))
.toBe('Manual by policy');
expect(manualFlowLabel(chat({ username: 'd', automationStatus: 'active' }))).toBeNull();
});
});
describe('filters', () => {
@@ -71,6 +100,7 @@ describe('conversation triage', () => {
const snoozed = chat({ username: 'snoozed', triageStatus: 'snoozed' });
const done = chat({ username: 'done', triageStatus: 'done' });
const withImage = chat({ username: 'image', hasReceivedImages: true });
const deleted = chat({ username: 'deleted', recipientAccountStatus: 'deleted' });
it('excludes snoozed and resolved conversations from the default view', () => {
expect(matchesFilter(open, 'all')).toBe(true);
@@ -84,6 +114,13 @@ describe('conversation triage', () => {
expect(matchesFilter(done, 'done')).toBe(true);
});
it('moves deleted accounts out of actionable buckets into their own bucket', () => {
expect(matchesFilter(deleted, 'all')).toBe(false);
expect(matchesFilter(deleted, 'needs-reply')).toBe(false);
expect(matchesFilter(deleted, 'account-deleted')).toBe(true);
expect(needsReply(deleted)).toBe(false);
});
it('scopes unread and image buckets to open conversations', () => {
expect(matchesFilter(open, 'unread')).toBe(true);
expect(matchesFilter(chat({ username: 'x', hasUnreadInbound: true, triageStatus: 'done' }), 'unread')).toBe(false);
@@ -91,6 +128,17 @@ describe('conversation triage', () => {
expect(matchesFilter(chat({ username: 'y', hasReceivedImages: true, triageStatus: 'done' }), 'received-images')).toBe(false);
});
it('collects conversations the operator is driving into the manual bucket', () => {
const manual = chat({ username: 'manual', automationStatus: 'user-overridden' });
expect(matchesFilter(manual, 'manual')).toBe(true);
expect(matchesFilter(open, 'manual')).toBe(false);
// Resolved work leaves the manual queue the same way it leaves the others.
expect(matchesFilter(
chat({ username: 'z', automationStatus: 'user-overridden', triageStatus: 'done' }),
'manual',
)).toBe(false);
});
it('matches search against username, snippet and loaded message text', () => {
const conversation = chat({
username: 'alice',
@@ -176,14 +224,18 @@ describe('conversation triage', () => {
chat({ username: 'replied', isFromUs: true }),
chat({ username: 'snoozed', triageStatus: 'snoozed' }),
chat({ username: 'done', triageStatus: 'done' }),
chat({ username: 'manual', isFromUs: true, automationStatus: 'user-overridden' }),
chat({ username: 'deleted', recipientAccountStatus: 'deleted' }),
], filters());
expect(counts.all).toBe(3);
expect(counts.all).toBe(4);
expect(counts.unread).toBe(1);
expect(counts['needs-reply']).toBe(2);
expect(counts['awaiting-reply']).toBe(1);
expect(counts['awaiting-reply']).toBe(2);
expect(counts.manual).toBe(1);
expect(counts.snoozed).toBe(1);
expect(counts.done).toBe(1);
expect(counts['account-deleted']).toBe(1);
});
it('respects the search term so a pill never promises rows the list will not show', () => {
+30
View File
@@ -20,6 +20,8 @@ interface ChatFixtureOptions {
lastMessageSnippet?: string;
hasReceivedImages?: boolean;
sellerDetected?: boolean;
automationStatus?: 'active' | 'image-received' | 'human-required' | 'boundary' | 'user-overridden';
automationHandoffReason?: string;
}
function chatFixture(options: ChatFixtureOptions) {
@@ -34,6 +36,10 @@ function chatFixture(options: ChatFixtureOptions) {
unread: options.unread ?? false,
...(options.hasReceivedImages === undefined ? {} : { hasReceivedImages: options.hasReceivedImages }),
...(options.sellerDetected === undefined ? {} : { sellerDetected: options.sellerDetected }),
...(options.automationStatus === undefined ? {} : { automationStatus: options.automationStatus }),
...(options.automationHandoffReason === undefined
? {}
: { automationHandoffReason: options.automationHandoffReason }),
};
}
@@ -269,6 +275,30 @@ describe('ConversationsPage', () => {
expect(screen.getByRole('img', { name: 'Image received from u/alice' })).not.toHaveClass('is-blurred');
});
it('marks and filters conversations that are being replied to by hand', async () => {
const manual = chatFixture({
username: 'alice',
roomId: 'room-alice',
isFromUs: true,
automationStatus: 'user-overridden',
automationHandoffReason: 'A manual reply was sent in this conversation.',
});
const automated = chatFixture({ username: 'bob', roomId: 'room-bob', automationStatus: 'active' });
server.use(http.get('/api/conversations', () => HttpResponse.json([manual, automated])));
const user = userEvent.setup();
renderConversations();
await screen.findByRole('button', { name: /u\/alice/ });
expect(screen.getByText('Manual', { selector: '.conversation-chip' })).toHaveAttribute(
'title',
'Manual — you replied — A manual reply was sent in this conversation.',
);
await user.click(screen.getByRole('button', { name: 'Manual, 1' }));
expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument();
});
it('turns a suggestion into an editable draft and confirms sending without losing failed input', async () => {
installConversationList();
let sendRequests = 0;
+2 -1
View File
@@ -107,13 +107,14 @@ describe('Database Client', () => {
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
.all() as { name: string }[];
// Existing domain/config tables plus account, conversation, sync and media ownership stores.
expect(tables.length).toBe(21);
expect(tables.length).toBe(22);
expect(tables.map((table) => table.name)).toEqual(expect.arrayContaining([
'candidate_queue',
'runtime_state',
'media_library_assets',
'media_library_sends',
'conversation_read_state',
'reddit_recipient_account_status',
]));
});
+51
View File
@@ -20,4 +20,55 @@ describe('MediaSyncService account authentication gate', () => {
expect(chat.refreshTopChats).not.toHaveBeenCalled();
expect(chat.fetchAllMessages).not.toHaveBeenCalled();
});
it('reports the actual number of rooms changed by an incremental sync', async () => {
const chat = {
refreshTopChats: vi.fn().mockResolvedValue(3),
fetchAllMessages: vi.fn(),
};
const service = new MediaSyncService(
chat as never,
createMockLogger(),
30 * 60 * 1000,
{ isFullyAuthenticated: () => true },
);
await expect(service.triggerSync()).resolves.toBe(true);
expect(service.getStatus().lastSyncChats).toBe(3);
});
it('requires a complete historical backfill for a full media sync', async () => {
const chat = {
refreshTopChats: vi.fn(),
fetchAllMessages: vi.fn().mockResolvedValue([]),
};
const service = new MediaSyncService(
chat as never,
createMockLogger(),
30 * 60 * 1000,
{ isFullyAuthenticated: () => true },
);
await service.triggerSync(true);
expect(chat.fetchAllMessages).toHaveBeenCalledWith({ requireComplete: true });
});
it('returns failure and preserves the operator-visible error when sync work is incomplete', async () => {
const chat = {
refreshTopChats: vi.fn().mockRejectedValue(new Error('media history incomplete')),
fetchAllMessages: vi.fn(),
};
const service = new MediaSyncService(
chat as never,
createMockLogger(),
30 * 60 * 1000,
{ isFullyAuthenticated: () => true },
);
await expect(service.triggerSync()).resolves.toBe(false);
expect(service.getStatus()).toMatchObject({
lastSyncAt: null,
lastError: 'media history incomplete',
});
});
});
+53 -8
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { MediaService } from '../src/services/media.js';
import { MediaAttachmentError, MediaService } from '../src/services/media.js';
import type { MediaDownloader } from '../src/services/media.js';
import type { ActiveChat, ChatMessage } from '../src/services/chat.js';
import { createMockLogger } from '../tests/mockLogger.js';
@@ -47,11 +47,24 @@ describe('MediaService', () => {
});
describe('resolveFullMediaUrl', () => {
it('converts blurred thumbnail to full resolution', () => {
it('falls back to the blurred thumbnail when the full-resolution response is not an image', async () => {
const blurred = 'https://matrix.redditspace.com/_matrix/media/v3/thumbnail/reddit.com/blur_XXXXX?height=300';
// We can't test private method directly, but we can test via attachMedia
// For now, verify the service is created
expect(service).toBeDefined();
const fetch = vi.fn()
.mockResolvedValueOnce({ buffer: Buffer.from('not an image'), contentType: 'text/html' })
.mockResolvedValueOnce({ buffer: Buffer.from('image data'), contentType: 'image/jpeg' });
service = new MediaService({ fetch }, logger, tempDir);
const [message] = await service.attachMedia(createMockChat(), [{
eventId: '$blurred', sender: 'user1', text: '', timestamp: 1, isSelf: false,
mediaUrls: [blurred],
}]);
expect(fetch).toHaveBeenNthCalledWith(
1,
'https://matrix.redditspace.com/_matrix/media/v3/download/reddit.com/XXXXX',
);
expect(fetch).toHaveBeenNthCalledWith(2, blurred);
expect(message.media).toHaveLength(1);
});
it('leaves non-thumbnail URLs unchanged', () => {
@@ -109,7 +122,7 @@ describe('MediaService', () => {
expect(result[0].media).toBeUndefined();
});
it('handles download failures gracefully', async () => {
it('surfaces download failures so the sync cursor can be retried', async () => {
const failingDownloader = createMockDownloader(null);
service = new MediaService(failingDownloader, logger);
@@ -118,8 +131,7 @@ describe('MediaService', () => {
{ sender: 'user1', text: 'Test', timestamp: Date.now(), isSelf: false, mediaUrls: ['http://example.com/img.jpg'] },
];
const result = await service.attachMedia(chat, messages);
expect(result[0].media).toBeUndefined();
await expect(service.attachMedia(chat, messages)).rejects.toBeInstanceOf(MediaAttachmentError);
});
it('caches downloaded media by URL hash', async () => {
@@ -147,6 +159,39 @@ describe('MediaService', () => {
expect(downloader.fetch).not.toHaveBeenCalled();
});
it('keeps separate gallery occurrences when the same asset appears in separate events', async () => {
const chat = createMockChat();
const url = 'http://example.com/reused.jpg';
await service.attachMedia(chat, [
{ eventId: '$one', sender: 'user1', text: 'First', timestamp: 1, isSelf: false, mediaUrls: [url] },
{ eventId: '$two', sender: 'user1', text: 'Second', timestamp: 2, isSelf: false, mediaUrls: [url] },
]);
const items = await service.getMediaItems();
expect(items).toHaveLength(2);
expect(new Set(items.map((item) => item.id)).size).toBe(2);
expect(new Set(items.map((item) => item.relativePath)).size).toBe(1);
expect(downloader.fetch).toHaveBeenCalledTimes(1);
});
it('recognizes image bytes when Matrix returns a generic content type', async () => {
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
);
service = new MediaService({
fetch: vi.fn(async () => ({ buffer: png, contentType: 'application/octet-stream' })),
}, logger, tempDir);
const [message] = await service.attachMedia(createMockChat(), [{
eventId: '$generic-mime', sender: 'user1', text: '', timestamp: 1, isSelf: false,
mediaUrls: ['mxc://example.test/no-extension'],
}]);
expect(message.media?.[0].fileName).toMatch(/\.png$/);
});
it('processes multiple messages', async () => {
const chat = createMockChat();
const messages: Array<ChatMessage & { mediaUrls: string[] }> = [
+62
View File
@@ -0,0 +1,62 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Database from 'better-sqlite3';
import { RedditRecipientAccountStatusRepository } from '../src/database/account-repositories.js';
import { runMigrations } from '../src/database/client.js';
import { RecipientAccountStatusService } from '../src/services/recipient-account-status.js';
import { createMockLogger } from './mockLogger.js';
describe('RecipientAccountStatusService', () => {
let db: Database.Database;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
});
afterEach(() => db.close());
it('checks each real username once, persists results, and skips fresh checks', async () => {
let now = 10_000;
const repository = new RedditRecipientAccountStatusRepository(db);
const lookup = {
getUserAccountStatus: vi.fn(async (username: string) => username === 'Alice' ? 'deleted' as const : 'active' as const),
};
const service = new RecipientAccountStatusService(
{
getCachedConversations: vi.fn(async () => [
{ username: 'Alice' }, { username: 'alice' }, { username: 'Bob' },
{ username: 'unknown' }, { username: 't2_abc' },
]),
} as never,
lookup,
repository,
createMockLogger(),
{ isFullyAuthenticated: () => true, invalidate: () => undefined },
4_000,
() => now,
);
await expect(service.checkNow()).resolves.toBe(2);
expect(lookup.getUserAccountStatus).toHaveBeenCalledTimes(2);
expect(repository.get('alice')).toMatchObject({ status: 'deleted', checkedAt: 10_000 });
now = 12_000;
await expect(service.checkNow()).resolves.toBe(0);
now = 15_000;
await expect(service.checkNow()).resolves.toBe(2);
});
it('does no profile work while account authentication is unavailable', async () => {
const lookup = { getUserAccountStatus: vi.fn() };
const service = new RecipientAccountStatusService(
{ getCachedConversations: vi.fn() } as never,
lookup,
new RedditRecipientAccountStatusRepository(db),
createMockLogger(),
{ isFullyAuthenticated: () => false, invalidate: () => undefined },
);
await expect(service.checkNow()).resolves.toBe(0);
expect(lookup.getUserAccountStatus).not.toHaveBeenCalled();
});
});
+30
View File
@@ -540,6 +540,36 @@ describe('RedditApiClient', () => {
});
});
describe('getUserAccountStatus', () => {
it('distinguishes active, suspended and deleted accounts', async () => {
const mockAxios = { get: vi.fn() };
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
const client2 = new RedditApiClient(getTestConfig(), logger);
mockAxios.get.mockResolvedValueOnce({ data: { data: {} } });
await expect(client2.getUserAccountStatus('active-user')).resolves.toBe('active');
mockAxios.get.mockResolvedValueOnce({ data: { data: { is_suspended: true } } });
await expect(client2.getUserAccountStatus('suspended-user')).resolves.toBe('suspended');
mockAxios.get.mockRejectedValueOnce({ isAxiosError: true, response: { status: 404 } });
await expect(client2.getUserAccountStatus('deleted-user')).resolves.toBe('deleted');
});
it('does not turn temporary or malformed responses into deletion signals', async () => {
const mockAxios = { get: vi.fn() };
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
const client2 = new RedditApiClient(getTestConfig(), logger);
const temporary = { isAxiosError: true, response: { status: 503 } };
mockAxios.get.mockRejectedValueOnce(temporary);
await expect(client2.getUserAccountStatus('alice')).rejects.toBe(temporary);
mockAxios.get.mockResolvedValueOnce({ data: {} });
await expect(client2.getUserAccountStatus('alice')).rejects.toThrow();
});
});
describe('getNewPosts', () => {
it('throws a permanent-disable signal for a plain 404 subreddit', async () => {
const error = { isAxiosError: true, response: { status: 404 } };
+65 -4
View File
@@ -44,7 +44,14 @@ describe('conversations route', () => {
});
const runtime = (accountId: string, redditUsername: string, conversationUsername: string) => ({
session: { accountId, redditUsername },
resources: { chatService: { getCachedConversations: vi.fn().mockResolvedValue([chat(conversationUsername)]) } },
resources: {
chatService: { getCachedConversations: vi.fn().mockResolvedValue([chat(conversationUsername)]) },
recipientAccountStatusRepo: {
get: vi.fn((username: string) => username === 'alice'
? { username, status: 'deleted', checkedAt: 123 }
: null),
},
},
});
const runtimes = new Map([
['@t2_one:reddit.com', runtime('@t2_one:reddit.com', 'owner-one', 'alice')],
@@ -67,7 +74,11 @@ describe('conversations route', () => {
expect(body).toHaveLength(2);
expect(body).toEqual(expect.arrayContaining([
expect.objectContaining({ roomId: 'same-room', username: 'alice', owner: { accountId: '@t2_one:reddit.com', redditUsername: 'owner-one' } }),
expect.objectContaining({
roomId: 'same-room', username: 'alice', needsReply: false,
recipientAccountStatus: 'deleted', recipientAccountStatusCheckedAt: 123,
owner: { accountId: '@t2_one:reddit.com', redditUsername: 'owner-one' },
}),
expect.objectContaining({ roomId: 'same-room', username: 'bob', owner: { accountId: '@t2_two:reddit.com', redditUsername: 'owner-two' } }),
]));
});
@@ -319,7 +330,7 @@ describe('conversations route', () => {
services: {
chat: { getCachedConversations: vi.fn().mockResolvedValue([conversation]) },
conversationState: {
get: vi.fn(() => ({ sellerDetected: true })),
get: vi.fn(() => ({ ...buildConversationState([]), sellerDetected: true })),
},
},
} as unknown as WebContext;
@@ -329,7 +340,57 @@ describe('conversations route', () => {
expect(response.status).toBe(200);
expect(await response.json()).toEqual([
{ ...conversation, ...UNREAD_INBOUND_TRIAGE, sellerDetected: true },
{
...conversation,
...UNREAD_INBOUND_TRIAGE,
sellerDetected: true,
automationStatus: 'active',
automationPolicyMode: 'legacy',
automationHandoffReason: null,
},
]);
});
it('projects the reply-loop status onto the list so manual conversations can be filtered', async () => {
const automated = {
username: 'alice', roomName: 'alice', roomId: 'room-alice',
lastMessageTime: 1, lastMessageSnippet: 'hey', isFromUs: false, unread: true,
};
const manual = {
username: 'bob', roomName: 'bob', roomId: 'room-bob',
lastMessageTime: 2, lastMessageSnippet: 'thanks', isFromUs: true, unread: false,
};
const untouched = {
username: 'carol', roomName: 'carol', roomId: 'room-carol',
lastMessageTime: 3, lastMessageSnippet: 'hi', isFromUs: false, unread: true,
};
const handedOver = { ...buildConversationState([]) };
handedOver.automation.status = 'user-overridden';
handedOver.automation.handoffReason = 'A manual reply was sent in this conversation.';
const context = {
logger: { error: vi.fn() },
services: {
chat: { getCachedConversations: vi.fn().mockResolvedValue([automated, manual, untouched]) },
conversationState: {
get: vi.fn((roomId: string) => {
if (roomId === 'room-alice') return buildConversationState([]);
if (roomId === 'room-bob') return handedOver;
return undefined;
}),
},
},
} as unknown as WebContext;
const app = express().use('/api', createConversationsRouter(context));
const response = await request(app, '/api/conversations');
expect(response.status).toBe(200);
const body = await response.json() as Array<Record<string, unknown>>;
expect(body.map((item) => [item.username, item.automationStatus, item.automationHandoffReason])).toEqual([
['alice', 'active', null],
['bob', 'user-overridden', 'A manual reply was sent in this conversation.'],
// Never through the reply loop, so there is nothing to report.
['carol', undefined, undefined],
]);
});
+22 -2
View File
@@ -7,12 +7,12 @@ import { createMediaRouter } from '../../src/web/routes/media.js';
const servers: Array<ReturnType<express.Express['listen']>> = [];
async function request(app: express.Express, path: string): Promise<Response> {
async function request(app: express.Express, path: string, init?: RequestInit): Promise<Response> {
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
return fetch(`http://127.0.0.1:${port}${path}`);
return fetch(`http://127.0.0.1:${port}${path}`, init);
}
afterEach(async () => {
@@ -179,4 +179,24 @@ describe('media route username presentation', () => {
]);
expect(body).not.toEqual([expect.objectContaining({ accountId: expect.anything() })]);
});
it('returns the recorded failure instead of claiming a manual full sync succeeded', async () => {
const context = {
logger: { error: vi.fn() },
broadcaster: { push: vi.fn() },
services: {
media: { getMediaItems: vi.fn().mockResolvedValue([]) },
mediaSync: {
triggerSync: vi.fn().mockResolvedValue(false),
getStatus: vi.fn().mockReturnValue({ lastError: '1 conversation failed' }),
},
},
} as unknown as WebContext;
const app = express().use('/api', createMediaRouter(context));
const response = await request(app, '/api/media/sync/full', { method: 'POST' });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ success: false, reason: '1 conversation failed' });
});
});