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). - **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. - **Async/Await**: properly handle promises. Use `p-queue` or `p-retry` for rate-limited operations.
### 4. Test-Driven Development (TDD) — REQUIRED ### 4. Testing
All new functionality and bug fixes MUST follow the red-green-refactor cycle. This is non-negotiable. - 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.
- **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.
- Tests live in `tests/` (mirroring `src/` structure) and run via `vitest` (`npm test`). - 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. - 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 ### 5. Project Specifics
- **Tech Stack**: Node.js, TypeScript, Playwright, Express, SQLite, Vitest. - **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 ## Workflow for Agents
1. **Analyze**: Before writing code, read the file and understand the context. 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. 2. **Plan**: Identify what needs to change and check if the change violates SRP.
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. 3. **Implement**: Make the scoped change. Create new small files when SRP calls for it.
4. **Implement (Green)**: Write the smallest change that makes the test pass. 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 the test with the suite green. Re-run tests after each refactor step. 5. **Refactor**: Tidy the implementation and tests while preserving behavior.
6. **Verify**: Ensure the full suite passes (`npm test`) and types check (`npm run build` or `tsc --noEmit`). 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 { return {
async list() { async list() {
return parseResponse(await request('/api/conversations'), ConversationsResponseSchema); return parseResponse(
await request('/api/conversations?includeImageMetadata=true'),
ConversationsResponseSchema,
);
}, },
async detail(id) { async detail(id) {
return parseResponse(await request(conversationPath(id)), ConversationDetailResponseSchema); return parseResponse(await request(conversationPath(id)), ConversationDetailResponseSchema);
@@ -1,5 +1,7 @@
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
import type { ConversationDetailResponse } from '../../../../../packages/api-contracts/src/index.js'; 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 { useConversationDetailQuery, useSendReplyMutation, useSuggestMediaAskMutation, useSuggestReplyMutation } from './conversation-queries.js';
import { SendConfirmationDialog } from './send-confirmation-dialog.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); 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 }) { function MessageHistory({ conversation }: { conversation: ConversationDetailResponse }) {
if (!conversation.messages || conversation.messages.length === 0) { if (!conversation.messages || conversation.messages.length === 0) {
return <div className="conversation-empty-detail">No messages have been loaded for this conversation.</div>; 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) => ( {conversation.messages.map((message) => (
<li className={message.isSelf ? 'is-self' : 'is-recipient'} key={`${message.timestamp}-${message.sender}-${message.text}`}> <li className={message.isSelf ? 'is-self' : 'is-recipient'} key={`${message.timestamp}-${message.sender}-${message.text}`}>
<div> <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> <time dateTime={new Date(message.timestamp).toISOString()}>{formatMessageTime(message.timestamp)}</time>
</div> </div>
<p>{message.text}</p> <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> </li>
))} ))}
</ol> </ol>
@@ -79,7 +97,7 @@ function ConversationComposer({ conversation }: { conversation: ConversationDeta
{mediaAskMutation.isPending ? 'Generating media ask…' : 'Suggest media ask'} {mediaAskMutation.isPending ? 'Generating media ask…' : 'Suggest media ask'}
</button> </button>
</div> </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 <textarea
id="conversation-reply" id="conversation-reply"
onChange={(event) => { 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} {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} {sendMutation.isError ? <div className="conversation-inline-error" role="alert">{sendMutation.error.message}</div> : null}
{wasSent ? ( {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} ) : null}
<div className="conversation-actions"> <div className="conversation-actions">
<span>{draft.length} characters</span> <span>{draft.length} characters</span>
@@ -143,7 +161,7 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
<header> <header>
<div> <div>
<span className="conversation-eyebrow">Conversation</span> <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} {conversation.unread ? <span className="conversation-badge">Unread</span> : null}
</div> </div>
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button> <button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
@@ -154,16 +172,14 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
</div> </div>
) : null} ) : null}
{conversation.triggeringPost ? ( {conversation.triggeringPost ? (
<a <div className="triggering-post">
aria-label={conversation.triggeringPost.title ?? `Open triggering post ${conversation.triggeringPost.id}`} <OriginPostCard post={{
className="triggering-post" title: conversation.triggeringPost.title ?? conversation.triggeringPost.id,
href={conversation.triggeringPost.url} url: conversation.triggeringPost.url,
rel="noreferrer" imageUrl: conversation.triggeringPost.imageUrl,
target="_blank" subreddit: conversation.triggeringPost.subreddit,
> }} />
<span>Started from {conversation.triggeringPost.subreddit ? `r/${conversation.triggeringPost.subreddit}` : 'Reddit'}</span> </div>
<strong>{conversation.triggeringPost.title ?? conversation.triggeringPost.id}</strong>
</a>
) : null} ) : null}
<MessageHistory conversation={conversation} /> <MessageHistory conversation={conversation} />
<ConversationComposer conversation={conversation} /> <ConversationComposer conversation={conversation} />
@@ -8,7 +8,12 @@ export function ConversationRoute() {
const filterValue = searchParams.get('filter'); const filterValue = searchParams.get('filter');
const filters: ConversationFilters = { const filters: ConversationFilters = {
search: searchParams.get('q') ?? '', 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) { function selectConversation(nextConversationId: string | null) {
@@ -1,12 +1,13 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js'; 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 { ConversationDetail } from './conversation-detail.js';
import { useConversationsQuery } from './conversation-queries.js'; import { useConversationsQuery } from './conversation-queries.js';
import { StartConversationForm } from './start-conversation-form.js'; import { StartConversationForm } from './start-conversation-form.js';
import { ProcessedUserLookup } from './processed-user-lookup.js'; import { ProcessedUserLookup } from './processed-user-lookup.js';
import './conversations.css'; import './conversations.css';
export type ConversationFilter = 'all' | 'unread' | 'awaiting-reply'; export type ConversationFilter = 'all' | 'unread' | 'awaiting-reply' | 'received-images';
export interface ConversationFilters { export interface ConversationFilters {
search: string; search: string;
@@ -27,6 +28,7 @@ function conversationId(conversation: ActiveChat): string {
function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean { function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
if (filter === 'unread') return conversation.unread; if (filter === 'unread') return conversation.unread;
if (filter === 'awaiting-reply') return conversation.isFromUs; if (filter === 'awaiting-reply') return conversation.isFromUs;
if (filter === 'received-images') return conversation.hasReceivedImages === true;
return true; return true;
} }
@@ -37,17 +39,17 @@ function ConversationListItem({ conversation, isSelected, onSelect }: {
}) { }) {
const state = conversation.unread ? 'Unread' : conversation.isFromUs ? 'Awaiting reply' : 'Up to date'; const state = conversation.unread ? 'Unread' : conversation.isFromUs ? 'Awaiting reply' : 'Up to date';
return ( return (
<li> <li className={isSelected ? 'conversation-list-item is-selected' : 'conversation-list-item'}>
<button <button
aria-label={`u/${conversation.username}, ${state}`} aria-label={`${redditUsernameLabel(conversation.username)}, ${state}${conversation.hasReceivedImages ? ', Image received' : ''}`}
aria-pressed={isSelected} aria-pressed={isSelected}
className={isSelected ? 'conversation-list-item is-selected' : 'conversation-list-item'} className="conversation-list-target"
onClick={onSelect} onClick={onSelect}
type="button" type="button"
> />
<span className="conversation-list-heading"><strong>u/{conversation.username}</strong><small>{state}</small></span> <span className="conversation-list-heading"><strong><RedditUsernameLink username={conversation.username} /></strong><small>{state}</small></span>
<span>{conversation.lastMessageSnippet || 'No message preview'}</span> {conversation.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null}
</button> <span>{conversation.lastMessageSnippet || 'No message preview'}</span>
</li> </li>
); );
} }
@@ -121,6 +123,7 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
<option value="all">All</option> <option value="all">All</option>
<option value="unread">Unread</option> <option value="unread">Unread</option>
<option value="awaiting-reply">Awaiting reply</option> <option value="awaiting-reply">Awaiting reply</option>
<option value="received-images">Received images</option>
</select> </select>
</div> </div>
{conversationsQuery.isPending ? ( {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-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-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 { 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 { 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) !important; } .conversation-list-item:hover { background: var(--surface-raised); }
.conversation-list-item.is-selected { background: var(--accent-dark) !important; border-color: #28593d !important; } .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-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 { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
.conversation-list-heading small { color: var(--accent); } .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, .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-list-state strong, .conversation-detail-state strong { color: #e9f1ec; }
.conversation-detail { min-height: 32rem; } .conversation-detail { min-height: 32rem; }
@@ -46,8 +51,7 @@
.conversation-detail h2 { display: inline; margin-right: .6rem; } .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-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; } .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 { margin: 1rem; padding: .75rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; }
.triggering-post span { color: var(--muted); font-size: .75rem; }
.conversation-messages { display: grid; gap: .75rem; max-height: 30rem; padding: 1rem; margin: 0; overflow: auto; list-style: none; } .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 { max-width: 85%; padding: .7rem .8rem; border-radius: .8rem; }
.conversation-messages li.is-self { justify-self: end; background: var(--accent-dark); } .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 li > div { display: flex; justify-content: space-between; gap: 1rem; margin-bottom: .35rem; }
.conversation-messages time { color: var(--muted); font-size: .7rem; } .conversation-messages time { color: var(--muted); font-size: .7rem; }
.conversation-messages p { margin: 0; line-height: 1.45; white-space: pre-wrap; } .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-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-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; } .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 { useState } from 'react';
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
import { useProcessedPostsQuery } from './conversation-queries.js'; import { useProcessedPostsQuery } from './conversation-queries.js';
export function ProcessedUserLookup() { export function ProcessedUserLookup() {
@@ -17,8 +18,8 @@ export function ProcessedUserLookup() {
{postsQuery.isPending && username ? <p role="status">Looking up processed posts…</p> : null} {postsQuery.isPending && username ? <p role="status">Looking up processed posts…</p> : null}
{postsQuery.isError ? <p role="alert">{postsQuery.error.message}</p> : null} {postsQuery.isError ? <p role="alert">{postsQuery.error.message}</p> : null}
{postsQuery.data ? ( {postsQuery.data ? (
postsQuery.data.length === 0 ? <p>No processed posts found for u/{username}.</p> : ( postsQuery.data.length === 0 ? <p>No processed posts found for <RedditUsernameLink username={username} />.</p> : (
<ol aria-label={`Processed posts for u/${username}`}> <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>)} {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> </ol>
) )
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { useStartConversationMutation } from './conversation-queries.js'; import { useStartConversationMutation } from './conversation-queries.js';
interface StartConversationFormProps { 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} {mutation.isError ? <div className="conversation-inline-error" role="alert">{mutation.error.message}</div> : null}
{successUsername ? ( {successUsername ? (
<div aria-label="Conversation started" className="conversation-inline-success" role="status"> <div aria-label="Conversation started" className="conversation-inline-success" role="status">
Conversation started with u/{successUsername} Conversation started with <RedditUsernameLink username={successUsername} />
</div> </div>
) : null} ) : null}
<button className="conversation-primary" disabled={!canSubmit} type="submit"> <button className="conversation-primary" disabled={!canSubmit} type="submit">
@@ -1,4 +1,5 @@
import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js'; import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { mediaConversation, mediaType } from './filters.js'; import { mediaConversation, mediaType } from './filters.js';
interface MediaCardProps { interface MediaCardProps {
@@ -29,8 +30,10 @@ export function MediaCard({ item, isRevealed, onToggleReveal, onOpenPreview }: M
</div> </div>
<div className="media-card-body"> <div className="media-card-body">
<div> <div>
<strong>{item.sender}</strong> <strong><RedditUsernameLink showPrefix={false} username={item.sender} /></strong>
<span>{mediaConversation(item)}</span> <span>{item.chatUsername
? <RedditUsernameLink showPrefix={false} username={item.chatUsername} />
: mediaConversation(item)}</span>
</div> </div>
<dl> <dl>
<div><dt>Type</dt><dd>{mediaType(item) === 'gif' ? 'GIF' : 'Image'}</dd></div> <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 dt { color: var(--muted); }
.media-card-body dd { margin: 0; text-align: right; } .media-card-body dd { margin: 0; text-align: right; }
.media-reveal { width: 100%; } .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-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: grid; place-items: center; width: min(100%, 90rem); height: min(100%, 90rem); } .media-preview-dialog-content { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
.media-preview-dialog-image { display: block; max-width: 100%; max-height: 100%; object-fit: contain; } .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 { 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; } .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 { useEffect, useReducer, useRef, useState } from 'react';
import type { ActivityItem, ChatSyncStatus, DashboardEvent } from '../../../../../packages/api-contracts/src/index.js'; import type { ActivityItem, ChatSyncStatus, DashboardEvent } from '../../../../../packages/api-contracts/src/index.js';
import { useDashboardRealtime } from '../../app/realtime-provider.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 { import {
createLiveConsoleState, createLiveConsoleState,
reduceLiveConsoleState, reduceLiveConsoleState,
@@ -27,7 +30,11 @@ function ActivityView({ items }: { items: readonly ActivityItem[] }) {
{items.map((item) => ( {items.map((item) => (
<li key={`${item.timestamp}-${item.message}`}> <li key={`${item.timestamp}-${item.message}`}>
<span className={`activity-marker ${item.type}`} aria-hidden="true" /> <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> </li>
))} ))}
</ol> </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} {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"> <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) => ( {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 ref={consoleEnd} />
</div> </div>
@@ -124,9 +124,11 @@ export function queryKeysForDashboardEvent(event: DashboardEvent): readonly Quer
return [['conversations'], ['operations', 'activity']]; return [['conversations'], ['operations', 'activity']];
case 'sync.started': case 'sync.started':
case 'sync.progress': case 'sync.progress':
return [['operations', 'sync-status']];
case 'sync.completed': case 'sync.completed':
case 'sync.failed':
return [['operations', 'sync-status'], ['conversations'], ['media']]; return [['operations', 'sync-status'], ['conversations'], ['media']];
case 'sync.failed':
return [['operations', 'sync-status']];
case 'reddit.session.changed': case 'reddit.session.changed':
return [['dashboard', 'login', 'status']]; return [['dashboard', 'login', 'status']];
case 'log.created': case 'log.created':
@@ -3,6 +3,8 @@ import { useQueryClient } from '@tanstack/react-query';
import { dashboardQueryKeys, useSetRunningMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js'; import { dashboardQueryKeys, useSetRunningMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js';
import type { BotStats, Timers } from '../../api/contracts.js'; import type { BotStats, Timers } from '../../api/contracts.js';
import { ConfirmationDialog } from '../../components/confirmation-dialog.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'; import { RecentMessagesPanel } from './recent-messages.js';
function errorMessage(error: Error | null): string { function errorMessage(error: Error | null): string {
@@ -71,7 +73,11 @@ function ActivityPanel({ stats }: { stats: BotStats }) {
{stats.recentActivity.map((item) => ( {stats.recentActivity.map((item) => (
<li key={`${item.timestamp}-${item.message}`}> <li key={`${item.timestamp}-${item.message}`}>
<span className={`activity-marker ${item.type}`} aria-hidden="true" /> <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> </li>
))} ))}
</ol> </ol>
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { MessagesResponseSchema, type SentMessage } from '../../../../../packages/api-contracts/src/index.js'; import { MessagesResponseSchema, type SentMessage } from '../../../../../packages/api-contracts/src/index.js';
import { dashboardRequest } from '../../api/request.js'; import { dashboardRequest } from '../../api/request.js';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
async function loadRecentMessages(): Promise<SentMessage[]> { async function loadRecentMessages(): Promise<SentMessage[]> {
const response = await dashboardRequest('/api/messages'); const response = await dashboardRequest('/api/messages');
@@ -24,7 +25,7 @@ export function RecentMessagesPanel() {
{query.data && query.data.length > 0 ? ( {query.data && query.data.length > 0 ? (
<ol aria-label="Recent sent messages" className="activity-list"> <ol aria-label="Recent sent messages" className="activity-list">
{query.data.map((message) => ( {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> </ol>
) : null} ) : null}
@@ -1,4 +1,5 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { useImportRedditSessionMutation, useLoginStatusQuery } from './settings-queries.js'; import { useImportRedditSessionMutation, useLoginStatusQuery } from './settings-queries.js';
function errorMessage(error: Error | null): string { function errorMessage(error: Error | null): string {
@@ -52,7 +53,7 @@ export function AccessPanel() {
<section aria-labelledby="access-heading" className="panel settings-section"> <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> <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> <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"> <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" /> <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> <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, ...response.config,
subreddits: response.subreddits, subreddits: response.subreddits,
tones: config.tones, tones: config.tones,
advanced: response.advanced ?? config.advanced,
}; };
const nextDraft = createSettingsDraft(nextConfig); const nextDraft = createSettingsDraft(nextConfig);
setPersistedDraft(nextDraft); setPersistedDraft(nextDraft);
@@ -23,6 +23,7 @@ export interface SettingsDraft {
prioritizeRecentPosts: boolean; prioritizeRecentPosts: boolean;
recentWindowMinutes: string; recentWindowMinutes: string;
subreddits: SubredditDraft[]; subreddits: SubredditDraft[];
advancedConfigJson: string;
} }
export interface ValidatedSettings { export interface ValidatedSettings {
@@ -71,6 +72,7 @@ export function createSettingsDraft(config: ConfigResponse): SettingsDraft {
tone: subreddit.tone, tone: subreddit.tone,
scripted: subreddit.scripted ?? '', 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 }; 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({ const parsed = ValidatedSettingsSchema.safeParse({
minDelayMinutes: Number(draft.minDelayMinutes), minDelayMinutes: Number(draft.minDelayMinutes),
maxMessagesPerHour: Number(draft.maxMessagesPerHour), maxMessagesPerHour: Number(draft.maxMessagesPerHour),
@@ -125,6 +140,7 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
recentWindowMinutes: parsed.data.recentWindowMinutes, recentWindowMinutes: parsed.data.recentWindowMinutes,
}, },
subreddits: parsed.data.subreddits, subreddits: parsed.data.subreddits,
...(advanced ? { advanced } : {}),
}); });
return { return {
@@ -20,6 +20,7 @@ export function useSaveSettingsMutation() {
...response.config, ...response.config,
subreddits: response.subreddits, subreddits: response.subreddits,
tones: current?.tones ?? [], tones: current?.tones ?? [],
advanced: response.advanced ?? current?.advanced,
})); }));
}, },
}); });
@@ -204,6 +204,23 @@ export function BehaviourSection({ config, draft, errors, onChange }: SectionPro
</fieldset> </fieldset>
))} ))}
</div> </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> </section>
); );
} }
+18
View File
@@ -20,6 +20,8 @@ button, input { font: inherit; }
button { cursor: pointer; } button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; } button:disabled { cursor: wait; opacity: .6; }
a { color: inherit; } 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 { position: fixed; left: 1rem; top: -5rem; z-index: 100; background: white; color: black; padding: .75rem 1rem; border-radius: .5rem; }
.skip-link:focus { top: 1rem; } .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 { display: flex; align-items: flex-start; gap: .75rem; }
.activity-list li div { display: grid; gap: .15rem; } .activity-list li div { display: grid; gap: .15rem; }
.activity-list small { color: var(--muted); } .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 { 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.sent, .activity-marker.response { background: var(--accent); }
.activity-marker.error { background: var(--danger); } .activity-marker.error { background: var(--danger); }
+2 -2
View File
@@ -761,8 +761,8 @@ GET /api/config
"jitterMs": 15000 "jitterMs": 15000
}, },
"browser": { "browser": {
"headless": true, "sessionDir": "./data/browser-session",
"userDataDir": "./data/browser" "dryRun": false
}, },
"database": { "database": {
"path": "./data/reddit-bot.db" "path": "./data/reddit-bot.db"
+7 -19
View File
@@ -182,8 +182,7 @@ RedditBot/
├── ai/ ├── ai/
│ └── openrouter.ts # OpenRouter AI client (image analysis, message gen) (307 lines) │ └── openrouter.ts # OpenRouter AI client (image analysis, message gen) (307 lines)
├── browser/ ├── browser/
│ ├── client.ts # Playwright persistent context + login (302 lines) │ ├── client.ts # Managed Playwright context + imported cookies
│ ├── message-page.ts # DM compose page automation + shadow DOM (560 lines)
│ └── media-downloader.ts # Playwright-based media downloader (29 lines) │ └── media-downloader.ts # Playwright-based media downloader (29 lines)
├── services/ ├── services/
│ ├── monitor.ts # Post discovery service (72 lines) │ ├── monitor.ts # Post discovery service (72 lines)
@@ -319,14 +318,7 @@ Playwright persistent context manager.
- Browser session persistence (cookies/storage) - Browser session persistence (cookies/storage)
- Login verification (multiple UI checks + API fallback) - Login verification (multiple UI checks + API fallback)
- Automated login flow (form fill, 2FA/captcha detection) - Extension-dump cookie import; no login form automation
- Debug screenshot capture on login failure
#### MessagePage (`src/browser/message-page.ts`)
Reddit DM compose page automation.
- Fills recipient/subject/body fields
- Extensive Shadow DOM support for `faceplate-*` components - Extensive Shadow DOM support for `faceplate-*` components
- Detects recipient errors, verifies submission success - Detects recipient errors, verifies submission success
- Supports dry-run mode for testing - 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) 1. **`config.yaml`** - Primary configuration file (project root)
2. **Environment Variables** - Override specific fields: 2. **Environment Variables** - Override specific fields:
- `OPENROUTER_API_KEY` - OpenRouter API key - `OPENROUTER_API_KEY` - OpenRouter API key
- `REDDIT_USERNAME` - Reddit username - `WEB_DASHBOARD_TOKEN` - Dashboard access token
- `REDDIT_PASSWORD` - Reddit password
- `BROWSER_HEADLESS` - Browser headless mode (`true`/`false`)
### Configuration Schema (Zod-Validated) ### 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 jitterMs: number // Random jitter range
} }
browser: { browser: {
headless: boolean // Headless mode sessionDir: string // Persistent imported session directory
userDataDir: string // Persistent session directory dryRun: boolean // Prevent outbound delivery
} }
database: { database: {
path: string // SQLite database path path: string // SQLite database path
@@ -866,7 +856,7 @@ class RateLimiter {
The project includes full Docker support: 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 - **`docker-compose.yml`** - Service orchestration
- **`.dockerignore`** - Excludes unnecessary files - **`.dockerignore`** - Excludes unnecessary files
@@ -879,9 +869,7 @@ Deployment documentation is provided in `DOKPLOY_DEPLOY.md`.
| Variable | Required | Description | | Variable | Required | Description |
|---|---|---| |---|---|---|
| `OPENROUTER_API_KEY` | Yes | OpenRouter API key for AI | | `OPENROUTER_API_KEY` | Yes | OpenRouter API key for AI |
| `REDDIT_USERNAME` | Yes | Reddit username | | `WEB_DASHBOARD_TOKEN` | Yes when dashboard enabled | Dashboard access token |
| `REDDIT_PASSWORD` | Yes | Reddit password |
| `BROWSER_HEADLESS` | No | Browser headless mode (default: `true`) |
### Pre-Deployment Checklist ### Pre-Deployment Checklist
+37 -40
View File
@@ -7,9 +7,9 @@ Complete reference for configuring RedditBot.
## Table of Contents ## Table of Contents
1. [Configuration Overview](#configuration-overview) 1. [Configuration Overview](#configuration-overview)
2. [Configuration Files](#configuration-files) 2. [Configuration Storage](#configuration-storage)
3. [Environment Variables](#environment-variables) 3. [Environment Variables](#environment-variables)
4. [config.yaml Reference](#configyaml-reference) 4. [Application Settings](#application-settings)
5. [Runtime Configuration](#runtime-configuration) 5. [Runtime Configuration](#runtime-configuration)
6. [Configuration Validation](#configuration-validation) 6. [Configuration Validation](#configuration-validation)
7. [Troubleshooting](#troubleshooting) 7. [Troubleshooting](#troubleshooting)
@@ -25,7 +25,7 @@ RedditBot uses a layered configuration approach:
│ Priority Order (High → Low) │ │ Priority Order (High → Low) │
├──────────────────────────────────────────────────────────────┤ ├──────────────────────────────────────────────────────────────┤
│ 1. Environment Variables │ │ 1. Environment Variables │
│ (OPENROUTER_API_KEY, REDDIT_USERNAME, etc.) │ │ (OPENROUTER_API_KEY, WEB_DASHBOARD_TOKEN) │
│ │ │ │
│ 2. config.yaml │ │ 2. config.yaml │
│ (Primary configuration file) │ │ (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: Configuration is loaded at startup by `loadConfig()` in `src/config/index.ts`, which:
1. Reads `config.yaml` from the project root 1. Reads the singleton `app_config` record from SQLite
2. Merges environment variable overrides 2. Imports `config.yaml` only when upgrading an existing database that has no settings record
3. Validates the entire configuration tree using Zod 3. Validates the entire configuration tree using Zod
4. Returns a strongly-typed `Config` object 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/ RedditBot/
@@ -79,14 +81,13 @@ data/
## Environment Variables ## 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 | | Variable | Required | Type | Description | Example |
|---|---|---|---|---| |---|---|---|---|---|
| `OPENROUTER_API_KEY` | Yes | string | OpenRouter API key for AI services | `sk-or-v1-abc123...` | | `OPENROUTER_API_KEY` | Yes | string | OpenRouter API key for AI services | `sk-or-v1-abc123...` |
| `REDDIT_USERNAME` | Yes | string | Reddit username for authentication | `MyBotUser` | | `WEB_DASHBOARD_TOKEN` | Yes when dashboard enabled | string | Dashboard access token | `long-random-secret` |
| `REDDIT_PASSWORD` | Yes | string | Reddit password for authentication | `secret123` | | `DATABASE_PATH` | No | string | SQLite database location | `./data/reddit-bot.db` |
| `BROWSER_HEADLESS` | No | boolean | Run browser in headless mode | `true` or `false` |
### Setting Environment Variables ### Setting Environment Variables
@@ -96,9 +97,7 @@ Create a `.env` file in the project root:
```env ```env
OPENROUTER_API_KEY=sk-or-v1-abc123... OPENROUTER_API_KEY=sk-or-v1-abc123...
REDDIT_USERNAME=MyBotUser WEB_DASHBOARD_TOKEN=long-random-secret
REDDIT_PASSWORD=secret123
BROWSER_HEADLESS=true
``` ```
> **Note**: Add `.env` to `.gitignore` - never commit credentials. > **Note**: Add `.env` to `.gitignore` - never commit credentials.
@@ -107,8 +106,7 @@ BROWSER_HEADLESS=true
```bash ```bash
export OPENROUTER_API_KEY=sk-or-v1-abc123... export OPENROUTER_API_KEY=sk-or-v1-abc123...
export REDDIT_USERNAME=MyBotUser export WEB_DASHBOARD_TOKEN=long-random-secret
export REDDIT_PASSWORD=secret123
``` ```
**Option 3: Docker** **Option 3: Docker**
@@ -118,13 +116,16 @@ Pass via `docker-compose.yml` or Docker CLI:
```yaml ```yaml
environment: environment:
- OPENROUTER_API_KEY=sk-or-v1-abc123... - OPENROUTER_API_KEY=sk-or-v1-abc123...
- REDDIT_USERNAME=MyBotUser - WEB_DASHBOARD_TOKEN=long-random-secret
- REDDIT_PASSWORD=secret123
``` ```
--- ---
## 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. Complete reference for every configuration option.
@@ -235,13 +236,9 @@ Max messages: 20 per hour
```yaml ```yaml
browser: browser:
# Run browser in headless mode (no UI) # Persistent managed-browser data populated only by verified imports
# Set to false for debugging sessionDir: "./data/browser-session"
headless: true dryRun: false
# Directory for persistent browser data
# Cookies and session storage are saved here
userDataDir: "./data/browser"
``` ```
**Browser session persistence:** **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.minDelayMs` | Must be positive integer |
| `rateLimit.maxMessagesPerHour` | Must be positive integer | | `rateLimit.maxMessagesPerHour` | Must be positive integer |
| `rateLimit.jitterMs` | Must be non-negative integer | | `rateLimit.jitterMs` | Must be non-negative integer |
| `browser.headless` | Must be boolean | | `browser.sessionDir` | Must be non-empty string |
| `browser.userDataDir` | Must be non-empty string | | `browser.dryRun` | Must be boolean |
| `database.path` | Must be non-empty string | | `database.path` | Must be non-empty string |
| `mediaSync.enabled` | Must be boolean | | `mediaSync.enabled` | Must be boolean |
| `mediaSync.intervalMinutes` | Must be positive integer (if enabled) | | `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` **Error**: `Invalid config: reddit.subreddits must be a non-empty array`
**Fix**: Add at least one subreddit to `reddit.subreddits` in `config.yaml` **Fix**: Add at least one subreddit to `reddit.subreddits` in `config.yaml`
**Error**: `Invalid config: browser.userDataDir must be a string` **Error**: `Invalid config: browser.sessionDir must be a string`
**Fix**: Ensure `browser.userDataDir` is set in `config.yaml` **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 3. Verify environment variables are set correctly
4. Check bot logs for specific validation errors 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**: **Steps**:
1. Verify `REDDIT_USERNAME` and `REDDIT_PASSWORD` are correct 1. Sign in to Reddit in local Chrome
2. Check that the account is not locked or requires 2FA 2. Open Reddit Chat and wait for the extension badge to show `OK`
3. Try manual login via `npm run login` 3. Export and upload a fresh combined session dump
4. Ensure `BROWSER_HEADLESS=false` for debugging login issues 4. Check dashboard logs for Reddit or Matrix identity verification errors
### AI Not Working ### 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. **Symptom**: Bot stops with "Browser session expired" error.
**Steps**: **Steps**:
1. Delete `data/browser/` directory 1. Open Reddit Chat in the local Chrome profile containing the extension
2. Run `npm run login` to re-authenticate 2. Export and upload a fresh session dump
3. Restart the bot 3. Delete the downloaded dump after verification
### Port Already in Use ### 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 └─► 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 ├─► Invalidate the shared account-auth gate
│ ├─► Stop account-bound schedulers
├─► Attempt automated re-login └─► Wait for a fresh combined dump from the Chrome extension
│ ├─► BrowserClient.login()
│ └─► If success: continue
│
└─► If re-login fails:
├─► Log error: "Browser session expired"
├─► Stop bot
└─► Require manual login (scripts/login.ts)
``` ```
--- ---
+22 -42
View File
@@ -74,8 +74,8 @@ rateLimit:
jitterMs: 15000 jitterMs: 15000
browser: browser:
headless: true sessionDir: "./data/browser-session"
userDataDir: "./data/browser" dryRun: true
database: database:
path: "./data/reddit-bot.db" path: "./data/reddit-bot.db"
@@ -89,23 +89,13 @@ dryRun: true # Start in dry run mode
Set environment variables: Set environment variables:
```bash ```bash
export REDDIT_USERNAME="your-username" export OPENROUTER_API_KEY="sk-or-v1-your-key"
export REDDIT_PASSWORD="your-password" export WEB_DASHBOARD_TOKEN="long-random-secret"
``` ```
### 3. Initial Login ### 3. Import account session
Run the login script to establish a browser session: 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.
```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/`
### 4. Start the Bot ### 4. Start the Bot
@@ -144,23 +134,19 @@ docker run -d \
-p 3000:3000 \ -p 3000:3000 \
-v $(pwd)/data:/app/data \ -v $(pwd)/data:/app/data \
-e OPENROUTER_API_KEY=sk-or-v1-your-key \ -e OPENROUTER_API_KEY=sk-or-v1-your-key \
-e REDDIT_USERNAME=your-username \ -e WEB_DASHBOARD_TOKEN=long-random-secret \
-e REDDIT_PASSWORD=your-password \
-e BROWSER_HEADLESS=true \
reddit-bot:latest reddit-bot:latest
``` ```
### Dockerfile behavior ### Dockerfile behavior
The image is based on the Playwright Jammy image rather than Alpine because the The image is based on the Playwright Jammy image because the remaining
runtime needs Chromium and its system libraries. It installs Xvfb, x11vnc, authenticated browser operations need Chromium and its system libraries. It
noVNC, Fluxbox, and Websockify for interactive Reddit login; installs the locked does not install a display server, VNC, or consumer Google Chrome.
Node dependencies; copies the repository; and runs `npm run build`.
That build produces both `dist/` for the Express server and That build produces both `dist/` for the Express server and
`apps/dashboard/dist/` for Vite assets. Ports 3000 (dashboard/API) and 6080 `apps/dashboard/dist/` for Vite assets. Port 3000 serves the dashboard/API and
(noVNC) are exposed, and `docker/entrypoint.sh` starts the display services and the container starts the Node process directly. This is a single-stage image and retains development dependencies
application. This is a single-stage image and retains development dependencies
needed by the current build/runtime layout. needed by the current build/runtime layout.
--- ---
@@ -182,9 +168,7 @@ services:
- ./data:/app/data - ./data:/app/data
environment: environment:
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY} - OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
- REDDIT_USERNAME=${REDDIT_USERNAME} - WEB_DASHBOARD_TOKEN=${WEB_DASHBOARD_TOKEN}
- REDDIT_PASSWORD=${REDDIT_PASSWORD}
- BROWSER_HEADLESS=true
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "wget", "--spider", "http://localhost:3000/api/stats"] 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 1. **Create Application**: In Dokploy dashboard, create a new Docker application
2. **Set Environment Variables**: 2. **Set Environment Variables**:
- `OPENROUTER_API_KEY` - `OPENROUTER_API_KEY`
- `REDDIT_USERNAME` - `WEB_DASHBOARD_TOKEN`
- `REDDIT_PASSWORD`
- `BROWSER_HEADLESS`
3. **Mount Volume**: Mount `./data` for persistent storage 3. **Mount Volume**: Mount `./data` for persistent storage
4. **Set Port**: Map container port 3000 to host port 4. **Set Port**: Map container port 3000 to host port
5. **Deploy**: Push to repository and trigger deployment 5. **Deploy**: Push to repository and trigger deployment
@@ -244,9 +226,7 @@ app:
environment: environment:
OPENROUTER_API_KEY: $OPENROUTER_API_KEY OPENROUTER_API_KEY: $OPENROUTER_API_KEY
REDDIT_USERNAME: $REDDIT_USERNAME WEB_DASHBOARD_TOKEN: $WEB_DASHBOARD_TOKEN
REDDIT_PASSWORD: $REDDIT_PASSWORD
BROWSER_HEADLESS: "true"
ports: ports:
- container: 3000 - container: 3000
@@ -276,7 +256,7 @@ app:
### Performance ### 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 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 3. **Rate Limiting**: Adjust based on Reddit account age and trust level
4. **Message Volume**: Start low (10-20/hour) and increase gradually 4. **Message Volume**: Start low (10-20/hour) and increase gradually
@@ -359,15 +339,15 @@ docker compose start
3. Verify environment variables are set 3. Verify environment variables are set
4. Check for Zod validation errors in logs 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**: **Steps**:
1. Delete browser data: `rm -rf data/browser/` 1. Sign in to Reddit in local Chrome
2. Re-run login: `npm run login` 2. Open Reddit Chat and wait for the extension badge to show `OK`
3. Check Reddit account is not locked 3. Export and upload a new combined session dump
4. Try `BROWSER_HEADLESS=false` for debugging 4. Check logs for Reddit or Matrix identity errors
### AI Analysis Fails ### AI Analysis Fails
+6 -23
View File
@@ -45,8 +45,7 @@ RedditBot/
├── ai/ ├── ai/
│ └── openrouter.ts # OpenRouter AI client │ └── openrouter.ts # OpenRouter AI client
├── browser/ ├── browser/
│ ├── client.ts # Playwright browser manager │ ├── client.ts # Managed Playwright context
│ ├── message-page.ts # DM compose automation
│ └── media-downloader.ts # Media download │ └── media-downloader.ts # Media download
├── services/ ├── services/
│ ├── monitor.ts # Post discovery │ ├── monitor.ts # Post discovery
@@ -87,17 +86,13 @@ cp config.yaml config.yaml.backup # Backup existing
### 3. Set Environment Variables ### 3. Set Environment Variables
```bash ```bash
export REDDIT_USERNAME="your-username"
export REDDIT_PASSWORD="your-password"
export OPENROUTER_API_KEY="sk-or-v1-your-key" export OPENROUTER_API_KEY="sk-or-v1-your-key"
export WEB_DASHBOARD_TOKEN="long-random-secret"
``` ```
### 4. Initial Login ### 4. Account authentication
```bash Start the app, then import a combined Reddit and Matrix session dump from the local Chrome extension under **Settings > Access**.
# Open browser for manual login
npm run login
```
### 5. Start Development Server ### 5. Start Development Server
@@ -357,21 +352,9 @@ curl http://localhost:3000/api/analytics/by-strategy
## Debugging ## Debugging
### Browser Debugging ### Account debugging
Run with visible browser: Use dashboard session status and structured logs. The backend does not expose or launch an interactive login browser.
```yaml
# config.yaml
browser:
headless: false
```
Or set environment variable:
```bash
BROWSER_HEADLESS=false npm run dev
```
### DOM Inspection ### DOM Inspection
+8 -81
View File
@@ -690,7 +690,7 @@ Generate a similar message for this image..."
### Purpose ### 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 ### Public Methods
@@ -704,8 +704,8 @@ class BrowserClient {
/** Verify browser session is active */ /** Verify browser session is active */
verifySession(): Promise<boolean> verifySession(): Promise<boolean>
/** Perform automated login */ /** Import Reddit cookies from a validated extension dump */
login(): Promise<void> importRedditCookies(cookies: Cookie[]): Promise<void>
/** Get page context for operations */ /** Get page context for operations */
getPage(): Promise<Page> getPage(): Promise<Page>
@@ -715,93 +715,20 @@ class BrowserClient {
} }
``` ```
### Login Flow ### Import Flow
``` 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.
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
```
### Session Persistence ### Session Persistence
- Cookies and storage are saved to `config.browser.userDataDir` - Cookies and storage are saved to `config.browser.userDataDir`
- Subsequent launches restore the session automatically - 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 ### Dependencies
- `Config` - Credentials, headless mode, user data dir - `Config` - Persistent session directory
- `Logger` - Login progress, errors - `Logger` - Import verification progress and 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
--- ---
+2
View File
@@ -15,6 +15,8 @@ export const ActivityItemSchema = z.strictObject({
message: z.string(), message: z.string(),
recipient: z.string().optional(), recipient: z.string().optional(),
imageUrl: z.string().optional(), imageUrl: z.string().optional(),
postTitle: z.string().optional(),
postUrl: z.string().optional(),
}); });
export const ActivityResponseSchema = z.array(ActivityItemSchema); export const ActivityResponseSchema = z.array(ActivityItemSchema);
+2
View File
@@ -24,6 +24,7 @@ export const ActiveChatSchema = z.strictObject({
roomId: z.string().optional(), roomId: z.string().optional(),
messages: z.array(ChatMessageSchema).optional(), messages: z.array(ChatMessageSchema).optional(),
originMessage: z.string().optional(), originMessage: z.string().optional(),
hasReceivedImages: z.boolean().optional(),
}); });
export const ChatsResponseSchema = z.array(ActiveChatSchema); export const ChatsResponseSchema = z.array(ActiveChatSchema);
export const ConversationsResponseSchema = ChatsResponseSchema; export const ConversationsResponseSchema = ChatsResponseSchema;
@@ -62,6 +63,7 @@ export const ConversationDetailResponseSchema = ActiveChatSchema.extend({
subreddit: z.string().optional(), subreddit: z.string().optional(),
title: z.string().optional(), title: z.string().optional(),
url: z.string(), url: z.string(),
imageUrl: z.string().nullable().optional(),
sentAt: z.number(), sentAt: z.number(),
}).nullable(), }).nullable(),
syncInProgress: z.strictObject({ syncInProgress: z.strictObject({
+3
View File
@@ -35,17 +35,20 @@ export const SubredditConfigSchema = z.strictObject({
export const ConfigResponseSchema = RuntimeConfigSchema.extend({ export const ConfigResponseSchema = RuntimeConfigSchema.extend({
subreddits: z.array(SubredditConfigSchema), subreddits: z.array(SubredditConfigSchema),
tones: z.array(ToneKeySchema), tones: z.array(ToneKeySchema),
advanced: z.record(z.string(), z.unknown()).optional(),
}); });
export const ConfigUpdateRequestSchema = z.strictObject({ export const ConfigUpdateRequestSchema = z.strictObject({
rateLimit: RateLimitConfigSchema.partial().optional(), rateLimit: RateLimitConfigSchema.partial().optional(),
browser: z.strictObject({ dryRun: z.boolean().optional() }).optional(), browser: z.strictObject({ dryRun: z.boolean().optional() }).optional(),
reddit: RuntimeRedditConfigSchema.partial().optional(), reddit: RuntimeRedditConfigSchema.partial().optional(),
subreddits: z.array(SubredditConfigSchema).min(1).optional(), subreddits: z.array(SubredditConfigSchema).min(1).optional(),
advanced: z.record(z.string(), z.unknown()).optional(),
}); });
export const ConfigUpdateResponseSchema = z.strictObject({ export const ConfigUpdateResponseSchema = z.strictObject({
success: z.literal(true), success: z.literal(true),
config: RuntimeConfigSchema, config: RuntimeConfigSchema,
subreddits: z.array(SubredditConfigSchema), subreddits: z.array(SubredditConfigSchema),
advanced: z.record(z.string(), z.unknown()).optional(),
}); });
export type RuntimeConfig = z.infer<typeof RuntimeConfigSchema>; export type RuntimeConfig = z.infer<typeof RuntimeConfigSchema>;
+2
View File
@@ -13,6 +13,7 @@ export const SentMessageSchema = z.strictObject({
chatRoomId: z.string().nullable(), chatRoomId: z.string().nullable(),
subreddit: z.string().optional(), subreddit: z.string().optional(),
postTitle: z.string().optional(), postTitle: z.string().optional(),
postUrl: z.string().optional(),
postImageUrl: z.string().optional(), postImageUrl: z.string().optional(),
postImageAnalysis: z.string().optional(), postImageAnalysis: z.string().optional(),
}); });
@@ -23,6 +24,7 @@ export const ProcessedPostSchema = z.strictObject({
subreddit: z.string(), subreddit: z.string(),
author: z.string(), author: z.string(),
title: z.string(), title: z.string(),
postUrl: z.string().nullable().optional(),
imageUrl: z.string().nullable(), imageUrl: z.string().nullable(),
imageAnalysis: z.string().nullable(), imageAnalysis: z.string().nullable(),
generatedMessage: z.string().nullable(), generatedMessage: z.string().nullable(),
@@ -106,6 +106,7 @@ describe('dashboard endpoint contracts', () => {
const processedPost = { const processedPost = {
id: 'post-1', subreddit: 'example', author: 'alice', title: 'Example', id: 'post-1', subreddit: 'example', author: 'alice', title: 'Example',
imageUrl: null, imageAnalysis: null, generatedMessage: 'Hello', 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, processedAt: 1_700_000_000_000, messageSent: true, error: null,
}; };
expect(ProcessedPostsResponseSchema.safeParse([processedPost]).success).toBe(true); expect(ProcessedPostsResponseSchema.safeParse([processedPost]).success).toBe(true);
@@ -136,7 +137,7 @@ describe('dashboard endpoint contracts', () => {
expect(ConfigResponseSchema.parse(payload)).toEqual(payload); expect(ConfigResponseSchema.parse(payload)).toEqual(payload);
expect(ConfigResponseSchema.safeParse({ ...payload, tones: [1] }).success).toBe(false); expect(ConfigResponseSchema.safeParse({ ...payload, tones: [1] }).success).toBe(false);
expect(ConfigUpdateRequestSchema.safeParse({ expect(ConfigUpdateRequestSchema.safeParse({
rateLimit: { maxMessagesPerHour: 10 }, browser: { dryRun: true }, rateLimit: { maxMessagesPerHour: 10 }, browser: { dryRun: true }, advanced: { media: { syncEnabled: false } },
}).success).toBe(true); }).success).toBe(true);
expect(ConfigUpdateRequestSchema.safeParse({ browser: { dryRun: 'yes' } }).success).toBe(false); expect(ConfigUpdateRequestSchema.safeParse({ browser: { dryRun: 'yes' } }).success).toBe(false);
}); });
@@ -160,7 +161,7 @@ describe('dashboard endpoint contracts', () => {
...chat, ...chat,
triggeringPost: { triggeringPost: {
id: 'post-1', subreddit: 'example', title: 'Example', 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, syncInProgress: null,
}).success).toBe(true); }).success).toBe(true);
+33 -38
View File
@@ -24,6 +24,7 @@ import {
ChatService, ChatService,
MatrixClient, MatrixClient,
MatrixMediaDownloader, MatrixMediaDownloader,
MatrixUsernameResolver,
MatrixTokenRefresher, MatrixTokenRefresher,
loadCachedCredentials, loadCachedCredentials,
saveCredentials, saveCredentials,
@@ -33,11 +34,11 @@ import {
import { MediaService } from './services/media.js'; import { MediaService } from './services/media.js';
import { MediaSyncService } from './services/media-sync.js'; import { MediaSyncService } from './services/media-sync.js';
import { RateLimiter } from './utils/rate-limiter.js'; import { RateLimiter } from './utils/rate-limiter.js';
import { fetchWithTimeout } from './utils/fetch-with-timeout.js';
import { WebServer } from './web/server.js'; import { WebServer } from './web/server.js';
import { reportReply } from './web/event-reporting.js'; import { reportReply } from './web/event-reporting.js';
import { RedditSessionImporter } from './services/reddit-session-importer.js'; import { RedditSessionImporter } from './services/reddit-session-importer.js';
import { AccountAuthContext } from './services/account-auth-context.js'; import { AccountAuthContext } from './services/account-auth-context.js';
import { ConfigStore } from './config/persistence.js';
export interface AppContainer { export interface AppContainer {
config: Config; config: Config;
@@ -81,7 +82,11 @@ export interface AppContainer {
* Returns a populated container. Caller owns lifecycle (start loop, handle * Returns a populated container. Caller owns lifecycle (start loop, handle
* shutdown). * 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 // Wire winston → dashboard log buffer immediately so the matrix-token
// sniff and other early bootstrap diagnostics are visible in /api/logs. // sniff and other early bootstrap diagnostics are visible in /api/logs.
const broadcaster = new LogBroadcaster(); const broadcaster = new LogBroadcaster();
@@ -90,7 +95,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
} }
// Database // Database
const db = createDatabase(config); const db = existingDatabase ?? createDatabase(config);
runMigrations(db); runMigrations(db);
const postsRepo = new PostsRepository(db); const postsRepo = new PostsRepository(db);
const messagesRepo = new MessagesRepository(db); const messagesRepo = new MessagesRepository(db);
@@ -186,35 +191,12 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
// or token-sniff flow. // or token-sniff flow.
const tokenRefresher = new MatrixTokenRefresher(); const tokenRefresher = new MatrixTokenRefresher();
// 4. Username resolver — turns t2 IDs into real Reddit usernames so the // 4. Username resolver — Matrix profiles expose Reddit display names using
// dashboard isn't full of `t2_abc123`. Uses Reddit's bulk endpoint. // the same authenticated session that powers chat sync.
const matrixUsernameResolver = new MatrixUsernameResolver(activeMatrixClient);
const usernameResolver: UsernameResolver = { const usernameResolver: UsernameResolver = {
async resolveT2Ids(ids) { resolveT2Ids: (ids) => matrixUsernameResolver.resolveT2Ids(ids),
if (ids.length === 0) return {}; resolveUsernameToT2: (username) => redditApi.resolveUsernameToT2(username),
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;
},
}; };
const chatService = new ChatService( const chatService = new ChatService(
@@ -244,6 +226,8 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
setMatrixClient(client) { setMatrixClient(client) {
activeMatrixClient = client; activeMatrixClient = client;
chatService.setMatrixClient(client); chatService.setMatrixClient(client);
mediaDownloader.setMatrixClient(client);
matrixUsernameResolver.setMatrixClient(client);
}, },
}, },
(credentials) => new MatrixClient(credentials, logger), (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 // Stream sync-progress frames over the dashboard's SSE channel so the
// full-sync UI can show live phase + per-room progress. // full-sync UI can show live phase + per-room progress.
let lastSyncProgress: { kind: 'full' | 'delta'; current: number; startedAt: number } | null = null; 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); webServer!.broadcastSyncProgress(progress as Record<string, unknown> | null);
if (progress) { if (progress) {
const syncId = `chat-${progress.startedAt}`; const syncId = `chat-${progress.startedAt}`;
@@ -286,12 +270,22 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
} }
lastSyncProgress = progress; lastSyncProgress = progress;
} else if (lastSyncProgress) { } else if (lastSyncProgress) {
broadcaster.dashboardEvents.sync({ const id = `chat-${lastSyncProgress.startedAt}`;
phase: 'completed', if (completion?.status === 'failed') {
id: `chat-${lastSyncProgress.startedAt}`, broadcaster.dashboardEvents.sync({
kind: lastSyncProgress.kind, phase: 'failed',
processed: lastSyncProgress.current, id,
}); kind: lastSyncProgress.kind,
error: completion.error,
});
} else {
broadcaster.dashboardEvents.sync({
phase: 'completed',
id,
kind: lastSyncProgress.kind,
processed: lastSyncProgress.current,
});
}
lastSyncProgress = null; lastSyncProgress = null;
} }
}); });
@@ -319,6 +313,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
webServer.setSessionImporter(sessionImporter); webServer.setSessionImporter(sessionImporter);
webServer.setAccountAuth(accountAuth); webServer.setAccountAuth(accountAuth);
webServer.setConversationStateRepository(conversationStateRepo); webServer.setConversationStateRepository(conversationStateRepo);
webServer.setConfigStore(new ConfigStore(db));
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard')); 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' }); const res = await fetch('https://www.reddit.com/api/me.json', { credentials: 'include' });
if (!res.ok) return null; if (!res.ok) return null;
const data: unknown = await res.json(); const data: unknown = await res.json();
if (!data || typeof data !== 'object' || !('name' in data) || typeof data.name !== 'string') return null; if (!data || typeof data !== 'object') return null;
const username = data.name.trim(); 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; return username || null;
} catch { } catch {
return null; return null;
+20 -1
View File
@@ -157,7 +157,20 @@ export const ConfigSchema = z.object({
export type Config = z.infer<typeof ConfigSchema>; 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'); const configPath = path.resolve(process.cwd(), 'config.yaml');
if (!fs.existsSync(configPath)) { if (!fs.existsSync(configPath)) {
@@ -173,6 +186,11 @@ export function loadConfig(): Config {
} }
rawConfig.openrouter.apiKey = process.env.OPENROUTER_API_KEY || rawConfig.openrouter.apiKey; 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 (process.env.WEB_DASHBOARD_TOKEN !== undefined) {
if (!rawConfig.web) rawConfig.web = {}; if (!rawConfig.web) rawConfig.web = {};
rawConfig.web.authToken = process.env.WEB_DASHBOARD_TOKEN; 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'); 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; return result.data;
} }
+73 -64
View File
@@ -1,74 +1,83 @@
import fs from 'fs'; import type Database from 'better-sqlite3';
import path from 'path'; import { ConfigSchema, type Config } from './index.js';
import YAML from 'yaml';
import type { SubredditConfig } 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 * Persists application-owned configuration in SQLite. Secrets and the
* surrounding YAML structure, comment blocks, and per-row inline comments * database location remain environment-owned bootstrap settings and are
* for entries that survive the update. * deliberately never written to the settings record.
*
* 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.
*/ */
export function persistSubreddits( export class ConfigStore {
subreddits: SubredditConfig[], constructor(private readonly db: Database.Database) {}
configPath: string = path.resolve(process.cwd(), 'config.yaml'),
): void {
const raw = fs.readFileSync(configPath, 'utf-8');
const doc = YAML.parseDocument(raw);
const seq = doc.getIn(['reddit', 'subreddits']) as YAML.YAMLSeq | undefined; load(environment: NodeJS.ProcessEnv = process.env): Config | null {
if (!seq || !YAML.isSeq(seq)) { const row = this.db.prepare('SELECT config_json FROM app_config WHERE id = 1').get() as
throw new Error('reddit.subreddits is not a sequence in config.yaml'); | { config_json: string }
} | undefined;
if (!row) return null;
// Map existing items by their `name` so we can reuse YAMLMap nodes let stored: unknown;
// (and the trailing comments attached to them). try {
const existingByName = new Map<string, YAML.YAMLMap>(); stored = JSON.parse(row.config_json);
for (const item of seq.items) { } catch (error) {
if (!YAML.isMap(item)) continue; throw new Error(`Stored application configuration is not valid JSON: ${String(error)}`);
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);
} }
setOrDelete(node, 'type', sub.type === 'image' ? undefined : sub.type);
setOrDelete(node, 'weight', sub.weight); return validateConfig(withBootstrapSettings(stored, environment));
setOrDelete(node, 'tone', sub.tone === 'default' ? undefined : sub.tone);
setOrDelete(node, 'scripted', sub.scripted);
nextItems.push(node);
} }
seq.items = nextItems; save(config: Config): void {
const stored = toStoredConfig(config);
const out = doc.toString(); this.db.prepare(`
const tmp = configPath + '.tmp'; INSERT INTO app_config (id, config_json, updated_at)
fs.writeFileSync(tmp, out, 'utf-8'); VALUES (1, ?, ?)
fs.renameSync(tmp, configPath); ON CONFLICT(id) DO UPDATE SET
} config_json = excluded.config_json,
updated_at = excluded.updated_at
function setOrDelete(node: YAML.YAMLMap, key: string, value: unknown): void { `).run(JSON.stringify(stored), Date.now());
if (value === undefined || value === null || value === '') {
if (node.has(key)) node.delete(key);
return;
} }
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 path from 'path';
import fs from 'fs'; 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:' const dbPath = config.database.path === ':memory:'
? ':memory:' ? ':memory:'
: path.resolve(process.cwd(), config.database.path); : 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) 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; let currentVersion = db.pragma('user_version', { simple: true }) as number;
+30 -11
View File
@@ -11,6 +11,7 @@ export interface ProcessedPostRecord {
subreddit: string; subreddit: string;
author: string; author: string;
title: string; title: string;
postUrl: string | null;
imageUrl: string | null; imageUrl: string | null;
imageAnalysis: string | null; imageAnalysis: string | null;
generatedMessage: string | null; generatedMessage: string | null;
@@ -32,6 +33,7 @@ export interface SentMessageRecord {
chatRoomId: string | null; chatRoomId: string | null;
subreddit?: string; subreddit?: string;
postTitle?: string; postTitle?: string;
postUrl?: string;
postImageUrl?: string; postImageUrl?: string;
postImageAnalysis?: string; postImageAnalysis?: string;
} }
@@ -74,6 +76,8 @@ export interface ActivityLogRecord {
message: string; message: string;
recipient: string | null; recipient: string | null;
imageUrl: string | null; imageUrl: string | null;
postTitle: string | null;
postUrl: string | null;
} }
export type DeliveryStatus = 'discovered' | 'prepared' | 'sending' | 'sent' | 'failed'; export type DeliveryStatus = 'discovered' | 'prepared' | 'sending' | 'sent' | 'failed';
@@ -233,12 +237,12 @@ export class ActivityLogRepository {
constructor(private db: Database.Database) { constructor(private db: Database.Database) {
this.insertStmt = db.prepare(` this.insertStmt = db.prepare(`
INSERT INTO activity_log (timestamp, type, message, recipient, image_url) INSERT INTO activity_log (timestamp, type, message, recipient, image_url, post_title, post_url)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`); `);
this.getRecentStmt = db.prepare(` 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 FROM activity_log
ORDER BY id DESC ORDER BY id DESC
LIMIT ? LIMIT ?
@@ -260,6 +264,8 @@ export class ActivityLogRepository {
message: string; message: string;
recipient?: string; recipient?: string;
imageUrl?: string; imageUrl?: string;
postTitle?: string;
postUrl?: string;
}): void { }): void {
this.insertStmt.run( this.insertStmt.run(
item.timestamp, item.timestamp,
@@ -267,6 +273,8 @@ export class ActivityLogRepository {
item.message, item.message,
item.recipient ?? null, item.recipient ?? null,
item.imageUrl ?? null, item.imageUrl ?? null,
item.postTitle ?? null,
item.postUrl ?? null,
); );
} }
@@ -278,6 +286,8 @@ export class ActivityLogRepository {
message: row.message as string, message: row.message as string,
recipient: (row.recipient as string) ?? null, recipient: (row.recipient as string) ?? null,
imageUrl: (row.image_url 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) { constructor(private db: Database.Database) {
this.insertStmt = db.prepare(` this.insertStmt = db.prepare(`
INSERT INTO processed_posts (id, subreddit, author, title, image_url, processed_at) INSERT INTO processed_posts (id, subreddit, author, title, post_url, image_url, processed_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`); `);
this.existsStmt = db.prepare(`SELECT 1 FROM processed_posts WHERE id = ?`); this.existsStmt = db.prepare(`SELECT 1 FROM processed_posts WHERE id = ?`);
@@ -334,6 +344,7 @@ export class PostsRepository {
subreddit: string; subreddit: string;
author: string; author: string;
title: string; title: string;
postUrl?: string | null;
imageUrl: string | null; imageUrl: string | null;
processedAt: number; processedAt: number;
}): void { }): void {
@@ -342,6 +353,7 @@ export class PostsRepository {
post.subreddit, post.subreddit,
post.author, post.author,
post.title, post.title,
post.postUrl ?? null,
post.imageUrl, post.imageUrl,
post.processedAt post.processedAt
); );
@@ -368,6 +380,7 @@ export class PostsRepository {
subreddit: row.subreddit as string, subreddit: row.subreddit as string,
author: row.author as string, author: row.author as string,
title: row.title as string, title: row.title as string,
postUrl: (row.post_url as string | null) ?? null,
imageUrl: row.image_url as string | null, imageUrl: row.image_url as string | null,
imageAnalysis: row.image_analysis as string | null, imageAnalysis: row.image_analysis as string | null,
generatedMessage: row.generated_message as string | null, generatedMessage: row.generated_message as string | null,
@@ -384,6 +397,7 @@ export class PostsRepository {
subreddit: row.subreddit as string, subreddit: row.subreddit as string,
author: row.author as string, author: row.author as string,
title: row.title as string, title: row.title as string,
postUrl: (row.post_url as string | null) ?? null,
imageUrl: row.image_url as string | null, imageUrl: row.image_url as string | null,
imageAnalysis: row.image_analysis as string | null, imageAnalysis: row.image_analysis as string | null,
generatedMessage: row.generated_message as string | null, generatedMessage: row.generated_message as string | null,
@@ -408,8 +422,8 @@ export class MessagesRepository {
constructor(private db: Database.Database) { constructor(private db: Database.Database) {
this.insertStmt = db.prepare(` this.insertStmt = db.prepare(`
INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at, strategy_id) INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at, strategy_id, chat_room_id)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`); `);
this.insertChatStmt = db.prepare(` this.insertChatStmt = db.prepare(`
@@ -418,18 +432,19 @@ export class MessagesRepository {
`); `);
this.getByChatRoomIdStmt = db.prepare(` 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 pp.image_analysis AS post_image_analysis
FROM sent_messages sm FROM sent_messages sm
LEFT JOIN processed_posts pp ON sm.post_id = pp.id LEFT JOIN processed_posts pp ON sm.post_id = pp.id
WHERE sm.chat_room_id = ? WHERE sm.chat_room_id = ?
ORDER BY sm.post_id IS NULL ASC, sm.sent_at DESC
LIMIT 1 LIMIT 1
`); `);
this.getByPostIdStmt = db.prepare(`SELECT * FROM sent_messages WHERE post_id = ?`); this.getByPostIdStmt = db.prepare(`SELECT * FROM sent_messages WHERE post_id = ?`);
this.getLatestByRecipientStmt = db.prepare(` 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 pp.image_analysis AS post_image_analysis
FROM sent_messages sm FROM sent_messages sm
LEFT JOIN processed_posts pp ON sm.post_id = pp.id LEFT JOIN processed_posts pp ON sm.post_id = pp.id
@@ -439,7 +454,7 @@ export class MessagesRepository {
`); `);
this.getRecentStmt = db.prepare(` 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 pp.image_analysis AS post_image_analysis
FROM sent_messages sm FROM sent_messages sm
LEFT JOIN processed_posts pp ON sm.post_id = pp.id LEFT JOIN processed_posts pp ON sm.post_id = pp.id
@@ -476,6 +491,7 @@ export class MessagesRepository {
message: string; message: string;
sentAt: number; sentAt: number;
strategyId?: string | null; strategyId?: string | null;
chatRoomId?: string | null;
}): number { }): number {
const result = this.insertStmt.run( const result = this.insertStmt.run(
message.postId, message.postId,
@@ -483,7 +499,8 @@ export class MessagesRepository {
message.subject, message.subject,
message.message, message.message,
message.sentAt, message.sentAt,
message.strategyId || null message.strategyId || null,
message.chatRoomId ?? null,
); );
return result.lastInsertRowid as number; 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 SUM(CASE WHEN got_response = 1 THEN 1 ELSE 0 END) as total_responses
FROM sent_messages FROM sent_messages
WHERE sent_at > ? WHERE sent_at > ?
AND (post_id IS NOT NULL OR strategy_id IS NOT NULL)
GROUP BY day GROUP BY day
ORDER BY day ASC ORDER BY day ASC
`).all(cutoff) as Record<string, unknown>[]; `).all(cutoff) as Record<string, unknown>[];
@@ -636,6 +654,7 @@ export class MessagesRepository {
chatRoomId: (row.chat_room_id as string) ?? null, chatRoomId: (row.chat_room_id as string) ?? null,
subreddit: (row.subreddit as string) || undefined, subreddit: (row.subreddit as string) || undefined,
postTitle: (row.post_title 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, postImageUrl: (row.post_image_url as string) || undefined,
postImageAnalysis: (row.post_image_analysis 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 { createLogger } from './utils/logger.js';
import { bootstrap } from './bootstrap.js'; import { bootstrap } from './bootstrap.js';
import { runMainLoop } from './main-loop.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> { async function main(): Promise<void> {
const logger = createLogger(); const logger = createLogger();
@@ -11,15 +13,19 @@ async function main(): Promise<void> {
logger.info('='.repeat(60)); logger.info('='.repeat(60));
let config; let config;
const db = createDatabase({
database: { path: process.env.DATABASE_PATH ?? './data/reddit-bot.db' },
});
runMigrations(db);
try { try {
config = loadConfig(); config = loadConfig(new ConfigStore(db));
logger.info(`Loaded configuration for ${config.reddit.subreddits.length} subreddits`); logger.info(`Loaded configuration for ${config.reddit.subreddits.length} subreddits`);
} catch (error) { } catch (error) {
logger.error('Failed to load configuration:', error); logger.error('Failed to load configuration:', error);
process.exit(1); process.exit(1);
} }
const app = await bootstrap(config, logger); const app = await bootstrap(config, logger, db);
await runMainLoop(app); 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 isChatSub = subredditEntry?.type === 'chat';
const postImageUrl = isChatSub ? null : redditApi.getImageUrl(post); const postImageUrl = isChatSub ? null : redditApi.getImageUrl(post);
const postUrl = redditApi.getPostUrl(post);
postsRepo.insert({ postsRepo.insert({
id: post.id, id: post.id,
subreddit: post.subreddit, subreddit: post.subreddit,
author: post.author, author: post.author,
title: post.title, title: post.title,
postUrl,
imageUrl: postImageUrl, imageUrl: postImageUrl,
processedAt: Date.now(), processedAt: Date.now(),
}); });
@@ -309,7 +311,11 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
logger.info(`Sending message to u/${post.author}...`); logger.info(`Sending message to u/${post.author}...`);
webServer?.pushLog('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; const success = chat !== null;
if (success) { if (success) {
@@ -327,6 +333,7 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
message: processed.generatedMessage, message: processed.generatedMessage,
sentAt: Date.now(), sentAt: Date.now(),
strategyId: processed.strategyId, strategyId: processed.strategyId,
chatRoomId: chat.roomId,
}); });
const currentCount = currentHourlySendCount(rateLimiter); 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})`, `Sent message to u/${post.author} (r/${post.subreddit})`,
post.author, post.author,
postImageUrl ?? undefined, postImageUrl ?? undefined,
{ postTitle: post.title, postUrl },
); );
webServer?.pushLog('info', `✅ Message sent to u/${post.author} (${currentCount}/${config.rateLimit.maxMessagesPerHour} this hour)`); webServer?.pushLog('info', `✅ Message sent to u/${post.author} (${currentCount}/${config.rateLimit.maxMessagesPerHour} this hour)`);
webServer?.updateStats(currentCount); webServer?.updateStats(currentCount);
+21
View File
@@ -8,6 +8,7 @@ export interface RedditPost {
name: string; // fullname like "t3_xxxxx" name: string; // fullname like "t3_xxxxx"
subreddit: string; subreddit: string;
author: string; author: string;
author_fullname?: string; // account fullname like "t2_xxxxx"
title: string; title: string;
url: string; url: string;
permalink: 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[]> { async getNewPosts(subreddit: string, limit = 25, type: 'image' | 'chat' = 'image'): Promise<RedditPost[]> {
try { try {
const cookieHeader = await this.getCookieHeader(); const cookieHeader = await this.getCookieHeader();
+2
View File
@@ -5,6 +5,7 @@ export type {
SyncStatus, SyncStatus,
SyncProgress, SyncProgress,
SyncPhase, SyncPhase,
SyncCompletion,
SyncProgressListener, SyncProgressListener,
} from './service.js'; } from './service.js';
export type { export type {
@@ -23,3 +24,4 @@ export {
MatrixTokenRefresher, MatrixTokenRefresher,
} from './matrix/token-refresher.js'; } from './matrix/token-refresher.js';
export { MatrixMediaDownloader } from './matrix/http-media-downloader.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) { 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. // Matrix-standard rate-limit response: { retry_after_ms } per spec.
@@ -251,6 +257,13 @@ export class MatrixClient {
return res.user_id; 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 — * All rooms the user is currently joined to. Use this for backfill —
* `/sync` only returns rooms with recent activity. * `/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)`); throw new Error(`Matrix media download ${url} timed out (exceeded ${maxAttempts} attempts)`);
} }
if (res.status === 401 || res.status === 403) { 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) { if (res.status === 429) {
const body = (await res.json().catch(() => ({}))) as { retry_after_ms?: number }; 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 { function parseMxc(mxc: string): { serverName: string; mediaId: string } | null {
const m = mxc.match(/^mxc:\/\/([^/]+)\/(.+)$/); const m = mxc.match(/^mxc:\/\/([^/]+)\/(.+)$/);
if (!m) return null; if (!m) return null;
@@ -16,6 +16,10 @@ export class MatrixMediaDownloader implements MediaDownloader {
private logger: Logger, private logger: Logger,
) {} ) {}
setMatrixClient(client: MatrixClient): void {
this.matrix = client;
}
async fetch(url: string): Promise<MediaDownloadResult | null> { async fetch(url: string): Promise<MediaDownloadResult | null> {
try { try {
if (url.startsWith('mxc://')) { 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 { import type {
MessagesRepository, MessagesRepository,
RedditUsersRepository, RedditUsersRepository,
SentMessageRecord,
SuccessfulMessagesRepository, SuccessfulMessagesRepository,
} from '../../database/repositories.js'; } from '../../database/repositories.js';
import type { MediaService } from '../media.js'; import type { MediaService } from '../media.js';
@@ -40,10 +41,13 @@ class MatrixAuthenticationUnavailableError extends Error {
export interface UsernameResolver { export interface UsernameResolver {
/** Map t2 IDs (with or without `t2_` prefix) to Reddit usernames. */ /** Map t2 IDs (with or without `t2_` prefix) to Reddit usernames. */
resolveT2Ids(t2Ids: string[]): Promise<Record<string, string>>; 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 { interface BuildChatOpts {
knownUsernames: Map<string, string>; knownUsernames: Map<string, string>;
includeTimelineMessages?: boolean;
} }
/** /**
@@ -98,8 +102,15 @@ export interface SyncStatus {
progress: SyncProgress | null; progress: SyncProgress | null;
} }
export type SyncCompletion =
| { status: 'completed' }
| { status: 'failed'; error: string };
/** `null` signals "sync ended" (clear progress UI). */ /** `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. */ /** Fired when a chat we sent into first receives a reply from the other party. */
export type NewReplyListener = (info: { export type NewReplyListener = (info: {
@@ -112,6 +123,7 @@ export type NewReplyListener = (info: {
export class ChatService { export class ChatService {
private cache: ChatCache; private cache: ChatCache;
private queue: Promise<unknown> = Promise.resolve(); private queue: Promise<unknown> = Promise.resolve();
private deltaSyncInFlight: Promise<void> | null = null;
private readonly t2ToUsername = new Map<string, string>(); private readonly t2ToUsername = new Map<string, string>();
/** Rooms whose /joined_members lookup has already failed — don't retry. */ /** Rooms whose /joined_members lookup has already failed — don't retry. */
private readonly unresolvableRooms = new Set<string>(); private readonly unresolvableRooms = new Set<string>();
@@ -263,6 +275,7 @@ export class ChatService {
return this.enqueue(async () => { return this.enqueue(async () => {
if (this.cache.isFresh()) return this.cache.get(); if (this.cache.isFresh()) return this.cache.get();
this.beginSync('delta'); this.beginSync('delta');
let failure: unknown;
try { try {
this.updateProgress({ phase: 'starting', current: 0, total: 0 }); this.updateProgress({ phase: 'starting', current: 0, total: 0 });
const merged = await this.syncOnce(); 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.logger.info(`[chat] sync → ${merged.length} known rooms, ${active.length} active`);
this.updateProgress({ phase: 'done', current: merged.length, total: merged.length }); this.updateProgress({ phase: 'done', current: merged.length, total: merged.length });
return active; return active;
} catch (error) {
failure = error;
throw error;
} finally { } finally {
this.endSync(); this.endSync(failure);
} }
}); });
} }
@@ -284,9 +300,12 @@ export class ChatService {
* cache. The `limit` arg is preserved for API compatibility but Matrix * cache. The `limit` arg is preserved for API compatibility but Matrix
* already returns only rooms with new events, so it's a no-op. * already returns only rooms with new events, so it's a no-op.
*/ */
async refreshTopChats(_limit = 5): Promise<void> { refreshTopChats(_limit = 5): Promise<void> {
await this.enqueue(async () => { if (this.deltaSyncInFlight) return this.deltaSyncInFlight;
const operation = this.enqueue(async () => {
this.beginSync('delta'); this.beginSync('delta');
let failure: unknown;
try { try {
this.updateProgress({ phase: 'starting', current: 0, total: 0 }); this.updateProgress({ phase: 'starting', current: 0, total: 0 });
await this.syncOnce(); await this.syncOnce();
@@ -297,11 +316,19 @@ export class ChatService {
total: this.cache.get().length, total: this.cache.get().length,
}); });
} catch (error) { } catch (error) {
failure = error;
this.logger.error('[chat] refreshTopChats failed:', error); this.logger.error('[chat] refreshTopChats failed:', error);
throw error;
} finally { } 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[]> { async fetchAllMessages(): Promise<ActiveChat[]> {
return this.enqueue(async () => { return this.enqueue(async () => {
this.beginSync('full'); this.beginSync('full');
let failure: unknown;
try { try {
this.logger.info('[chat] full backfill starting (via /joined_rooms)...'); this.logger.info('[chat] full backfill starting (via /joined_rooms)...');
const t0 = Date.now(); const t0 = Date.now();
@@ -326,24 +354,46 @@ export class ChatService {
const joinedIds = await this.withAuth(() => this.matrix.getJoinedRooms()); const joinedIds = await this.withAuth(() => this.matrix.getJoinedRooms());
this.logger.info(`[chat] backfill: ${joinedIds.length} joined rooms`); this.logger.info(`[chat] backfill: ${joinedIds.length} joined rooms`);
this.updateProgress({ const knownUsernames = new Map(this.t2ToUsername);
phase: 'resolving-usernames',
current: 0,
total: joinedIds.length,
});
const knownUsernames = await this.resolveUsernamesForRooms(joinedIds);
const chats: ActiveChat[] = []; const chats: ActiveChat[] = [];
for (const roomId of joinedIds) { for (const roomId of joinedIds) {
try { try {
const room = seedJoined[roomId]; const room = seedJoined[roomId];
const chat = this.roomToActiveChat(roomId, room, { knownUsernames }); const chat = this.roomToActiveChat(roomId, room, {
knownUsernames,
includeTimelineMessages: true,
});
chats.push(chat); chats.push(chat);
} catch (err) { } catch (err) {
this.logger.warn(`[chat] backfill: failed to seed ${roomId}: ${(err as Error).message}`); 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; let done = 0;
for (const chat of chats) { for (const chat of chats) {
this.updateProgress({ this.updateProgress({
@@ -377,10 +427,12 @@ export class ChatService {
this.updateProgress({ phase: 'done', current: chats.length, total: chats.length }); this.updateProgress({ phase: 'done', current: chats.length, total: chats.length });
return chats; return chats;
} catch (error) { } catch (error) {
failure = error;
this.logger.error('[chat] fetchAllMessages failed:', error); this.logger.error('[chat] fetchAllMessages failed:', error);
if (error instanceof MatrixAuthenticationUnavailableError) throw error;
return this.cache.get(); return this.cache.get();
} finally { } finally {
this.endSync(); this.endSync(failure);
} }
}); });
} }
@@ -446,12 +498,18 @@ export class ChatService {
this.emitProgress(); this.emitProgress();
} }
private endSync(): void { private endSync(error?: unknown): void {
this.syncingKind = null; this.syncingKind = null;
this.progress = null; this.progress = null;
if (this.progressListener) { if (this.progressListener) {
try { 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) { } catch (err) {
this.logger.debug('progress listener error:', 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 * Start a brand-new DM with the given Reddit user and send the opening
* line. Returns the new chat (or null on failure). * 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; if (!this.accountAuth?.isFullyAuthenticated()) return null;
const active = this.getInvitationCooldown(); const active = this.getInvitationCooldown();
if (active) { if (active) {
@@ -534,7 +596,7 @@ export class ChatService {
} }
return this.enqueue(async () => { return this.enqueue(async () => {
try { try {
const t2Id = await this.resolveUsernameToT2(username); const t2Id = await this.resolveUsernameToT2(username, knownT2Id);
if (!t2Id) { if (!t2Id) {
this.logger.error(`[chat] cannot start conversation: unknown user "${username}"`); this.logger.error(`[chat] cannot start conversation: unknown user "${username}"`);
return null; return null;
@@ -657,7 +719,7 @@ export class ChatService {
const joined = sync.rooms?.join ?? {}; const joined = sync.rooms?.join ?? {};
const existing = new Map(this.cache.get().map((c) => [c.roomId!, c] as const)); 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)) { for (const [roomId, room] of Object.entries(joined)) {
const prev = existing.get(roomId); const prev = existing.get(roomId);
@@ -786,38 +848,36 @@ export class ChatService {
if (firstSelf) chat.originMessage = firstSelf.text; if (firstSelf) chat.originMessage = firstSelf.text;
const hasReply = chat.messages.some((m) => !m.isSelf); const hasReply = chat.messages.some((m) => !m.isSelf);
let performanceMessage: SentMessageRecord | undefined;
if (firstSelf && chat.roomId && this.messagesRepo) { if (firstSelf && chat.roomId && this.messagesRepo) {
try { try {
const existing = this.messagesRepo.getByChatRoomId(chat.roomId); const existing = this.messagesRepo.getByChatRoomId(chat.roomId);
const id = existing const hasPerformanceContext = Boolean(
? existing.id existing?.postId || existing?.strategyId || existing?.subreddit,
: this.messagesRepo.insertChat({ );
recipient: chat.username, if (existing && hasPerformanceContext) {
subject: chat.username, performanceMessage = existing;
message: firstSelf.text, if (hasReply && !existing.gotResponse) {
sentAt: firstSelf.timestamp, this.messagesRepo.markGotResponse(existing.id);
chatRoomId: chat.roomId, }
});
if (hasReply && !existing?.gotResponse) {
this.messagesRepo.markGotResponse(id);
} }
} catch (err) { } 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)) { if (!this.successfulMessagesRepo.existsByMessage(firstSelf.text)) {
const firstReply = chat.messages.find((m) => !m.isSelf); const firstReply = chat.messages.find((m) => !m.isSelf);
try { try {
this.successfulMessagesRepo.insert({ this.successfulMessagesRepo.insert({
sentMessageId: null, sentMessageId: performanceMessage.id,
originalMessage: firstSelf.text, originalMessage: firstSelf.text,
imageAnalysis: null, imageAnalysis: performanceMessage.postImageAnalysis ?? null,
postTitle: `Chat with ${chat.username}`, postTitle: performanceMessage.postTitle ?? null,
responseSnippet: firstReply?.text.slice(0, 200) || 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}`); this.logger.info(`[chat] stored successful chat example from ${chat.username}`);
try { try {
@@ -916,6 +976,10 @@ export class ChatService {
isFromUs = lastMessage.sender === this.matrix.userId; 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; const unread = (room?.unread_notifications?.notification_count ?? 0) > 0;
return { return {
@@ -928,6 +992,7 @@ export class ChatService {
unread, unread,
url: ROOM_URL_PREFIX + encodeURIComponent(roomId), url: ROOM_URL_PREFIX + encodeURIComponent(roomId),
roomId, roomId,
...(timelineMessages ? { messages: timelineMessages } : {}),
}; };
} }
@@ -956,16 +1021,58 @@ export class ChatService {
* as a fallback. Failures are logged and ignored, with the t2 ID * as a fallback. Failures are logged and ignored, with the t2 ID
* staying as the fallback display. * staying as the fallback display.
*/ */
private async resolveUsernamesForRooms(roomIds: string[]): Promise<Map<string, string>> { private async resolveUsernamesForRooms(
// Collect all known t2 IDs (from the in-memory cache) and query roomIds: string[],
// Reddit's alfred API for reverse-resolution. rooms: Record<string, MatrixJoinedRoom> = {},
): Promise<Map<string, string>> {
const cached = new Map(this.t2ToUsername); 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); const name = cached.get(id);
return !name || name.startsWith('t2_') || name.startsWith('@') || this.isStaleUsername(name); 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) { if (staleT2s.length > 0) {
const numericIds = staleT2s.map(id => id.replace(/^t2_/, '')); const numericIds = staleT2s.map(id => id.replace(/^t2_/, ''));
try { 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; return cached;
} }
@@ -1170,28 +1260,21 @@ export class ChatService {
if (dirty) await this.cache.save(); if (dirty) await this.cache.save();
} }
/** /** Resolve an account ID from listing data, the durable cache, or Reddit auth. */
* Look up the t2 ID for a Reddit username via the standard about.json private async resolveUsernameToT2(
* endpoint. Returns `t2_xxx` or null. username: string,
*/ knownT2Id?: string,
private async resolveUsernameToT2(username: string): Promise<string | null> { ): Promise<string | null> {
try { if (knownT2Id && /^t2_[a-z0-9]+$/i.test(knownT2Id)) return knownT2Id;
const res = await fetchWithTimeout(
`https://www.reddit.com/user/${encodeURIComponent(username)}/about.json`, const cached = this.redditUsersRepo?.getByUsername(username);
{ headers: { 'User-Agent': 'reddit-bot-chat/1.0' }, timeoutMs: 15_000 }, if (cached) return cached.t2Id;
);
if (!res.ok) { if (this.usernameResolver?.resolveUsernameToT2) {
this.logger.warn(`[chat] /user/${username}/about.json → ${res.status}`); return this.usernameResolver.resolveUsernameToT2(username);
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;
} }
return null;
} }
/** Parse a chat URL like `…/room/!abc%3Areddit.com` → `!abc:reddit.com`. */ /** Parse a chat URL like `…/room/!abc%3Areddit.com` → `!abc:reddit.com`. */
+1
View File
@@ -23,6 +23,7 @@ export interface ActiveChat {
roomId?: string; roomId?: string;
messages?: ChatMessage[]; messages?: ChatMessage[];
originMessage?: string; originMessage?: string;
hasReceivedImages?: boolean;
} }
/** /**
+11 -1
View File
@@ -57,7 +57,17 @@ export class ResponseCheckerService {
results.push(result); results.push(result);
if (result.gotResponse) { 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); this.messagesRepo.markGotResponse(message.id);
// Get the original post info // Get the original post info
+10 -2
View File
@@ -20,12 +20,20 @@ export class ActivityLog {
message: r.message, message: r.message,
recipient: r.recipient ?? undefined, recipient: r.recipient ?? undefined,
imageUrl: r.imageUrl ?? undefined, imageUrl: r.imageUrl ?? undefined,
postTitle: r.postTitle ?? undefined,
postUrl: r.postUrl ?? undefined,
})) }))
.reverse(); .reverse();
} }
push(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void { push(
const item: ActivityItem = { timestamp: Date.now(), type, message, recipient, imageUrl }; 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); this.items.push(item);
if (this.items.length > MAX_ITEMS) { if (this.items.length > MAX_ITEMS) {
this.items = this.items.slice(-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 { RuntimeConfig } from './types.js';
import type { RedditSessionImporter } from '../services/reddit-session-importer.js'; import type { RedditSessionImporter } from '../services/reddit-session-importer.js';
import type { AccountAuthContext } from '../services/account-auth-context.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. */ /** Bag of everything the route modules might need. */
export interface WebContext { export interface WebContext {
@@ -46,6 +47,7 @@ export interface WebContext {
sessionImporter?: RedditSessionImporter; sessionImporter?: RedditSessionImporter;
accountAuth?: AccountAuthContext; accountAuth?: AccountAuthContext;
conversationState?: ConversationStateRepository; conversationState?: ConversationStateRepository;
configStore?: ConfigStore;
}; };
/** Bot pause/resume toggle read by the main loop. */ /** 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 { Router } from 'express';
import type { WebContext } from '../context.js'; 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 { TONE_KEYS } from '../../config/tone-profiles.js';
import { persistSubreddits } from '../../config/persistence.js';
import { z } from 'zod'; import { z } from 'zod';
const SubredditsUpdateSchema = z.array(SubredditConfigSchema).min(1); 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 { export function createConfigRouter(ctx: WebContext): Router {
const router = Router(); const router = Router();
@@ -17,12 +42,30 @@ export function createConfigRouter(ctx: WebContext): Router {
reddit: ctx.runtimeConfig.reddit, reddit: ctx.runtimeConfig.reddit,
subreddits: ctx.config.reddit.subreddits, subreddits: ctx.config.reddit.subreddits,
tones: TONE_KEYS, tones: TONE_KEYS,
advanced: advancedConfig(ctx),
}); });
}); });
router.put('/config', (req, res) => { router.put('/config', (req, res) => {
try { try {
const updates = req.body; 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 (updates.rateLimit) {
if (typeof updates.rateLimit.minDelayBetweenMessagesMs === 'number') { 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.prioritizeRecentPosts = ctx.runtimeConfig.reddit.prioritizeRecentPosts;
ctx.config.reddit.recentWindowMinutes = ctx.runtimeConfig.reddit.recentWindowMinutes; ctx.config.reddit.recentWindowMinutes = ctx.runtimeConfig.reddit.recentWindowMinutes;
if (updates.subreddits !== undefined) { if (parsedSubreddits?.success) {
const parsed = SubredditsUpdateSchema.safeParse(updates.subreddits); const next: SubredditConfig[] = parsedSubreddits.data;
if (!parsed.success) {
res.status(400).json({ error: 'Invalid subreddits payload', detail: parsed.error.format() });
return;
}
const next: SubredditConfig[] = parsed.data;
const seen = new Set<string>(); const seen = new Set<string>();
for (const s of next) { for (const s of next) {
const key = s.name.toLowerCase(); const key = s.name.toLowerCase();
@@ -81,10 +119,20 @@ export function createConfigRouter(ctx: WebContext): Router {
} }
seen.add(key); seen.add(key);
} }
persistSubreddits(next);
ctx.config.reddit.subreddits = 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.getOnConfigChange()?.(ctx.runtimeConfig);
ctx.activity.push('info', 'Config updated via dashboard'); ctx.activity.push('info', 'Config updated via dashboard');
@@ -94,6 +142,7 @@ export function createConfigRouter(ctx: WebContext): Router {
success: true, success: true,
config: ctx.runtimeConfig, config: ctx.runtimeConfig,
subreddits: ctx.config.reddit.subreddits, subreddits: ctx.config.reddit.subreddits,
advanced: advancedConfig(ctx),
}); });
} catch (error) { } catch (error) {
ctx.logger.error('Failed to update config:', 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) }; return { messages, state, intent: chooseConversationIntent(messages, state, requestedIntent) };
} }
router.get('/conversations', async (_req, res) => { router.get('/conversations', async (req, res) => {
if (!ctx.services.chat) { if (!ctx.services.chat) {
res.json([]); res.status(503).json({ error: 'Chat service not initialized' });
return; return;
} }
try { 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) { } catch (error) {
ctx.logger.error('Failed to fetch conversations:', 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; return;
} }
try { try {
// If a sync is already in flight (e.g. a Full sync the user kicked off // Conversation reads never initiate a sync. Sync lifecycle events can
// from the dashboard), don't queue *another* delta + lazy-load behind it // invalidate this query, so starting a sync here would create a loop.
// — 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.
const syncStatus = ctx.services.chat.getSyncStatus(); const syncStatus = ctx.services.chat.getSyncStatus();
const syncInFlight = syncStatus.syncing; 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)); const conversation = await findConversation(decodeURIComponent(req.params.id));
if (!conversation) { if (!conversation) {
res.status(404).json({ error: 'Conversation not found' }); res.status(404).json({ error: 'Conversation not found' });
@@ -94,19 +112,24 @@ export function createConversationsRouter(ctx: WebContext): Router {
subreddit?: string; subreddit?: string;
title?: string; title?: string;
url: string; url: string;
imageUrl: string | null;
sentAt: number; sentAt: number;
} | null = null; } | null = null;
try { 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) { 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://www.reddit.com/r/${sent.subreddit}/comments/${sent.postId}/`
: `https://redd.it/${sent.postId}`; : `https://redd.it/${sent.postId}`);
triggeringPost = { triggeringPost = {
id: sent.postId, id: sent.postId,
subreddit: sent.subreddit, subreddit: sent.subreddit,
title: sent.postTitle, title: sent.postTitle,
url, url,
imageUrl: sent.postImageUrl ?? null,
sentAt: sent.sentAt, sentAt: sent.sentAt,
}; };
} }
+9 -2
View File
@@ -1,5 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import type { WebContext } from '../context.js'; import type { WebContext } from '../context.js';
import { resolveRedditUsername } from '../reddit-identities.js';
export function createMediaRouter(ctx: WebContext): Router { export function createMediaRouter(ctx: WebContext): Router {
const router = Router(); const router = Router();
@@ -23,11 +24,17 @@ export function createMediaRouter(ctx: WebContext): Router {
res.json(items.map((item) => { res.json(items.map((item) => {
const live = item.chatRoomId ? byRoom.get(item.chatRoomId) : undefined; const live = item.chatRoomId ? byRoom.get(item.chatRoomId) : undefined;
const liveName = live?.username && live.username !== 'unknown' ? live.username : null; const username = resolveRedditUsername(
const username = liveName ?? item.chatUsername ?? null; 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 { return {
...item, ...item,
chatUsername: username, chatUsername: username,
sender,
urlPath: `/media/${encodeURIComponent(item.relativePath)}`, urlPath: `/media/${encodeURIComponent(item.relativePath)}`,
}; };
})); }));
+7 -2
View File
@@ -1,10 +1,15 @@
import { Router } from 'express'; import { Router } from 'express';
import type { WebContext } from '../context.js'; import type { WebContext } from '../context.js';
import type { BotStats } from '../types.js'; import type { BotStats } from '../types.js';
import { presentActivityItem } from '../reddit-identities.js';
export function createStatsRouter(ctx: WebContext): Router { export function createStatsRouter(ctx: WebContext): Router {
const router = Router(); const router = Router();
const recentActivity = (limit: number) => ctx.activity
.recent(limit)
.map((item) => presentActivityItem(item, ctx.services.chat));
router.get('/stats', (_req, res) => { router.get('/stats', (_req, res) => {
const messages = ctx.repos.messages.getRecent(1000); const messages = ctx.repos.messages.getRecent(1000);
const totalSent = messages.length; const totalSent = messages.length;
@@ -23,7 +28,7 @@ export function createStatsRouter(ctx: WebContext): Router {
maxMessagesPerHour: ctx.config.rateLimit.maxMessagesPerHour, maxMessagesPerHour: ctx.config.rateLimit.maxMessagesPerHour,
subreddits: ctx.config.reddit.subreddits.map((s) => ({ name: s.name, type: s.type })), subreddits: ctx.config.reddit.subreddits.map((s) => ({ name: s.name, type: s.type })),
targetGenders: ctx.config.reddit.targetGenders, targetGenders: ctx.config.reddit.targetGenders,
recentActivity: ctx.activity.recent(10), recentActivity: recentActivity(10),
invitationCooldown: cooldown invitationCooldown: cooldown
? { until: cooldown.until, reason: cooldown.reason, redditCode: cooldown.redditCode } ? { until: cooldown.until, reason: cooldown.reason, redditCode: cooldown.redditCode }
: null, : null,
@@ -33,7 +38,7 @@ export function createStatsRouter(ctx: WebContext): Router {
}); });
router.get('/activity', (_req, res) => { router.get('/activity', (_req, res) => {
res.json(ctx.activity.recent(50)); res.json(recentActivity(50));
}); });
router.get('/posts/by-author/:username', (req, res) => { 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 { BrowserClient } from '../browser/client.js';
import type { RedditSessionImporter } from '../services/reddit-session-importer.js'; import type { RedditSessionImporter } from '../services/reddit-session-importer.js';
import type { AccountAuthContext } from '../services/account-auth-context.js'; import type { AccountAuthContext } from '../services/account-auth-context.js';
import type { ConfigStore } from '../config/persistence.js';
import { ActivityLog } from './activity-log.js'; import { ActivityLog } from './activity-log.js';
import { LogBroadcaster } from './log-broadcaster.js'; import { LogBroadcaster } from './log-broadcaster.js';
import type { WebContext } from './context.js'; import type { WebContext } from './context.js';
@@ -214,8 +215,18 @@ export class WebServer {
this.services.conversationState = conversationState; this.services.conversationState = conversationState;
} }
logActivity(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void { setConfigStore(configStore: ConfigStore): void {
this.activity.push(type, message, recipient, imageUrl); 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 { pushLog(level: LogEntry['level'], message: string, data?: unknown): void {
+2
View File
@@ -24,6 +24,8 @@ export interface ActivityItem {
message: string; message: string;
recipient?: string; recipient?: string;
imageUrl?: string; imageUrl?: string;
postTitle?: string;
postUrl?: string;
} }
export interface LogEntry { 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 { 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 { BrowserClient, type BrowserRuntime } from '../src/browser/client.js';
import type { Logger } from '../src/utils/logger.js'; import type { Logger } from '../src/utils/logger.js';
import { getTestConfig } from './testUtils.js'; import { getTestConfig } from './testUtils.js';
@@ -35,4 +35,25 @@ describe('BrowserClient', () => {
expect(client.getSessionState()).toBe('unknown'); expect(client.getSessionState()).toBe('unknown');
expect(JSON.stringify(logger)).not.toContain('secret'); 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, auth,
); );
await service.refreshTopChats(); await expect(service.refreshTopChats()).rejects.toThrow(
'Matrix authentication could not be restored',
);
expect(matrix.sync).not.toHaveBeenCalled(); 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'); 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 () => { it('upgrades cached chats with t2_ usernames from the db map on loadCache', async () => {
const db = new Database(':memory:'); const db = new Database(':memory:');
runMigrations(db); runMigrations(db);
+78 -113
View File
@@ -1,128 +1,93 @@
import fs from 'fs'; import Database from 'better-sqlite3';
import os from 'os'; import fs from 'node:fs';
import path from 'path'; import os from 'node:os';
import YAML from 'yaml'; import path from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { persistSubreddits } from '../src/config/persistence.js'; 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 describe('ConfigStore', () => {
reddit: let db: Database.Database;
# weights bias picking let store: ConfigStore;
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;
beforeEach(() => { beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rb-config-')); db = new Database(':memory:');
configPath = path.join(tmpDir, 'config.yaml'); runMigrations(db);
fs.writeFileSync(configPath, SAMPLE, 'utf-8'); store = new ConfigStore(db);
}); });
afterEach(() => { afterEach(() => db.close());
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('mutates an existing row in place and preserves its inline comment', () => { it('round-trips application configuration through SQLite', () => {
persistSubreddits( const config = getTestConfig({
[ rateLimit: { minDelayBetweenMessagesMs: 42, maxMessagesPerHour: 7, jitterMs: 3 },
{ 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',
}); });
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", () => { it('never stores secrets in the database record', () => {
persistSubreddits( const config = getTestConfig();
[{ name: 'OnlyOne', type: 'image', weight: 1, tone: 'default' }], config.openrouter.apiKey = 'database-must-not-contain-this';
configPath, config.web.authToken = 'also-secret';
); store.save(config);
const out = fs.readFileSync(configPath, 'utf-8');
expect(out).not.toMatch(/tone:\s*default/); const row = db.prepare('SELECT config_json FROM app_config WHERE id = 1').get() as { config_json: string };
expect(out).not.toMatch(/type:\s*image/); 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', () => { it('updates the singleton record instead of appending versions', () => {
persistSubreddits( store.save(getTestConfig());
[{ name: 'OnlyOne', type: 'image', weight: 1, tone: 'direct' }], store.save(getTestConfig({ browser: { sessionDir: './data/browser-session', dryRun: true } }));
configPath,
); expect(db.prepare('SELECT COUNT(*) AS count FROM app_config').get()).toEqual({ count: 1 });
expect(fs.existsSync(configPath + '.tmp')).toBe(false); expect(store.load({
OPENROUTER_API_KEY: 'key',
WEB_DASHBOARD_TOKEN: 'dashboard-token',
})?.browser.dryRun).toBe(true);
}); });
it('reorders rows according to input order', () => { it('imports legacy YAML once and then starts without the file', () => {
persistSubreddits( const originalDirectory = process.cwd();
[ const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'redditbot-config-import-'));
{ name: 'tightandtoned', type: 'image', weight: 1, tone: 'default' }, const previousApiKey = process.env.OPENROUTER_API_KEY;
{ name: 'AverageNudes', type: 'image', weight: 4, tone: 'direct' }, const previousDashboardToken = process.env.WEB_DASHBOARD_TOKEN;
{ name: 'gonewild40plus', type: 'image', weight: 4, tone: 'gentle' }, try {
{ name: 'normalnudes', type: 'image', weight: 3, tone: 'default' }, process.chdir(tempDirectory);
], process.env.OPENROUTER_API_KEY = 'environment-key';
configPath, process.env.WEB_DASHBOARD_TOKEN = 'environment-dashboard-token';
); fs.writeFileSync('config.yaml', `
const parsed = YAML.parse(fs.readFileSync(configPath, 'utf-8')); reddit:
expect(parsed.reddit.subreddits.map((s: { name: string }) => s.name)).toEqual([ subreddits: [example]
'tightandtoned', openrouter:
'AverageNudes', imageAnalysisPrompt: analyze
'gonewild40plus', messageGenerationPrompt: compose
'normalnudes', `);
]);
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(screen.getByText('Dry run')).toBeInTheDocument();
expect(await screen.findByRole('list', { name: 'Recent sent messages' })).toHaveTextContent('A recent opener'); 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('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 () => { 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); 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 () => { it('styles the recent-messages retry control when its request fails', async () => {
installSuccessfulApi(); installSuccessfulApi();
server.use(http.get('/api/messages', () => HttpResponse.json({ error: 'Messages unavailable' }, { status: 503 }))); server.use(http.get('/api/messages', () => HttpResponse.json({ error: 'Messages unavailable' }, { status: 503 })));
+56
View File
@@ -15,6 +15,7 @@ interface ChatFixtureOptions {
unread?: boolean; unread?: boolean;
isFromUs?: boolean; isFromUs?: boolean;
lastMessageSnippet?: string; lastMessageSnippet?: string;
hasReceivedImages?: boolean;
} }
function chatFixture(options: ChatFixtureOptions) { function chatFixture(options: ChatFixtureOptions) {
@@ -27,6 +28,7 @@ function chatFixture(options: ChatFixtureOptions) {
lastMessageSnippet: options.lastMessageSnippet ?? `Latest from ${options.username}`, lastMessageSnippet: options.lastMessageSnippet ?? `Latest from ${options.username}`,
isFromUs: options.isFromUs ?? false, isFromUs: options.isFromUs ?? false,
unread: options.unread ?? false, unread: options.unread ?? false,
...(options.hasReceivedImages === undefined ? {} : { hasReceivedImages: options.hasReceivedImages }),
}; };
} }
@@ -45,6 +47,7 @@ function detailFixture(chat = alice) {
subreddit: 'casualconversation', subreddit: 'casualconversation',
title: 'Looking for a chat', title: 'Looking for a chat',
url: 'https://reddit.com/r/casualconversation/comments/post-1', url: 'https://reddit.com/r/casualconversation/comments/post-1',
imageUrl: 'https://i.redd.it/source-post.jpg',
sentAt: 1_785_999_800_000, sentAt: 1_785_999_800_000,
}, },
syncInProgress: null, syncInProgress: null,
@@ -123,8 +126,59 @@ describe('ConversationsPage', () => {
await user.click(screen.getByRole('button', { name: /u\/alice/ })); await user.click(screen.getByRole('button', { name: /u\/alice/ }));
expect(await screen.findByRole('heading', { name: 'u/alice' })).toBeInTheDocument(); 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.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('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 () => { 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' })); await user.click(screen.getByRole('button', { name: 'Start conversation' }));
expect(await screen.findByRole('status', { name: 'Conversation started' })).toHaveTextContent('Conversation started with u/charlie'); 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(username).toHaveValue('');
expect(message).toHaveValue(''); expect(message).toHaveValue('');
expect(startRequests).toBe(2); 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 aliceImage = await screen.findByRole('img', { name: 'Media from alice' });
const charlieImage = screen.getByRole('img', { name: 'Media from charlie' }); 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(aliceImage).toHaveClass('is-blurred');
expect(charlieImage).toHaveClass('is-blurred'); expect(charlieImage).toHaveClass('is-blurred');
expect(credentials.every((value) => value === 'same-origin')).toBe(true); expect(credentials.every((value) => value === 'same-origin')).toBe(true);
@@ -124,7 +124,9 @@ describe('OperationsPage', () => {
expect(await screen.findByRole('heading', { name: 'Operations' })).toBeInTheDocument(); expect(await screen.findByRole('heading', { name: 'Operations' })).toBeInTheDocument();
const activityRegion = screen.getByRole('region', { name: 'Activity' }); 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' }); const jobsRegion = screen.getByRole('region', { name: 'Sync jobs' });
expect(await within(jobsRegion).findByText('4 of 10')).toBeInTheDocument(); expect(await within(jobsRegion).findByText('4 of 10')).toBeInTheDocument();
for (const name of ['Sync latest chats', 'Full cache sync', 'Fetch all chats']) { 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', type: 'sync.progress',
payload: { syncId: 'sync-1', kind: 'media', completed: 2, total: 5 }, 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([ ).toEqual([
['operations', 'sync-status'], ['operations', 'sync-status'],
['conversations'], ['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 () => { it('hydrates and writes conversation search and status filters', async () => {
server.use(http.get('/api/conversations', () => HttpResponse.json([]))); server.use(http.get('/api/conversations', () => HttpResponse.json([])));
const user = userEvent.setup(); 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' }); const search = await screen.findByRole('searchbox', { name: 'Search conversations' });
expect(search).toHaveValue('alice'); 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.clear(search);
await user.type(search, 'bob'); await user.type(search, 'bob');
expect(screen.getByLabelText('Current URL')).toHaveTextContent('q=bob'); expect(screen.getByLabelText('Current URL')).toHaveTextContent('q=bob');
+24
View File
@@ -16,6 +16,7 @@ const config = {
reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60 }, reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60 },
subreddits: [{ name: 'casualconversation', type: 'chat', weight: 2, tone: 'default' }], subreddits: [{ name: 'casualconversation', type: 'chat', weight: 2, tone: 'default' }],
tones: ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing'], tones: ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing'],
advanced: { openrouter: { chatModel: 'example/chat-model' } },
}; };
const server = setupServer(); const server = setupServer();
@@ -228,6 +229,25 @@ describe('settings and access', () => {
expect(updateRequests).toBe(1); 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 () => { it('confirms unusually large hourly limits', async () => {
installConfigApi(); installConfigApi();
let updateRequests = 0; let updateRequests = 0;
@@ -287,6 +307,10 @@ describe('settings and access', () => {
expect(importRequests).toBe(1); expect(importRequests).toBe(1);
expect(await screen.findByText('Authenticated')).toBeInTheDocument(); expect(await screen.findByText('Authenticated')).toBeInTheDocument();
expect(screen.getByText(/u\/example-account/)).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' })); await user.click(screen.getByRole('button', { name: 'Recheck session' }));
expect(statusRequests).toBeGreaterThanOrEqual(2); expect(statusRequests).toBeGreaterThanOrEqual(2);
+8 -2
View File
@@ -105,16 +105,18 @@ describe('Database Client', () => {
const tables = db const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
.all() as { name: string }[]; .all() as { name: string }[];
// processed_posts, sent_messages, successful_messages, activity_log, reddit_users, delivery_outbox, conversation_states, conversation_outcomes // Eight domain tables plus the singleton application configuration table.
expect(tables.length).toBe(8); expect(tables.length).toBe(9);
}); });
it('tracks schema version and migrates a legacy sent_messages table transactionally', () => { it('tracks schema version and migrates a legacy sent_messages table transactionally', () => {
db.exec(` 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 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 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 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 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); 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 }; 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(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 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', () => { it('processed_posts has correct schema', () => {
+61 -2
View File
@@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { MatrixClient, MatrixRateLimitError } from '../src/services/chat/matrix/client.js'; import {
MatrixAuthError,
MatrixClient,
MatrixRateLimitError,
} from '../src/services/chat/matrix/client.js';
import { createMockLogger } from './mockLogger.js'; import { createMockLogger } from './mockLogger.js';
const creds = { const creds = {
@@ -10,6 +14,31 @@ const creds = {
}; };
describe('MatrixClient HTTP timeout', () => { 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 () => { 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 // 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. // end it. If the client lacked an AbortSignal, this test would hang.
@@ -64,6 +93,36 @@ describe('MatrixClient HTTP timeout', () => {
}, 10_000); }, 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', () => { describe('MatrixClient 429 handling', () => {
it('throws MatrixRateLimitError on a long retry_after rather than sleeping it out', async () => { it('throws MatrixRateLimitError on a long retry_after rather than sleeping it out', async () => {
const fetchImpl = (() => 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 { describe, it, expect, vi, beforeEach } from 'vitest';
import axios from 'axios'; import axios from 'axios';
import type { AxiosInstance } from 'axios';
import type { BrowserContext } from 'playwright';
import { RedditApiClient } from '../src/reddit/api.js'; import { RedditApiClient } from '../src/reddit/api.js';
import type { RedditPost } from '../src/reddit/api.js'; import type { RedditPost } from '../src/reddit/api.js';
import { createMockLogger } from '../tests/mockLogger.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', () => { describe('getNewPosts', () => {
it('returns empty array for 404 subreddit', async () => { it('returns empty array for 404 subreddit', async () => {
const error = { isAxiosError: true, response: { status: 404 } }; const error = { isAxiosError: true, response: { status: 404 } };
+8 -2
View File
@@ -27,6 +27,7 @@ describe('PostsRepository', () => {
subreddit: 'test', subreddit: 'test',
author: 'user1', author: 'user1',
title: 'Test Post', title: 'Test Post',
postUrl: 'https://www.reddit.com/r/test/comments/post1/test_post/',
imageUrl: 'http://example.com/img.jpg', imageUrl: 'http://example.com/img.jpg',
processedAt: Date.now(), processedAt: Date.now(),
}); });
@@ -37,6 +38,7 @@ describe('PostsRepository', () => {
expect(post!.subreddit).toBe('test'); expect(post!.subreddit).toBe('test');
expect(post!.author).toBe('user1'); expect(post!.author).toBe('user1');
expect(post!.title).toBe('Test Post'); 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!.imageUrl).toBe('http://example.com/img.jpg');
expect(post!.messageSent).toBe(false); expect(post!.messageSent).toBe(false);
expect(post!.imageAnalysis).toBeNull(); expect(post!.imageAnalysis).toBeNull();
@@ -203,7 +205,7 @@ describe('MessagesRepository', () => {
expect(updated!.gotResponse).toBe(true); 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({ messagesRepo.insertChat({
recipient: 'bob', recipient: 'bob',
subject: 'bob', subject: 'bob',
@@ -213,6 +215,7 @@ describe('MessagesRepository', () => {
}); });
const recent = messagesRepo.getRecent(10); const recent = messagesRepo.getRecent(10);
expect(recent.some((m) => m.chatRoomId === '!r1:reddit.com')).toBe(true); expect(recent.some((m) => m.chatRoomId === '!r1:reddit.com')).toBe(true);
expect(messagesRepo.getResponseRateTrend()).toEqual([]);
}); });
it('inserts and retrieves a message', () => { it('inserts and retrieves a message', () => {
@@ -305,19 +308,22 @@ describe('MessagesRepository', () => {
subreddit: 'gonewild', subreddit: 'gonewild',
author: 'alice', author: 'alice',
title: 'Second post', title: 'Second post',
postUrl: 'https://www.reddit.com/r/gonewild/comments/post-b/second_post/',
imageUrl: null, imageUrl: null,
processedAt: Date.now(), processedAt: Date.now(),
}); });
const base = Date.now(); const base = Date.now();
messagesRepo.insert({ postId: 'post-a', recipient: 'alice', subject: 's', message: 'm', sentAt: base }); 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'); const latest = messagesRepo.getLatestByRecipient('alice');
expect(latest).toBeDefined(); expect(latest).toBeDefined();
expect(latest!.postId).toBe('post-b'); expect(latest!.postId).toBe('post-b');
expect(latest!.subreddit).toBe('gonewild'); expect(latest!.subreddit).toBe('gonewild');
expect(latest!.postTitle).toBe('Second post'); 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('ALICE')).toBeDefined();
expect(messagesRepo.getLatestByRecipient('nobody')).toBeUndefined(); expect(messagesRepo.getLatestByRecipient('nobody')).toBeUndefined();
+33
View File
@@ -161,6 +161,39 @@ describe('ResponseCheckerService', () => {
expect(messagesRepo.markChecked).toHaveBeenCalled(); 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 () => { it('stores successful messages for learning', async () => {
const post: ProcessedPostRecord = { const post: ProcessedPostRecord = {
id: 'post1', 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();
});
});