feat: persist settings and enrich conversation context

This commit is contained in:
jjbrunton committed 2026-08-08 18:18:28 +01:00
1 parent a36723a325
commit 2c100d72b6
84 files changed
+2248 -644

No files matched your search

+9 -18
View File
@@ -19,20 +19,11 @@ Refactor code to improve maintainability, testability, and clarity. Break down l
- **Zod Validation**: Use `zod` (already in deps) to validate external data (config files, API responses).
- **Async/Await**: properly handle promises. Use `p-queue` or `p-retry` for rate-limited operations.
### 4. Test-Driven Development (TDD) — REQUIRED
All new functionality and bug fixes MUST follow the red-green-refactor cycle. This is non-negotiable.
- **Red**: Write a failing test FIRST that describes the desired behavior. Run it and confirm it fails for the expected reason.
- **Green**: Write the minimum production code needed to make the test pass. No extra features, no speculative abstractions.
- **Refactor**: With tests green, clean up implementation and tests. Re-run the suite after each change.
**Rules**:
- Never commit production code without an accompanying test that fails without it.
### 4. Testing
- TDD is not required. Agents may implement changes before writing or updating tests.
- Add or update tests when they provide useful coverage for new functionality or bug fixes.
- Tests live in `tests/` (mirroring `src/` structure) and run via `vitest` (`npm test`).
- Use dependency injection so collaborators (DB, HTTP, browser, clock) can be faked — do not mock what you don't own; wrap third-party APIs in a thin interface and fake the interface.
- Prefer behavior-focused tests (inputs → outputs / observable side effects) over tests that pin internal structure.
- Bug fixes start with a regression test that reproduces the bug.
- If a change is genuinely untestable (e.g. a one-line config tweak), state that explicitly in the PR/commit rather than skipping silently.
### 5. Project Specifics
- **Tech Stack**: Node.js, TypeScript, Playwright, Express, SQLite, Vitest.
@@ -45,10 +36,10 @@ All new functionality and bug fixes MUST follow the red-green-refactor cycle. Th
## Workflow for Agents
1. **Analyze**: Before writing code, read the file and understand the context.
2. **Plan**: Identify what needs to change. Check if the change violates SRP. Identify the test(s) that will drive the change.
3. **Write failing test (Red)**: Add a vitest spec that captures the new behavior or reproduces the bug. Run `npm test` and confirm it fails for the right reason.
4. **Implement (Green)**: Write the smallest change that makes the test pass. Create new small files when SRP calls for it.
5. **Refactor**: Tidy the implementation and the test with the suite green. Re-run tests after each refactor step.
6. **Verify**: Ensure the full suite passes (`npm test`) and types check (`npm run build` or `tsc --noEmit`).
2. **Plan**: Identify what needs to change and check if the change violates SRP.
3. **Implement**: Make the scoped change. Create new small files when SRP calls for it.
4. **Test**: Add or update useful tests as appropriate for the change.
5. **Refactor**: Tidy the implementation and tests while preserving behavior.
6. **Verify**: Run the relevant tests and type checks (`npm run build` or `tsc --noEmit`) in proportion to the change.
You must use the dokploy credentials in .env for access to dokploy
You must use the dokploy credentials in .env for access to dokploy
@@ -0,0 +1,72 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
export interface OriginPost {
title: string;
url: string;
imageUrl?: string | null;
subreddit?: string;
}
export function OriginPostCard({ post }: { post: OriginPost }) {
return (
<a aria-label={post.title} className="origin-post-card" href={post.url} rel="noreferrer" target="_blank">
{post.imageUrl ? <img alt={`Source post: ${post.title}`} loading="lazy" src={post.imageUrl} /> : null}
<span>
<small>Started from {post.subreddit ? `r/${post.subreddit}` : 'Reddit'}</small>
<strong>{post.title}</strong>
<span>Open original post</span>
</span>
</a>
);
}
export function OriginPostButton({ children, post }: { children: ReactNode; post: OriginPost }) {
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!isOpen) return;
closeRef.current?.focus();
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsOpen(false);
};
document.addEventListener('keydown', closeOnEscape);
return () => document.removeEventListener('keydown', closeOnEscape);
}, [isOpen]);
function close() {
setIsOpen(false);
queueMicrotask(() => triggerRef.current?.focus());
}
return (
<>
<button className="origin-post-trigger" onClick={() => setIsOpen(true)} ref={triggerRef} type="button">
{children}
</button>
{isOpen ? (
<div
aria-labelledby="origin-post-dialog-title"
aria-modal="true"
className="origin-post-backdrop"
onMouseDown={(event) => { if (event.target === event.currentTarget) close(); }}
role="dialog"
>
<article className="origin-post-dialog">
<div className="origin-post-dialog-heading">
<span>Conversation origin</span>
<button aria-label="Close source post" onClick={close} ref={closeRef} type="button">×</button>
</div>
{post.imageUrl ? <img alt={`Source post: ${post.title}`} loading="lazy" src={post.imageUrl} /> : null}
<div className="origin-post-dialog-copy">
{post.subreddit ? <small>r/{post.subreddit}</small> : null}
<h2 id="origin-post-dialog-title">{post.title}</h2>
<a className="button primary" href={post.url} rel="noreferrer" target="_blank">Open Reddit post</a>
</div>
</article>
</div>
) : null}
</>
);
}
@@ -0,0 +1,16 @@
import { Fragment } from 'react';
import { RedditUsernameLink } from './reddit-username-link.js';
const USER_MENTION = /(u\/[A-Za-z0-9_-]+|@?t2_[A-Za-z0-9]+(?::reddit\.com)?)/gi;
export function RedditUserText({ children }: { children: string }) {
return children.split(USER_MENTION).map((part, index) => {
if (/^u\/(?!t2_)[A-Za-z0-9_-]+$/i.test(part)) {
return <RedditUsernameLink key={`${part}-${index}`} username={part} />;
}
if (/^(?:@?t2_)/i.test(part) || /^u\/t2_/i.test(part)) {
return <Fragment key={`${part}-${index}`}>Reddit user</Fragment>;
}
return <Fragment key={`${part}-${index}`}>{part}</Fragment>;
});
}
@@ -0,0 +1,42 @@
export interface RedditUsernameLinkProps {
username: string;
showPrefix?: boolean;
}
export function normalizeRedditUsername(username: string): string {
return username.trim().replace(/^\/?u\//i, '');
}
export function isDisplayableRedditUsername(username: string): boolean {
const normalized = normalizeRedditUsername(username);
return normalized.length > 0
&& !/^(?:me|you|unknown|unknown reddit user)$/i.test(normalized)
&& !/^t2_/i.test(normalized)
&& !/^@t2_/i.test(normalized);
}
export function redditUsernameLabel(username: string, showPrefix = true): string {
if (!isDisplayableRedditUsername(username)) return 'Unknown Reddit user';
const normalized = normalizeRedditUsername(username);
return showPrefix ? `u/${normalized}` : normalized;
}
export function RedditUsernameLink({ username, showPrefix = true }: RedditUsernameLinkProps) {
const normalizedUsername = normalizeRedditUsername(username);
const label = redditUsernameLabel(username, showPrefix);
if (!isDisplayableRedditUsername(username)) {
return <span className="reddit-username-link is-unresolved">{label}</span>;
}
return (
<a
className="reddit-username-link"
href={`https://www.reddit.com/u/${encodeURIComponent(normalizedUsername)}`}
rel="noreferrer"
target="_blank"
>
{label}
</a>
);
}
@@ -44,7 +44,10 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve
return {
async list() {
return parseResponse(await request('/api/conversations'), ConversationsResponseSchema);
return parseResponse(
await request('/api/conversations?includeImageMetadata=true'),
ConversationsResponseSchema,
);
},
async detail(id) {
return parseResponse(await request(conversationPath(id)), ConversationDetailResponseSchema);
@@ -1,5 +1,7 @@
import { useCallback, useRef, useState } from 'react';
import type { ConversationDetailResponse } from '../../../../../packages/api-contracts/src/index.js';
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
import { OriginPostCard } from '../../components/origin-post.js';
import { useConversationDetailQuery, useSendReplyMutation, useSuggestMediaAskMutation, useSuggestReplyMutation } from './conversation-queries.js';
import { SendConfirmationDialog } from './send-confirmation-dialog.js';
@@ -12,6 +14,10 @@ function formatMessageTime(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
}
function mediaPath(localPath: string): string {
return `/media/${localPath.split('/').map(encodeURIComponent).join('/')}`;
}
function MessageHistory({ conversation }: { conversation: ConversationDetailResponse }) {
if (!conversation.messages || conversation.messages.length === 0) {
return <div className="conversation-empty-detail">No messages have been loaded for this conversation.</div>;
@@ -22,10 +28,22 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp
{conversation.messages.map((message) => (
<li className={message.isSelf ? 'is-self' : 'is-recipient'} key={`${message.timestamp}-${message.sender}-${message.text}`}>
<div>
<strong>{message.isSelf ? 'You' : `u/${conversation.username}`}</strong>
<strong>{message.isSelf ? 'You' : <RedditUsernameLink username={conversation.username} />}</strong>
<time dateTime={new Date(message.timestamp).toISOString()}>{formatMessageTime(message.timestamp)}</time>
</div>
<p>{message.text}</p>
{message.media?.length ? (
<div className="conversation-message-images">
{message.media.map((media) => (
<img
alt={`Image ${message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`}`}
key={`${message.timestamp}-${media.localPath}`}
loading="lazy"
src={mediaPath(media.localPath)}
/>
))}
</div>
) : null}
</li>
))}
</ol>
@@ -79,7 +97,7 @@ function ConversationComposer({ conversation }: { conversation: ConversationDeta
{mediaAskMutation.isPending ? 'Generating media ask…' : 'Suggest media ask'}
</button>
</div>
<label className="sr-only" htmlFor="conversation-reply">Reply to u/{conversation.username}</label>
<label className="sr-only" htmlFor="conversation-reply">Reply to {redditUsernameLabel(conversation.username)}</label>
<textarea
id="conversation-reply"
onChange={(event) => {
@@ -94,7 +112,7 @@ function ConversationComposer({ conversation }: { conversation: ConversationDeta
{mediaAskMutation.isError ? <div className="conversation-inline-error" role="alert">{mediaAskMutation.error.message}</div> : null}
{sendMutation.isError ? <div className="conversation-inline-error" role="alert">{sendMutation.error.message}</div> : null}
{wasSent ? (
<div aria-label="Reply sent" className="conversation-inline-success" role="status">Reply sent to u/{conversation.username}</div>
<div aria-label="Reply sent" className="conversation-inline-success" role="status">Reply sent to <RedditUsernameLink username={conversation.username} /></div>
) : null}
<div className="conversation-actions">
<span>{draft.length} characters</span>
@@ -143,7 +161,7 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
<header>
<div>
<span className="conversation-eyebrow">Conversation</span>
<h2>u/{conversation.username}</h2>
<h2><RedditUsernameLink username={conversation.username} /></h2>
{conversation.unread ? <span className="conversation-badge">Unread</span> : null}
</div>
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
@@ -154,16 +172,14 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
</div>
) : null}
{conversation.triggeringPost ? (
<a
aria-label={conversation.triggeringPost.title ?? `Open triggering post ${conversation.triggeringPost.id}`}
className="triggering-post"
href={conversation.triggeringPost.url}
rel="noreferrer"
target="_blank"
>
<span>Started from {conversation.triggeringPost.subreddit ? `r/${conversation.triggeringPost.subreddit}` : 'Reddit'}</span>
<strong>{conversation.triggeringPost.title ?? conversation.triggeringPost.id}</strong>
</a>
<div className="triggering-post">
<OriginPostCard post={{
title: conversation.triggeringPost.title ?? conversation.triggeringPost.id,
url: conversation.triggeringPost.url,
imageUrl: conversation.triggeringPost.imageUrl,
subreddit: conversation.triggeringPost.subreddit,
}} />
</div>
) : null}
<MessageHistory conversation={conversation} />
<ConversationComposer conversation={conversation} />
@@ -8,7 +8,12 @@ export function ConversationRoute() {
const filterValue = searchParams.get('filter');
const filters: ConversationFilters = {
search: searchParams.get('q') ?? '',
filter: filterValue === 'unread' || filterValue === 'awaiting-reply' ? filterValue : 'all',
filter:
filterValue === 'unread'
|| filterValue === 'awaiting-reply'
|| filterValue === 'received-images'
? filterValue
: 'all',
};
function selectConversation(nextConversationId: string | null) {
@@ -1,12 +1,13 @@
import { useMemo, useState } from 'react';
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
import { ConversationDetail } from './conversation-detail.js';
import { useConversationsQuery } from './conversation-queries.js';
import { StartConversationForm } from './start-conversation-form.js';
import { ProcessedUserLookup } from './processed-user-lookup.js';
import './conversations.css';
export type ConversationFilter = 'all' | 'unread' | 'awaiting-reply';
export type ConversationFilter = 'all' | 'unread' | 'awaiting-reply' | 'received-images';
export interface ConversationFilters {
search: string;
@@ -27,6 +28,7 @@ function conversationId(conversation: ActiveChat): string {
function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
if (filter === 'unread') return conversation.unread;
if (filter === 'awaiting-reply') return conversation.isFromUs;
if (filter === 'received-images') return conversation.hasReceivedImages === true;
return true;
}
@@ -37,17 +39,17 @@ function ConversationListItem({ conversation, isSelected, onSelect }: {
}) {
const state = conversation.unread ? 'Unread' : conversation.isFromUs ? 'Awaiting reply' : 'Up to date';
return (
<li>
<li className={isSelected ? 'conversation-list-item is-selected' : 'conversation-list-item'}>
<button
aria-label={`u/${conversation.username}, ${state}`}
aria-label={`${redditUsernameLabel(conversation.username)}, ${state}${conversation.hasReceivedImages ? ', Image received' : ''}`}
aria-pressed={isSelected}
className={isSelected ? 'conversation-list-item is-selected' : 'conversation-list-item'}
className="conversation-list-target"
onClick={onSelect}
type="button"
>
<span className="conversation-list-heading"><strong>u/{conversation.username}</strong><small>{state}</small></span>
<span>{conversation.lastMessageSnippet || 'No message preview'}</span>
</button>
/>
<span className="conversation-list-heading"><strong><RedditUsernameLink username={conversation.username} /></strong><small>{state}</small></span>
{conversation.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null}
<span>{conversation.lastMessageSnippet || 'No message preview'}</span>
</li>
);
}
@@ -121,6 +123,7 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
<option value="all">All</option>
<option value="unread">Unread</option>
<option value="awaiting-reply">Awaiting reply</option>
<option value="received-images">Received images</option>
</select>
</div>
{conversationsQuery.isPending ? (
@@ -33,12 +33,17 @@
.conversation-master, .conversation-detail-panel { min-width: 0; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; overflow: hidden; }
.conversation-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem; padding: .75rem; border-bottom: 1px solid var(--border); }
.conversation-list { display: grid; gap: .35rem; max-height: 46rem; padding: .5rem; margin: 0; overflow: auto; list-style: none; }
.conversation-list-item { display: grid; gap: .4rem; width: 100%; padding: .8rem !important; text-align: left; background: transparent !important; border-color: transparent !important; }
.conversation-list-item:hover { background: var(--surface-raised) !important; }
.conversation-list-item.is-selected { background: var(--accent-dark) !important; border-color: #28593d !important; }
.conversation-list-item { position: relative; display: grid; gap: .4rem; width: 100%; padding: .8rem; text-align: left; background: transparent; border: 1px solid transparent; border-radius: .6rem; }
.conversation-list-item:hover { background: var(--surface-raised); }
.conversation-list-item.is-selected { background: var(--accent-dark); border-color: #28593d; }
.conversation-list-target { position: absolute; inset: 0; z-index: 0; min-height: 0 !important; padding: 0 !important; background: transparent !important; border: 0 !important; }
.conversation-list-target:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.conversation-list-item > span { position: relative; z-index: 1; pointer-events: none; }
.conversation-list-item .reddit-username-link { pointer-events: auto; }
.conversation-list-item > span:last-child { overflow: hidden; color: var(--muted); font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
.conversation-list-heading { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
.conversation-list-heading small { color: var(--accent); }
.conversation-image-marker { justify-self: start; padding: .18rem .45rem; color: #d8e8ff !important; background: #18324d; border: 1px solid #315d85; border-radius: 999px; font-size: .68rem !important; font-weight: 750; }
.conversation-list-state, .conversation-detail-state { display: grid; place-items: center; align-content: center; gap: .5rem; min-height: 16rem; padding: 1.5rem; color: var(--muted); text-align: center; }
.conversation-list-state strong, .conversation-detail-state strong { color: #e9f1ec; }
.conversation-detail { min-height: 32rem; }
@@ -46,8 +51,7 @@
.conversation-detail h2 { display: inline; margin-right: .6rem; }
.conversation-badge { display: inline-flex; padding: .2rem .45rem; color: #aaf0c4; background: #163a26; border-radius: 999px; font-size: .7rem; font-weight: 800; }
.conversation-sync-notice { padding: .75rem 1rem; color: #ffe1a0; background: #2a2111; border-bottom: 1px solid #59451e; }
.triggering-post { display: grid; gap: .2rem; margin: 1rem; padding: .75rem; color: inherit; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; text-decoration: none; }
.triggering-post span { color: var(--muted); font-size: .75rem; }
.triggering-post { margin: 1rem; padding: .75rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; }
.conversation-messages { display: grid; gap: .75rem; max-height: 30rem; padding: 1rem; margin: 0; overflow: auto; list-style: none; }
.conversation-messages li { max-width: 85%; padding: .7rem .8rem; border-radius: .8rem; }
.conversation-messages li.is-self { justify-self: end; background: var(--accent-dark); }
@@ -55,6 +59,8 @@
.conversation-messages li > div { display: flex; justify-content: space-between; gap: 1rem; margin-bottom: .35rem; }
.conversation-messages time { color: var(--muted); font-size: .7rem; }
.conversation-messages p { margin: 0; line-height: 1.45; white-space: pre-wrap; }
.conversation-message-images { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(12rem, 100%), 1fr)); gap: .5rem; margin-top: .65rem; }
.conversation-message-images img { display: block; width: 100%; max-width: 28rem; max-height: 32rem; object-fit: contain; background: #08100c; border: 1px solid var(--border); border-radius: .6rem; }
.conversation-empty-detail { padding: 2rem; color: var(--muted); text-align: center; }
.conversation-composer { display: grid; gap: .65rem; padding: 1rem; border-top: 1px solid var(--border); }
.conversation-section-heading, .conversation-actions { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
import { useProcessedPostsQuery } from './conversation-queries.js';
export function ProcessedUserLookup() {
@@ -17,8 +18,8 @@ export function ProcessedUserLookup() {
{postsQuery.isPending && username ? <p role="status">Looking up processed posts…</p> : null}
{postsQuery.isError ? <p role="alert">{postsQuery.error.message}</p> : null}
{postsQuery.data ? (
postsQuery.data.length === 0 ? <p>No processed posts found for u/{username}.</p> : (
<ol aria-label={`Processed posts for u/${username}`}>
postsQuery.data.length === 0 ? <p>No processed posts found for <RedditUsernameLink username={username} />.</p> : (
<ol aria-label={`Processed posts for ${redditUsernameLabel(username)}`}>
{postsQuery.data.map((post) => <li key={post.id}><strong>{post.title}</strong><span>r/{post.subreddit} · {post.messageSent ? 'Message sent' : post.error ?? 'Processed'}</span></li>)}
</ol>
)
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { useStartConversationMutation } from './conversation-queries.js';
interface StartConversationFormProps {
@@ -53,7 +54,7 @@ export function StartConversationForm({ onStarted }: StartConversationFormProps)
{mutation.isError ? <div className="conversation-inline-error" role="alert">{mutation.error.message}</div> : null}
{successUsername ? (
<div aria-label="Conversation started" className="conversation-inline-success" role="status">
Conversation started with u/{successUsername}
Conversation started with <RedditUsernameLink username={successUsername} />
</div>
) : null}
<button className="conversation-primary" disabled={!canSubmit} type="submit">
@@ -1,4 +1,5 @@
import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { mediaConversation, mediaType } from './filters.js';
interface MediaCardProps {
@@ -29,8 +30,10 @@ export function MediaCard({ item, isRevealed, onToggleReveal, onOpenPreview }: M
</div>
<div className="media-card-body">
<div>
<strong>{item.sender}</strong>
<span>{mediaConversation(item)}</span>
<strong><RedditUsernameLink showPrefix={false} username={item.sender} /></strong>
<span>{item.chatUsername
? <RedditUsernameLink showPrefix={false} username={item.chatUsername} />
: mediaConversation(item)}</span>
</div>
<dl>
<div><dt>Type</dt><dd>{mediaType(item) === 'gif' ? 'GIF' : 'Image'}</dd></div>
+3 -3
View File
@@ -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; }
@@ -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) => (
<li key={`${item.timestamp}-${item.message}`}>
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
<div><strong>{item.message}</strong><small>{formatTimestamp(item.timestamp)}{item.recipient ? ` · u/${item.recipient}` : ''}</small></div>
<div><strong>{item.postUrl && item.postTitle ? (
<OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}>
{item.message}
</OriginPostButton>
) : <RedditUserText>{item.message}</RedditUserText>}</strong><small>{formatTimestamp(item.timestamp)}{item.recipient && !item.message.includes(`u/${item.recipient}`) ? <> · <RedditUsernameLink username={item.recipient} /></> : null}</small></div>
</li>
))}
</ol>
@@ -167,7 +174,7 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
{consoleClearNotice ? <p role="status">Cleared from this browser view. Server logs were not deleted.</p> : null}
<div aria-live="polite" className="live-console" role="log">
{logsQuery.isPending ? <p>Loading logs…</p> : visibleEntries.length === 0 ? <p>No logs match the current filters.</p> : visibleEntries.map((entry) => (
<article className={`console-entry ${entry.level}`} key={entry.id}><time dateTime={entry.occurredAt}>{formatTimestamp(entry.occurredAt)}</time><strong>{entry.level.toUpperCase()}</strong><span>{entry.message}</span></article>
<article className={`console-entry ${entry.level}`} key={entry.id}><time dateTime={entry.occurredAt}>{formatTimestamp(entry.occurredAt)}</time><strong>{entry.level.toUpperCase()}</strong><span><RedditUserText>{entry.message}</RedditUserText></span></article>
))}
<div ref={consoleEnd} />
</div>
@@ -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':
@@ -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) => (
<li key={`${item.timestamp}-${item.message}`}>
<span className={`activity-marker ${item.type}`} aria-hidden="true" />
<div><strong>{item.message}</strong><small>{formatTime(item.timestamp)}</small></div>
<div><strong>{item.postUrl && item.postTitle ? (
<OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}>
{item.message}
</OriginPostButton>
) : <RedditUserText>{item.message}</RedditUserText>}</strong><small>{formatTime(item.timestamp)}</small></div>
</li>
))}
</ol>
@@ -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<SentMessage[]> {
const response = await dashboardRequest('/api/messages');
@@ -24,7 +25,7 @@ export function RecentMessagesPanel() {
{query.data && query.data.length > 0 ? (
<ol aria-label="Recent sent messages" className="activity-list">
{query.data.map((message) => (
<li key={message.id}><div><strong>u/{message.recipient}</strong><span>{message.message}</span><small>{formatSentAt(message.sentAt)} · {message.gotResponse ? 'Responded' : 'No response recorded'}</small></div></li>
<li key={message.id}><div><strong><RedditUsernameLink username={message.recipient} /></strong><span>{message.message}</span><small>{formatSentAt(message.sentAt)} · {message.gotResponse ? 'Responded' : 'No response recorded'}</small></div></li>
))}
</ol>
) : null}
@@ -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() {
<section aria-labelledby="access-heading" className="panel settings-section">
<div className="panel-heading"><div><span className="eyebrow">Reddit session</span><h2 id="access-heading">Access</h2></div><span className={session.ready && 'status' in session && session.loggedIn ? 'pill success' : 'pill warning'}>{sessionLabel(session)}</span></div>
<p className="settings-copy">When Reddit is logged out, account work is paused and waiting for a fresh session dump from the extension.</p>
{username ? <p className="settings-copy">Signed in as <strong>u/{username}</strong>.</p> : null}
{username ? <p className="settings-copy">Signed in as <strong><RedditUsernameLink username={username} /></strong>.</p> : null}
<div className="access-actions">
<input accept="application/json" aria-label="Reddit session export" className="sr-only" onChange={(event) => void importSession(event.target.files?.[0])} ref={importInput} type="file" />
<button className="button primary" disabled={importMutation.isPending} onClick={() => importInput.current?.click()} type="button">{importMutation.isPending ? 'Importing session…' : 'Import Reddit session'}</button>
@@ -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);
@@ -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<string, unknown> | 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<string, unknown>;
} 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 {
@@ -20,6 +20,7 @@ export function useSaveSettingsMutation() {
...response.config,
subreddits: response.subreddits,
tones: current?.tones ?? [],
advanced: response.advanced ?? current?.advanced,
}));
},
});
@@ -204,6 +204,23 @@ export function BehaviourSection({ config, draft, errors, onChange }: SectionPro
</fieldset>
))}
</div>
{config.advanced ? (
<div className="advanced-settings">
<div><h3>Advanced application configuration</h3><p>Models, prompts, filtering, media sync, and strategy settings. Some changes take effect after a restart.</p></div>
<label className="settings-field">
<span>Advanced configuration (JSON)</span>
<textarea
aria-invalid={errors.advancedConfigJson ? 'true' : undefined}
aria-label="Advanced configuration JSON"
onChange={(event) => onChange({ ...draft, advancedConfigJson: event.target.value })}
rows={24}
spellCheck={false}
value={draft.advancedConfigJson}
/>
{errors.advancedConfigJson ? <small className="field-error">{errors.advancedConfigJson}</small> : null}
</label>
</div>
) : null}
</section>
);
}
+18
View File
@@ -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); }
+2 -2
View File
@@ -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"
+7 -19
View File
@@ -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
+37 -40
View File
@@ -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
+5 -12
View File
@@ -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
```
---
+22 -42
View File
@@ -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
+6 -23
View File
@@ -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
+8 -81
View File
@@ -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<boolean>
/** Perform automated login */
login(): Promise<void>
/** Import Reddit cookies from a validated extension dump */
importRedditCookies(cookies: Cookie[]): Promise<void>
/** Get page context for operations */
getPage(): Promise<Page>
@@ -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<boolean>
/** Check if recipient field shows an error */
hasRecipientError(page: Page): Promise<boolean>
}
```
### 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
---
+2
View File
@@ -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);
+2
View File
@@ -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({
+3
View File
@@ -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<typeof RuntimeConfigSchema>;
+2
View File
@@ -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(),
@@ -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);
+33 -38
View File
@@ -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<AppContainer> {
export async function bootstrap(
config: Config,
logger: Logger,
existingDatabase?: Database.Database,
): Promise<AppContainer> {
// 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<AppCont
}
// Database
const db = createDatabase(config);
const db = existingDatabase ?? createDatabase(config);
runMigrations(db);
const postsRepo = new PostsRepository(db);
const messagesRepo = new MessagesRepository(db);
@@ -186,35 +191,12 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
// or token-sniff flow.
const tokenRefresher = new MatrixTokenRefresher();
// 4. Username resolver — turns t2 IDs into real Reddit usernames so the
// dashboard isn't full of `t2_abc123`. Uses Reddit's bulk endpoint.
// 4. Username resolver — Matrix profiles expose Reddit display names using
// the same authenticated session that powers chat sync.
const matrixUsernameResolver = new MatrixUsernameResolver(activeMatrixClient);
const usernameResolver: UsernameResolver = {
async resolveT2Ids(ids) {
if (ids.length === 0) return {};
const out: Record<string, string> = {};
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<string, { name?: string }>;
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<AppCont
setMatrixClient(client) {
activeMatrixClient = client;
chatService.setMatrixClient(client);
mediaDownloader.setMatrixClient(client);
matrixUsernameResolver.setMatrixClient(client);
},
},
(credentials) => new MatrixClient(credentials, logger),
@@ -268,7 +252,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
// Stream sync-progress frames over the dashboard's SSE channel so the
// full-sync UI can show live phase + per-room progress.
let lastSyncProgress: { kind: 'full' | 'delta'; current: number; startedAt: number } | null = null;
chatService.setProgressListener((progress) => {
chatService.setProgressListener((progress, completion) => {
webServer!.broadcastSyncProgress(progress as Record<string, unknown> | null);
if (progress) {
const syncId = `chat-${progress.startedAt}`;
@@ -286,12 +270,22 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
}
lastSyncProgress = progress;
} else if (lastSyncProgress) {
broadcaster.dashboardEvents.sync({
phase: 'completed',
id: `chat-${lastSyncProgress.startedAt}`,
kind: lastSyncProgress.kind,
processed: lastSyncProgress.current,
});
const id = `chat-${lastSyncProgress.startedAt}`;
if (completion?.status === 'failed') {
broadcaster.dashboardEvents.sync({
phase: 'failed',
id,
kind: lastSyncProgress.kind,
error: completion.error,
});
} else {
broadcaster.dashboardEvents.sync({
phase: 'completed',
id,
kind: lastSyncProgress.kind,
processed: lastSyncProgress.current,
});
}
lastSyncProgress = null;
}
});
@@ -319,6 +313,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
webServer.setSessionImporter(sessionImporter);
webServer.setAccountAuth(accountAuth);
webServer.setConversationStateRepository(conversationStateRepo);
webServer.setConfigStore(new ConfigStore(db));
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
}
+6 -2
View File
@@ -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;
+20 -1
View File
@@ -157,7 +157,20 @@ export const ConfigSchema = z.object({
export type Config = z.infer<typeof ConfigSchema>;
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;
}
+73 -64
View File
@@ -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<Config, 'database' | 'openrouter' | 'web'> & {
openrouter: Omit<Config['openrouter'], 'apiKey'>;
web: Omit<Config['web'], 'authToken'>;
};
/**
* 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<string, YAML.YAMLMap>();
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<string, unknown>;
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<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
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;
}
+51 -1
View File
@@ -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<Config, 'database'>): 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;
+30 -11
View File
@@ -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<string, unknown>[];
@@ -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,
};
+8 -2
View File
@@ -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<void> {
const logger = createLogger();
@@ -11,15 +13,19 @@ async function main(): Promise<void> {
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);
}
+9 -1
View File
@@ -242,11 +242,13 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
);
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<void> {
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<void> {
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<void> {
`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);
+21
View File
@@ -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<string | null> {
const cookieHeader = await this.getCookieHeader();
const headers: Record<string, string> = {};
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<RedditPost[]> {
try {
const cookieHeader = await this.getCookieHeader();
+2
View File
@@ -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';
+46 -2
View File
@@ -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<MatrixFailure> {
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;
@@ -16,6 +16,10 @@ export class MatrixMediaDownloader implements MediaDownloader {
private logger: Logger,
) {}
setMatrixClient(client: MatrixClient): void {
this.matrix = client;
}
async fetch(url: string): Promise<MediaDownloadResult | null> {
try {
if (url.startsWith('mxc://')) {
@@ -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<Record<string, string>> {
const resolved: Record<string, string> = {};
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;
}
}
+160 -77
View File
@@ -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<Record<string, string>>;
/** Resolve a Reddit username to its t2 account ID when starting a DM. */
resolveUsernameToT2?(username: string): Promise<string | null>;
}
interface BuildChatOpts {
knownUsernames: Map<string, string>;
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<unknown> = Promise.resolve();
private deltaSyncInFlight: Promise<void> | null = null;
private readonly t2ToUsername = new Map<string, string>();
/** Rooms whose /joined_members lookup has already failed — don't retry. */
private readonly unresolvableRooms = new Set<string>();
@@ -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<void> {
await this.enqueue(async () => {
refreshTopChats(_limit = 5): Promise<void> {
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<ActiveChat[]> {
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<ActiveChat | null> {
async startConversation(
username: string,
message: string,
knownT2Id?: string,
): Promise<ActiveChat | null> {
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<Map<string, string>> {
// 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<string, MatrixJoinedRoom> = {},
): Promise<Map<string, string>> {
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<string | null> {
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<string | null> {
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`. */
+1
View File
@@ -23,6 +23,7 @@ export interface ActiveChat {
roomId?: string;
messages?: ChatMessage[];
originMessage?: string;
hasReceivedImages?: boolean;
}
/**
+11 -1
View File
@@ -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
+10 -2
View File
@@ -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);
+2
View File
@@ -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. */
+39
View File
@@ -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 }),
};
}
+59 -10
View File
@@ -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<typeof AdvancedConfigSchema> {
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<string>();
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);
+43 -20
View File
@@ -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<string>();
const receivedMediaUsers = new Set<string>();
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,
};
}
+9 -2
View File
@@ -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)}`,
};
}));
+7 -2
View File
@@ -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) => {
+13 -2
View File
@@ -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 {
+2
View File
@@ -24,6 +24,8 @@ export interface ActivityItem {
message: string;
recipient?: string;
imageUrl?: string;
postTitle?: string;
postUrl?: string;
}
export interface LogEntry {
+30
View File
@@ -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();
});
});
+22 -1
View File
@@ -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<string | null>) => 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();
});
});
+72 -1
View File
@@ -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',
});
});
});
@@ -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<MatrixMessagesResponse>((resolve) => {
finishHistory = resolve;
});
let finishUsernameResolution!: (usernames: Record<string, string>) => void;
const pendingUsernames = new Promise<Record<string, string>>((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 });
}
});
});
@@ -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);
+78 -113
View File
@@ -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;
}
});
});
+47
View File
@@ -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 })));
+56
View File
@@ -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);
+3
View File
@@ -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);
@@ -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']) {
+10
View File
@@ -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'],
@@ -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(<RedditUsernameLink username="u/example user" />);
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(<RedditUsernameLink showPrefix={false} username="alice" />);
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(<RedditUsernameLink username="@t2_private123:reddit.com" />);
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(<RedditUserText>Reply from u/alice after syncing t2_private123</RedditUserText>);
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();
});
});
+2 -2
View File
@@ -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', <ConversationRoute />);
renderRoute('/conversations?q=alice&filter=received-images', <ConversationRoute />);
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');
+24
View File
@@ -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);
+8 -2
View File
@@ -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', () => {
+61 -2
View File
@@ -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 = (() =>
@@ -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');
});
});
+50
View File
@@ -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<string, { displayname?: string }> = {
'@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');
});
});
+40
View File
@@ -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 } };
+8 -2
View File
@@ -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();
+33
View File
@@ -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',
+180
View File
@@ -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<ReturnType<express.Express['listen']>> = [];
async function request(app: express.Express, path: string): Promise<Response> {
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
return fetch(`http://127.0.0.1:${port}${path}`);
}
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((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();
});
});
+66
View File
@@ -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<ReturnType<express.Express['listen']>> = [];
async function request(app: express.Express, path: string): Promise<Response> {
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
return fetch(`http://127.0.0.1:${port}${path}`);
}
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((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' });
});
});
+41
View File
@@ -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();
});
});