feat: persist settings and enrich conversation context
This commit is contained in:
1 parent
a36723a325
commit
2c100d72b6
84 files changed
+2247
-643
No files matched your search
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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
@@ -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'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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
@@ -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`. */
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface ActiveChat {
|
|||||||
roomId?: string;
|
roomId?: string;
|
||||||
messages?: ChatMessage[];
|
messages?: ChatMessage[];
|
||||||
originMessage?: string;
|
originMessage?: string;
|
||||||
|
hasReceivedImages?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}`,
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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 })));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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']) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 } };
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user