- {item.sender}
- {mediaConversation(item)}
+
+ {item.chatUsername
+ ?
+ : mediaConversation(item)}
Type {mediaType(item) === 'gif' ? 'GIF' : 'Image'}
diff --git a/apps/dashboard/src/features/media/media.css b/apps/dashboard/src/features/media/media.css
index d982402..c054087 100644
--- a/apps/dashboard/src/features/media/media.css
+++ b/apps/dashboard/src/features/media/media.css
@@ -25,9 +25,9 @@
.media-card-body dt { color: var(--muted); }
.media-card-body dd { margin: 0; text-align: right; }
.media-reveal { width: 100%; }
-.media-preview-dialog-backdrop { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; padding: 1rem; background: rgba(0, 0, 0, .88); }
-.media-preview-dialog-content { position: relative; display: grid; place-items: center; width: min(100%, 90rem); height: min(100%, 90rem); }
-.media-preview-dialog-image { display: block; max-width: 100%; max-height: 100%; object-fit: contain; }
+.media-preview-dialog-backdrop { position: fixed; inset: 0; z-index: 70; display: flex; align-items: center; justify-content: center; padding: 1rem; background: rgba(0, 0, 0, .88); }
+.media-preview-dialog-content { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
+.media-preview-dialog-image { display: block; width: auto; height: auto; max-width: 100%; max-height: 100%; object-fit: contain; }
.media-preview-dialog-close { position: absolute; top: .5rem; right: .5rem; width: 2.75rem; height: 2.75rem; padding: 0; border: 1px solid rgba(255, 255, 255, .5); border-radius: 50%; color: white; background: rgba(0, 0, 0, .7); font-size: 2rem; line-height: 1; cursor: pointer; }
.media-preview-dialog-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
diff --git a/apps/dashboard/src/features/operations/operations-page.tsx b/apps/dashboard/src/features/operations/operations-page.tsx
index c76b8c7..1113cb1 100644
--- a/apps/dashboard/src/features/operations/operations-page.tsx
+++ b/apps/dashboard/src/features/operations/operations-page.tsx
@@ -1,6 +1,9 @@
import { useEffect, useReducer, useRef, useState } from 'react';
import type { ActivityItem, ChatSyncStatus, DashboardEvent } from '../../../../../packages/api-contracts/src/index.js';
import { useDashboardRealtime } from '../../app/realtime-provider.js';
+import { RedditUsernameLink } from '../../components/reddit-username-link.js';
+import { RedditUserText } from '../../components/reddit-user-text.js';
+import { OriginPostButton } from '../../components/origin-post.js';
import {
createLiveConsoleState,
reduceLiveConsoleState,
@@ -27,7 +30,11 @@ function ActivityView({ items }: { items: readonly ActivityItem[] }) {
{items.map((item) => (
- {item.message} {formatTimestamp(item.timestamp)}{item.recipient ? ` · u/${item.recipient}` : ''}
+ {item.postUrl && item.postTitle ? (
+
+ {item.message}
+
+ ) : {item.message} } {formatTimestamp(item.timestamp)}{item.recipient && !item.message.includes(`u/${item.recipient}`) ? <> · > : null}
))}
@@ -167,7 +174,7 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
{consoleClearNotice ?
Cleared from this browser view. Server logs were not deleted.
: null}
{logsQuery.isPending ?
Loading logs…
: visibleEntries.length === 0 ?
No logs match the current filters.
: visibleEntries.map((entry) => (
-
{formatTimestamp(entry.occurredAt)} {entry.level.toUpperCase()} {entry.message}
+
{formatTimestamp(entry.occurredAt)} {entry.level.toUpperCase()} {entry.message}
))}
diff --git a/apps/dashboard/src/features/operations/realtime-client.ts b/apps/dashboard/src/features/operations/realtime-client.ts
index a3a0394..0d1925a 100644
--- a/apps/dashboard/src/features/operations/realtime-client.ts
+++ b/apps/dashboard/src/features/operations/realtime-client.ts
@@ -124,9 +124,11 @@ export function queryKeysForDashboardEvent(event: DashboardEvent): readonly Quer
return [['conversations'], ['operations', 'activity']];
case 'sync.started':
case 'sync.progress':
+ return [['operations', 'sync-status']];
case 'sync.completed':
- case 'sync.failed':
return [['operations', 'sync-status'], ['conversations'], ['media']];
+ case 'sync.failed':
+ return [['operations', 'sync-status']];
case 'reddit.session.changed':
return [['dashboard', 'login', 'status']];
case 'log.created':
diff --git a/apps/dashboard/src/features/overview/overview-page.tsx b/apps/dashboard/src/features/overview/overview-page.tsx
index e4df504..c3454af 100644
--- a/apps/dashboard/src/features/overview/overview-page.tsx
+++ b/apps/dashboard/src/features/overview/overview-page.tsx
@@ -3,6 +3,8 @@ import { useQueryClient } from '@tanstack/react-query';
import { dashboardQueryKeys, useSetRunningMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js';
import type { BotStats, Timers } from '../../api/contracts.js';
import { ConfirmationDialog } from '../../components/confirmation-dialog.js';
+import { RedditUserText } from '../../components/reddit-user-text.js';
+import { OriginPostButton } from '../../components/origin-post.js';
import { RecentMessagesPanel } from './recent-messages.js';
function errorMessage(error: Error | null): string {
@@ -71,7 +73,11 @@ function ActivityPanel({ stats }: { stats: BotStats }) {
{stats.recentActivity.map((item) => (
- {item.message} {formatTime(item.timestamp)}
+ {item.postUrl && item.postTitle ? (
+
+ {item.message}
+
+ ) : {item.message} } {formatTime(item.timestamp)}
))}
diff --git a/apps/dashboard/src/features/overview/recent-messages.tsx b/apps/dashboard/src/features/overview/recent-messages.tsx
index a121157..98ce859 100644
--- a/apps/dashboard/src/features/overview/recent-messages.tsx
+++ b/apps/dashboard/src/features/overview/recent-messages.tsx
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { MessagesResponseSchema, type SentMessage } from '../../../../../packages/api-contracts/src/index.js';
import { dashboardRequest } from '../../api/request.js';
+import { RedditUsernameLink } from '../../components/reddit-username-link.js';
async function loadRecentMessages(): Promise
{
const response = await dashboardRequest('/api/messages');
@@ -24,7 +25,7 @@ export function RecentMessagesPanel() {
{query.data && query.data.length > 0 ? (
{query.data.map((message) => (
- u/{message.recipient} {message.message} {formatSentAt(message.sentAt)} · {message.gotResponse ? 'Responded' : 'No response recorded'}
+ {message.message} {formatSentAt(message.sentAt)} · {message.gotResponse ? 'Responded' : 'No response recorded'}
))}
) : null}
diff --git a/apps/dashboard/src/features/settings/access-panel.tsx b/apps/dashboard/src/features/settings/access-panel.tsx
index 511757e..386bbcf 100644
--- a/apps/dashboard/src/features/settings/access-panel.tsx
+++ b/apps/dashboard/src/features/settings/access-panel.tsx
@@ -1,4 +1,5 @@
import { useRef } from 'react';
+import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { useImportRedditSessionMutation, useLoginStatusQuery } from './settings-queries.js';
function errorMessage(error: Error | null): string {
@@ -52,7 +53,7 @@ export function AccessPanel() {
Reddit session
Access {sessionLabel(session)}
When Reddit is logged out, account work is paused and waiting for a fresh session dump from the extension.
- {username ? Signed in as u/{username} .
: null}
+ {username ? Signed in as .
: null}
void importSession(event.target.files?.[0])} ref={importInput} type="file" />
importInput.current?.click()} type="button">{importMutation.isPending ? 'Importing session…' : 'Import Reddit session'}
diff --git a/apps/dashboard/src/features/settings/settings-editor.tsx b/apps/dashboard/src/features/settings/settings-editor.tsx
index 2dbd46c..4d27250 100644
--- a/apps/dashboard/src/features/settings/settings-editor.tsx
+++ b/apps/dashboard/src/features/settings/settings-editor.tsx
@@ -44,6 +44,7 @@ export function SettingsEditor({ config, section }: SettingsEditorProps) {
...response.config,
subreddits: response.subreddits,
tones: config.tones,
+ advanced: response.advanced ?? config.advanced,
};
const nextDraft = createSettingsDraft(nextConfig);
setPersistedDraft(nextDraft);
diff --git a/apps/dashboard/src/features/settings/settings-model.ts b/apps/dashboard/src/features/settings/settings-model.ts
index 93afbda..acf6cc8 100644
--- a/apps/dashboard/src/features/settings/settings-model.ts
+++ b/apps/dashboard/src/features/settings/settings-model.ts
@@ -23,6 +23,7 @@ export interface SettingsDraft {
prioritizeRecentPosts: boolean;
recentWindowMinutes: string;
subreddits: SubredditDraft[];
+ advancedConfigJson: string;
}
export interface ValidatedSettings {
@@ -71,6 +72,7 @@ export function createSettingsDraft(config: ConfigResponse): SettingsDraft {
tone: subreddit.tone,
scripted: subreddit.scripted ?? '',
})),
+ advancedConfigJson: config.advanced ? JSON.stringify(config.advanced, null, 2) : '',
};
}
@@ -88,6 +90,19 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
});
if (Object.keys(requiredErrors).length > 0) return { errors: requiredErrors };
+ let advanced: Record | undefined;
+ if (draft.advancedConfigJson.trim() !== '') {
+ try {
+ const value: unknown = JSON.parse(draft.advancedConfigJson);
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return { errors: { advancedConfigJson: 'Advanced configuration must be a JSON object.' } };
+ }
+ advanced = value as Record;
+ } catch {
+ return { errors: { advancedConfigJson: 'Advanced configuration is not valid JSON.' } };
+ }
+ }
+
const parsed = ValidatedSettingsSchema.safeParse({
minDelayMinutes: Number(draft.minDelayMinutes),
maxMessagesPerHour: Number(draft.maxMessagesPerHour),
@@ -125,6 +140,7 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
recentWindowMinutes: parsed.data.recentWindowMinutes,
},
subreddits: parsed.data.subreddits,
+ ...(advanced ? { advanced } : {}),
});
return {
diff --git a/apps/dashboard/src/features/settings/settings-queries.ts b/apps/dashboard/src/features/settings/settings-queries.ts
index e9cb344..8b19777 100644
--- a/apps/dashboard/src/features/settings/settings-queries.ts
+++ b/apps/dashboard/src/features/settings/settings-queries.ts
@@ -20,6 +20,7 @@ export function useSaveSettingsMutation() {
...response.config,
subreddits: response.subreddits,
tones: current?.tones ?? [],
+ advanced: response.advanced ?? current?.advanced,
}));
},
});
diff --git a/apps/dashboard/src/features/settings/settings-sections.tsx b/apps/dashboard/src/features/settings/settings-sections.tsx
index a8901b1..45ea5d4 100644
--- a/apps/dashboard/src/features/settings/settings-sections.tsx
+++ b/apps/dashboard/src/features/settings/settings-sections.tsx
@@ -204,6 +204,23 @@ export function BehaviourSection({ config, draft, errors, onChange }: SectionPro
))}
+ {config.advanced ? (
+
+
Advanced application configuration Models, prompts, filtering, media sync, and strategy settings. Some changes take effect after a restart.
+
+ Advanced configuration (JSON)
+
+
+ ) : null}
);
}
diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css
index 88bc883..aa41686 100644
--- a/apps/dashboard/src/styles.css
+++ b/apps/dashboard/src/styles.css
@@ -20,6 +20,8 @@ button, input { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; }
a { color: inherit; }
+.reddit-username-link { color: var(--accent); text-decoration: underline; text-decoration-thickness: .08em; text-underline-offset: .16em; }
+.reddit-username-link:hover { color: #aaf0c4; }
.skip-link { position: fixed; left: 1rem; top: -5rem; z-index: 100; background: white; color: black; padding: .75rem 1rem; border-radius: .5rem; }
.skip-link:focus { top: 1rem; }
@@ -86,6 +88,22 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
.activity-list li { display: flex; align-items: flex-start; gap: .75rem; }
.activity-list li div { display: grid; gap: .15rem; }
.activity-list small { color: var(--muted); }
+.origin-post-trigger { padding: 0; color: inherit; background: transparent; border: 0; font: inherit; font-weight: inherit; text-align: left; text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: .2em; cursor: pointer; }
+.origin-post-trigger:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
+.origin-post-backdrop { position: fixed; inset: 0; z-index: 80; display: grid; place-items: center; padding: 1rem; background: rgba(0, 0, 0, .82); }
+.origin-post-dialog { width: min(34rem, 100%); max-height: calc(100vh - 2rem); overflow: auto; background: var(--surface-raised); border: 1px solid var(--border); border-radius: 1rem; box-shadow: 0 2rem 5rem rgba(0, 0, 0, .5); }
+.origin-post-dialog > img { display: block; width: 100%; max-height: 28rem; object-fit: contain; background: #050907; }
+.origin-post-dialog-heading { display: flex; align-items: center; justify-content: space-between; padding: .65rem .8rem; color: var(--muted); border-bottom: 1px solid var(--border); font-size: .75rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
+.origin-post-dialog-heading button { width: 2.25rem; height: 2.25rem; padding: 0; color: inherit; background: transparent; border: 0; font-size: 1.6rem; cursor: pointer; }
+.origin-post-dialog-copy { display: grid; gap: .7rem; padding: 1rem; }
+.origin-post-dialog-copy h2 { margin: 0; font-size: 1.25rem; }
+.origin-post-dialog-copy small { color: var(--muted); }
+.origin-post-dialog-copy a { justify-self: start; text-decoration: none; }
+.origin-post-card { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .8rem; align-items: center; color: inherit; text-decoration: none; }
+.origin-post-card > img { width: 5rem; height: 5rem; object-fit: cover; background: #050907; border-radius: .45rem; }
+.origin-post-card > span { display: grid; gap: .2rem; min-width: 0; }
+.origin-post-card small, .origin-post-card span span { color: var(--muted); font-size: .75rem; }
+.origin-post-card strong { overflow-wrap: anywhere; }
.activity-marker { flex: 0 0 auto; width: .55rem; height: .55rem; margin-top: .3rem; border-radius: 50%; background: var(--muted); }
.activity-marker.sent, .activity-marker.response { background: var(--accent); }
.activity-marker.error { background: var(--danger); }
diff --git a/docs/api.md b/docs/api.md
index 6bac249..9096147 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -761,8 +761,8 @@ GET /api/config
"jitterMs": 15000
},
"browser": {
- "headless": true,
- "userDataDir": "./data/browser"
+ "sessionDir": "./data/browser-session",
+ "dryRun": false
},
"database": {
"path": "./data/reddit-bot.db"
diff --git a/docs/architecture.md b/docs/architecture.md
index 90f9528..ec46f9e 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -182,8 +182,7 @@ RedditBot/
├── ai/
│ └── openrouter.ts # OpenRouter AI client (image analysis, message gen) (307 lines)
├── browser/
- │ ├── client.ts # Playwright persistent context + login (302 lines)
- │ ├── message-page.ts # DM compose page automation + shadow DOM (560 lines)
+ │ ├── client.ts # Managed Playwright context + imported cookies
│ └── media-downloader.ts # Playwright-based media downloader (29 lines)
├── services/
│ ├── monitor.ts # Post discovery service (72 lines)
@@ -319,14 +318,7 @@ Playwright persistent context manager.
- Browser session persistence (cookies/storage)
- Login verification (multiple UI checks + API fallback)
-- Automated login flow (form fill, 2FA/captcha detection)
-- Debug screenshot capture on login failure
-
-#### MessagePage (`src/browser/message-page.ts`)
-
-Reddit DM compose page automation.
-
-- Fills recipient/subject/body fields
+- Extension-dump cookie import; no login form automation
- Extensive Shadow DOM support for `faceplate-*` components
- Detects recipient errors, verifies submission success
- Supports dry-run mode for testing
@@ -653,9 +645,7 @@ All endpoints are served by the Express server at `http://localhost:{port}`.
1. **`config.yaml`** - Primary configuration file (project root)
2. **Environment Variables** - Override specific fields:
- `OPENROUTER_API_KEY` - OpenRouter API key
- - `REDDIT_USERNAME` - Reddit username
- - `REDDIT_PASSWORD` - Reddit password
- - `BROWSER_HEADLESS` - Browser headless mode (`true`/`false`)
+ - `WEB_DASHBOARD_TOKEN` - Dashboard access token
### Configuration Schema (Zod-Validated)
@@ -680,8 +670,8 @@ All endpoints are served by the Express server at `http://localhost:{port}`.
jitterMs: number // Random jitter range
}
browser: {
- headless: boolean // Headless mode
- userDataDir: string // Persistent session directory
+ sessionDir: string // Persistent imported session directory
+ dryRun: boolean // Prevent outbound delivery
}
database: {
path: string // SQLite database path
@@ -866,7 +856,7 @@ class RateLimiter {
The project includes full Docker support:
-- **`Dockerfile`** - Playwright image with browser, VNC, and frontend/server build tooling
+- **`Dockerfile`** - Playwright image with frontend/server build tooling; no VNC or interactive Chrome process
- **`docker-compose.yml`** - Service orchestration
- **`.dockerignore`** - Excludes unnecessary files
@@ -879,9 +869,7 @@ Deployment documentation is provided in `DOKPLOY_DEPLOY.md`.
| Variable | Required | Description |
|---|---|---|
| `OPENROUTER_API_KEY` | Yes | OpenRouter API key for AI |
-| `REDDIT_USERNAME` | Yes | Reddit username |
-| `REDDIT_PASSWORD` | Yes | Reddit password |
-| `BROWSER_HEADLESS` | No | Browser headless mode (default: `true`) |
+| `WEB_DASHBOARD_TOKEN` | Yes when dashboard enabled | Dashboard access token |
### Pre-Deployment Checklist
diff --git a/docs/configuration.md b/docs/configuration.md
index 88f8764..49af2ea 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -7,9 +7,9 @@ Complete reference for configuring RedditBot.
## Table of Contents
1. [Configuration Overview](#configuration-overview)
-2. [Configuration Files](#configuration-files)
+2. [Configuration Storage](#configuration-storage)
3. [Environment Variables](#environment-variables)
-4. [config.yaml Reference](#configyaml-reference)
+4. [Application Settings](#application-settings)
5. [Runtime Configuration](#runtime-configuration)
6. [Configuration Validation](#configuration-validation)
7. [Troubleshooting](#troubleshooting)
@@ -25,7 +25,7 @@ RedditBot uses a layered configuration approach:
│ Priority Order (High → Low) │
├──────────────────────────────────────────────────────────────┤
│ 1. Environment Variables │
-│ (OPENROUTER_API_KEY, REDDIT_USERNAME, etc.) │
+│ (OPENROUTER_API_KEY, WEB_DASHBOARD_TOKEN) │
│ │
│ 2. config.yaml │
│ (Primary configuration file) │
@@ -37,18 +37,20 @@ RedditBot uses a layered configuration approach:
Configuration is loaded at startup by `loadConfig()` in `src/config/index.ts`, which:
-1. Reads `config.yaml` from the project root
-2. Merges environment variable overrides
+1. Reads the singleton `app_config` record from SQLite
+2. Imports `config.yaml` only when upgrading an existing database that has no settings record
3. Validates the entire configuration tree using Zod
4. Returns a strongly-typed `Config` object
+Application-owned settings are edited from the dashboard and saved to SQLite. `OPENROUTER_API_KEY`, `WEB_DASHBOARD_TOKEN`, and `DATABASE_PATH` remain environment-owned bootstrap values and are never stored in `app_config`.
+
---
-## Configuration Files
+## Configuration Storage
-### config.yaml
+### Legacy config.yaml
-The primary configuration file, located at the project root:
+`config.yaml` is retained temporarily as a one-time migration source for existing installations. Once every deployed database contains an `app_config` row, it can be removed in a follow-up release.
```
RedditBot/
@@ -79,14 +81,13 @@ data/
## Environment Variables
-Environment variables override corresponding values in `config.yaml`.
+Environment variables provide secrets and bootstrap settings that are intentionally excluded from SQLite.
| Variable | Required | Type | Description | Example |
|---|---|---|---|---|
| `OPENROUTER_API_KEY` | Yes | string | OpenRouter API key for AI services | `sk-or-v1-abc123...` |
-| `REDDIT_USERNAME` | Yes | string | Reddit username for authentication | `MyBotUser` |
-| `REDDIT_PASSWORD` | Yes | string | Reddit password for authentication | `secret123` |
-| `BROWSER_HEADLESS` | No | boolean | Run browser in headless mode | `true` or `false` |
+| `WEB_DASHBOARD_TOKEN` | Yes when dashboard enabled | string | Dashboard access token | `long-random-secret` |
+| `DATABASE_PATH` | No | string | SQLite database location | `./data/reddit-bot.db` |
### Setting Environment Variables
@@ -96,9 +97,7 @@ Create a `.env` file in the project root:
```env
OPENROUTER_API_KEY=sk-or-v1-abc123...
-REDDIT_USERNAME=MyBotUser
-REDDIT_PASSWORD=secret123
-BROWSER_HEADLESS=true
+WEB_DASHBOARD_TOKEN=long-random-secret
```
> **Note**: Add `.env` to `.gitignore` - never commit credentials.
@@ -107,8 +106,7 @@ BROWSER_HEADLESS=true
```bash
export OPENROUTER_API_KEY=sk-or-v1-abc123...
-export REDDIT_USERNAME=MyBotUser
-export REDDIT_PASSWORD=secret123
+export WEB_DASHBOARD_TOKEN=long-random-secret
```
**Option 3: Docker**
@@ -118,13 +116,16 @@ Pass via `docker-compose.yml` or Docker CLI:
```yaml
environment:
- OPENROUTER_API_KEY=sk-or-v1-abc123...
- - REDDIT_USERNAME=MyBotUser
- - REDDIT_PASSWORD=secret123
+ - WEB_DASHBOARD_TOKEN=long-random-secret
```
---
-## config.yaml Reference
+## Application Settings
+
+Use **Settings** in the dashboard for monitoring, rate limits, dry-run behaviour, subreddits, models, prompts, filtering, media sync, and strategy configuration. The advanced JSON editor exposes settings that do not yet have dedicated controls; its payload is validated by the server before it is saved.
+
+The YAML examples below document the legacy field layout used during one-time migration.
Complete reference for every configuration option.
@@ -235,13 +236,9 @@ Max messages: 20 per hour
```yaml
browser:
- # Run browser in headless mode (no UI)
- # Set to false for debugging
- headless: true
-
- # Directory for persistent browser data
- # Cookies and session storage are saved here
- userDataDir: "./data/browser"
+ # Persistent managed-browser data populated only by verified imports
+ sessionDir: "./data/browser-session"
+ dryRun: false
```
**Browser session persistence:**
@@ -412,8 +409,8 @@ All configuration is validated using Zod at startup. Invalid configuration will
| `rateLimit.minDelayMs` | Must be positive integer |
| `rateLimit.maxMessagesPerHour` | Must be positive integer |
| `rateLimit.jitterMs` | Must be non-negative integer |
-| `browser.headless` | Must be boolean |
-| `browser.userDataDir` | Must be non-empty string |
+| `browser.sessionDir` | Must be non-empty string |
+| `browser.dryRun` | Must be boolean |
| `database.path` | Must be non-empty string |
| `mediaSync.enabled` | Must be boolean |
| `mediaSync.intervalMinutes` | Must be positive integer (if enabled) |
@@ -432,8 +429,8 @@ All configuration is validated using Zod at startup. Invalid configuration will
**Error**: `Invalid config: reddit.subreddits must be a non-empty array`
**Fix**: Add at least one subreddit to `reddit.subreddits` in `config.yaml`
-**Error**: `Invalid config: browser.userDataDir must be a string`
-**Fix**: Ensure `browser.userDataDir` is set in `config.yaml`
+**Error**: `Invalid config: browser.sessionDir must be a string`
+**Fix**: Ensure `browser.sessionDir` is set in `config.yaml`
---
@@ -449,15 +446,15 @@ All configuration is validated using Zod at startup. Invalid configuration will
3. Verify environment variables are set correctly
4. Check bot logs for specific validation errors
-### Credentials Not Working
+### Account Authentication Not Working
-**Symptom**: Browser login fails.
+**Symptom**: Account work remains paused.
**Steps**:
-1. Verify `REDDIT_USERNAME` and `REDDIT_PASSWORD` are correct
-2. Check that the account is not locked or requires 2FA
-3. Try manual login via `npm run login`
-4. Ensure `BROWSER_HEADLESS=false` for debugging login issues
+1. Sign in to Reddit in local Chrome
+2. Open Reddit Chat and wait for the extension badge to show `OK`
+3. Export and upload a fresh combined session dump
+4. Check dashboard logs for Reddit or Matrix identity verification errors
### AI Not Working
@@ -475,9 +472,9 @@ All configuration is validated using Zod at startup. Invalid configuration will
**Symptom**: Bot stops with "Browser session expired" error.
**Steps**:
-1. Delete `data/browser/` directory
-2. Run `npm run login` to re-authenticate
-3. Restart the bot
+1. Open Reddit Chat in the local Chrome profile containing the extension
+2. Export and upload a fresh session dump
+3. Delete the downloaded dump after verification
### Port Already in Use
diff --git a/docs/data-flow.md b/docs/data-flow.md
index 8866899..4e99c8e 100644
--- a/docs/data-flow.md
+++ b/docs/data-flow.md
@@ -791,21 +791,14 @@ The historical diagram below is retained for migration context only.
└─► Continue to next subreddit
```
-### Browser Session Recovery
+### Account Session Recovery
```
-1. BrowserClient.verifySession() returns false
+1. Reddit or Matrix authentication is rejected
│
- ├─► Check for expired cookies
- │
- ├─► Attempt automated re-login
- │ ├─► BrowserClient.login()
- │ └─► If success: continue
- │
- └─► If re-login fails:
- ├─► Log error: "Browser session expired"
- ├─► Stop bot
- └─► Require manual login (scripts/login.ts)
+ ├─► Invalidate the shared account-auth gate
+ ├─► Stop account-bound schedulers
+ └─► Wait for a fresh combined dump from the Chrome extension
```
---
diff --git a/docs/deployment.md b/docs/deployment.md
index acd736f..cbdd35a 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -74,8 +74,8 @@ rateLimit:
jitterMs: 15000
browser:
- headless: true
- userDataDir: "./data/browser"
+ sessionDir: "./data/browser-session"
+ dryRun: true
database:
path: "./data/reddit-bot.db"
@@ -89,23 +89,13 @@ dryRun: true # Start in dry run mode
Set environment variables:
```bash
-export REDDIT_USERNAME="your-username"
-export REDDIT_PASSWORD="your-password"
+export OPENROUTER_API_KEY="sk-or-v1-your-key"
+export WEB_DASHBOARD_TOKEN="long-random-secret"
```
-### 3. Initial Login
+### 3. Import account session
-Run the login script to establish a browser session:
-
-```bash
-npm run login
-```
-
-This will:
-1. Open a browser window
-2. Navigate to Reddit login
-3. Wait for you to complete login (including 2FA if enabled)
-4. Save cookies to `data/browser/`
+Start the dashboard, then upload a combined Reddit and Matrix dump created by the local Chrome extension. Until both identities verify, account work remains paused.
### 4. Start the Bot
@@ -144,23 +134,19 @@ docker run -d \
-p 3000:3000 \
-v $(pwd)/data:/app/data \
-e OPENROUTER_API_KEY=sk-or-v1-your-key \
- -e REDDIT_USERNAME=your-username \
- -e REDDIT_PASSWORD=your-password \
- -e BROWSER_HEADLESS=true \
+ -e WEB_DASHBOARD_TOKEN=long-random-secret \
reddit-bot:latest
```
### Dockerfile behavior
-The image is based on the Playwright Jammy image rather than Alpine because the
-runtime needs Chromium and its system libraries. It installs Xvfb, x11vnc,
-noVNC, Fluxbox, and Websockify for interactive Reddit login; installs the locked
-Node dependencies; copies the repository; and runs `npm run build`.
+The image is based on the Playwright Jammy image because the remaining
+authenticated browser operations need Chromium and its system libraries. It
+does not install a display server, VNC, or consumer Google Chrome.
That build produces both `dist/` for the Express server and
-`apps/dashboard/dist/` for Vite assets. Ports 3000 (dashboard/API) and 6080
-(noVNC) are exposed, and `docker/entrypoint.sh` starts the display services and
-application. This is a single-stage image and retains development dependencies
+`apps/dashboard/dist/` for Vite assets. Port 3000 serves the dashboard/API and
+the container starts the Node process directly. This is a single-stage image and retains development dependencies
needed by the current build/runtime layout.
---
@@ -182,9 +168,7 @@ services:
- ./data:/app/data
environment:
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
- - REDDIT_USERNAME=${REDDIT_USERNAME}
- - REDDIT_PASSWORD=${REDDIT_PASSWORD}
- - BROWSER_HEADLESS=true
+ - WEB_DASHBOARD_TOKEN=${WEB_DASHBOARD_TOKEN}
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--spider", "http://localhost:3000/api/stats"]
@@ -225,9 +209,7 @@ Dokploy is a self-hosted platform for deploying applications. See `DOKPLOY_DEPLO
1. **Create Application**: In Dokploy dashboard, create a new Docker application
2. **Set Environment Variables**:
- `OPENROUTER_API_KEY`
- - `REDDIT_USERNAME`
- - `REDDIT_PASSWORD`
- - `BROWSER_HEADLESS`
+ - `WEB_DASHBOARD_TOKEN`
3. **Mount Volume**: Mount `./data` for persistent storage
4. **Set Port**: Map container port 3000 to host port
5. **Deploy**: Push to repository and trigger deployment
@@ -244,9 +226,7 @@ app:
environment:
OPENROUTER_API_KEY: $OPENROUTER_API_KEY
- REDDIT_USERNAME: $REDDIT_USERNAME
- REDDIT_PASSWORD: $REDDIT_PASSWORD
- BROWSER_HEADLESS: "true"
+ WEB_DASHBOARD_TOKEN: $WEB_DASHBOARD_TOKEN
ports:
- container: 3000
@@ -276,7 +256,7 @@ app:
### Performance
-1. **Browser Headless**: Always use headless mode in production
+1. **Account dumps**: Delete downloaded dumps after the dashboard verifies them
2. **WAL Mode**: SQLite WAL mode is enabled by default for concurrent reads
3. **Rate Limiting**: Adjust based on Reddit account age and trust level
4. **Message Volume**: Start low (10-20/hour) and increase gradually
@@ -359,15 +339,15 @@ docker compose start
3. Verify environment variables are set
4. Check for Zod validation errors in logs
-### Browser Login Fails
+### Account Import Fails
-**Symptom**: "Browser session expired" or login fails.
+**Symptom**: Account work stays paused or import verification fails.
**Steps**:
-1. Delete browser data: `rm -rf data/browser/`
-2. Re-run login: `npm run login`
-3. Check Reddit account is not locked
-4. Try `BROWSER_HEADLESS=false` for debugging
+1. Sign in to Reddit in local Chrome
+2. Open Reddit Chat and wait for the extension badge to show `OK`
+3. Export and upload a new combined session dump
+4. Check logs for Reddit or Matrix identity errors
### AI Analysis Fails
diff --git a/docs/development.md b/docs/development.md
index 79a86db..da9914f 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -45,8 +45,7 @@ RedditBot/
├── ai/
│ └── openrouter.ts # OpenRouter AI client
├── browser/
- │ ├── client.ts # Playwright browser manager
- │ ├── message-page.ts # DM compose automation
+ │ ├── client.ts # Managed Playwright context
│ └── media-downloader.ts # Media download
├── services/
│ ├── monitor.ts # Post discovery
@@ -87,17 +86,13 @@ cp config.yaml config.yaml.backup # Backup existing
### 3. Set Environment Variables
```bash
-export REDDIT_USERNAME="your-username"
-export REDDIT_PASSWORD="your-password"
export OPENROUTER_API_KEY="sk-or-v1-your-key"
+export WEB_DASHBOARD_TOKEN="long-random-secret"
```
-### 4. Initial Login
+### 4. Account authentication
-```bash
-# Open browser for manual login
-npm run login
-```
+Start the app, then import a combined Reddit and Matrix session dump from the local Chrome extension under **Settings > Access**.
### 5. Start Development Server
@@ -357,21 +352,9 @@ curl http://localhost:3000/api/analytics/by-strategy
## Debugging
-### Browser Debugging
+### Account debugging
-Run with visible browser:
-
-```yaml
-# config.yaml
-browser:
- headless: false
-```
-
-Or set environment variable:
-
-```bash
-BROWSER_HEADLESS=false npm run dev
-```
+Use dashboard session status and structured logs. The backend does not expose or launch an interactive login browser.
### DOM Inspection
diff --git a/docs/services.md b/docs/services.md
index 7d23a9f..459f5bb 100644
--- a/docs/services.md
+++ b/docs/services.md
@@ -690,7 +690,7 @@ Generate a similar message for this image..."
### Purpose
-Manages the Playwright browser session with persistent cookies/storage. Handles automated login, session verification, and 2FA/captcha detection.
+Manages the headless Playwright context used after an extension dump is imported. It accepts Reddit cookies, verifies account identity, and supplies authenticated context to the remaining browser-backed operations. It never performs login.
### Public Methods
@@ -704,8 +704,8 @@ class BrowserClient {
/** Verify browser session is active */
verifySession(): Promise
- /** Perform automated login */
- login(): Promise
+ /** Import Reddit cookies from a validated extension dump */
+ importRedditCookies(cookies: Cookie[]): Promise
/** Get page context for operations */
getPage(): Promise
@@ -715,93 +715,20 @@ class BrowserClient {
}
```
-### Login Flow
+### Import Flow
-```
-1. Navigate to Reddit login page
-2. Fill username and password
-3. Submit form
-4. Check for 2FA prompt
- └─► If present: wait for manual input (with timeout)
-5. Check for captcha
- └─► If present: log warning, wait
-6. Verify login success:
- └─► Check for user menu indicator
- └─► Check for profile avatar
- └─► Fallback: API request to /api/me
-7. Save cookies to persistent directory
-8. On failure: capture debug screenshot
-```
+The dashboard validates a combined extension dump, verifies Matrix with `whoami`, imports Reddit cookies, verifies Reddit identity, and only then marks the shared account gate authenticated.
### Session Persistence
- Cookies and storage are saved to `config.browser.userDataDir`
- Subsequent launches restore the session automatically
-- Session expires when Reddit cookies expire (requires re-login)
+- Session expiry pauses account work until a fresh extension dump is imported
### Dependencies
-- `Config` - Credentials, headless mode, user data dir
-- `Logger` - Login progress, errors
-
----
-
-## MessagePage
-
-**File**: `src/browser/message-page.ts`
-**Lines**: 560
-**Responsibility**: Reddit DM compose page automation
-
-### Purpose
-
-Automates the Reddit DM compose page: filling recipient/subject/body fields, handling shadow DOM components, submitting messages, and verifying success.
-
-### Public Methods
-
-```typescript
-class MessagePage {
- constructor(
- private browser: BrowserClient,
- private config: Config,
- private logger: Logger
- )
-
- /** Send a DM to a recipient */
- sendDM(recipient: string, subject: string, body: string): Promise
-
- /** Check if recipient field shows an error */
- hasRecipientError(page: Page): Promise
-}
-```
-
-### Shadow DOM Handling
-
-Reddit's compose form uses `faceplate-*` web components:
-
-```javascript
-// Fill shadow DOM input fields
-page.evaluate(({ selector, value }) => {
- const shadowInput = document.querySelector(`${selector} input`);
- if (shadowInput) {
- shadowInput.value = value;
- shadowInput.dispatchEvent(new Event('input', { bubbles: true }));
- }
-}, { selector: 'faceplate-textarea[name="body"]', value: body });
-```
-
-### Dry Run Mode
-
-When `config.dryRun` is enabled:
-- All fields are filled as normal
-- Submit button is NOT clicked
-- Message is logged but not sent
-- Returns `true` (simulates success)
-
-### Dependencies
-
-- `BrowserClient` - Playwright page access
-- `Config` - Dry run mode
-- `Logger` - Send activity
+- `Config` - Persistent session directory
+- `Logger` - Import verification progress and errors
---
diff --git a/packages/api-contracts/src/activity.ts b/packages/api-contracts/src/activity.ts
index 99a659b..4427074 100644
--- a/packages/api-contracts/src/activity.ts
+++ b/packages/api-contracts/src/activity.ts
@@ -15,6 +15,8 @@ export const ActivityItemSchema = z.strictObject({
message: z.string(),
recipient: z.string().optional(),
imageUrl: z.string().optional(),
+ postTitle: z.string().optional(),
+ postUrl: z.string().optional(),
});
export const ActivityResponseSchema = z.array(ActivityItemSchema);
diff --git a/packages/api-contracts/src/chats.ts b/packages/api-contracts/src/chats.ts
index f053ff4..54618bb 100644
--- a/packages/api-contracts/src/chats.ts
+++ b/packages/api-contracts/src/chats.ts
@@ -24,6 +24,7 @@ export const ActiveChatSchema = z.strictObject({
roomId: z.string().optional(),
messages: z.array(ChatMessageSchema).optional(),
originMessage: z.string().optional(),
+ hasReceivedImages: z.boolean().optional(),
});
export const ChatsResponseSchema = z.array(ActiveChatSchema);
export const ConversationsResponseSchema = ChatsResponseSchema;
@@ -62,6 +63,7 @@ export const ConversationDetailResponseSchema = ActiveChatSchema.extend({
subreddit: z.string().optional(),
title: z.string().optional(),
url: z.string(),
+ imageUrl: z.string().nullable().optional(),
sentAt: z.number(),
}).nullable(),
syncInProgress: z.strictObject({
diff --git a/packages/api-contracts/src/config.ts b/packages/api-contracts/src/config.ts
index 99382fe..bd55049 100644
--- a/packages/api-contracts/src/config.ts
+++ b/packages/api-contracts/src/config.ts
@@ -35,17 +35,20 @@ export const SubredditConfigSchema = z.strictObject({
export const ConfigResponseSchema = RuntimeConfigSchema.extend({
subreddits: z.array(SubredditConfigSchema),
tones: z.array(ToneKeySchema),
+ advanced: z.record(z.string(), z.unknown()).optional(),
});
export const ConfigUpdateRequestSchema = z.strictObject({
rateLimit: RateLimitConfigSchema.partial().optional(),
browser: z.strictObject({ dryRun: z.boolean().optional() }).optional(),
reddit: RuntimeRedditConfigSchema.partial().optional(),
subreddits: z.array(SubredditConfigSchema).min(1).optional(),
+ advanced: z.record(z.string(), z.unknown()).optional(),
});
export const ConfigUpdateResponseSchema = z.strictObject({
success: z.literal(true),
config: RuntimeConfigSchema,
subreddits: z.array(SubredditConfigSchema),
+ advanced: z.record(z.string(), z.unknown()).optional(),
});
export type RuntimeConfig = z.infer;
diff --git a/packages/api-contracts/src/messages.ts b/packages/api-contracts/src/messages.ts
index 27299d3..b9b8c5b 100644
--- a/packages/api-contracts/src/messages.ts
+++ b/packages/api-contracts/src/messages.ts
@@ -13,6 +13,7 @@ export const SentMessageSchema = z.strictObject({
chatRoomId: z.string().nullable(),
subreddit: z.string().optional(),
postTitle: z.string().optional(),
+ postUrl: z.string().optional(),
postImageUrl: z.string().optional(),
postImageAnalysis: z.string().optional(),
});
@@ -23,6 +24,7 @@ export const ProcessedPostSchema = z.strictObject({
subreddit: z.string(),
author: z.string(),
title: z.string(),
+ postUrl: z.string().nullable().optional(),
imageUrl: z.string().nullable(),
imageAnalysis: z.string().nullable(),
generatedMessage: z.string().nullable(),
diff --git a/packages/api-contracts/tests/contracts.test.ts b/packages/api-contracts/tests/contracts.test.ts
index 6a4655a..a8a112e 100644
--- a/packages/api-contracts/tests/contracts.test.ts
+++ b/packages/api-contracts/tests/contracts.test.ts
@@ -106,6 +106,7 @@ describe('dashboard endpoint contracts', () => {
const processedPost = {
id: 'post-1', subreddit: 'example', author: 'alice', title: 'Example',
imageUrl: null, imageAnalysis: null, generatedMessage: 'Hello',
+ postUrl: 'https://www.reddit.com/r/example/comments/post-1/',
processedAt: 1_700_000_000_000, messageSent: true, error: null,
};
expect(ProcessedPostsResponseSchema.safeParse([processedPost]).success).toBe(true);
@@ -136,7 +137,7 @@ describe('dashboard endpoint contracts', () => {
expect(ConfigResponseSchema.parse(payload)).toEqual(payload);
expect(ConfigResponseSchema.safeParse({ ...payload, tones: [1] }).success).toBe(false);
expect(ConfigUpdateRequestSchema.safeParse({
- rateLimit: { maxMessagesPerHour: 10 }, browser: { dryRun: true },
+ rateLimit: { maxMessagesPerHour: 10 }, browser: { dryRun: true }, advanced: { media: { syncEnabled: false } },
}).success).toBe(true);
expect(ConfigUpdateRequestSchema.safeParse({ browser: { dryRun: 'yes' } }).success).toBe(false);
});
@@ -160,7 +161,7 @@ describe('dashboard endpoint contracts', () => {
...chat,
triggeringPost: {
id: 'post-1', subreddit: 'example', title: 'Example',
- url: 'https://www.reddit.com/r/example/comments/post-1/', sentAt: 1_700_000_000_000,
+ url: 'https://www.reddit.com/r/example/comments/post-1/', imageUrl: 'https://i.redd.it/example.jpg', sentAt: 1_700_000_000_000,
},
syncInProgress: null,
}).success).toBe(true);
diff --git a/src/bootstrap.ts b/src/bootstrap.ts
index 4ef1e7f..9650e0e 100644
--- a/src/bootstrap.ts
+++ b/src/bootstrap.ts
@@ -24,6 +24,7 @@ import {
ChatService,
MatrixClient,
MatrixMediaDownloader,
+ MatrixUsernameResolver,
MatrixTokenRefresher,
loadCachedCredentials,
saveCredentials,
@@ -33,11 +34,11 @@ import {
import { MediaService } from './services/media.js';
import { MediaSyncService } from './services/media-sync.js';
import { RateLimiter } from './utils/rate-limiter.js';
-import { fetchWithTimeout } from './utils/fetch-with-timeout.js';
import { WebServer } from './web/server.js';
import { reportReply } from './web/event-reporting.js';
import { RedditSessionImporter } from './services/reddit-session-importer.js';
import { AccountAuthContext } from './services/account-auth-context.js';
+import { ConfigStore } from './config/persistence.js';
export interface AppContainer {
config: Config;
@@ -81,7 +82,11 @@ export interface AppContainer {
* Returns a populated container. Caller owns lifecycle (start loop, handle
* shutdown).
*/
-export async function bootstrap(config: Config, logger: Logger): Promise {
+export async function bootstrap(
+ config: Config,
+ logger: Logger,
+ existingDatabase?: Database.Database,
+): Promise {
// Wire winston → dashboard log buffer immediately so the matrix-token
// sniff and other early bootstrap diagnostics are visible in /api/logs.
const broadcaster = new LogBroadcaster();
@@ -90,7 +95,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise = {};
- for (let i = 0; i < ids.length; i += 100) {
- const chunk = ids.slice(i, i + 100).join(',');
- try {
- const res = await fetchWithTimeout(
- // www.reddit.com serves this unauthenticated; the api.reddit.com
- // host 404s the same path without an OAuth token.
- `https://www.reddit.com/api/user_data_by_account_ids.json?ids=${encodeURIComponent(chunk)}`,
- { headers: { 'User-Agent': config.reddit.userAgent }, timeoutMs: 15_000 },
- );
- if (!res.ok) {
- logger.debug(`user_data_by_account_ids → ${res.status}`);
- continue;
- }
- const json = (await res.json()) as Record;
- for (const [t2, info] of Object.entries(json)) {
- if (info?.name) out[t2] = info.name;
- }
- } catch (err) {
- logger.debug('user_data_by_account_ids failed:', err);
- }
- }
- return out;
- },
+ resolveT2Ids: (ids) => matrixUsernameResolver.resolveT2Ids(ids),
+ resolveUsernameToT2: (username) => redditApi.resolveUsernameToT2(username),
};
const chatService = new ChatService(
@@ -244,6 +226,8 @@ export async function bootstrap(config: Config, logger: Logger): Promise new MatrixClient(credentials, logger),
@@ -268,7 +252,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise {
+ chatService.setProgressListener((progress, completion) => {
webServer!.broadcastSyncProgress(progress as Record | null);
if (progress) {
const syncId = `chat-${progress.startedAt}`;
@@ -286,12 +270,22 @@ export async function bootstrap(config: Config, logger: Logger): Promise logger.info('Runtime config updated via dashboard'));
}
diff --git a/src/browser/client.ts b/src/browser/client.ts
index 27eba7b..9cbac49 100644
--- a/src/browser/client.ts
+++ b/src/browser/client.ts
@@ -175,8 +175,12 @@ export class BrowserClient {
const res = await fetch('https://www.reddit.com/api/me.json', { credentials: 'include' });
if (!res.ok) return null;
const data: unknown = await res.json();
- if (!data || typeof data !== 'object' || !('name' in data) || typeof data.name !== 'string') return null;
- const username = data.name.trim();
+ if (!data || typeof data !== 'object') return null;
+ const account = 'data' in data && data.data && typeof data.data === 'object'
+ ? data.data
+ : data;
+ if (!('name' in account) || typeof account.name !== 'string') return null;
+ const username = account.name.trim();
return username || null;
} catch {
return null;
diff --git a/src/config/index.ts b/src/config/index.ts
index a7317c2..bfdd1fe 100644
--- a/src/config/index.ts
+++ b/src/config/index.ts
@@ -157,7 +157,20 @@ export const ConfigSchema = z.object({
export type Config = z.infer;
-export function loadConfig(): Config {
+export interface ConfigPersistence {
+ load(environment?: NodeJS.ProcessEnv): Config | null;
+ save(config: Config): void;
+}
+
+/**
+ * Loads the database-backed configuration. A legacy YAML file is imported
+ * exactly once when the database has not yet been seeded, which makes the
+ * migration safe for existing installations.
+ */
+export function loadConfig(store?: ConfigPersistence): Config {
+ const storedConfig = store?.load(process.env);
+ if (storedConfig) return storedConfig;
+
const configPath = path.resolve(process.cwd(), 'config.yaml');
if (!fs.existsSync(configPath)) {
@@ -173,6 +186,11 @@ export function loadConfig(): Config {
}
rawConfig.openrouter.apiKey = process.env.OPENROUTER_API_KEY || rawConfig.openrouter.apiKey;
+ if (!rawConfig.database) rawConfig.database = {};
+ // The database must be located before application settings can be read, so
+ // its path is an environment-owned bootstrap value rather than app config.
+ rawConfig.database.path = process.env.DATABASE_PATH ?? './data/reddit-bot.db';
+
if (process.env.WEB_DASHBOARD_TOKEN !== undefined) {
if (!rawConfig.web) rawConfig.web = {};
rawConfig.web.authToken = process.env.WEB_DASHBOARD_TOKEN;
@@ -195,5 +213,6 @@ export function loadConfig(): Config {
throw new Error('WEB_DASHBOARD_TOKEN environment variable or web.authToken configuration is required when the dashboard is enabled');
}
+ store?.save(result.data);
return result.data;
}
diff --git a/src/config/persistence.ts b/src/config/persistence.ts
index 8ee0613..20e58e3 100644
--- a/src/config/persistence.ts
+++ b/src/config/persistence.ts
@@ -1,74 +1,83 @@
-import fs from 'fs';
-import path from 'path';
-import YAML from 'yaml';
-import type { SubredditConfig } from './index.js';
+import type Database from 'better-sqlite3';
+import { ConfigSchema, type Config } from './index.js';
+
+type StoredConfig = Omit & {
+ openrouter: Omit;
+ web: Omit;
+};
/**
- * Writes an updated subreddit list back to `config.yaml`, preserving the
- * surrounding YAML structure, comment blocks, and per-row inline comments
- * for entries that survive the update.
- *
- * Strategy:
- * - Parse with `YAML.parseDocument` so the CST (including comments) is
- * intact.
- * - For each updated entry that matches an existing row by name, mutate
- * the existing node's fields in place — its `.comment` (the trailing
- * `# ...` annotation on the line) is preserved.
- * - New entries become fresh flow-style maps appended in the caller's
- * order; entries no longer in the list are dropped (and their comments
- * go with them, since the comment is attached to the row).
- * - Atomic write via tmp file + rename so a crash mid-write can't leave
- * a half-written config on disk.
+ * Persists application-owned configuration in SQLite. Secrets and the
+ * database location remain environment-owned bootstrap settings and are
+ * deliberately never written to the settings record.
*/
-export function persistSubreddits(
- subreddits: SubredditConfig[],
- configPath: string = path.resolve(process.cwd(), 'config.yaml'),
-): void {
- const raw = fs.readFileSync(configPath, 'utf-8');
- const doc = YAML.parseDocument(raw);
+export class ConfigStore {
+ constructor(private readonly db: Database.Database) {}
- const seq = doc.getIn(['reddit', 'subreddits']) as YAML.YAMLSeq | undefined;
- if (!seq || !YAML.isSeq(seq)) {
- throw new Error('reddit.subreddits is not a sequence in config.yaml');
- }
+ load(environment: NodeJS.ProcessEnv = process.env): Config | null {
+ const row = this.db.prepare('SELECT config_json FROM app_config WHERE id = 1').get() as
+ | { config_json: string }
+ | undefined;
+ if (!row) return null;
- // Map existing items by their `name` so we can reuse YAMLMap nodes
- // (and the trailing comments attached to them).
- const existingByName = new Map();
- for (const item of seq.items) {
- if (!YAML.isMap(item)) continue;
- const name = item.get('name');
- if (typeof name === 'string') existingByName.set(name, item);
- }
-
- const nextItems: YAML.YAMLMap[] = [];
- for (const sub of subreddits) {
- const existing = existingByName.get(sub.name);
- const node = existing ?? new YAML.YAMLMap();
- if (!existing) {
- // Match the on-disk style: flow-mapped, single-quoted strings.
- node.flow = true;
- node.set('name', sub.name);
+ let stored: unknown;
+ try {
+ stored = JSON.parse(row.config_json);
+ } catch (error) {
+ throw new Error(`Stored application configuration is not valid JSON: ${String(error)}`);
}
- setOrDelete(node, 'type', sub.type === 'image' ? undefined : sub.type);
- setOrDelete(node, 'weight', sub.weight);
- setOrDelete(node, 'tone', sub.tone === 'default' ? undefined : sub.tone);
- setOrDelete(node, 'scripted', sub.scripted);
- nextItems.push(node);
+
+ return validateConfig(withBootstrapSettings(stored, environment));
}
- seq.items = nextItems;
-
- const out = doc.toString();
- const tmp = configPath + '.tmp';
- fs.writeFileSync(tmp, out, 'utf-8');
- fs.renameSync(tmp, configPath);
-}
-
-function setOrDelete(node: YAML.YAMLMap, key: string, value: unknown): void {
- if (value === undefined || value === null || value === '') {
- if (node.has(key)) node.delete(key);
- return;
+ save(config: Config): void {
+ const stored = toStoredConfig(config);
+ this.db.prepare(`
+ INSERT INTO app_config (id, config_json, updated_at)
+ VALUES (1, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET
+ config_json = excluded.config_json,
+ updated_at = excluded.updated_at
+ `).run(JSON.stringify(stored), Date.now());
}
- node.set(key, value);
+}
+
+function toStoredConfig(config: Config): StoredConfig {
+ const { database: _database, ...applicationConfig } = config;
+ const { apiKey: _apiKey, ...openrouter } = applicationConfig.openrouter;
+ const { authToken: _authToken, ...web } = applicationConfig.web;
+ return { ...applicationConfig, openrouter, web };
+}
+
+function withBootstrapSettings(stored: unknown, environment: NodeJS.ProcessEnv): unknown {
+ if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return stored;
+ const record = stored as Record;
+ const openrouter = objectValue(record.openrouter);
+ const web = objectValue(record.web);
+ return {
+ ...record,
+ database: { path: environment.DATABASE_PATH ?? './data/reddit-bot.db' },
+ openrouter: { ...openrouter, apiKey: environment.OPENROUTER_API_KEY },
+ web: { ...web, authToken: environment.WEB_DASHBOARD_TOKEN },
+ };
+}
+
+function objectValue(value: unknown): Record {
+ return value && typeof value === 'object' && !Array.isArray(value)
+ ? value as Record
+ : {};
+}
+
+function validateConfig(raw: unknown): Config {
+ const result = ConfigSchema.safeParse(raw);
+ if (!result.success) {
+ const errors = result.error.issues
+ .map((issue) => ` - ${String(issue.path.join('.'))}: ${issue.message}`)
+ .join('\n');
+ throw new Error(`Invalid stored configuration:\n${errors}`);
+ }
+ if (result.data.web.enabled && !result.data.web.authToken) {
+ throw new Error('WEB_DASHBOARD_TOKEN environment variable is required when the dashboard is enabled');
+ }
+ return result.data;
}
diff --git a/src/database/client.ts b/src/database/client.ts
index 382e385..1f91791 100644
--- a/src/database/client.ts
+++ b/src/database/client.ts
@@ -3,7 +3,7 @@ import type { Config } from '../config/index.js';
import path from 'path';
import fs from 'fs';
-export function createDatabase(config: Config): Database.Database {
+export function createDatabase(config: Pick): Database.Database {
const dbPath = config.database.path === ':memory:'
? ':memory:'
: path.resolve(process.cwd(), config.database.path);
@@ -142,6 +142,56 @@ export function runMigrations(db: Database.Database): void {
FOREIGN KEY (sent_message_id) REFERENCES sent_messages(id)
);
`),
+ () => {
+ addColumnIfNotExists(db, 'processed_posts', 'post_url', 'TEXT');
+ addColumnIfNotExists(db, 'activity_log', 'post_title', 'TEXT');
+ addColumnIfNotExists(db, 'activity_log', 'post_url', 'TEXT');
+ db.exec(`
+ UPDATE processed_posts
+ SET post_url = 'https://www.reddit.com/r/' || subreddit || '/comments/' || id || '/'
+ WHERE post_url IS NULL;
+
+ UPDATE activity_log
+ SET
+ post_title = (
+ SELECT pp.title
+ FROM sent_messages sm
+ JOIN processed_posts pp ON pp.id = sm.post_id
+ WHERE sm.recipient = activity_log.recipient COLLATE NOCASE
+ AND sm.sent_at <= activity_log.timestamp
+ ORDER BY sm.sent_at DESC
+ LIMIT 1
+ ),
+ post_url = (
+ SELECT pp.post_url
+ FROM sent_messages sm
+ JOIN processed_posts pp ON pp.id = sm.post_id
+ WHERE sm.recipient = activity_log.recipient COLLATE NOCASE
+ AND sm.sent_at <= activity_log.timestamp
+ ORDER BY sm.sent_at DESC
+ LIMIT 1
+ ),
+ image_url = COALESCE(image_url, (
+ SELECT pp.image_url
+ FROM sent_messages sm
+ JOIN processed_posts pp ON pp.id = sm.post_id
+ WHERE sm.recipient = activity_log.recipient COLLATE NOCASE
+ AND sm.sent_at <= activity_log.timestamp
+ ORDER BY sm.sent_at DESC
+ LIMIT 1
+ ))
+ WHERE type = 'sent'
+ AND recipient IS NOT NULL
+ AND post_url IS NULL;
+ `);
+ },
+ () => db.exec(`
+ CREATE TABLE IF NOT EXISTS app_config (
+ id INTEGER PRIMARY KEY CHECK(id = 1),
+ config_json TEXT NOT NULL,
+ updated_at INTEGER NOT NULL
+ );
+ `),
];
let currentVersion = db.pragma('user_version', { simple: true }) as number;
diff --git a/src/database/repositories.ts b/src/database/repositories.ts
index 304cb3f..3818f5d 100644
--- a/src/database/repositories.ts
+++ b/src/database/repositories.ts
@@ -11,6 +11,7 @@ export interface ProcessedPostRecord {
subreddit: string;
author: string;
title: string;
+ postUrl: string | null;
imageUrl: string | null;
imageAnalysis: string | null;
generatedMessage: string | null;
@@ -32,6 +33,7 @@ export interface SentMessageRecord {
chatRoomId: string | null;
subreddit?: string;
postTitle?: string;
+ postUrl?: string;
postImageUrl?: string;
postImageAnalysis?: string;
}
@@ -74,6 +76,8 @@ export interface ActivityLogRecord {
message: string;
recipient: string | null;
imageUrl: string | null;
+ postTitle: string | null;
+ postUrl: string | null;
}
export type DeliveryStatus = 'discovered' | 'prepared' | 'sending' | 'sent' | 'failed';
@@ -233,12 +237,12 @@ export class ActivityLogRepository {
constructor(private db: Database.Database) {
this.insertStmt = db.prepare(`
- INSERT INTO activity_log (timestamp, type, message, recipient, image_url)
- VALUES (?, ?, ?, ?, ?)
+ INSERT INTO activity_log (timestamp, type, message, recipient, image_url, post_title, post_url)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
`);
this.getRecentStmt = db.prepare(`
- SELECT timestamp, type, message, recipient, image_url
+ SELECT timestamp, type, message, recipient, image_url, post_title, post_url
FROM activity_log
ORDER BY id DESC
LIMIT ?
@@ -260,6 +264,8 @@ export class ActivityLogRepository {
message: string;
recipient?: string;
imageUrl?: string;
+ postTitle?: string;
+ postUrl?: string;
}): void {
this.insertStmt.run(
item.timestamp,
@@ -267,6 +273,8 @@ export class ActivityLogRepository {
item.message,
item.recipient ?? null,
item.imageUrl ?? null,
+ item.postTitle ?? null,
+ item.postUrl ?? null,
);
}
@@ -278,6 +286,8 @@ export class ActivityLogRepository {
message: row.message as string,
recipient: (row.recipient as string) ?? null,
imageUrl: (row.image_url as string) ?? null,
+ postTitle: (row.post_title as string) ?? null,
+ postUrl: (row.post_url as string) ?? null,
}));
}
@@ -302,8 +312,8 @@ export class PostsRepository {
constructor(private db: Database.Database) {
this.insertStmt = db.prepare(`
- INSERT INTO processed_posts (id, subreddit, author, title, image_url, processed_at)
- VALUES (?, ?, ?, ?, ?, ?)
+ INSERT INTO processed_posts (id, subreddit, author, title, post_url, image_url, processed_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
`);
this.existsStmt = db.prepare(`SELECT 1 FROM processed_posts WHERE id = ?`);
@@ -334,6 +344,7 @@ export class PostsRepository {
subreddit: string;
author: string;
title: string;
+ postUrl?: string | null;
imageUrl: string | null;
processedAt: number;
}): void {
@@ -342,6 +353,7 @@ export class PostsRepository {
post.subreddit,
post.author,
post.title,
+ post.postUrl ?? null,
post.imageUrl,
post.processedAt
);
@@ -368,6 +380,7 @@ export class PostsRepository {
subreddit: row.subreddit as string,
author: row.author as string,
title: row.title as string,
+ postUrl: (row.post_url as string | null) ?? null,
imageUrl: row.image_url as string | null,
imageAnalysis: row.image_analysis as string | null,
generatedMessage: row.generated_message as string | null,
@@ -384,6 +397,7 @@ export class PostsRepository {
subreddit: row.subreddit as string,
author: row.author as string,
title: row.title as string,
+ postUrl: (row.post_url as string | null) ?? null,
imageUrl: row.image_url as string | null,
imageAnalysis: row.image_analysis as string | null,
generatedMessage: row.generated_message as string | null,
@@ -408,8 +422,8 @@ export class MessagesRepository {
constructor(private db: Database.Database) {
this.insertStmt = db.prepare(`
- INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at, strategy_id)
- VALUES (?, ?, ?, ?, ?, ?)
+ INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at, strategy_id, chat_room_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
`);
this.insertChatStmt = db.prepare(`
@@ -418,18 +432,19 @@ export class MessagesRepository {
`);
this.getByChatRoomIdStmt = db.prepare(`
- SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.image_url AS post_image_url,
+ SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.post_url, pp.image_url AS post_image_url,
pp.image_analysis AS post_image_analysis
FROM sent_messages sm
LEFT JOIN processed_posts pp ON sm.post_id = pp.id
WHERE sm.chat_room_id = ?
+ ORDER BY sm.post_id IS NULL ASC, sm.sent_at DESC
LIMIT 1
`);
this.getByPostIdStmt = db.prepare(`SELECT * FROM sent_messages WHERE post_id = ?`);
this.getLatestByRecipientStmt = db.prepare(`
- SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.image_url AS post_image_url,
+ SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.post_url, pp.image_url AS post_image_url,
pp.image_analysis AS post_image_analysis
FROM sent_messages sm
LEFT JOIN processed_posts pp ON sm.post_id = pp.id
@@ -439,7 +454,7 @@ export class MessagesRepository {
`);
this.getRecentStmt = db.prepare(`
- SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.image_url AS post_image_url,
+ SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.post_url, pp.image_url AS post_image_url,
pp.image_analysis AS post_image_analysis
FROM sent_messages sm
LEFT JOIN processed_posts pp ON sm.post_id = pp.id
@@ -476,6 +491,7 @@ export class MessagesRepository {
message: string;
sentAt: number;
strategyId?: string | null;
+ chatRoomId?: string | null;
}): number {
const result = this.insertStmt.run(
message.postId,
@@ -483,7 +499,8 @@ export class MessagesRepository {
message.subject,
message.message,
message.sentAt,
- message.strategyId || null
+ message.strategyId || null,
+ message.chatRoomId ?? null,
);
return result.lastInsertRowid as number;
}
@@ -608,6 +625,7 @@ export class MessagesRepository {
SUM(CASE WHEN got_response = 1 THEN 1 ELSE 0 END) as total_responses
FROM sent_messages
WHERE sent_at > ?
+ AND (post_id IS NOT NULL OR strategy_id IS NOT NULL)
GROUP BY day
ORDER BY day ASC
`).all(cutoff) as Record[];
@@ -636,6 +654,7 @@ export class MessagesRepository {
chatRoomId: (row.chat_room_id as string) ?? null,
subreddit: (row.subreddit as string) || undefined,
postTitle: (row.post_title as string) || undefined,
+ postUrl: (row.post_url as string) || undefined,
postImageUrl: (row.post_image_url as string) || undefined,
postImageAnalysis: (row.post_image_analysis as string) || undefined,
};
diff --git a/src/index.ts b/src/index.ts
index 834b4eb..a32002a 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,6 +2,8 @@ import { loadConfig } from './config/index.js';
import { createLogger } from './utils/logger.js';
import { bootstrap } from './bootstrap.js';
import { runMainLoop } from './main-loop.js';
+import { createDatabase, runMigrations } from './database/client.js';
+import { ConfigStore } from './config/persistence.js';
async function main(): Promise {
const logger = createLogger();
@@ -11,15 +13,19 @@ async function main(): Promise {
logger.info('='.repeat(60));
let config;
+ const db = createDatabase({
+ database: { path: process.env.DATABASE_PATH ?? './data/reddit-bot.db' },
+ });
+ runMigrations(db);
try {
- config = loadConfig();
+ config = loadConfig(new ConfigStore(db));
logger.info(`Loaded configuration for ${config.reddit.subreddits.length} subreddits`);
} catch (error) {
logger.error('Failed to load configuration:', error);
process.exit(1);
}
- const app = await bootstrap(config, logger);
+ const app = await bootstrap(config, logger, db);
await runMainLoop(app);
}
diff --git a/src/main-loop.ts b/src/main-loop.ts
index fff9bf5..793f4bc 100644
--- a/src/main-loop.ts
+++ b/src/main-loop.ts
@@ -242,11 +242,13 @@ export async function runMainLoop(app: AppContainer): Promise {
);
const isChatSub = subredditEntry?.type === 'chat';
const postImageUrl = isChatSub ? null : redditApi.getImageUrl(post);
+ const postUrl = redditApi.getPostUrl(post);
postsRepo.insert({
id: post.id,
subreddit: post.subreddit,
author: post.author,
title: post.title,
+ postUrl,
imageUrl: postImageUrl,
processedAt: Date.now(),
});
@@ -309,7 +311,11 @@ export async function runMainLoop(app: AppContainer): Promise {
logger.info(`Sending message to u/${post.author}...`);
webServer?.pushLog('info', `Sending message to u/${post.author}...`);
- const chat = await chatService.startConversation(post.author, processed.generatedMessage);
+ const chat = await chatService.startConversation(
+ post.author,
+ processed.generatedMessage,
+ post.author_fullname,
+ );
const success = chat !== null;
if (success) {
@@ -327,6 +333,7 @@ export async function runMainLoop(app: AppContainer): Promise {
message: processed.generatedMessage,
sentAt: Date.now(),
strategyId: processed.strategyId,
+ chatRoomId: chat.roomId,
});
const currentCount = currentHourlySendCount(rateLimiter);
@@ -338,6 +345,7 @@ export async function runMainLoop(app: AppContainer): Promise {
`Sent message to u/${post.author} (r/${post.subreddit})`,
post.author,
postImageUrl ?? undefined,
+ { postTitle: post.title, postUrl },
);
webServer?.pushLog('info', `✅ Message sent to u/${post.author} (${currentCount}/${config.rateLimit.maxMessagesPerHour} this hour)`);
webServer?.updateStats(currentCount);
diff --git a/src/reddit/api.ts b/src/reddit/api.ts
index 9f74011..2b2287a 100644
--- a/src/reddit/api.ts
+++ b/src/reddit/api.ts
@@ -8,6 +8,7 @@ export interface RedditPost {
name: string; // fullname like "t3_xxxxx"
subreddit: string;
author: string;
+ author_fullname?: string; // account fullname like "t2_xxxxx"
title: string;
url: string;
permalink: string;
@@ -83,6 +84,26 @@ export class RedditApiClient {
}
}
+ /** Resolve a Reddit username through the authenticated browser session. */
+ async resolveUsernameToT2(username: string): Promise {
+ const cookieHeader = await this.getCookieHeader();
+ const headers: Record = {};
+ if (cookieHeader) headers.Cookie = cookieHeader;
+
+ try {
+ const response = await this.client.get(
+ `/user/${encodeURIComponent(username)}/about.json`,
+ { params: { raw_json: 1 }, headers },
+ );
+ const id = response.data?.data?.id;
+ if (typeof id !== 'string' || id.length === 0) return null;
+ return id.startsWith('t2_') ? id : `t2_${id}`;
+ } catch (error) {
+ if (axios.isAxiosError(error) && error.response?.status === 404) return null;
+ throw error;
+ }
+ }
+
async getNewPosts(subreddit: string, limit = 25, type: 'image' | 'chat' = 'image'): Promise {
try {
const cookieHeader = await this.getCookieHeader();
diff --git a/src/services/chat/index.ts b/src/services/chat/index.ts
index eba3e6f..79edcc9 100644
--- a/src/services/chat/index.ts
+++ b/src/services/chat/index.ts
@@ -5,6 +5,7 @@ export type {
SyncStatus,
SyncProgress,
SyncPhase,
+ SyncCompletion,
SyncProgressListener,
} from './service.js';
export type {
@@ -23,3 +24,4 @@ export {
MatrixTokenRefresher,
} from './matrix/token-refresher.js';
export { MatrixMediaDownloader } from './matrix/http-media-downloader.js';
+export { MatrixUsernameResolver } from './matrix/username-resolver.js';
diff --git a/src/services/chat/matrix/client.ts b/src/services/chat/matrix/client.ts
index 16140ac..961a13f 100644
--- a/src/services/chat/matrix/client.ts
+++ b/src/services/chat/matrix/client.ts
@@ -190,7 +190,13 @@ export class MatrixClient {
}
if (res.status === 401 || res.status === 403) {
- throw new MatrixAuthError(`Matrix ${method} ${path} → ${res.status} (token likely expired)`);
+ const failure = await readMatrixFailure(res);
+ if (isMatrixAuthenticationFailure(res.status, failure.errcode)) {
+ throw new MatrixAuthError(
+ `Matrix ${method} ${path} → ${res.status} ${failure.errcode ?? ''} (token rejected)`,
+ );
+ }
+ throw new Error(`Matrix ${method} ${path} → ${res.status}: ${failure.text.slice(0, 300)}`);
}
// Matrix-standard rate-limit response: { retry_after_ms } per spec.
@@ -251,6 +257,13 @@ export class MatrixClient {
return res.user_id;
}
+ async getUserProfile(mxid: string): Promise<{ displayname?: string; avatar_url?: string }> {
+ return this.request<{ displayname?: string; avatar_url?: string }>(
+ 'GET',
+ `/_matrix/client/v3/profile/${encodeURIComponent(mxid)}`,
+ );
+ }
+
/**
* All rooms the user is currently joined to. Use this for backfill —
* `/sync` only returns rooms with recent activity.
@@ -502,7 +515,15 @@ export class MatrixClient {
throw new Error(`Matrix media download ${url} timed out (exceeded ${maxAttempts} attempts)`);
}
if (res.status === 401 || res.status === 403) {
- throw new MatrixAuthError(`Matrix media download → ${res.status} (token likely expired)`);
+ const failure = await readMatrixFailure(res);
+ if (isMatrixAuthenticationFailure(res.status, failure.errcode)) {
+ throw new MatrixAuthError(
+ `Matrix media download → ${res.status} ${failure.errcode ?? ''} (token rejected)`,
+ );
+ }
+ throw new Error(
+ `Matrix media download ${url} → ${res.status}: ${failure.text.slice(0, 200)}`,
+ );
}
if (res.status === 429) {
const body = (await res.json().catch(() => ({}))) as { retry_after_ms?: number };
@@ -529,6 +550,29 @@ export class MatrixClient {
}
}
+interface MatrixFailure {
+ errcode?: string;
+ text: string;
+}
+
+async function readMatrixFailure(response: Response): Promise {
+ const text = await response.text().catch(() => '');
+ if (!text) return { text };
+ try {
+ const parsed = JSON.parse(text) as { errcode?: unknown };
+ return {
+ text,
+ ...(typeof parsed.errcode === 'string' ? { errcode: parsed.errcode } : {}),
+ };
+ } catch {
+ return { text };
+ }
+}
+
+function isMatrixAuthenticationFailure(status: number, errcode?: string): boolean {
+ return status === 401 || errcode === 'M_UNKNOWN_TOKEN' || errcode === 'M_MISSING_TOKEN';
+}
+
function parseMxc(mxc: string): { serverName: string; mediaId: string } | null {
const m = mxc.match(/^mxc:\/\/([^/]+)\/(.+)$/);
if (!m) return null;
diff --git a/src/services/chat/matrix/http-media-downloader.ts b/src/services/chat/matrix/http-media-downloader.ts
index 31afd84..ea6f02b 100644
--- a/src/services/chat/matrix/http-media-downloader.ts
+++ b/src/services/chat/matrix/http-media-downloader.ts
@@ -16,6 +16,10 @@ export class MatrixMediaDownloader implements MediaDownloader {
private logger: Logger,
) {}
+ setMatrixClient(client: MatrixClient): void {
+ this.matrix = client;
+ }
+
async fetch(url: string): Promise {
try {
if (url.startsWith('mxc://')) {
diff --git a/src/services/chat/matrix/username-resolver.ts b/src/services/chat/matrix/username-resolver.ts
new file mode 100644
index 0000000..2727433
--- /dev/null
+++ b/src/services/chat/matrix/username-resolver.ts
@@ -0,0 +1,29 @@
+import type { UsernameResolver } from '../service.js';
+
+interface MatrixProfileClient {
+ getUserProfile(mxid: string): Promise<{ displayname?: string }>;
+}
+
+export class MatrixUsernameResolver implements UsernameResolver {
+ constructor(private profiles: MatrixProfileClient) {}
+
+ setMatrixClient(client: MatrixProfileClient): void {
+ this.profiles = client;
+ }
+
+ async resolveT2Ids(t2Ids: string[]): Promise> {
+ const resolved: Record = {};
+ await Promise.all(t2Ids.map(async (value) => {
+ const t2Id = value.startsWith('t2_') ? value : `t2_${value}`;
+ try {
+ const username = (await this.profiles.getUserProfile(`@${t2Id}:reddit.com`)).displayname?.trim();
+ if (username && !username.startsWith('t2_') && !username.startsWith('@')) {
+ resolved[t2Id] = username;
+ }
+ } catch {
+ // A missing profile should not prevent the rest of the batch resolving.
+ }
+ }));
+ return resolved;
+ }
+}
diff --git a/src/services/chat/service.ts b/src/services/chat/service.ts
index cfcaa5f..d455722 100644
--- a/src/services/chat/service.ts
+++ b/src/services/chat/service.ts
@@ -2,6 +2,7 @@ import type { Logger } from '../../utils/logger.js';
import type {
MessagesRepository,
RedditUsersRepository,
+ SentMessageRecord,
SuccessfulMessagesRepository,
} from '../../database/repositories.js';
import type { MediaService } from '../media.js';
@@ -40,10 +41,13 @@ class MatrixAuthenticationUnavailableError extends Error {
export interface UsernameResolver {
/** Map t2 IDs (with or without `t2_` prefix) to Reddit usernames. */
resolveT2Ids(t2Ids: string[]): Promise>;
+ /** Resolve a Reddit username to its t2 account ID when starting a DM. */
+ resolveUsernameToT2?(username: string): Promise;
}
interface BuildChatOpts {
knownUsernames: Map;
+ includeTimelineMessages?: boolean;
}
/**
@@ -98,8 +102,15 @@ export interface SyncStatus {
progress: SyncProgress | null;
}
+export type SyncCompletion =
+ | { status: 'completed' }
+ | { status: 'failed'; error: string };
+
/** `null` signals "sync ended" (clear progress UI). */
-export type SyncProgressListener = (progress: SyncProgress | null) => void;
+export type SyncProgressListener = (
+ progress: SyncProgress | null,
+ completion?: SyncCompletion,
+) => void;
/** Fired when a chat we sent into first receives a reply from the other party. */
export type NewReplyListener = (info: {
@@ -112,6 +123,7 @@ export type NewReplyListener = (info: {
export class ChatService {
private cache: ChatCache;
private queue: Promise = Promise.resolve();
+ private deltaSyncInFlight: Promise | null = null;
private readonly t2ToUsername = new Map();
/** Rooms whose /joined_members lookup has already failed — don't retry. */
private readonly unresolvableRooms = new Set();
@@ -263,6 +275,7 @@ export class ChatService {
return this.enqueue(async () => {
if (this.cache.isFresh()) return this.cache.get();
this.beginSync('delta');
+ let failure: unknown;
try {
this.updateProgress({ phase: 'starting', current: 0, total: 0 });
const merged = await this.syncOnce();
@@ -273,8 +286,11 @@ export class ChatService {
this.logger.info(`[chat] sync → ${merged.length} known rooms, ${active.length} active`);
this.updateProgress({ phase: 'done', current: merged.length, total: merged.length });
return active;
+ } catch (error) {
+ failure = error;
+ throw error;
} finally {
- this.endSync();
+ this.endSync(failure);
}
});
}
@@ -284,9 +300,12 @@ 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.
*/
- async refreshTopChats(_limit = 5): Promise {
- await this.enqueue(async () => {
+ refreshTopChats(_limit = 5): Promise {
+ if (this.deltaSyncInFlight) return this.deltaSyncInFlight;
+
+ const operation = this.enqueue(async () => {
this.beginSync('delta');
+ let failure: unknown;
try {
this.updateProgress({ phase: 'starting', current: 0, total: 0 });
await this.syncOnce();
@@ -297,11 +316,19 @@ export class ChatService {
total: this.cache.get().length,
});
} catch (error) {
+ failure = error;
this.logger.error('[chat] refreshTopChats failed:', error);
+ throw error;
} finally {
- this.endSync();
+ this.endSync(failure);
}
});
+
+ const tracked = operation.finally(() => {
+ this.deltaSyncInFlight = null;
+ });
+ this.deltaSyncInFlight = tracked;
+ return tracked;
}
/**
@@ -312,6 +339,7 @@ export class ChatService {
async fetchAllMessages(): Promise {
return this.enqueue(async () => {
this.beginSync('full');
+ let failure: unknown;
try {
this.logger.info('[chat] full backfill starting (via /joined_rooms)...');
const t0 = Date.now();
@@ -326,24 +354,46 @@ export class ChatService {
const joinedIds = await this.withAuth(() => this.matrix.getJoinedRooms());
this.logger.info(`[chat] backfill: ${joinedIds.length} joined rooms`);
- this.updateProgress({
- phase: 'resolving-usernames',
- current: 0,
- total: joinedIds.length,
- });
- const knownUsernames = await this.resolveUsernamesForRooms(joinedIds);
+ const knownUsernames = new Map(this.t2ToUsername);
const chats: ActiveChat[] = [];
for (const roomId of joinedIds) {
try {
const room = seedJoined[roomId];
- const chat = this.roomToActiveChat(roomId, room, { knownUsernames });
+ const chat = this.roomToActiveChat(roomId, room, {
+ knownUsernames,
+ includeTimelineMessages: true,
+ });
chats.push(chat);
} catch (err) {
this.logger.warn(`[chat] backfill: failed to seed ${roomId}: ${(err as Error).message}`);
}
}
+ // Make the inbox usable as soon as Matrix has listed the rooms. A
+ // full history backfill can take many minutes for large accounts, so
+ // keeping these summaries local until the final room has loaded makes
+ // a healthy sync look like an empty inbox for the entire run.
+ chats.sort((a, b) => b.lastMessageTime - a.lastMessageTime);
+ this.cache.set(chats);
+ await this.cache.save(chats);
+
+ this.updateProgress({
+ phase: 'resolving-usernames',
+ current: 0,
+ total: joinedIds.length,
+ });
+ const resolvedUsernames = await this.resolveUsernamesForRooms(joinedIds, seedJoined);
+ for (const chat of chats) {
+ const t2 = this.extractT2(chat);
+ if (!t2) continue;
+ const resolved = resolvedUsernames.get(t2);
+ if (!resolved) continue;
+ chat.username = resolved;
+ if (!chat.roomName || chat.roomName.startsWith('t2_')) chat.roomName = resolved;
+ }
+ await this.cache.save(chats);
+
let done = 0;
for (const chat of chats) {
this.updateProgress({
@@ -377,10 +427,12 @@ export class ChatService {
this.updateProgress({ phase: 'done', current: chats.length, total: chats.length });
return chats;
} catch (error) {
+ failure = error;
this.logger.error('[chat] fetchAllMessages failed:', error);
+ if (error instanceof MatrixAuthenticationUnavailableError) throw error;
return this.cache.get();
} finally {
- this.endSync();
+ this.endSync(failure);
}
});
}
@@ -446,12 +498,18 @@ export class ChatService {
this.emitProgress();
}
- private endSync(): void {
+ private endSync(error?: unknown): void {
this.syncingKind = null;
this.progress = null;
if (this.progressListener) {
try {
- this.progressListener(null);
+ const completion: SyncCompletion = error === undefined
+ ? { status: 'completed' }
+ : {
+ status: 'failed',
+ error: error instanceof Error ? error.message : String(error),
+ };
+ this.progressListener(null, completion);
} catch (err) {
this.logger.debug('progress listener error:', err);
}
@@ -522,7 +580,11 @@ export class ChatService {
* Start a brand-new DM with the given Reddit user and send the opening
* line. Returns the new chat (or null on failure).
*/
- async startConversation(username: string, message: string): Promise {
+ async startConversation(
+ username: string,
+ message: string,
+ knownT2Id?: string,
+ ): Promise {
if (!this.accountAuth?.isFullyAuthenticated()) return null;
const active = this.getInvitationCooldown();
if (active) {
@@ -534,7 +596,7 @@ export class ChatService {
}
return this.enqueue(async () => {
try {
- const t2Id = await this.resolveUsernameToT2(username);
+ const t2Id = await this.resolveUsernameToT2(username, knownT2Id);
if (!t2Id) {
this.logger.error(`[chat] cannot start conversation: unknown user "${username}"`);
return null;
@@ -657,7 +719,7 @@ export class ChatService {
const joined = sync.rooms?.join ?? {};
const existing = new Map(this.cache.get().map((c) => [c.roomId!, c] as const));
- const knownUsernames = await this.resolveUsernamesForRooms(Object.keys(joined));
+ const knownUsernames = await this.resolveUsernamesForRooms(Object.keys(joined), joined);
for (const [roomId, room] of Object.entries(joined)) {
const prev = existing.get(roomId);
@@ -786,38 +848,36 @@ export class ChatService {
if (firstSelf) chat.originMessage = firstSelf.text;
const hasReply = chat.messages.some((m) => !m.isSelf);
+ let performanceMessage: SentMessageRecord | undefined;
if (firstSelf && chat.roomId && this.messagesRepo) {
try {
const existing = this.messagesRepo.getByChatRoomId(chat.roomId);
- const id = existing
- ? existing.id
- : this.messagesRepo.insertChat({
- recipient: chat.username,
- subject: chat.username,
- message: firstSelf.text,
- sentAt: firstSelf.timestamp,
- chatRoomId: chat.roomId,
- });
- if (hasReply && !existing?.gotResponse) {
- this.messagesRepo.markGotResponse(id);
+ const hasPerformanceContext = Boolean(
+ existing?.postId || existing?.strategyId || existing?.subreddit,
+ );
+ if (existing && hasPerformanceContext) {
+ performanceMessage = existing;
+ if (hasReply && !existing.gotResponse) {
+ this.messagesRepo.markGotResponse(existing.id);
+ }
}
} catch (err) {
- this.logger.warn('[chat] failed to record sent_message for stats:', err);
+ this.logger.warn('[chat] failed to associate reply with performance context:', err);
}
}
- if (firstSelf && hasReply && this.successfulMessagesRepo) {
+ if (firstSelf && hasReply && performanceMessage && this.successfulMessagesRepo) {
if (!this.successfulMessagesRepo.existsByMessage(firstSelf.text)) {
const firstReply = chat.messages.find((m) => !m.isSelf);
try {
this.successfulMessagesRepo.insert({
- sentMessageId: null,
+ sentMessageId: performanceMessage.id,
originalMessage: firstSelf.text,
- imageAnalysis: null,
- postTitle: `Chat with ${chat.username}`,
+ imageAnalysis: performanceMessage.postImageAnalysis ?? null,
+ postTitle: performanceMessage.postTitle ?? null,
responseSnippet: firstReply?.text.slice(0, 200) || null,
- subreddit: null,
+ subreddit: performanceMessage.subreddit ?? null,
});
this.logger.info(`[chat] stored successful chat example from ${chat.username}`);
try {
@@ -916,6 +976,10 @@ export class ChatService {
isFromUs = lastMessage.sender === this.matrix.userId;
}
+ const timelineMessages = opts.includeTimelineMessages && !prev?.messages
+ ? this.scrapeMessageEvents(timeline).map(({ mediaUrls: _ignored, ...message }) => message)
+ : prev?.messages;
+
const unread = (room?.unread_notifications?.notification_count ?? 0) > 0;
return {
@@ -928,6 +992,7 @@ export class ChatService {
unread,
url: ROOM_URL_PREFIX + encodeURIComponent(roomId),
roomId,
+ ...(timelineMessages ? { messages: timelineMessages } : {}),
};
}
@@ -956,16 +1021,58 @@ export class ChatService {
* as a fallback. Failures are logged and ignored, with the t2 ID
* staying as the fallback display.
*/
- private async resolveUsernamesForRooms(roomIds: string[]): Promise> {
- // Collect all known t2 IDs (from the in-memory cache) and query
- // Reddit's alfred API for reverse-resolution.
+ private async resolveUsernamesForRooms(
+ roomIds: string[],
+ rooms: Record = {},
+ ): Promise> {
const cached = new Map(this.t2ToUsername);
+ const candidateT2s = new Set(cached.keys());
- const staleT2s = Array.from(cached.keys()).filter(id => {
+ for (const roomId of roomIds) {
+ const room = rooms[roomId];
+ const mxids = [
+ ...(room?.summary?.['m.heroes'] ?? []),
+ ...(room?.state?.events ?? []).map((event) =>
+ (event as MatrixTimelineEvent & { state_key?: string }).state_key,
+ ),
+ ...(room?.timeline?.events ?? []).map((event) => event.sender),
+ ];
+ for (const mxid of mxids) {
+ if (!mxid || mxid === this.matrix.userId) continue;
+ const t2 = mxid.match(/^@t2_([^:]+):/)?.[1];
+ if (t2) candidateT2s.add(t2);
+ }
+ }
+
+ let staleT2s = Array.from(candidateT2s).filter(id => {
const name = cached.get(id);
return !name || name.startsWith('t2_') || name.startsWith('@') || this.isStaleUsername(name);
});
+ // Matrix already knows the display name attached to each room member, so
+ // prefer that authenticated source before trying Reddit's legacy lookup.
+ if (staleT2s.length > 0 && this.usernameResolver) {
+ try {
+ const resolved = await this.usernameResolver.resolveT2Ids(
+ staleT2s.map((id) => `t2_${id}`),
+ );
+ for (const [t2, name] of Object.entries(resolved)) {
+ const bare = t2.replace(/^t2_/, '');
+ cached.set(bare, name);
+ this.t2ToUsername.set(bare, name);
+ this.persistUsername(t2, name);
+ }
+ } catch (err) {
+ this.logger.debug('[chat] MXID resolution failed:', err);
+ }
+ staleT2s = staleT2s.filter((id) => {
+ const name = cached.get(id);
+ return !name || this.isStaleUsername(name);
+ });
+ }
+
+ // Fall back to Reddit's alfred API for profiles without a Matrix display
+ // name. This is best-effort and leaves the stable t2 ID visible on error.
if (staleT2s.length > 0) {
const numericIds = staleT2s.map(id => id.replace(/^t2_/, ''));
try {
@@ -1014,23 +1121,6 @@ export class ChatService {
}
}
- // Also resolve MXID-based t2 IDs via the lifted UsernameResolver,
- // while avoiding re-queries for IDs we already resolved via alfred.
- const mxidT2s = Array.from(new Set(staleT2s.filter(id => !cached.get(id.replace(/^t2_/, '')))));
- if (mxidT2s.length > 0 && this.usernameResolver) {
- try {
- const resolved = await this.usernameResolver.resolveT2Ids(mxidT2s);
- for (const [t2, name] of Object.entries(resolved)) {
- const bare = t2.replace(/^t2_/, '');
- cached.set(bare, name);
- this.t2ToUsername.set(bare, name);
- this.persistUsername(t2, name);
- }
- } catch (err) {
- this.logger.debug('[chat] MXID resolution failed:', err);
- }
- }
-
return cached;
}
@@ -1170,28 +1260,21 @@ export class ChatService {
if (dirty) await this.cache.save();
}
- /**
- * Look up the t2 ID for a Reddit username via the standard about.json
- * endpoint. Returns `t2_xxx` or null.
- */
- private async resolveUsernameToT2(username: string): Promise {
- try {
- const res = await fetchWithTimeout(
- `https://www.reddit.com/user/${encodeURIComponent(username)}/about.json`,
- { headers: { 'User-Agent': 'reddit-bot-chat/1.0' }, timeoutMs: 15_000 },
- );
- if (!res.ok) {
- this.logger.warn(`[chat] /user/${username}/about.json → ${res.status}`);
- return null;
- }
- const json = (await res.json()) as { data?: { id?: string } };
- const id = json.data?.id;
- if (!id) return null;
- return id.startsWith('t2_') ? id : `t2_${id}`;
- } catch (err) {
- this.logger.error(`[chat] failed to resolve username "${username}":`, err);
- return null;
+ /** Resolve an account ID from listing data, the durable cache, or Reddit auth. */
+ private async resolveUsernameToT2(
+ username: string,
+ knownT2Id?: string,
+ ): Promise {
+ if (knownT2Id && /^t2_[a-z0-9]+$/i.test(knownT2Id)) return knownT2Id;
+
+ const cached = this.redditUsersRepo?.getByUsername(username);
+ if (cached) return cached.t2Id;
+
+ if (this.usernameResolver?.resolveUsernameToT2) {
+ return this.usernameResolver.resolveUsernameToT2(username);
}
+
+ return null;
}
/** Parse a chat URL like `…/room/!abc%3Areddit.com` → `!abc:reddit.com`. */
diff --git a/src/services/chat/types.ts b/src/services/chat/types.ts
index d655f58..54e6960 100644
--- a/src/services/chat/types.ts
+++ b/src/services/chat/types.ts
@@ -23,6 +23,7 @@ export interface ActiveChat {
roomId?: string;
messages?: ChatMessage[];
originMessage?: string;
+ hasReceivedImages?: boolean;
}
/**
diff --git a/src/services/response-checker.ts b/src/services/response-checker.ts
index 2c0720d..4d21b06 100644
--- a/src/services/response-checker.ts
+++ b/src/services/response-checker.ts
@@ -57,7 +57,17 @@ export class ResponseCheckerService {
results.push(result);
if (result.gotResponse) {
- // Mark as got response
+ const hasPerformanceContext = Boolean(
+ message.postId || message.strategyId || message.subreddit,
+ );
+ if (!hasPerformanceContext) {
+ this.messagesRepo.markChecked(message.id);
+ this.logger.info(
+ `Response from u/${message.recipient} excluded from performance: no post, strategy, or subreddit context.`,
+ );
+ continue;
+ }
+
this.messagesRepo.markGotResponse(message.id);
// Get the original post info
diff --git a/src/web/activity-log.ts b/src/web/activity-log.ts
index 759b4eb..909783a 100644
--- a/src/web/activity-log.ts
+++ b/src/web/activity-log.ts
@@ -20,12 +20,20 @@ export class ActivityLog {
message: r.message,
recipient: r.recipient ?? undefined,
imageUrl: r.imageUrl ?? undefined,
+ postTitle: r.postTitle ?? undefined,
+ postUrl: r.postUrl ?? undefined,
}))
.reverse();
}
- push(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void {
- const item: ActivityItem = { timestamp: Date.now(), type, message, recipient, imageUrl };
+ push(
+ type: ActivityItem['type'],
+ message: string,
+ recipient?: string,
+ imageUrl?: string,
+ origin?: { postTitle: string; postUrl: string },
+ ): void {
+ const item: ActivityItem = { timestamp: Date.now(), type, message, recipient, imageUrl, ...origin };
this.items.push(item);
if (this.items.length > MAX_ITEMS) {
this.items = this.items.slice(-MAX_ITEMS);
diff --git a/src/web/context.ts b/src/web/context.ts
index 4471a00..fb4eafe 100644
--- a/src/web/context.ts
+++ b/src/web/context.ts
@@ -19,6 +19,7 @@ import type { LogBroadcaster } from './log-broadcaster.js';
import type { RuntimeConfig } from './types.js';
import type { RedditSessionImporter } from '../services/reddit-session-importer.js';
import type { AccountAuthContext } from '../services/account-auth-context.js';
+import type { ConfigStore } from '../config/persistence.js';
/** Bag of everything the route modules might need. */
export interface WebContext {
@@ -46,6 +47,7 @@ export interface WebContext {
sessionImporter?: RedditSessionImporter;
accountAuth?: AccountAuthContext;
conversationState?: ConversationStateRepository;
+ configStore?: ConfigStore;
};
/** Bot pause/resume toggle read by the main loop. */
diff --git a/src/web/reddit-identities.ts b/src/web/reddit-identities.ts
new file mode 100644
index 0000000..2219851
--- /dev/null
+++ b/src/web/reddit-identities.ts
@@ -0,0 +1,39 @@
+import type { ActivityItem } from './types.js';
+
+const INTERNAL_REDDIT_ID = /@?t2_[a-z0-9]+(?::reddit\.com)?/gi;
+
+export interface RedditIdentityResolver {
+ resolveSenderDisplay(identity: string): string;
+}
+
+function isInternalIdentity(value: string): boolean {
+ return value === 'unknown' || value.startsWith('t2_') || /^@t2_[^:]+:/i.test(value);
+}
+
+export function resolveRedditUsername(
+ identity: string | null | undefined,
+ resolver?: RedditIdentityResolver,
+): string | null {
+ if (!identity) return null;
+ const candidate = resolver?.resolveSenderDisplay(identity) ?? identity;
+ if (isInternalIdentity(candidate)) return null;
+ return candidate.replace(/^\/?u\//i, '');
+}
+
+export function redactInternalRedditIds(message: string, username?: string | null): string {
+ return message
+ .replace(/u\/@?t2_[a-z0-9]+(?::reddit\.com)?/gi, username ? `u/${username}` : 'Reddit user')
+ .replace(INTERNAL_REDDIT_ID, username ?? 'Reddit user');
+}
+
+export function presentActivityItem(
+ item: ActivityItem,
+ resolver?: RedditIdentityResolver,
+): ActivityItem {
+ const recipient = resolveRedditUsername(item.recipient, resolver);
+ return {
+ ...item,
+ message: redactInternalRedditIds(item.message, recipient),
+ ...(recipient ? { recipient } : { recipient: undefined }),
+ };
+}
diff --git a/src/web/routes/config.ts b/src/web/routes/config.ts
index df93fec..2416c5a 100644
--- a/src/web/routes/config.ts
+++ b/src/web/routes/config.ts
@@ -1,11 +1,36 @@
import { Router } from 'express';
import type { WebContext } from '../context.js';
-import { SubredditConfigSchema, type SubredditConfig } from '../../config/index.js';
+import { ConfigSchema, SubredditConfigSchema, type SubredditConfig } from '../../config/index.js';
import { TONE_KEYS } from '../../config/tone-profiles.js';
-import { persistSubreddits } from '../../config/persistence.js';
import { z } from 'zod';
const SubredditsUpdateSchema = z.array(SubredditConfigSchema).min(1);
+const AdvancedConfigSchema = z.strictObject({
+ reddit: ConfigSchema.shape.reddit.omit({
+ subreddits: true,
+ prioritizeRecentPosts: true,
+ recentWindowMinutes: true,
+ }),
+ openrouter: ConfigSchema.shape.openrouter.omit({ apiKey: true }),
+ browser: z.strictObject({ sessionDir: z.string() }),
+ media: ConfigSchema.shape.media,
+ strategies: ConfigSchema.shape.strategies,
+ chatStrategies: ConfigSchema.shape.chatStrategies,
+});
+
+function advancedConfig(ctx: WebContext): z.infer {
+ const { subreddits: _subreddits, prioritizeRecentPosts: _prioritize, recentWindowMinutes: _window, ...reddit } = ctx.config.reddit;
+ const { apiKey: _apiKey, ...openrouter } = ctx.config.openrouter;
+ const { dryRun: _dryRun, ...browser } = ctx.config.browser;
+ return {
+ reddit,
+ openrouter,
+ browser,
+ media: ctx.config.media,
+ strategies: ctx.config.strategies,
+ chatStrategies: ctx.config.chatStrategies,
+ };
+}
export function createConfigRouter(ctx: WebContext): Router {
const router = Router();
@@ -17,12 +42,30 @@ export function createConfigRouter(ctx: WebContext): Router {
reddit: ctx.runtimeConfig.reddit,
subreddits: ctx.config.reddit.subreddits,
tones: TONE_KEYS,
+ advanced: advancedConfig(ctx),
});
});
router.put('/config', (req, res) => {
try {
const updates = req.body;
+ const configStore = ctx.services.configStore;
+ if (!configStore) throw new Error('Configuration persistence is unavailable');
+
+ const parsedSubreddits = updates.subreddits === undefined
+ ? null
+ : SubredditsUpdateSchema.safeParse(updates.subreddits);
+ if (parsedSubreddits && !parsedSubreddits.success) {
+ res.status(400).json({ error: 'Invalid subreddits payload', detail: parsedSubreddits.error.format() });
+ return;
+ }
+ const parsedAdvanced = updates.advanced === undefined
+ ? null
+ : AdvancedConfigSchema.safeParse(updates.advanced);
+ if (parsedAdvanced && !parsedAdvanced.success) {
+ res.status(400).json({ error: 'Invalid advanced configuration', detail: parsedAdvanced.error.format() });
+ return;
+ }
if (updates.rateLimit) {
if (typeof updates.rateLimit.minDelayBetweenMessagesMs === 'number') {
@@ -65,13 +108,8 @@ export function createConfigRouter(ctx: WebContext): Router {
ctx.config.reddit.prioritizeRecentPosts = ctx.runtimeConfig.reddit.prioritizeRecentPosts;
ctx.config.reddit.recentWindowMinutes = ctx.runtimeConfig.reddit.recentWindowMinutes;
- if (updates.subreddits !== undefined) {
- const parsed = SubredditsUpdateSchema.safeParse(updates.subreddits);
- if (!parsed.success) {
- res.status(400).json({ error: 'Invalid subreddits payload', detail: parsed.error.format() });
- return;
- }
- const next: SubredditConfig[] = parsed.data;
+ if (parsedSubreddits?.success) {
+ const next: SubredditConfig[] = parsedSubreddits.data;
const seen = new Set();
for (const s of next) {
const key = s.name.toLowerCase();
@@ -81,10 +119,20 @@ export function createConfigRouter(ctx: WebContext): Router {
}
seen.add(key);
}
- persistSubreddits(next);
ctx.config.reddit.subreddits = next;
}
+ if (parsedAdvanced?.success) {
+ Object.assign(ctx.config.reddit, parsedAdvanced.data.reddit);
+ Object.assign(ctx.config.openrouter, parsedAdvanced.data.openrouter);
+ Object.assign(ctx.config.browser, parsedAdvanced.data.browser);
+ ctx.config.media = parsedAdvanced.data.media;
+ ctx.config.strategies = parsedAdvanced.data.strategies;
+ ctx.config.chatStrategies = parsedAdvanced.data.chatStrategies;
+ }
+
+ configStore.save(ctx.config);
+
ctx.getOnConfigChange()?.(ctx.runtimeConfig);
ctx.activity.push('info', 'Config updated via dashboard');
@@ -94,6 +142,7 @@ export function createConfigRouter(ctx: WebContext): Router {
success: true,
config: ctx.runtimeConfig,
subreddits: ctx.config.reddit.subreddits,
+ advanced: advancedConfig(ctx),
});
} catch (error) {
ctx.logger.error('Failed to update config:', error);
diff --git a/src/web/routes/conversations.ts b/src/web/routes/conversations.ts
index 0221537..abb5b7b 100644
--- a/src/web/routes/conversations.ts
+++ b/src/web/routes/conversations.ts
@@ -30,16 +30,45 @@ export function createConversationsRouter(ctx: WebContext): Router {
return { messages, state, intent: chooseConversationIntent(messages, state, requestedIntent) };
}
- router.get('/conversations', async (_req, res) => {
+ router.get('/conversations', async (req, res) => {
if (!ctx.services.chat) {
- res.json([]);
+ res.status(503).json({ error: 'Chat service not initialized' });
return;
}
try {
- res.json(await ctx.services.chat.getCachedConversations());
+ const conversations = await ctx.services.chat.getCachedConversations();
+ if (req.query.includeImageMetadata !== 'true') {
+ res.json(conversations);
+ return;
+ }
+ const receivedMediaRooms = new Set();
+ const receivedMediaUsers = new Set();
+
+ if (ctx.services.media) {
+ try {
+ const mediaItems = await ctx.services.media.getMediaItems();
+ for (const item of mediaItems) {
+ if (item.sender.toLowerCase() === 'me') continue;
+ if (item.chatRoomId) receivedMediaRooms.add(item.chatRoomId);
+ if (item.chatUsername) receivedMediaUsers.add(item.chatUsername.toLowerCase());
+ }
+ } catch (error) {
+ ctx.logger.warn('Failed to read media index for conversation markers:', error);
+ }
+ }
+
+ res.json(conversations.map((conversation) => ({
+ ...conversation,
+ hasReceivedImages:
+ receivedMediaRooms.has(conversation.roomId ?? '')
+ || receivedMediaUsers.has(conversation.username.toLowerCase())
+ || (conversation.messages ?? []).some(
+ (message) => !message.isSelf && (message.media?.length ?? 0) > 0,
+ ),
+ })));
} catch (error) {
ctx.logger.error('Failed to fetch conversations:', error);
- res.json([]);
+ res.status(500).json({ error: 'Failed to fetch conversations' });
}
});
@@ -49,21 +78,10 @@ export function createConversationsRouter(ctx: WebContext): Router {
return;
}
try {
- // If a sync is already in flight (e.g. a Full sync the user kicked off
- // from the dashboard), don't queue *another* delta + lazy-load behind it
- // — that's what makes the modal sit on "Loading…" for minutes. Render
- // from cache and let the frontend show a "sync in progress" banner; the
- // user can reopen once it's done.
+ // Conversation reads never initiate a sync. Sync lifecycle events can
+ // invalidate this query, so starting a sync here would create a loop.
const syncStatus = ctx.services.chat.getSyncStatus();
const syncInFlight = syncStatus.syncing;
-
- if (!syncInFlight) {
- try {
- await ctx.services.chat.refreshTopChats();
- } catch (err) {
- ctx.logger.warn('Pre-open delta sync failed:', err);
- }
- }
const conversation = await findConversation(decodeURIComponent(req.params.id));
if (!conversation) {
res.status(404).json({ error: 'Conversation not found' });
@@ -94,19 +112,24 @@ export function createConversationsRouter(ctx: WebContext): Router {
subreddit?: string;
title?: string;
url: string;
+ imageUrl: string | null;
sentAt: number;
} | null = null;
try {
- const sent = ctx.repos.messages.getLatestByRecipient(conversation.username);
+ const sent = conversation.roomId
+ ? ctx.repos.messages.getByChatRoomId(conversation.roomId)
+ ?? ctx.repos.messages.getLatestByRecipient(conversation.username)
+ : ctx.repos.messages.getLatestByRecipient(conversation.username);
if (sent && sent.postId) {
- const url = sent.subreddit
+ const url = sent.postUrl ?? (sent.subreddit
? `https://www.reddit.com/r/${sent.subreddit}/comments/${sent.postId}/`
- : `https://redd.it/${sent.postId}`;
+ : `https://redd.it/${sent.postId}`);
triggeringPost = {
id: sent.postId,
subreddit: sent.subreddit,
title: sent.postTitle,
url,
+ imageUrl: sent.postImageUrl ?? null,
sentAt: sent.sentAt,
};
}
diff --git a/src/web/routes/media.ts b/src/web/routes/media.ts
index 3526ec0..608b1a5 100644
--- a/src/web/routes/media.ts
+++ b/src/web/routes/media.ts
@@ -1,5 +1,6 @@
import { Router } from 'express';
import type { WebContext } from '../context.js';
+import { resolveRedditUsername } from '../reddit-identities.js';
export function createMediaRouter(ctx: WebContext): Router {
const router = Router();
@@ -23,11 +24,17 @@ export function createMediaRouter(ctx: WebContext): Router {
res.json(items.map((item) => {
const live = item.chatRoomId ? byRoom.get(item.chatRoomId) : undefined;
- const liveName = live?.username && live.username !== 'unknown' ? live.username : null;
- const username = liveName ?? item.chatUsername ?? null;
+ const username = resolveRedditUsername(
+ live?.username ?? item.chatUsername,
+ ctx.services.chat,
+ );
+ const sender = /^(me|you)$/i.test(item.sender)
+ ? 'You'
+ : resolveRedditUsername(item.sender, ctx.services.chat) ?? username ?? 'Unknown Reddit user';
return {
...item,
chatUsername: username,
+ sender,
urlPath: `/media/${encodeURIComponent(item.relativePath)}`,
};
}));
diff --git a/src/web/routes/stats.ts b/src/web/routes/stats.ts
index eadbe30..b9a4546 100644
--- a/src/web/routes/stats.ts
+++ b/src/web/routes/stats.ts
@@ -1,10 +1,15 @@
import { Router } from 'express';
import type { WebContext } from '../context.js';
import type { BotStats } from '../types.js';
+import { presentActivityItem } from '../reddit-identities.js';
export function createStatsRouter(ctx: WebContext): Router {
const router = Router();
+ const recentActivity = (limit: number) => ctx.activity
+ .recent(limit)
+ .map((item) => presentActivityItem(item, ctx.services.chat));
+
router.get('/stats', (_req, res) => {
const messages = ctx.repos.messages.getRecent(1000);
const totalSent = messages.length;
@@ -23,7 +28,7 @@ export function createStatsRouter(ctx: WebContext): Router {
maxMessagesPerHour: ctx.config.rateLimit.maxMessagesPerHour,
subreddits: ctx.config.reddit.subreddits.map((s) => ({ name: s.name, type: s.type })),
targetGenders: ctx.config.reddit.targetGenders,
- recentActivity: ctx.activity.recent(10),
+ recentActivity: recentActivity(10),
invitationCooldown: cooldown
? { until: cooldown.until, reason: cooldown.reason, redditCode: cooldown.redditCode }
: null,
@@ -33,7 +38,7 @@ export function createStatsRouter(ctx: WebContext): Router {
});
router.get('/activity', (_req, res) => {
- res.json(ctx.activity.recent(50));
+ res.json(recentActivity(50));
});
router.get('/posts/by-author/:username', (req, res) => {
diff --git a/src/web/server.ts b/src/web/server.ts
index fd52000..4f542d7 100644
--- a/src/web/server.ts
+++ b/src/web/server.ts
@@ -21,6 +21,7 @@ import type { StrategySelector } from '../services/strategy-selector.js';
import type { BrowserClient } from '../browser/client.js';
import type { RedditSessionImporter } from '../services/reddit-session-importer.js';
import type { AccountAuthContext } from '../services/account-auth-context.js';
+import type { ConfigStore } from '../config/persistence.js';
import { ActivityLog } from './activity-log.js';
import { LogBroadcaster } from './log-broadcaster.js';
import type { WebContext } from './context.js';
@@ -214,8 +215,18 @@ export class WebServer {
this.services.conversationState = conversationState;
}
- logActivity(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void {
- this.activity.push(type, message, recipient, imageUrl);
+ setConfigStore(configStore: ConfigStore): void {
+ this.services.configStore = configStore;
+ }
+
+ logActivity(
+ type: ActivityItem['type'],
+ message: string,
+ recipient?: string,
+ imageUrl?: string,
+ origin?: { postTitle: string; postUrl: string },
+ ): void {
+ this.activity.push(type, message, recipient, imageUrl, origin);
}
pushLog(level: LogEntry['level'], message: string, data?: unknown): void {
diff --git a/src/web/types.ts b/src/web/types.ts
index 3719666..cfc3240 100644
--- a/src/web/types.ts
+++ b/src/web/types.ts
@@ -24,6 +24,8 @@ export interface ActivityItem {
message: string;
recipient?: string;
imageUrl?: string;
+ postTitle?: string;
+ postUrl?: string;
}
export interface LogEntry {
diff --git a/tests/activity-log-origin.test.ts b/tests/activity-log-origin.test.ts
new file mode 100644
index 0000000..ee97a8d
--- /dev/null
+++ b/tests/activity-log-origin.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest';
+import { ActivityLogRepository } from '../src/database/repositories.js';
+import { ActivityLog } from '../src/web/activity-log.js';
+import { createTestDb } from './testUtils.js';
+
+describe('activity origin context', () => {
+ it('persists and hydrates the source post title, URL, and image', () => {
+ const db = createTestDb();
+ const repository = new ActivityLogRepository(db);
+ const activity = new ActivityLog(repository);
+
+ activity.push(
+ 'sent',
+ 'Sent message to u/alice (r/example)',
+ 'alice',
+ 'https://i.redd.it/source.jpg',
+ {
+ postTitle: 'Source title',
+ postUrl: 'https://www.reddit.com/r/example/comments/source/source_title/',
+ },
+ );
+
+ expect(new ActivityLog(repository).recent(1)).toEqual([expect.objectContaining({
+ imageUrl: 'https://i.redd.it/source.jpg',
+ postTitle: 'Source title',
+ postUrl: 'https://www.reddit.com/r/example/comments/source/source_title/',
+ })]);
+ db.close();
+ });
+});
diff --git a/tests/browser-client.test.ts b/tests/browser-client.test.ts
index ab11aad..dcddd27 100644
--- a/tests/browser-client.test.ts
+++ b/tests/browser-client.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
-import type { BrowserContext } from 'playwright';
+import type { BrowserContext, Page } from 'playwright';
import { BrowserClient, type BrowserRuntime } from '../src/browser/client.js';
import type { Logger } from '../src/utils/logger.js';
import { getTestConfig } from './testUtils.js';
@@ -35,4 +35,25 @@ describe('BrowserClient', () => {
expect(client.getSessionState()).toBe('unknown');
expect(JSON.stringify(logger)).not.toContain('secret');
});
+
+ it('reads the username from Reddit\'s nested me.json account response', async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ kind: 't2', data: { name: 'bot-account' } }),
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ const page = {
+ evaluate: async (pageFunction: () => Promise) => pageFunction(),
+ } as unknown as Page;
+ const context = { pages: () => [page] } as unknown as BrowserContext;
+ const runtime: BrowserRuntime = { launchPersistentContext: vi.fn().mockResolvedValue(context) };
+ const client = new BrowserClient(getTestConfig(), createLogger(), runtime);
+
+ await expect(client.getAuthenticatedUsername()).resolves.toBe('bot-account');
+ expect(fetchMock).toHaveBeenCalledWith(
+ 'https://www.reddit.com/api/me.json',
+ { credentials: 'include' },
+ );
+ vi.unstubAllGlobals();
+ });
});
diff --git a/tests/chat-service-auth-gate.test.ts b/tests/chat-service-auth-gate.test.ts
index fc14b15..f6f59d4 100644
--- a/tests/chat-service-auth-gate.test.ts
+++ b/tests/chat-service-auth-gate.test.ts
@@ -34,8 +34,79 @@ describe('ChatService account authentication gate', () => {
auth,
);
- await service.refreshTopChats();
+ await expect(service.refreshTopChats()).rejects.toThrow(
+ 'Matrix authentication could not be restored',
+ );
expect(matrix.sync).not.toHaveBeenCalled();
});
+
+ it('coalesces concurrent delta refresh requests before they reach Matrix', async () => {
+ const logger = createMockLogger();
+ const matrix = new MatrixClient({
+ homeserver: 'https://matrix.example.test',
+ accessToken: 'verified',
+ userId: '@t2_account:reddit.com',
+ capturedAt: 1,
+ }, logger, { minRequestIntervalMs: 0 });
+ let finishSync!: (value: { next_batch: string }) => void;
+ matrix.sync = vi.fn(() => new Promise((resolve) => {
+ finishSync = resolve;
+ })) as typeof matrix.sync;
+ const cache = new ChatCache(
+ logger,
+ path.join(os.tmpdir(), `chat-auth-${Date.now()}-${Math.random()}.json`),
+ );
+ const service = new ChatService(
+ matrix,
+ logger,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ cache,
+ undefined,
+ undefined,
+ { isFullyAuthenticated: () => true, invalidate: vi.fn() },
+ );
+
+ const first = service.refreshTopChats();
+ const second = service.refreshTopChats();
+ await vi.waitFor(() => expect(matrix.sync).toHaveBeenCalledOnce());
+ finishSync({ next_batch: 's1' });
+
+ await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined]);
+ expect(matrix.sync).toHaveBeenCalledOnce();
+ });
+
+ it('reports failed sync termination to progress listeners', async () => {
+ const logger = createMockLogger();
+ const matrix = new MatrixClient({
+ homeserver: 'https://matrix.example.test',
+ accessToken: 'unverified',
+ userId: '@t2_account:reddit.com',
+ capturedAt: 1,
+ }, logger, { minRequestIntervalMs: 0 });
+ const service = new ChatService(
+ matrix,
+ logger,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ new ChatCache(logger, path.join(os.tmpdir(), `chat-auth-${Date.now()}-${Math.random()}.json`)),
+ undefined,
+ undefined,
+ { isFullyAuthenticated: () => false, invalidate: vi.fn() },
+ );
+ const listener = vi.fn();
+ service.setProgressListener(listener);
+
+ await expect(service.refreshTopChats()).rejects.toThrow();
+
+ expect(listener).toHaveBeenLastCalledWith(null, {
+ status: 'failed',
+ error: 'Matrix authentication could not be restored',
+ });
+ });
});
diff --git a/tests/chat-service-progressive-backfill.test.ts b/tests/chat-service-progressive-backfill.test.ts
new file mode 100644
index 0000000..9984243
--- /dev/null
+++ b/tests/chat-service-progressive-backfill.test.ts
@@ -0,0 +1,197 @@
+import fs from 'fs/promises';
+import os from 'os';
+import path from 'path';
+import { describe, expect, it, vi } from 'vitest';
+import { ChatCache } from '../src/services/chat/cache.js';
+import { MatrixClient } from '../src/services/chat/matrix/client.js';
+import type { MatrixMessagesResponse } from '../src/services/chat/matrix/types.js';
+import { ChatService } from '../src/services/chat/service.js';
+import { createMockLogger } from './mockLogger.js';
+import { authenticatedAccountAuth, createTestDb, createTestRepositories } from './testUtils.js';
+
+const credentials = {
+ homeserver: 'https://matrix.example.test',
+ accessToken: 'token',
+ userId: '@me:reddit.com',
+ capturedAt: Date.now(),
+};
+
+describe('ChatService progressive full backfill', () => {
+ it('records replies for performance only when the room has source context', async () => {
+ const logger = createMockLogger();
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-performance-context-'));
+ const db = createTestDb();
+ const repos = createTestRepositories(db);
+
+ try {
+ repos.postsRepo.insert({
+ id: 'post-alice',
+ subreddit: 'example',
+ author: 'alice',
+ title: 'Source post',
+ imageUrl: null,
+ processedAt: 1_000,
+ });
+ repos.messagesRepo.insert({
+ postId: 'post-alice',
+ recipient: 'alice',
+ subject: 'alice',
+ message: 'Hello Alice',
+ sentAt: Date.now(),
+ strategyId: 'direct',
+ chatRoomId: '!alice:reddit.com',
+ });
+
+ 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', '!bob:reddit.com']);
+ matrix.getRoomMessages = vi.fn(async (roomId: string) => ({
+ chunk: [
+ {
+ event_id: `$reply-${roomId}`,
+ type: 'm.room.message',
+ sender: roomId === '!alice:reddit.com' ? '@t2_alice:reddit.com' : '@t2_bob:reddit.com',
+ origin_server_ts: 2_000,
+ content: { body: 'Hi there' },
+ },
+ {
+ event_id: `$first-${roomId}`,
+ type: 'm.room.message',
+ sender: '@me:reddit.com',
+ origin_server_ts: 1_000,
+ content: { body: roomId === '!alice:reddit.com' ? 'Hello Alice' : 'Hello Bob' },
+ },
+ ],
+ start: 'start',
+ }));
+
+ const service = new ChatService(
+ matrix,
+ logger,
+ repos.successfulMessagesRepo,
+ undefined,
+ undefined,
+ { resolveT2Ids: vi.fn(async () => ({ t2_alice: 'alice', t2_bob: 'bob' })) },
+ new ChatCache(logger, path.join(tmpDir, 'chat-cache.json')),
+ repos.messagesRepo,
+ undefined,
+ authenticatedAccountAuth,
+ );
+
+ await service.fetchAllMessages();
+
+ expect(repos.messagesRepo.getByChatRoomId('!alice:reddit.com')?.gotResponse).toBe(true);
+ expect(repos.messagesRepo.getByChatRoomId('!bob:reddit.com')).toBeUndefined();
+ expect(repos.messagesRepo.getResponseRateTrend()).toEqual([
+ expect.objectContaining({ totalSent: 1, totalResponses: 1, responseRate: 100 }),
+ ]);
+ expect(repos.successfulMessagesRepo.getRecent()).toEqual([
+ expect.objectContaining({
+ originalMessage: 'Hello Alice',
+ postTitle: 'Source post',
+ subreddit: 'example',
+ }),
+ ]);
+ } finally {
+ db.close();
+ await fs.rm(tmpDir, { recursive: true, force: true });
+ }
+ });
+
+ it('publishes room summaries and recent timeline messages before deep history finishes', async () => {
+ const logger = createMockLogger();
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-progressive-backfill-'));
+ let finishHistory!: (response: MatrixMessagesResponse) => void;
+ const pendingHistory = new Promise((resolve) => {
+ finishHistory = resolve;
+ });
+ let finishUsernameResolution!: (usernames: Record) => void;
+ const pendingUsernames = new Promise>((resolve) => {
+ finishUsernameResolution = resolve;
+ });
+
+ try {
+ const matrix = new MatrixClient(credentials, logger, { minRequestIntervalMs: 0 });
+ matrix.sync = vi.fn(async () => ({
+ next_batch: 's1',
+ rooms: {
+ join: {
+ '!alice:reddit.com': {
+ summary: { 'm.heroes': ['@t2_alice:reddit.com'] },
+ timeline: {
+ events: [
+ {
+ event_id: '$first',
+ type: 'm.room.message',
+ sender: '@me:reddit.com',
+ origin_server_ts: 1_000,
+ content: { body: 'Hello Alice' },
+ },
+ {
+ event_id: '$reply',
+ type: 'm.room.message',
+ sender: '@t2_alice:reddit.com',
+ origin_server_ts: 2_000,
+ content: { body: 'Hi there' },
+ },
+ ],
+ },
+ },
+ },
+ },
+ })) as typeof matrix.sync;
+ matrix.getJoinedRooms = vi.fn(async () => ['!alice:reddit.com']);
+ matrix.getRoomMessages = vi.fn(() => pendingHistory);
+ const usernameResolver = {
+ resolveT2Ids: vi.fn(() => pendingUsernames),
+ };
+
+ const cachePath = path.join(tmpDir, 'chat-cache.json');
+ const service = new ChatService(
+ matrix,
+ logger,
+ undefined,
+ undefined,
+ undefined,
+ usernameResolver,
+ new ChatCache(logger, cachePath),
+ undefined,
+ undefined,
+ authenticatedAccountAuth,
+ );
+
+ const backfill = service.fetchAllMessages();
+ await vi.waitFor(() => expect(usernameResolver.resolveT2Ids).toHaveBeenCalledOnce());
+
+ const visibleChats = await service.getCachedConversations();
+ expect(visibleChats).toHaveLength(1);
+ expect(visibleChats[0]).toMatchObject({
+ roomId: '!alice:reddit.com',
+ username: 't2_alice',
+ lastMessageSnippet: 'Hi there',
+ messages: [
+ { text: 'Hello Alice', isSelf: true },
+ { text: 'Hi there', isSelf: false },
+ ],
+ });
+ expect(usernameResolver.resolveT2Ids).toHaveBeenCalledWith(['t2_alice']);
+
+ const persisted = JSON.parse(await fs.readFile(cachePath, 'utf-8')) as { chats: unknown[] };
+ expect(persisted.chats).toHaveLength(1);
+
+ finishUsernameResolution({ t2_alice: 'alice' });
+ await vi.waitFor(() => expect(matrix.getRoomMessages).toHaveBeenCalledOnce());
+ await vi.waitFor(async () => {
+ const enrichedChats = await service.getCachedConversations();
+ expect(enrichedChats[0].username).toBe('alice');
+ });
+
+ finishHistory({ chunk: [], start: 'start' });
+ await backfill;
+ } finally {
+ finishUsernameResolution({});
+ finishHistory({ chunk: [], start: 'start' });
+ await fs.rm(tmpDir, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/tests/chat-service-username-persist.test.ts b/tests/chat-service-username-persist.test.ts
index 5655204..999daa6 100644
--- a/tests/chat-service-username-persist.test.ts
+++ b/tests/chat-service-username-persist.test.ts
@@ -70,6 +70,73 @@ describe('ChatService username persistence', () => {
expect(svc.resolveSenderDisplay('@t2_unknown:reddit.com')).toBe('t2_unknown');
});
+ it('starts a conversation with the t2 ID supplied by the Reddit listing', async () => {
+ const logger = createMockLogger();
+ const matrix = buildMatrix();
+ matrix.sendInitialDirectMessage = vi.fn().mockResolvedValue({
+ roomId: '!room:reddit.com',
+ eventId: '$event',
+ });
+ const usernameResolver = {
+ resolveT2Ids: vi.fn().mockResolvedValue({}),
+ resolveUsernameToT2: vi.fn().mockRejectedValue(new Error('profile lookup returned 403')),
+ };
+ const svc = new ChatService(
+ matrix,
+ logger,
+ undefined, undefined, undefined, usernameResolver,
+ tmpCache(logger),
+ undefined,
+ undefined,
+ authenticatedAccountAuth,
+ );
+
+ const chat = await svc.startConversation('alice', 'hello', 't2_abc123');
+
+ expect(chat?.username).toBe('alice');
+ expect(usernameResolver.resolveUsernameToT2).not.toHaveBeenCalled();
+ expect(matrix.sendInitialDirectMessage).toHaveBeenCalledWith(
+ '@t2_abc123:reddit.com',
+ 'hello',
+ );
+ });
+
+ it('uses the durable username mapping before a live profile lookup', async () => {
+ const db = new Database(':memory:');
+ runMigrations(db);
+ const repo = new RedditUsersRepository(db);
+ repo.upsert({ t2Id: 't2_cached', username: 'alice' });
+ const logger = createMockLogger();
+ const matrix = buildMatrix();
+ matrix.sendInitialDirectMessage = vi.fn().mockResolvedValue({
+ roomId: '!room:reddit.com',
+ eventId: '$event',
+ });
+ const usernameResolver = {
+ resolveT2Ids: vi.fn().mockResolvedValue({}),
+ resolveUsernameToT2: vi.fn().mockRejectedValue(new Error('profile lookup returned 403')),
+ };
+ const svc = new ChatService(
+ matrix,
+ logger,
+ undefined, undefined, undefined, usernameResolver,
+ tmpCache(logger),
+ undefined,
+ repo,
+ authenticatedAccountAuth,
+ );
+
+ const chat = await svc.startConversation('ALICE', 'hello');
+
+ expect(chat?.username).toBe('ALICE');
+ expect(usernameResolver.resolveUsernameToT2).not.toHaveBeenCalled();
+ expect(matrix.sendInitialDirectMessage).toHaveBeenCalledWith(
+ '@t2_cached:reddit.com',
+ 'hello',
+ );
+ db.close();
+ });
+
it('upgrades cached chats with t2_ usernames from the db map on loadCache', async () => {
const db = new Database(':memory:');
runMigrations(db);
diff --git a/tests/config-persistence.test.ts b/tests/config-persistence.test.ts
index c3dc9d2..f58a8df 100644
--- a/tests/config-persistence.test.ts
+++ b/tests/config-persistence.test.ts
@@ -1,128 +1,93 @@
-import fs from 'fs';
-import os from 'os';
-import path from 'path';
-import YAML from 'yaml';
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
-import { persistSubreddits } from '../src/config/persistence.js';
+import Database from 'better-sqlite3';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { ConfigStore } from '../src/config/persistence.js';
+import { loadConfig } from '../src/config/index.js';
+import { runMigrations } from '../src/database/client.js';
+import { getTestConfig } from './testUtils.js';
-const SAMPLE = `# top-level note that must survive
-reddit:
- # weights bias picking
- subreddits:
- - { name: AverageNudes, weight: 4, tone: 'direct' } # validation seekers
- - { name: gonewild40plus, weight: 4, tone: 'gentle' } # mature
- - { name: normalnudes, weight: 3 } # real bodies
- - { name: tightandtoned, weight: 1 }
-
- pollIntervalMs: 30000
- userAgent: "ua"
-`;
-
-describe('persistSubreddits', () => {
- let tmpDir: string;
- let configPath: string;
+describe('ConfigStore', () => {
+ let db: Database.Database;
+ let store: ConfigStore;
beforeEach(() => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rb-config-'));
- configPath = path.join(tmpDir, 'config.yaml');
- fs.writeFileSync(configPath, SAMPLE, 'utf-8');
+ db = new Database(':memory:');
+ runMigrations(db);
+ store = new ConfigStore(db);
});
- afterEach(() => {
- fs.rmSync(tmpDir, { recursive: true, force: true });
- });
+ afterEach(() => db.close());
- it('mutates an existing row in place and preserves its inline comment', () => {
- persistSubreddits(
- [
- { name: 'AverageNudes', type: 'image', weight: 4, tone: 'playful' },
- { name: 'gonewild40plus', type: 'image', weight: 4, tone: 'gentle' },
- { name: 'normalnudes', type: 'image', weight: 3, tone: 'default' },
- { name: 'tightandtoned', type: 'image', weight: 1, tone: 'default' },
- ],
- configPath,
- );
- const out = fs.readFileSync(configPath, 'utf-8');
- expect(out).toMatch(/tone:\s*['"]?playful['"]?/);
- expect(out).toContain('# validation seekers');
- expect(out).toContain('# top-level note that must survive');
- expect(out).toContain('# weights bias picking');
- expect(out).toContain('# mature');
- expect(out).toContain('pollIntervalMs: 30000');
- });
-
- it('drops removed rows (and their attached comments)', () => {
- persistSubreddits(
- [
- { name: 'AverageNudes', type: 'image', weight: 4, tone: 'direct' },
- { name: 'tightandtoned', type: 'image', weight: 1, tone: 'default' },
- ],
- configPath,
- );
- const out = fs.readFileSync(configPath, 'utf-8');
- expect(out).not.toContain('gonewild40plus');
- expect(out).not.toContain('normalnudes');
- expect(out).not.toContain('# mature');
- expect(out).toContain('AverageNudes');
- expect(out).toContain('tightandtoned');
- expect(out).toContain('# validation seekers');
- });
-
- it('appends new rows in caller order', () => {
- persistSubreddits(
- [
- { name: 'AverageNudes', type: 'image', weight: 4, tone: 'direct' },
- { name: 'gonewild40plus', type: 'image', weight: 4, tone: 'gentle' },
- { name: 'normalnudes', type: 'image', weight: 3, tone: 'default' },
- { name: 'tightandtoned', type: 'image', weight: 1, tone: 'default' },
- { name: 'NewSub', type: 'chat', weight: 2, tone: 'playful' },
- ],
- configPath,
- );
- const parsed = YAML.parse(fs.readFileSync(configPath, 'utf-8'));
- const subs = parsed.reddit.subreddits;
- expect(subs[subs.length - 1]).toMatchObject({
- name: 'NewSub',
- type: 'chat',
- weight: 2,
- tone: 'playful',
+ it('round-trips application configuration through SQLite', () => {
+ const config = getTestConfig({
+ rateLimit: { minDelayBetweenMessagesMs: 42, maxMessagesPerHour: 7, jitterMs: 3 },
});
+ store.save(config);
+
+ const loaded = store.load({
+ OPENROUTER_API_KEY: 'environment-key',
+ WEB_DASHBOARD_TOKEN: 'environment-dashboard-token',
+ DATABASE_PATH: './data/from-environment.db',
+ });
+
+ expect(loaded?.rateLimit).toEqual(config.rateLimit);
+ expect(loaded?.openrouter.apiKey).toBe('environment-key');
+ expect(loaded?.web.authToken).toBe('environment-dashboard-token');
+ expect(loaded?.database.path).toBe('./data/from-environment.db');
});
- it("omits 'default' tone and 'image' type on write to match on-disk style", () => {
- persistSubreddits(
- [{ name: 'OnlyOne', type: 'image', weight: 1, tone: 'default' }],
- configPath,
- );
- const out = fs.readFileSync(configPath, 'utf-8');
- expect(out).not.toMatch(/tone:\s*default/);
- expect(out).not.toMatch(/type:\s*image/);
+ it('never stores secrets in the database record', () => {
+ const config = getTestConfig();
+ config.openrouter.apiKey = 'database-must-not-contain-this';
+ config.web.authToken = 'also-secret';
+ store.save(config);
+
+ const row = db.prepare('SELECT config_json FROM app_config WHERE id = 1').get() as { config_json: string };
+ expect(row.config_json).not.toContain('database-must-not-contain-this');
+ expect(row.config_json).not.toContain('also-secret');
+ expect(JSON.parse(row.config_json)).not.toHaveProperty('database');
});
- it('atomic write — tmp file not left behind on success', () => {
- persistSubreddits(
- [{ name: 'OnlyOne', type: 'image', weight: 1, tone: 'direct' }],
- configPath,
- );
- expect(fs.existsSync(configPath + '.tmp')).toBe(false);
+ it('updates the singleton record instead of appending versions', () => {
+ store.save(getTestConfig());
+ store.save(getTestConfig({ browser: { sessionDir: './data/browser-session', dryRun: true } }));
+
+ expect(db.prepare('SELECT COUNT(*) AS count FROM app_config').get()).toEqual({ count: 1 });
+ expect(store.load({
+ OPENROUTER_API_KEY: 'key',
+ WEB_DASHBOARD_TOKEN: 'dashboard-token',
+ })?.browser.dryRun).toBe(true);
});
- it('reorders rows according to input order', () => {
- persistSubreddits(
- [
- { name: 'tightandtoned', type: 'image', weight: 1, tone: 'default' },
- { name: 'AverageNudes', type: 'image', weight: 4, tone: 'direct' },
- { name: 'gonewild40plus', type: 'image', weight: 4, tone: 'gentle' },
- { name: 'normalnudes', type: 'image', weight: 3, tone: 'default' },
- ],
- configPath,
- );
- const parsed = YAML.parse(fs.readFileSync(configPath, 'utf-8'));
- expect(parsed.reddit.subreddits.map((s: { name: string }) => s.name)).toEqual([
- 'tightandtoned',
- 'AverageNudes',
- 'gonewild40plus',
- 'normalnudes',
- ]);
+ it('imports legacy YAML once and then starts without the file', () => {
+ const originalDirectory = process.cwd();
+ const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'redditbot-config-import-'));
+ const previousApiKey = process.env.OPENROUTER_API_KEY;
+ const previousDashboardToken = process.env.WEB_DASHBOARD_TOKEN;
+ try {
+ process.chdir(tempDirectory);
+ process.env.OPENROUTER_API_KEY = 'environment-key';
+ process.env.WEB_DASHBOARD_TOKEN = 'environment-dashboard-token';
+ fs.writeFileSync('config.yaml', `
+reddit:
+ subreddits: [example]
+openrouter:
+ imageAnalysisPrompt: analyze
+ messageGenerationPrompt: compose
+`);
+
+ expect(loadConfig(store).reddit.subreddits[0].name).toBe('example');
+ fs.rmSync('config.yaml');
+ expect(loadConfig(store).reddit.subreddits[0].name).toBe('example');
+ } finally {
+ process.chdir(originalDirectory);
+ fs.rmSync(tempDirectory, { recursive: true, force: true });
+ if (previousApiKey === undefined) delete process.env.OPENROUTER_API_KEY;
+ else process.env.OPENROUTER_API_KEY = previousApiKey;
+ if (previousDashboardToken === undefined) delete process.env.WEB_DASHBOARD_TOKEN;
+ else process.env.WEB_DASHBOARD_TOKEN = previousDashboardToken;
+ }
});
});
diff --git a/tests/dashboard/app.test.tsx b/tests/dashboard/app.test.tsx
index 4241ede..f80c403 100644
--- a/tests/dashboard/app.test.tsx
+++ b/tests/dashboard/app.test.tsx
@@ -171,6 +171,7 @@ describe('dashboard foundation', () => {
expect(screen.getByText('Dry run')).toBeInTheDocument();
expect(await screen.findByRole('list', { name: 'Recent sent messages' })).toHaveTextContent('A recent opener');
expect(screen.getByRole('list', { name: 'Recent sent messages' })).toHaveTextContent('Responded');
+ expect(screen.getByRole('link', { name: 'u/alice' })).toHaveAttribute('href', 'https://www.reddit.com/u/alice');
});
it('keeps stats visible when timers fail and retries only the failed panel', async () => {
@@ -200,6 +201,52 @@ describe('dashboard foundation', () => {
expect(timerRequests).toBe(2);
});
+ it('makes usernames in recent activity clickable', async () => {
+ installSuccessfulApi({
+ recentActivity: [{
+ timestamp: Date.now(),
+ type: 'response',
+ message: 'Reply from u/alice',
+ recipient: 'alice',
+ }],
+ });
+ renderDashboard();
+
+ const activity = await screen.findByRole('heading', { name: 'Recent activity' });
+ const panel = activity.closest('section');
+ expect(panel).not.toBeNull();
+ expect(within(panel!).getByRole('link', { name: 'u/alice' })).toHaveAttribute(
+ 'href',
+ 'https://www.reddit.com/u/alice',
+ );
+ });
+
+ it('opens saved source-post context from a sent activity entry', async () => {
+ installSuccessfulApi({
+ recentActivity: [{
+ timestamp: Date.now(),
+ type: 'sent',
+ message: 'Sent message to u/alice (r/example)',
+ recipient: 'alice',
+ imageUrl: 'https://i.redd.it/source.jpg',
+ postTitle: 'The source post title',
+ postUrl: 'https://www.reddit.com/r/example/comments/source/the_source_post_title/',
+ }],
+ });
+ const user = userEvent.setup();
+ renderDashboard();
+
+ await user.click(await screen.findByRole('button', { name: 'Sent message to u/alice (r/example)' }));
+
+ const dialog = screen.getByRole('dialog', { name: 'The source post title' });
+ expect(within(dialog).getByRole('img', { name: 'Source post: The source post title' })).toHaveAttribute(
+ 'src', 'https://i.redd.it/source.jpg',
+ );
+ expect(within(dialog).getByRole('link', { name: 'Open Reddit post' })).toHaveAttribute(
+ 'href', 'https://www.reddit.com/r/example/comments/source/the_source_post_title/',
+ );
+ });
+
it('styles the recent-messages retry control when its request fails', async () => {
installSuccessfulApi();
server.use(http.get('/api/messages', () => HttpResponse.json({ error: 'Messages unavailable' }, { status: 503 })));
diff --git a/tests/dashboard/conversations.test.tsx b/tests/dashboard/conversations.test.tsx
index 0a356a9..0d9a55a 100644
--- a/tests/dashboard/conversations.test.tsx
+++ b/tests/dashboard/conversations.test.tsx
@@ -15,6 +15,7 @@ interface ChatFixtureOptions {
unread?: boolean;
isFromUs?: boolean;
lastMessageSnippet?: string;
+ hasReceivedImages?: boolean;
}
function chatFixture(options: ChatFixtureOptions) {
@@ -27,6 +28,7 @@ function chatFixture(options: ChatFixtureOptions) {
lastMessageSnippet: options.lastMessageSnippet ?? `Latest from ${options.username}`,
isFromUs: options.isFromUs ?? false,
unread: options.unread ?? false,
+ ...(options.hasReceivedImages === undefined ? {} : { hasReceivedImages: options.hasReceivedImages }),
};
}
@@ -45,6 +47,7 @@ function detailFixture(chat = alice) {
subreddit: 'casualconversation',
title: 'Looking for a chat',
url: 'https://reddit.com/r/casualconversation/comments/post-1',
+ imageUrl: 'https://i.redd.it/source-post.jpg',
sentAt: 1_785_999_800_000,
},
syncInProgress: null,
@@ -123,8 +126,59 @@ describe('ConversationsPage', () => {
await user.click(screen.getByRole('button', { name: /u\/alice/ }));
expect(await screen.findByRole('heading', { name: 'u/alice' })).toBeInTheDocument();
+ expect(screen.getAllByRole('link', { name: 'u/alice' })).not.toHaveLength(0);
+ for (const profileLink of screen.getAllByRole('link', { name: 'u/alice' })) {
+ expect(profileLink).toHaveAttribute('href', 'https://www.reddit.com/u/alice');
+ }
expect(screen.getByText('Good to hear from you')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Looking for a chat' })).toHaveAttribute('href', expect.stringContaining('post-1'));
+ expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveAttribute(
+ 'src',
+ 'https://i.redd.it/source-post.jpg',
+ );
+ });
+
+ it('marks and filters conversations with received images and renders the images in chat', async () => {
+ const imageChat = chatFixture({
+ username: 'alice',
+ roomId: 'room-alice',
+ hasReceivedImages: true,
+ lastMessageSnippet: '[Image]',
+ });
+ server.use(
+ http.get('/api/conversations', () => HttpResponse.json([imageChat, bob])),
+ http.get('/api/conversations/room-alice', () => HttpResponse.json({
+ ...detailFixture(imageChat),
+ messages: [
+ {
+ sender: 'alice',
+ text: '[Image]',
+ timestamp: 1_786_000_000_000,
+ isSelf: false,
+ media: [{
+ originalUrl: 'mxc://reddit.com/image-one',
+ localPath: 'received image.jpg',
+ fileName: 'received image.jpg',
+ }],
+ },
+ ],
+ })),
+ );
+ const user = userEvent.setup();
+ renderConversations();
+
+ const aliceItem = await screen.findByRole('button', { name: /u\/alice/ });
+ expect(screen.getByText('Image received')).toBeInTheDocument();
+
+ await user.selectOptions(screen.getByRole('combobox', { name: 'Filter conversations' }), 'received-images');
+ expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: /u\/alice/ }));
+ expect(await screen.findByRole('img', { name: 'Image received from u/alice' })).toHaveAttribute(
+ 'src',
+ '/media/received%20image.jpg',
+ );
});
it('turns a suggestion into an editable draft and confirms sending without losing failed input', async () => {
@@ -230,6 +284,8 @@ describe('ConversationsPage', () => {
);
await user.click(screen.getByRole('button', { name: 'Start conversation' }));
expect(await screen.findByRole('status', { name: 'Conversation started' })).toHaveTextContent('Conversation started with u/charlie');
+ expect(within(screen.getByRole('status', { name: 'Conversation started' })).getByRole('link', { name: 'u/charlie' }))
+ .toHaveAttribute('href', 'https://www.reddit.com/u/charlie');
expect(username).toHaveValue('');
expect(message).toHaveValue('');
expect(startRequests).toBe(2);
diff --git a/tests/dashboard/media.test.tsx b/tests/dashboard/media.test.tsx
index f3a3a95..87cf5ff 100644
--- a/tests/dashboard/media.test.tsx
+++ b/tests/dashboard/media.test.tsx
@@ -89,6 +89,9 @@ describe('media page', () => {
const aliceImage = await screen.findByRole('img', { name: 'Media from alice' });
const charlieImage = screen.getByRole('img', { name: 'Media from charlie' });
+ for (const profileLink of screen.getAllByRole('link', { name: 'alice' })) {
+ expect(profileLink).toHaveAttribute('href', 'https://www.reddit.com/u/alice');
+ }
expect(aliceImage).toHaveClass('is-blurred');
expect(charlieImage).toHaveClass('is-blurred');
expect(credentials.every((value) => value === 'same-origin')).toBe(true);
diff --git a/tests/dashboard/performance-operations.test.tsx b/tests/dashboard/performance-operations.test.tsx
index b1121a0..7d52c48 100644
--- a/tests/dashboard/performance-operations.test.tsx
+++ b/tests/dashboard/performance-operations.test.tsx
@@ -124,7 +124,9 @@ describe('OperationsPage', () => {
expect(await screen.findByRole('heading', { name: 'Operations' })).toBeInTheDocument();
const activityRegion = screen.getByRole('region', { name: 'Activity' });
- expect(await within(activityRegion).findByText('Reply from u/alex')).toBeInTheDocument();
+ expect(await within(activityRegion).findByRole('link', { name: 'u/alex' })).toBeInTheDocument();
+ expect(within(activityRegion).getByText((_content, element) => element?.tagName === 'STRONG' && element.textContent === 'Reply from u/alex')).toBeInTheDocument();
+ expect(within(activityRegion).getByRole('link', { name: 'u/alex' })).toHaveAttribute('href', 'https://www.reddit.com/u/alex');
const jobsRegion = screen.getByRole('region', { name: 'Sync jobs' });
expect(await within(jobsRegion).findByText('4 of 10')).toBeInTheDocument();
for (const name of ['Sync latest chats', 'Full cache sync', 'Fetch all chats']) {
diff --git a/tests/dashboard/realtime-client.test.ts b/tests/dashboard/realtime-client.test.ts
index 528b897..bde17e7 100644
--- a/tests/dashboard/realtime-client.test.ts
+++ b/tests/dashboard/realtime-client.test.ts
@@ -83,6 +83,16 @@ describe('dashboard realtime client', () => {
type: 'sync.progress',
payload: { syncId: 'sync-1', kind: 'media', completed: 2, total: 5 },
}),
+ ).toEqual([
+ ['operations', 'sync-status'],
+ ]);
+ expect(
+ queryKeysForDashboardEvent({
+ id: 'completed',
+ occurredAt: '2026-08-08T12:00:01.000Z',
+ type: 'sync.completed',
+ payload: { syncId: 'sync-1', kind: 'conversations', processed: 5 },
+ }),
).toEqual([
['operations', 'sync-status'],
['conversations'],
diff --git a/tests/dashboard/reddit-username-link.test.tsx b/tests/dashboard/reddit-username-link.test.tsx
new file mode 100644
index 0000000..4d1e7af
--- /dev/null
+++ b/tests/dashboard/reddit-username-link.test.tsx
@@ -0,0 +1,48 @@
+// @vitest-environment jsdom
+
+import '@testing-library/jest-dom/vitest';
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { RedditUsernameLink } from '../../apps/dashboard/src/components/reddit-username-link.js';
+import { RedditUserText } from '../../apps/dashboard/src/components/reddit-user-text.js';
+
+describe('RedditUsernameLink', () => {
+ it('normalizes a username and opens its Reddit profile in a new tab', () => {
+ render( );
+
+ expect(screen.getByRole('link', { name: 'u/example user' })).toHaveAttribute(
+ 'href',
+ 'https://www.reddit.com/u/example%20user',
+ );
+ expect(screen.getByRole('link', { name: 'u/example user' })).toHaveAttribute('target', '_blank');
+ expect(screen.getByRole('link', { name: 'u/example user' })).toHaveAttribute('rel', 'noreferrer');
+ });
+
+ it('can preserve an existing unprefixed display label', () => {
+ render( );
+
+ expect(screen.getByRole('link', { name: 'alice' })).toHaveAttribute(
+ 'href',
+ 'https://www.reddit.com/u/alice',
+ );
+ });
+
+ it('never exposes or links an internal t2 identity', () => {
+ const view = render( );
+
+ expect(view.container).not.toHaveTextContent('t2_private123');
+ expect(view.container.querySelector('a')).toBeNull();
+ expect(view.container).toHaveTextContent('Unknown Reddit user');
+ });
+
+ it('links username mentions embedded in activity text and redacts raw ids', () => {
+ render(Reply from u/alice after syncing t2_private123 );
+
+ expect(screen.getByRole('link', { name: 'u/alice' })).toHaveAttribute(
+ 'href',
+ 'https://www.reddit.com/u/alice',
+ );
+ expect(screen.queryByText(/t2_private123/)).not.toBeInTheDocument();
+ expect(screen.getByText(/after syncing Reddit user/)).toBeInTheDocument();
+ });
+});
diff --git a/tests/dashboard/route-filters.test.tsx b/tests/dashboard/route-filters.test.tsx
index 1f2896f..9025af2 100644
--- a/tests/dashboard/route-filters.test.tsx
+++ b/tests/dashboard/route-filters.test.tsx
@@ -32,10 +32,10 @@ describe('route-friendly filters', () => {
it('hydrates and writes conversation search and status filters', async () => {
server.use(http.get('/api/conversations', () => HttpResponse.json([])));
const user = userEvent.setup();
- renderRoute('/conversations?q=alice&filter=unread', );
+ renderRoute('/conversations?q=alice&filter=received-images', );
const search = await screen.findByRole('searchbox', { name: 'Search conversations' });
expect(search).toHaveValue('alice');
- expect(screen.getByRole('combobox', { name: 'Filter conversations' })).toHaveValue('unread');
+ expect(screen.getByRole('combobox', { name: 'Filter conversations' })).toHaveValue('received-images');
await user.clear(search);
await user.type(search, 'bob');
expect(screen.getByLabelText('Current URL')).toHaveTextContent('q=bob');
diff --git a/tests/dashboard/settings.test.tsx b/tests/dashboard/settings.test.tsx
index 0f15765..60ea03e 100644
--- a/tests/dashboard/settings.test.tsx
+++ b/tests/dashboard/settings.test.tsx
@@ -16,6 +16,7 @@ const config = {
reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60 },
subreddits: [{ name: 'casualconversation', type: 'chat', weight: 2, tone: 'default' }],
tones: ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing'],
+ advanced: { openrouter: { chatModel: 'example/chat-model' } },
};
const server = setupServer();
@@ -228,6 +229,25 @@ describe('settings and access', () => {
expect(updateRequests).toBe(1);
});
+ it('validates advanced application JSON before saving', async () => {
+ installConfigApi();
+ let updateRequests = 0;
+ server.use(http.put('/api/config', () => {
+ updateRequests += 1;
+ return HttpResponse.json({ success: true, config, subreddits: config.subreddits });
+ }));
+ const user = userEvent.setup();
+ renderSettings('/settings?section=behaviour');
+
+ const editor = await screen.findByRole('textbox', { name: 'Advanced configuration JSON' });
+ await user.clear(editor);
+ await user.type(editor, 'invalid');
+ await user.click(screen.getByRole('button', { name: 'Save settings' }));
+
+ expect(screen.getByText('Advanced configuration is not valid JSON.')).toBeInTheDocument();
+ expect(updateRequests).toBe(0);
+ });
+
it('confirms unusually large hourly limits', async () => {
installConfigApi();
let updateRequests = 0;
@@ -287,6 +307,10 @@ describe('settings and access', () => {
expect(importRequests).toBe(1);
expect(await screen.findByText('Authenticated')).toBeInTheDocument();
expect(screen.getByText(/u\/example-account/)).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'u/example-account' })).toHaveAttribute(
+ 'href',
+ 'https://www.reddit.com/u/example-account',
+ );
await user.click(screen.getByRole('button', { name: 'Recheck session' }));
expect(statusRequests).toBeGreaterThanOrEqual(2);
diff --git a/tests/database.test.ts b/tests/database.test.ts
index cd9ebaf..5623161 100644
--- a/tests/database.test.ts
+++ b/tests/database.test.ts
@@ -105,16 +105,18 @@ describe('Database Client', () => {
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
.all() as { name: string }[];
- // processed_posts, sent_messages, successful_messages, activity_log, reddit_users, delivery_outbox, conversation_states, conversation_outcomes
- expect(tables.length).toBe(8);
+ // Eight domain tables plus the singleton application configuration table.
+ expect(tables.length).toBe(9);
});
it('tracks schema version and migrates a legacy sent_messages table transactionally', () => {
db.exec(`
CREATE TABLE processed_posts (id TEXT PRIMARY KEY, subreddit TEXT NOT NULL, author TEXT NOT NULL, title TEXT NOT NULL, image_url TEXT, image_analysis TEXT, generated_message TEXT, processed_at INTEGER NOT NULL, message_sent INTEGER DEFAULT 0, error TEXT);
CREATE TABLE sent_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, post_id TEXT NOT NULL, recipient TEXT NOT NULL, subject TEXT NOT NULL, message TEXT NOT NULL, sent_at INTEGER NOT NULL);
+ CREATE TABLE activity_log (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER NOT NULL, type TEXT NOT NULL, message TEXT NOT NULL, recipient TEXT, image_url TEXT);
INSERT INTO processed_posts VALUES ('post-1', 'test', 'author', 'title', NULL, NULL, NULL, 1, 0, NULL);
INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at) VALUES ('post-1', 'recipient', 'subject', 'message', 2);
+ INSERT INTO activity_log (timestamp, type, message, recipient) VALUES (2, 'sent', 'Sent message to u/recipient (r/test)', 'recipient');
`);
runMigrations(db);
@@ -124,6 +126,10 @@ describe('Database Client', () => {
const postId = db.prepare('PRAGMA table_info(sent_messages)').all().find((column: { name: string }) => column.name === 'post_id') as { notnull: number };
expect(postId.notnull).toBe(0);
expect(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'delivery_outbox'").get()).toBeDefined();
+ expect(db.prepare('SELECT post_title, post_url FROM activity_log').get()).toEqual({
+ post_title: 'title',
+ post_url: 'https://www.reddit.com/r/test/comments/post-1/',
+ });
});
it('processed_posts has correct schema', () => {
diff --git a/tests/matrix-client.test.ts b/tests/matrix-client.test.ts
index 424215b..b36457b 100644
--- a/tests/matrix-client.test.ts
+++ b/tests/matrix-client.test.ts
@@ -1,5 +1,9 @@
-import { describe, it, expect } from 'vitest';
-import { MatrixClient, MatrixRateLimitError } from '../src/services/chat/matrix/client.js';
+import { describe, it, expect, vi } from 'vitest';
+import {
+ MatrixAuthError,
+ MatrixClient,
+ MatrixRateLimitError,
+} from '../src/services/chat/matrix/client.js';
import { createMockLogger } from './mockLogger.js';
const creds = {
@@ -10,6 +14,31 @@ const creds = {
};
describe('MatrixClient HTTP timeout', () => {
+ it('loads an encoded Matrix user profile with bearer authentication', async () => {
+ let requestedUrl = '';
+ let authorization = '';
+ const fetchImpl = ((url: string | URL | Request, init?: RequestInit) => {
+ requestedUrl = String(url);
+ authorization = new Headers(init?.headers).get('Authorization') ?? '';
+ return Promise.resolve(new Response(JSON.stringify({ displayname: 'MilkyBabe_10' }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }));
+ }) as typeof fetch;
+ const client = new MatrixClient(creds, createMockLogger(), {
+ minRequestIntervalMs: 0,
+ fetchImpl,
+ });
+
+ await expect(client.getUserProfile('@t2_26q364ua7n:reddit.com')).resolves.toEqual({
+ displayname: 'MilkyBabe_10',
+ });
+ expect(requestedUrl).toBe(
+ 'https://matrix.example.test/_matrix/client/v3/profile/%40t2_26q364ua7n%3Areddit.com',
+ );
+ expect(authorization).toBe('Bearer tok');
+ });
+
it('aborts a hanging fetch and surfaces a timeout error rather than blocking forever', async () => {
// A fetch impl that never resolves on its own — only the AbortSignal can
// end it. If the client lacked an AbortSignal, this test would hang.
@@ -64,6 +93,36 @@ describe('MatrixClient HTTP timeout', () => {
}, 10_000);
});
+describe('MatrixClient authentication classification', () => {
+ it('does not classify a room-level 403 as an expired account token', async () => {
+ const client = new MatrixClient(creds, createMockLogger(), {
+ minRequestIntervalMs: 0,
+ fetchImpl: vi.fn().mockResolvedValue(new Response('', { status: 403 })),
+ });
+
+ const error = await client.getRoomFullState('!left-room:example.test').catch((cause) => cause);
+
+ expect(error).toBeInstanceOf(Error);
+ expect(error).not.toBeInstanceOf(MatrixAuthError);
+ expect((error as Error).message).toMatch(/403/);
+ });
+
+ it('still classifies an explicit unknown-token 403 as authentication failure', async () => {
+ const client = new MatrixClient(creds, createMockLogger(), {
+ minRequestIntervalMs: 0,
+ fetchImpl: vi.fn().mockResolvedValue(new Response(JSON.stringify({
+ errcode: 'M_UNKNOWN_TOKEN',
+ error: 'Access token has expired',
+ }), {
+ status: 403,
+ headers: { 'content-type': 'application/json' },
+ })),
+ });
+
+ await expect(client.whoami()).rejects.toBeInstanceOf(MatrixAuthError);
+ });
+});
+
describe('MatrixClient 429 handling', () => {
it('throws MatrixRateLimitError on a long retry_after rather than sleeping it out', async () => {
const fetchImpl = (() =>
diff --git a/tests/matrix-http-media-downloader.test.ts b/tests/matrix-http-media-downloader.test.ts
new file mode 100644
index 0000000..fea5a48
--- /dev/null
+++ b/tests/matrix-http-media-downloader.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it, vi } from 'vitest';
+import { MatrixMediaDownloader } from '../src/services/chat/matrix/http-media-downloader.js';
+import { createMockLogger } from './mockLogger.js';
+
+describe('MatrixMediaDownloader', () => {
+ it('uses replacement Matrix credentials for subsequent media downloads', async () => {
+ const staleClient = { downloadMedia: vi.fn().mockRejectedValue(new Error('expired')) };
+ const freshClient = {
+ downloadMedia: vi.fn().mockResolvedValue({
+ bytes: Buffer.from('fresh-image'),
+ contentType: 'image/jpeg',
+ }),
+ };
+ const downloader = new MatrixMediaDownloader(staleClient as never, createMockLogger());
+
+ downloader.setMatrixClient(freshClient as never);
+
+ await expect(downloader.fetch('mxc://reddit.com/image')).resolves.toEqual({
+ buffer: Buffer.from('fresh-image'),
+ contentType: 'image/jpeg',
+ });
+ expect(staleClient.downloadMedia).not.toHaveBeenCalled();
+ expect(freshClient.downloadMedia).toHaveBeenCalledWith('mxc://reddit.com/image');
+ });
+});
diff --git a/tests/matrix-username-resolver.test.ts b/tests/matrix-username-resolver.test.ts
new file mode 100644
index 0000000..1d2ffcc
--- /dev/null
+++ b/tests/matrix-username-resolver.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it, vi } from 'vitest';
+import { MatrixUsernameResolver } from '../src/services/chat/matrix/username-resolver.js';
+
+describe('MatrixUsernameResolver', () => {
+ it('resolves Reddit t2 IDs from authenticated Matrix profile display names', async () => {
+ const getUserProfile = vi.fn(async (mxid: string) => {
+ const profiles: Record = {
+ '@t2_26q364ua7n:reddit.com': { displayname: 'MilkyBabe_10' },
+ '@t2_j8e83d38:reddit.com': { displayname: 'IssueLesss' },
+ };
+ return profiles[mxid] ?? {};
+ });
+ const resolver = new MatrixUsernameResolver({ getUserProfile });
+
+ await expect(
+ resolver.resolveT2Ids(['t2_26q364ua7n', 'j8e83d38']),
+ ).resolves.toEqual({
+ t2_26q364ua7n: 'MilkyBabe_10',
+ t2_j8e83d38: 'IssueLesss',
+ });
+ expect(getUserProfile).toHaveBeenCalledWith('@t2_26q364ua7n:reddit.com');
+ expect(getUserProfile).toHaveBeenCalledWith('@t2_j8e83d38:reddit.com');
+ });
+
+ it('ignores missing and t2-shaped display names without failing the batch', async () => {
+ const getUserProfile = vi.fn(async (mxid: string) => {
+ if (mxid === '@t2_missing:reddit.com') throw new Error('not found');
+ return { displayname: 't2_unresolved' };
+ });
+ const resolver = new MatrixUsernameResolver({ getUserProfile });
+
+ await expect(
+ resolver.resolveT2Ids(['t2_missing', 't2_unresolved']),
+ ).resolves.toEqual({});
+ });
+
+ it('uses replacement Matrix credentials for subsequent profile requests', async () => {
+ const staleClient = { getUserProfile: vi.fn().mockRejectedValue(new Error('expired')) };
+ const freshClient = {
+ getUserProfile: vi.fn().mockResolvedValue({ displayname: 'alice' }),
+ };
+ const resolver = new MatrixUsernameResolver(staleClient);
+
+ resolver.setMatrixClient(freshClient);
+
+ await expect(resolver.resolveT2Ids(['t2_alice'])).resolves.toEqual({ t2_alice: 'alice' });
+ expect(staleClient.getUserProfile).not.toHaveBeenCalled();
+ expect(freshClient.getUserProfile).toHaveBeenCalledWith('@t2_alice:reddit.com');
+ });
+});
diff --git a/tests/reddit-api.test.ts b/tests/reddit-api.test.ts
index ffa70ae..ea700ea 100644
--- a/tests/reddit-api.test.ts
+++ b/tests/reddit-api.test.ts
@@ -1,5 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import axios from 'axios';
+import type { AxiosInstance } from 'axios';
+import type { BrowserContext } from 'playwright';
import { RedditApiClient } from '../src/reddit/api.js';
import type { RedditPost } from '../src/reddit/api.js';
import { createMockLogger } from '../tests/mockLogger.js';
@@ -394,6 +396,44 @@ describe('RedditApiClient', () => {
});
});
+ describe('resolveUsernameToT2', () => {
+ it('uses the imported Reddit session and normalizes the account ID', async () => {
+ const mockAxios = {
+ get: vi.fn().mockResolvedValue({ data: { data: { id: 'abc123' } } }),
+ };
+ vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
+ const client2 = new RedditApiClient(getTestConfig(), logger);
+ const cookies = vi.fn().mockResolvedValue([
+ { name: 'reddit_session', value: 'session-value' },
+ ]);
+ client2.setBrowserContext({ cookies } as unknown as BrowserContext);
+
+ await expect(client2.resolveUsernameToT2('alice')).resolves.toBe('t2_abc123');
+ expect(mockAxios.get).toHaveBeenCalledWith('/user/alice/about.json', {
+ params: { raw_json: 1 },
+ headers: { Cookie: 'reddit_session=session-value' },
+ });
+ });
+
+ it('returns null only when Reddit confirms the user is missing', async () => {
+ const error = { isAxiosError: true, response: { status: 404 } };
+ const mockAxios = { get: vi.fn().mockRejectedValue(error) };
+ vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
+ const client2 = new RedditApiClient(getTestConfig(), logger);
+
+ await expect(client2.resolveUsernameToT2('missing')).resolves.toBeNull();
+ });
+
+ it('does not misclassify a 403 as an unknown user', async () => {
+ const error = { isAxiosError: true, response: { status: 403 } };
+ const mockAxios = { get: vi.fn().mockRejectedValue(error) };
+ vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
+ const client2 = new RedditApiClient(getTestConfig(), logger);
+
+ await expect(client2.resolveUsernameToT2('alice')).rejects.toBe(error);
+ });
+ });
+
describe('getNewPosts', () => {
it('returns empty array for 404 subreddit', async () => {
const error = { isAxiosError: true, response: { status: 404 } };
diff --git a/tests/repositories.test.ts b/tests/repositories.test.ts
index 76640e0..e3b9791 100644
--- a/tests/repositories.test.ts
+++ b/tests/repositories.test.ts
@@ -27,6 +27,7 @@ describe('PostsRepository', () => {
subreddit: 'test',
author: 'user1',
title: 'Test Post',
+ postUrl: 'https://www.reddit.com/r/test/comments/post1/test_post/',
imageUrl: 'http://example.com/img.jpg',
processedAt: Date.now(),
});
@@ -37,6 +38,7 @@ describe('PostsRepository', () => {
expect(post!.subreddit).toBe('test');
expect(post!.author).toBe('user1');
expect(post!.title).toBe('Test Post');
+ expect(post!.postUrl).toBe('https://www.reddit.com/r/test/comments/post1/test_post/');
expect(post!.imageUrl).toBe('http://example.com/img.jpg');
expect(post!.messageSent).toBe(false);
expect(post!.imageAnalysis).toBeNull();
@@ -203,7 +205,7 @@ describe('MessagesRepository', () => {
expect(updated!.gotResponse).toBe(true);
});
- it('chat-originated rows appear in getRecent and contribute to stats', () => {
+ it('keeps unassociated chat history out of performance trends', () => {
messagesRepo.insertChat({
recipient: 'bob',
subject: 'bob',
@@ -213,6 +215,7 @@ describe('MessagesRepository', () => {
});
const recent = messagesRepo.getRecent(10);
expect(recent.some((m) => m.chatRoomId === '!r1:reddit.com')).toBe(true);
+ expect(messagesRepo.getResponseRateTrend()).toEqual([]);
});
it('inserts and retrieves a message', () => {
@@ -305,19 +308,22 @@ describe('MessagesRepository', () => {
subreddit: 'gonewild',
author: 'alice',
title: 'Second post',
+ postUrl: 'https://www.reddit.com/r/gonewild/comments/post-b/second_post/',
imageUrl: null,
processedAt: Date.now(),
});
const base = Date.now();
messagesRepo.insert({ postId: 'post-a', recipient: 'alice', subject: 's', message: 'm', sentAt: base });
- messagesRepo.insert({ postId: 'post-b', recipient: 'alice', subject: 's', message: 'm', sentAt: base + 1000 });
+ messagesRepo.insert({ postId: 'post-b', recipient: 'alice', subject: 's', message: 'm', sentAt: base + 1000, chatRoomId: 'room-b' });
const latest = messagesRepo.getLatestByRecipient('alice');
expect(latest).toBeDefined();
expect(latest!.postId).toBe('post-b');
expect(latest!.subreddit).toBe('gonewild');
expect(latest!.postTitle).toBe('Second post');
+ expect(latest!.postUrl).toBe('https://www.reddit.com/r/gonewild/comments/post-b/second_post/');
+ expect(messagesRepo.getByChatRoomId('room-b')).toMatchObject({ postId: 'post-b', postTitle: 'Second post' });
expect(messagesRepo.getLatestByRecipient('ALICE')).toBeDefined();
expect(messagesRepo.getLatestByRecipient('nobody')).toBeUndefined();
diff --git a/tests/response-checker.test.ts b/tests/response-checker.test.ts
index 8ded4ca..977c286 100644
--- a/tests/response-checker.test.ts
+++ b/tests/response-checker.test.ts
@@ -161,6 +161,39 @@ describe('ResponseCheckerService', () => {
expect(messagesRepo.markChecked).toHaveBeenCalled();
});
+ it('does not track an unassociated reply as performance', async () => {
+ const message: SentMessageRecord = {
+ id: 1,
+ postId: null,
+ recipient: 'user1',
+ subject: 'Hello',
+ message: 'Hey!',
+ sentAt: Date.now() - 2 * 3600000,
+ gotResponse: false,
+ responseCheckedAt: null,
+ strategyId: null,
+ chatRoomId: '!room:reddit.com',
+ };
+ messagesRepo = createMockMessagesRepo([message]);
+ checker = new ResponseCheckerService(page, messagesRepo, successfulMessagesRepo, postsRepo, logger);
+ const mockItem = {
+ $: vi.fn(async (selector: string) => selector.includes('author') || selector.includes('sender')
+ ? { textContent: vi.fn(async () => 'user1') }
+ : { textContent: vi.fn(async () => 'Hi there') }),
+ evaluate: vi.fn(async () => true),
+ };
+ vi.mocked(page.$$).mockResolvedValue([mockItem as any]);
+
+ const results = await checker.checkForResponses();
+
+ expect(results).toEqual([
+ expect.objectContaining({ messageId: 1, gotResponse: true }),
+ ]);
+ expect(messagesRepo.markGotResponse).not.toHaveBeenCalled();
+ expect(messagesRepo.markChecked).toHaveBeenCalledWith(1);
+ expect(successfulMessagesRepo.insert).not.toHaveBeenCalled();
+ });
+
it('stores successful messages for learning', async () => {
const post: ProcessedPostRecord = {
id: 'post1',
diff --git a/tests/web/conversations-route.test.ts b/tests/web/conversations-route.test.ts
new file mode 100644
index 0000000..cf047e5
--- /dev/null
+++ b/tests/web/conversations-route.test.ts
@@ -0,0 +1,180 @@
+import express from 'express';
+import type { AddressInfo } from 'node:net';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import type { WebContext } from '../../src/web/context.js';
+import { createConversationsRouter } from '../../src/web/routes/conversations.js';
+
+const servers: Array> = [];
+
+async function request(app: express.Express, path: string): Promise {
+ const server = app.listen(0);
+ servers.push(server);
+ await new Promise((resolve) => server.once('listening', resolve));
+ const { port } = server.address() as AddressInfo;
+ return fetch(`http://127.0.0.1:${port}${path}`);
+}
+
+afterEach(async () => {
+ await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve()))));
+});
+
+describe('conversations route', () => {
+ it('reports an unavailable chat service instead of presenting it as an empty inbox', async () => {
+ const context = {
+ logger: { error: vi.fn() },
+ services: {},
+ } as unknown as WebContext;
+ const app = express().use('/api', createConversationsRouter(context));
+
+ const response = await request(app, '/api/conversations');
+
+ expect(response.status).toBe(503);
+ expect(await response.json()).toEqual({ error: 'Chat service not initialized' });
+ });
+
+ it('reports a cache read failure instead of presenting it as an empty inbox', async () => {
+ const logger = { error: vi.fn() };
+ const context = {
+ logger,
+ services: {
+ chat: {
+ getCachedConversations: vi.fn().mockRejectedValue(new Error('cache unavailable')),
+ },
+ },
+ } as unknown as WebContext;
+ const app = express().use('/api', createConversationsRouter(context));
+
+ const response = await request(app, '/api/conversations');
+
+ expect(response.status).toBe(500);
+ expect(await response.json()).toEqual({ error: 'Failed to fetch conversations' });
+ expect(logger.error).toHaveBeenCalledWith('Failed to fetch conversations:', expect.any(Error));
+ });
+
+ it('marks conversations that have received images in the durable media index', async () => {
+ const conversation = {
+ username: 'alice',
+ roomName: 'alice',
+ roomId: 'room-alice',
+ lastMessageTime: 1_786_000_000_000,
+ lastMessageSnippet: '[Image]',
+ isFromUs: false,
+ unread: true,
+ };
+ const context = {
+ logger: { error: vi.fn() },
+ services: {
+ chat: { getCachedConversations: vi.fn().mockResolvedValue([conversation]) },
+ media: {
+ getMediaItems: vi.fn().mockResolvedValue([
+ {
+ id: 'image-one',
+ chatUsername: 'alice',
+ chatRoomId: 'room-alice',
+ sender: 'alice',
+ timestamp: 1_786_000_000_000,
+ originalUrl: 'mxc://reddit.com/image-one',
+ fileName: 'image-one.jpg',
+ relativePath: 'image-one.jpg',
+ createdAt: 1_786_000_000_100,
+ },
+ ]),
+ },
+ },
+ } as unknown as WebContext;
+ const app = express().use('/api', createConversationsRouter(context));
+
+ const legacyResponse = await request(app, '/api/conversations');
+ const response = await request(app, '/api/conversations?includeImageMetadata=true');
+
+ expect(legacyResponse.status).toBe(200);
+ expect(await legacyResponse.json()).toEqual([conversation]);
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual([{ ...conversation, hasReceivedImages: true }]);
+ });
+
+ it('serves conversation details without starting a delta sync', async () => {
+ const conversation = {
+ username: 'alice',
+ roomName: 'alice',
+ roomId: 'room-alice',
+ lastMessageTime: 1_786_000_000_000,
+ lastMessageSnippet: 'Hello',
+ isFromUs: false,
+ unread: false,
+ messages: [{
+ sender: 'alice',
+ text: 'Hello',
+ timestamp: 1_786_000_000_000,
+ isSelf: false,
+ }],
+ };
+ const refreshTopChats = vi.fn();
+ const loadConversationDetails = vi.fn();
+ const context = {
+ logger: { error: vi.fn(), warn: vi.fn() },
+ repos: {
+ messages: { getLatestByRecipient: vi.fn().mockReturnValue(undefined) },
+ },
+ services: {
+ chat: {
+ getSyncStatus: vi.fn().mockReturnValue({
+ syncing: false,
+ syncingKind: null,
+ progress: null,
+ }),
+ getCachedConversations: vi.fn().mockResolvedValue([conversation]),
+ refreshTopChats,
+ loadConversationDetails,
+ saveCacheExternal: vi.fn(),
+ },
+ },
+ } as unknown as WebContext;
+ const app = express().use('/api', createConversationsRouter(context));
+
+ const response = await request(app, '/api/conversations/room-alice');
+
+ expect(response.status).toBe(200);
+ expect((await response.json()).username).toBe('alice');
+ expect(refreshTopChats).not.toHaveBeenCalled();
+ expect(loadConversationDetails).not.toHaveBeenCalled();
+ });
+
+ it('attaches the exact room origin with its saved URL, title, and image', async () => {
+ const conversation = {
+ username: 'alice', roomName: 'alice', roomId: 'room-alice',
+ lastMessageTime: 10, lastMessageSnippet: 'Hello', isFromUs: true, unread: false,
+ messages: [{ sender: 'me', text: 'Hello', timestamp: 10, isSelf: true }],
+ };
+ const roomOrigin = {
+ id: 7, postId: 'post-exact', recipient: 'alice', subject: 'alice', message: 'Hello',
+ sentAt: 9, gotResponse: false, responseCheckedAt: null, strategyId: null,
+ chatRoomId: 'room-alice', subreddit: 'example', postTitle: 'Exact source post',
+ postUrl: 'https://www.reddit.com/r/example/comments/post-exact/exact_source_post/',
+ postImageUrl: 'https://i.redd.it/exact-source.jpg',
+ };
+ const latestRecipientOrigin = { ...roomOrigin, postId: 'wrong-latest-post', postTitle: 'Wrong post' };
+ const context = {
+ logger: { error: vi.fn(), warn: vi.fn() },
+ repos: { messages: {
+ getByChatRoomId: vi.fn().mockReturnValue(roomOrigin),
+ getLatestByRecipient: vi.fn().mockReturnValue(latestRecipientOrigin),
+ } },
+ services: { chat: {
+ getSyncStatus: vi.fn().mockReturnValue({ syncing: false, syncingKind: null, progress: null }),
+ getCachedConversations: vi.fn().mockResolvedValue([conversation]),
+ loadConversationDetails: vi.fn(), saveCacheExternal: vi.fn(),
+ } },
+ } as unknown as WebContext;
+ const app = express().use('/api', createConversationsRouter(context));
+
+ const response = await request(app, '/api/conversations/room-alice');
+
+ expect(response.status).toBe(200);
+ expect((await response.json()).triggeringPost).toEqual({
+ id: 'post-exact', subreddit: 'example', title: 'Exact source post',
+ url: roomOrigin.postUrl, imageUrl: roomOrigin.postImageUrl, sentAt: 9,
+ });
+ expect(context.repos.messages.getLatestByRecipient).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/web/media-route.test.ts b/tests/web/media-route.test.ts
new file mode 100644
index 0000000..ed05781
--- /dev/null
+++ b/tests/web/media-route.test.ts
@@ -0,0 +1,66 @@
+import express from 'express';
+import type { AddressInfo } from 'node:net';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import type { WebContext } from '../../src/web/context.js';
+import { createMediaRouter } from '../../src/web/routes/media.js';
+
+const servers: Array> = [];
+
+async function request(app: express.Express, path: string): Promise {
+ const server = app.listen(0);
+ servers.push(server);
+ await new Promise((resolve) => server.once('listening', resolve));
+ const { port } = server.address() as AddressInfo;
+ return fetch(`http://127.0.0.1:${port}${path}`);
+}
+
+afterEach(async () => {
+ await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve()))));
+});
+
+describe('media route username presentation', () => {
+ it('upgrades historical t2 fields through the chat username map', async () => {
+ const context = {
+ logger: { error: vi.fn() },
+ services: {
+ media: { getMediaItems: vi.fn().mockResolvedValue([{
+ id: 'one', chatUsername: 't2_known', chatRoomId: 'room-one', sender: 't2_known',
+ timestamp: 1, originalUrl: 'mxc://one', fileName: 'one.jpg', relativePath: 'one.jpg', createdAt: 2,
+ }]) },
+ chat: {
+ getCachedConversations: vi.fn().mockResolvedValue([]),
+ resolveSenderDisplay: vi.fn((identity: string) => identity === 't2_known' ? 'alice' : identity),
+ },
+ },
+ } as unknown as WebContext;
+ const app = express().use('/api', createMediaRouter(context));
+
+ const response = await request(app, '/api/media');
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual([expect.objectContaining({ chatUsername: 'alice', sender: 'alice' })]);
+ });
+
+ it('uses a safe fallback when no username mapping exists', async () => {
+ const context = {
+ logger: { error: vi.fn() },
+ services: {
+ media: { getMediaItems: vi.fn().mockResolvedValue([{
+ id: 'one', chatUsername: 't2_missing', chatRoomId: 'room-one', sender: '@t2_missing:reddit.com',
+ timestamp: 1, originalUrl: 'mxc://one', fileName: 'one.jpg', relativePath: 'one.jpg', createdAt: 2,
+ }]) },
+ chat: {
+ getCachedConversations: vi.fn().mockResolvedValue([]),
+ resolveSenderDisplay: vi.fn((identity: string) => identity),
+ },
+ },
+ } as unknown as WebContext;
+ const app = express().use('/api', createMediaRouter(context));
+
+ const response = await request(app, '/api/media');
+ const body = await response.json();
+
+ expect(JSON.stringify(body)).not.toContain('t2_missing');
+ expect(body[0]).toMatchObject({ chatUsername: null, sender: 'Unknown Reddit user' });
+ });
+});
diff --git a/tests/web/reddit-identities.test.ts b/tests/web/reddit-identities.test.ts
new file mode 100644
index 0000000..ddf2887
--- /dev/null
+++ b/tests/web/reddit-identities.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest';
+import { presentActivityItem, resolveRedditUsername } from '../../src/web/reddit-identities.js';
+
+const resolver = {
+ resolveSenderDisplay(identity: string) {
+ return identity.replace(/^@?t2_known(?::reddit\.com)?$/, 'alice');
+ },
+};
+
+describe('Reddit identity presentation', () => {
+ it('resolves internal ids and rejects unresolved ids', () => {
+ expect(resolveRedditUsername('@t2_known:reddit.com', resolver)).toBe('alice');
+ expect(resolveRedditUsername('t2_missing', resolver)).toBeNull();
+ });
+
+ it('upgrades both the activity recipient and its persisted message text', () => {
+ expect(presentActivityItem({
+ timestamp: 1,
+ type: 'response',
+ message: 'Reply from u/t2_known',
+ recipient: 't2_known',
+ }, resolver)).toEqual({
+ timestamp: 1,
+ type: 'response',
+ message: 'Reply from u/alice',
+ recipient: 'alice',
+ });
+ });
+
+ it('redacts unresolved ids instead of exposing them', () => {
+ const item = presentActivityItem({
+ timestamp: 1,
+ type: 'response',
+ message: 'Reply from @t2_missing:reddit.com',
+ recipient: 't2_missing',
+ }, resolver);
+
+ expect(item.message).toBe('Reply from Reddit user');
+ expect(item.recipient).toBeUndefined();
+ });
+});