feat: support concurrent Reddit accounts

This commit is contained in:
jjbrunton committed 2026-08-08 19:00:38 +01:00
1 parent d819a0f1e6
commit 16a31335d0
51 files changed
+3233 -242

No files matched your search

@@ -0,0 +1,11 @@
import type { RedditAccountOwner } from '../../../../packages/api-contracts/src/index.js';
import { RedditUsernameLink } from './reddit-username-link.js';
export function AccountOwnerBadge({ owner }: { owner?: RedditAccountOwner }) {
return (
<span className={owner ? 'account-owner-badge' : 'account-owner-badge is-unassigned'}>
<span aria-hidden="true">Account</span>
{owner ? <RedditUsernameLink username={owner.redditUsername} /> : <strong>Unassigned legacy content</strong>}
</span>
);
}
@@ -36,7 +36,7 @@ export interface ConversationApi {
suggestMediaAsk(id: string): Promise<string>; suggestMediaAsk(id: string): Promise<string>;
processedPosts(username: string): Promise<ProcessedPost[]>; processedPosts(username: string): Promise<ProcessedPost[]>;
sendReply(id: string, message: string): Promise<SuccessResponse>; sendReply(id: string, message: string): Promise<SuccessResponse>;
startConversation(username: string, message: string): Promise<ActiveChat>; startConversation(accountId: string, username: string, message: string): Promise<ActiveChat>;
} }
export function createConversationApi(fetchImplementation?: typeof fetch): ConversationApi { export function createConversationApi(fetchImplementation?: typeof fetch): ConversationApi {
@@ -83,8 +83,9 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve
SuccessResponseSchema, SuccessResponseSchema,
); );
}, },
async startConversation(username, message) { async startConversation(accountId, username, message) {
const body = StartConversationRequestSchema.parse({ const body = StartConversationRequestSchema.parse({
accountId,
username: username.trim().replace(/^u\//i, ''), username: username.trim().replace(/^u\//i, ''),
message: message.trim(), message: message.trim(),
}); });
@@ -4,6 +4,7 @@ import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit
import { OriginPostCard } from '../../components/origin-post.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';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
interface ConversationDetailProps { interface ConversationDetailProps {
conversationId: string; conversationId: string;
@@ -15,7 +16,7 @@ function formatMessageTime(timestamp: number): string {
} }
function mediaPath(localPath: string): string { function mediaPath(localPath: string): string {
return `/media/${localPath.split('/').map(encodeURIComponent).join('/')}`; return `/media/${localPath.split(/[\\/]/).map(encodeURIComponent).join('/')}`;
} }
function MessageHistory({ conversation }: { conversation: ConversationDetailResponse }) { function MessageHistory({ conversation }: { conversation: ConversationDetailResponse }) {
@@ -28,7 +29,9 @@ 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' : <RedditUsernameLink username={conversation.username} />}</strong> <strong>{message.isSelf
? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}`
: <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>
@@ -50,14 +53,14 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp
); );
} }
function ConversationComposer({ conversation }: { conversation: ConversationDetailResponse }) { function ConversationComposer({ conversation, conversationId }: { conversation: ConversationDetailResponse; conversationId: string }) {
const [draft, setDraft] = useState(''); const [draft, setDraft] = useState('');
const [isConfirming, setIsConfirming] = useState(false); const [isConfirming, setIsConfirming] = useState(false);
const [wasSent, setWasSent] = useState(false); const [wasSent, setWasSent] = useState(false);
const sendButtonRef = useRef<HTMLButtonElement>(null); const sendButtonRef = useRef<HTMLButtonElement>(null);
const suggestionMutation = useSuggestReplyMutation(conversation.roomId ?? conversation.username); const suggestionMutation = useSuggestReplyMutation(conversationId);
const mediaAskMutation = useSuggestMediaAskMutation(conversation.roomId ?? conversation.username); const mediaAskMutation = useSuggestMediaAskMutation(conversationId);
const sendMutation = useSendReplyMutation(conversation.roomId ?? conversation.username); const sendMutation = useSendReplyMutation(conversationId);
const canSend = draft.trim().length > 0 && !sendMutation.isPending; const canSend = draft.trim().length > 0 && !sendMutation.isPending;
const closeConfirmation = useCallback(() => { const closeConfirmation = useCallback(() => {
@@ -162,6 +165,7 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
<div> <div>
<span className="conversation-eyebrow">Conversation</span> <span className="conversation-eyebrow">Conversation</span>
<h2><RedditUsernameLink username={conversation.username} /></h2> <h2><RedditUsernameLink username={conversation.username} /></h2>
<AccountOwnerBadge owner={conversation.owner} />
{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>
@@ -182,7 +186,7 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
</div> </div>
) : null} ) : null}
<MessageHistory conversation={conversation} /> <MessageHistory conversation={conversation} />
<ConversationComposer conversation={conversation} /> <ConversationComposer conversation={conversation} conversationId={conversationId} />
</article> </article>
); );
} }
@@ -0,0 +1,8 @@
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
export function ownedConversationId(conversation: ActiveChat): string {
const conversationId = conversation.roomId ?? conversation.username;
return conversation.owner
? `${encodeURIComponent(conversation.owner.accountId)}~${encodeURIComponent(conversationId)}`
: conversationId;
}
@@ -51,8 +51,8 @@ export function useSendReplyMutation(id: string) {
export function useStartConversationMutation() { export function useStartConversationMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ username, message }: { username: string; message: string }) => mutationFn: ({ accountId, username, message }: { accountId: string; username: string; message: string }) =>
conversationApi.startConversation(username, message), conversationApi.startConversation(accountId, username, message),
async onSuccess() { async onSuccess() {
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all }); await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
}, },
@@ -5,7 +5,9 @@ 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 { AccountOwnerBadge } from '../../components/account-owner-badge.js';
import './conversations.css'; import './conversations.css';
import { ownedConversationId } from './conversation-identity.js';
export type ConversationFilter = 'all' | 'unread' | 'awaiting-reply' | 'received-images'; export type ConversationFilter = 'all' | 'unread' | 'awaiting-reply' | 'received-images';
@@ -21,10 +23,6 @@ export interface ConversationsPageProps {
onFiltersChange?(filters: ConversationFilters): void; onFiltersChange?(filters: ConversationFilters): void;
} }
function conversationId(conversation: ActiveChat): string {
return conversation.roomId ?? conversation.username;
}
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;
@@ -48,6 +46,7 @@ function ConversationListItem({ conversation, isSelected, onSelect }: {
type="button" type="button"
/> />
<span className="conversation-list-heading"><strong><RedditUsernameLink username={conversation.username} /></strong><small>{state}</small></span> <span className="conversation-list-heading"><strong><RedditUsernameLink username={conversation.username} /></strong><small>{state}</small></span>
<AccountOwnerBadge owner={conversation.owner} />
{conversation.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null} {conversation.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null}
<span>{conversation.lastMessageSnippet || 'No message preview'}</span> <span>{conversation.lastMessageSnippet || 'No message preview'}</span>
</li> </li>
@@ -143,9 +142,9 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
{visibleConversations.map((conversation) => ( {visibleConversations.map((conversation) => (
<ConversationListItem <ConversationListItem
conversation={conversation} conversation={conversation}
isSelected={conversationId(conversation) === activeConversationId} isSelected={ownedConversationId(conversation) === activeConversationId}
key={conversationId(conversation)} key={ownedConversationId(conversation)}
onSelect={() => selectConversation(conversationId(conversation))} onSelect={() => selectConversation(ownedConversationId(conversation))}
/> />
))} ))}
</ul> </ul>
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js'; import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
import { useProcessedPostsQuery } from './conversation-queries.js'; import { useProcessedPostsQuery } from './conversation-queries.js';
@@ -15,12 +16,12 @@ export function ProcessedUserLookup() {
<input id="processed-user" onChange={(event) => setInput(event.target.value)} value={input} /> <input id="processed-user" onChange={(event) => setInput(event.target.value)} value={input} />
<button disabled={input.trim().length === 0} type="submit">Look up user</button> <button disabled={input.trim().length === 0} type="submit">Look up user</button>
</form> </form>
{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 <RedditUsernameLink username={username} />.</p> : ( postsQuery.data.length === 0 ? <p>No processed posts found for <RedditUsernameLink username={username} />.</p> : (
<ol aria-label={`Processed posts for ${redditUsernameLabel(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.owner?.accountId ?? 'legacy'}:${post.id}`}><strong>{post.title}</strong><AccountOwnerBadge owner={post.owner} /><span>r/{post.subreddit} · {post.messageSent ? 'Message sent' : post.error ?? 'Processed'}</span></li>)}
</ol> </ol>
) )
) : null} ) : null}
@@ -1,6 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { RedditUsernameLink } from '../../components/reddit-username-link.js'; import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { useStartConversationMutation } from './conversation-queries.js'; import { useStartConversationMutation } from './conversation-queries.js';
import { useRedditAccountsQuery } from '../settings/settings-queries.js';
import { ownedConversationId } from './conversation-identity.js';
interface StartConversationFormProps { interface StartConversationFormProps {
onStarted(conversationId: string): void; onStarted(conversationId: string): void;
@@ -9,21 +11,25 @@ interface StartConversationFormProps {
export function StartConversationForm({ onStarted }: StartConversationFormProps) { export function StartConversationForm({ onStarted }: StartConversationFormProps) {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const accountsQuery = useRedditAccountsQuery();
const runningAccounts = (accountsQuery.data ?? []).filter((account) => account.isRunning);
const [selectedAccountId, setSelectedAccountId] = useState('');
const accountId = selectedAccountId || runningAccounts[0]?.accountId || '';
const [successUsername, setSuccessUsername] = useState<string | null>(null); const [successUsername, setSuccessUsername] = useState<string | null>(null);
const mutation = useStartConversationMutation(); const mutation = useStartConversationMutation();
const canSubmit = username.trim().length > 0 && message.trim().length > 0 && !mutation.isPending; const canSubmit = accountId.length > 0 && username.trim().length > 0 && message.trim().length > 0 && !mutation.isPending;
function submit(event: React.FormEvent<HTMLFormElement>) { function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!canSubmit) return; if (!canSubmit) return;
mutation.mutate( mutation.mutate(
{ username, message }, { accountId, username, message },
{ {
onSuccess(chat) { onSuccess(chat) {
setSuccessUsername(chat.username); setSuccessUsername(chat.username);
setUsername(''); setUsername('');
setMessage(''); setMessage('');
onStarted(chat.roomId ?? chat.username); onStarted(ownedConversationId(chat));
}, },
}, },
); );
@@ -31,6 +37,12 @@ export function StartConversationForm({ onStarted }: StartConversationFormProps)
return ( return (
<form className="start-conversation-form" onSubmit={submit}> <form className="start-conversation-form" onSubmit={submit}>
<div className="conversation-field">
<label htmlFor="new-conversation-account">Send from account</label>
<select id="new-conversation-account" onChange={(event) => setSelectedAccountId(event.target.value)} value={accountId}>
{runningAccounts.map((account) => <option key={account.accountId} value={account.accountId}>u/{account.redditUsername}</option>)}
</select>
</div>
<div className="conversation-field"> <div className="conversation-field">
<label htmlFor="new-conversation-username">Reddit username</label> <label htmlFor="new-conversation-username">Reddit username</label>
<input <input
@@ -1,6 +1,7 @@
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 { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { mediaConversation, mediaType } from './filters.js'; import { mediaConversation, mediaType } from './filters.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
interface MediaCardProps { interface MediaCardProps {
item: MediaItem; item: MediaItem;
@@ -29,6 +30,7 @@ export function MediaCard({ item, isRevealed, onToggleReveal, onOpenPreview }: M
{!isRevealed ? <span className="privacy-label">Private media hidden</span> : null} {!isRevealed ? <span className="privacy-label">Private media hidden</span> : null}
</div> </div>
<div className="media-card-body"> <div className="media-card-body">
<AccountOwnerBadge owner={item.owner} />
<div> <div>
<strong><RedditUsernameLink showPrefix={false} username={item.sender} /></strong> <strong><RedditUsernameLink showPrefix={false} username={item.sender} /></strong>
<span>{item.chatUsername <span>{item.chatUsername
@@ -4,6 +4,7 @@ import { useDashboardRealtime } from '../../app/realtime-provider.js';
import { RedditUsernameLink } from '../../components/reddit-username-link.js'; import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { RedditUserText } from '../../components/reddit-user-text.js'; import { RedditUserText } from '../../components/reddit-user-text.js';
import { OriginPostButton } from '../../components/origin-post.js'; import { OriginPostButton } from '../../components/origin-post.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
import { import {
createLiveConsoleState, createLiveConsoleState,
reduceLiveConsoleState, reduceLiveConsoleState,
@@ -30,6 +31,7 @@ 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" />
<AccountOwnerBadge owner={item.owner} />
<div><strong>{item.postUrl && item.postTitle ? ( <div><strong>{item.postUrl && item.postTitle ? (
<OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}> <OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}>
{item.message} {item.message}
@@ -6,6 +6,7 @@ import { ConfirmationDialog } from '../../components/confirmation-dialog.js';
import { RedditUserText } from '../../components/reddit-user-text.js'; import { RedditUserText } from '../../components/reddit-user-text.js';
import { OriginPostButton } from '../../components/origin-post.js'; import { OriginPostButton } from '../../components/origin-post.js';
import { RecentMessagesPanel } from './recent-messages.js'; import { RecentMessagesPanel } from './recent-messages.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
function errorMessage(error: Error | null): string { function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.'; return error?.message ?? 'Something went wrong.';
@@ -77,7 +78,7 @@ function ActivityPanel({ stats }: { stats: BotStats }) {
<OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}> <OriginPostButton post={{ title: item.postTitle, url: item.postUrl, imageUrl: item.imageUrl }}>
{item.message} {item.message}
</OriginPostButton> </OriginPostButton>
) : <RedditUserText>{item.message}</RedditUserText>}</strong><small>{formatTime(item.timestamp)}</small></div> ) : <RedditUserText>{item.message}</RedditUserText>}</strong><AccountOwnerBadge owner={item.owner} /><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 { AccountOwnerBadge } from '../../components/account-owner-badge.js';
import { RedditUsernameLink } from '../../components/reddit-username-link.js'; import { RedditUsernameLink } from '../../components/reddit-username-link.js';
async function loadRecentMessages(): Promise<SentMessage[]> { async function loadRecentMessages(): Promise<SentMessage[]> {
@@ -19,13 +20,13 @@ export function RecentMessagesPanel() {
return ( return (
<section aria-labelledby="recent-messages-heading" className="panel"> <section aria-labelledby="recent-messages-heading" className="panel">
<div className="panel-heading"><div><span className="eyebrow">Durable records</span><h2 id="recent-messages-heading">Recent sent messages</h2></div></div> <div className="panel-heading"><div><span className="eyebrow">Durable records</span><h2 id="recent-messages-heading">Recent sent messages</h2></div></div>
{query.isPending ? <p role="status">Loading recent messages…</p> : null} {query.isPending ? <p role="status">Loading recent messages...</p> : null}
{query.isError ? <div role="alert"><span>{query.error.message}</span><button className="button secondary" onClick={() => void query.refetch()} type="button">Retry messages</button></div> : null} {query.isError ? <div role="alert"><span>{query.error.message}</span><button className="button secondary" onClick={() => void query.refetch()} type="button">Retry messages</button></div> : null}
{query.data?.length === 0 ? <p>No sent messages recorded yet.</p> : null} {query.data?.length === 0 ? <p>No sent messages recorded yet.</p> : null}
{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><RedditUsernameLink username={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><AccountOwnerBadge owner={message.owner} /><span>{message.message}</span><small>{formatSentAt(message.sentAt)} · {message.gotResponse ? 'Responded' : 'No response recorded'}</small></div></li>
))} ))}
</ol> </ol>
) : null} ) : null}
@@ -1,6 +1,10 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { RedditUsernameLink } from '../../components/reddit-username-link.js'; import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { useImportRedditSessionMutation, useLoginStatusQuery } from './settings-queries.js'; import {
useImportRedditSessionMutation,
useLoginStatusQuery,
useRedditAccountsQuery,
} from './settings-queries.js';
function errorMessage(error: Error | null): string { function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.'; return error?.message ?? 'Something went wrong.';
@@ -20,6 +24,7 @@ function sessionLabel(status: ReturnType<typeof useLoginStatusQuery>['data']): s
export function AccessPanel() { export function AccessPanel() {
const statusQuery = useLoginStatusQuery(); const statusQuery = useLoginStatusQuery();
const accountsQuery = useRedditAccountsQuery();
const importMutation = useImportRedditSessionMutation(); const importMutation = useImportRedditSessionMutation();
const importInput = useRef<HTMLInputElement>(null); const importInput = useRef<HTMLInputElement>(null);
@@ -35,7 +40,7 @@ export function AccessPanel() {
} }
if (statusQuery.isPending) { if (statusQuery.isPending) {
return <section aria-label="Loading access" className="panel panel-loading" role="status"><span className="spinner" />Loading Reddit access…</section>; return <section aria-label="Loading access" className="panel panel-loading" role="status"><span className="spinner" />Loading Reddit access...</section>;
} }
if (statusQuery.isError) { if (statusQuery.isError) {
@@ -51,16 +56,25 @@ export function AccessPanel() {
const username = session.ready && 'loggedIn' in session && session.loggedIn ? session.username ?? null : null; const username = session.ready && 'loggedIn' in session && session.loggedIn ? session.username ?? null : null;
return ( return (
<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 accounts</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">Add each Reddit account with its own extension session dump. Content and sync state stay owned by that account in the database. Any account with an expired login pauses while waiting for a fresh session dump; other accounts keep running.</p>
{username ? <p className="settings-copy">Signed in as <strong><RedditUsernameLink username={username} /></strong>.</p> : null} {username ? <p className="settings-copy">Verified account: <strong><RedditUsernameLink username={username} /></strong>.</p> : null}
<div aria-label="Reddit accounts" className="reddit-account-list">
{(accountsQuery.data ?? []).map((account) => (
<div className={account.isRunning ? 'reddit-account-row is-active' : 'reddit-account-row'} key={account.accountId}>
<div><strong><RedditUsernameLink username={account.redditUsername} /></strong><span>{account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}</span></div>
<span className={account.isRunning ? 'pill success' : 'pill warning'}>{account.isRunning ? 'Running' : 'Paused'}</span>
</div>
))}
{accountsQuery.isSuccess && accountsQuery.data.length === 0 ? <p className="settings-copy">No Reddit accounts have been added yet.</p> : null}
</div>
<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 ? 'Adding account...' : 'Add or refresh account'}</button>
<button className="button secondary" disabled={statusQuery.isFetching} onClick={() => void statusQuery.refetch()} type="button">{statusQuery.isFetching ? 'Checking session…' : 'Recheck session'}</button> <button className="button secondary" disabled={statusQuery.isFetching} onClick={() => void statusQuery.refetch()} type="button">{statusQuery.isFetching ? 'Checking sessions...' : 'Recheck accounts'}</button>
</div> </div>
<p className="settings-copy">Open Reddit Chat in Chrome, wait for the extension badge to show OK, then upload its JSON dump here. It contains login credentials; do not share it.</p> <p className="settings-copy">Open Reddit Chat in Chrome for the account you want to add, wait for the extension badge to show OK, then upload its JSON dump. It contains login credentials; do not share it.</p>
{importMutation.isSuccess ? <div className="settings-notice success" role="status">Reddit session imported and verified.</div> : null} {importMutation.isSuccess ? <div className="settings-notice success" role="status"><span>Reddit session imported and verified.</span> <span>Account runtime is running independently.</span></div> : null}
{importMutation.isError ? <div className="settings-notice error" role="alert">{errorMessage(importMutation.error)}</div> : null} {importMutation.isError ? <div className="settings-notice error" role="alert">{errorMessage(importMutation.error)}</div> : null}
</section> </section>
); );
@@ -5,11 +5,13 @@ import {
ConfigUpdateResponseSchema, ConfigUpdateResponseSchema,
ErrorResponseSchema, ErrorResponseSchema,
LoginStatusResponseSchema, LoginStatusResponseSchema,
RedditAccountsResponseSchema,
RedditCookieImportRequestSchema, RedditCookieImportRequestSchema,
RedditCookieImportResponseSchema, RedditCookieImportResponseSchema,
type ConfigResponse, type ConfigResponse,
type ConfigUpdateRequest, type ConfigUpdateRequest,
type LoginStatusResponse, type LoginStatusResponse,
type RedditAccountSummary,
} from '../../../../../packages/api-contracts/src/index.js'; } from '../../../../../packages/api-contracts/src/index.js';
import { dashboardRequest } from '../../api/request.js'; import { dashboardRequest } from '../../api/request.js';
@@ -46,7 +48,11 @@ export function fetchLoginStatus(): Promise<LoginStatusResponse> {
return request('/api/login/status').then((response) => parseResponse(response, LoginStatusResponseSchema)); return request('/api/login/status').then((response) => parseResponse(response, LoginStatusResponseSchema));
} }
export async function importRedditSession(exported: unknown): Promise<void> { export function fetchRedditAccounts(): Promise<RedditAccountSummary[]> {
return request('/api/accounts').then((response) => parseResponse(response, RedditAccountsResponseSchema));
}
export async function importRedditSession(exported: unknown): Promise<RedditAccountSummary> {
const body = RedditCookieImportRequestSchema.parse(exported); const body = RedditCookieImportRequestSchema.parse(exported);
const response = await parseResponse( const response = await parseResponse(
await request('/api/login/session-import', { await request('/api/login/session-import', {
@@ -57,4 +63,5 @@ export async function importRedditSession(exported: unknown): Promise<void> {
RedditCookieImportResponseSchema, RedditCookieImportResponseSchema,
); );
if (!response.ok) throw new Error(response.error); if (!response.ok) throw new Error(response.error);
return response.account;
} }
@@ -1,10 +1,11 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js'; import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
import { fetchLoginStatus, fetchSettings, importRedditSession, saveSettings } from './settings-api.js'; import { fetchLoginStatus, fetchRedditAccounts, fetchSettings, importRedditSession, saveSettings } from './settings-api.js';
export const settingsQueryKeys = { export const settingsQueryKeys = {
config: ['dashboard', 'config'] as const, config: ['dashboard', 'config'] as const,
loginStatus: ['dashboard', 'login', 'status'] as const, loginStatus: ['dashboard', 'login', 'status'] as const,
accounts: ['dashboard', 'reddit-accounts'] as const,
}; };
export function useSettingsQuery() { export function useSettingsQuery() {
@@ -30,12 +31,23 @@ export function useLoginStatusQuery() {
return useQuery({ queryKey: settingsQueryKeys.loginStatus, queryFn: fetchLoginStatus }); return useQuery({ queryKey: settingsQueryKeys.loginStatus, queryFn: fetchLoginStatus });
} }
export function useRedditAccountsQuery() {
return useQuery({ queryKey: settingsQueryKeys.accounts, queryFn: fetchRedditAccounts });
}
function invalidateAccountContent(queryClient: ReturnType<typeof useQueryClient>) {
void queryClient.invalidateQueries({ queryKey: settingsQueryKeys.loginStatus });
void queryClient.invalidateQueries({ queryKey: settingsQueryKeys.accounts });
void queryClient.invalidateQueries({ queryKey: ['dashboard', 'conversations'] });
void queryClient.invalidateQueries({ queryKey: ['dashboard', 'media'] });
}
export function useImportRedditSessionMutation() { export function useImportRedditSessionMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: importRedditSession, mutationFn: importRedditSession,
onSuccess() { onSuccess() {
void queryClient.invalidateQueries({ queryKey: settingsQueryKeys.loginStatus }); invalidateAccountContent(queryClient);
}, },
}); });
} }
@@ -46,3 +46,26 @@
@media (min-width: 70rem) { @media (min-width: 70rem) {
.settings-navigation { grid-template-columns: repeat(4, minmax(0, 1fr)); } .settings-navigation { grid-template-columns: repeat(4, minmax(0, 1fr)); }
} }
.reddit-account-list {
display: grid;
gap: .65rem;
}
.reddit-account-row {
align-items: center;
border: 1px solid var(--border-color, #d9d9d9);
border-radius: .75rem;
display: flex;
gap: 1rem;
justify-content: space-between;
padding: .75rem;
}
.reddit-account-row.is-active {
border-color: var(--accent, #ff6534);
}
.reddit-account-row > div {
display: grid;
gap: .2rem;
}
+17
View File
@@ -119,6 +119,23 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
.toast { position: fixed; right: 1rem; bottom: 1rem; max-width: 25rem; padding: .8rem 1rem; border-radius: .7rem; } .toast { position: fixed; right: 1rem; bottom: 1rem; max-width: 25rem; padding: .8rem 1rem; border-radius: .7rem; }
.toast.error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; } .toast.error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; }
.account-owner-badge {
align-items: center;
background: color-mix(in srgb, var(--accent, #ff6534) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--accent, #ff6534) 35%, transparent);
border-radius: 999px;
display: inline-flex;
font-size: .75rem;
gap: .35rem;
padding: .2rem .55rem;
width: fit-content;
}
.account-owner-badge.is-unassigned {
background: color-mix(in srgb, #d99a24 12%, transparent);
border-color: color-mix(in srgb, #d99a24 40%, transparent);
}
@media (min-width: 48rem) { @media (min-width: 48rem) {
.sidebar { visibility: visible; transform: none; } .sidebar { visibility: visible; transform: none; }
.workspace { margin-left: 17.5rem; } .workspace { margin-left: 17.5rem; }
+2
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { RedditAccountOwnerSchema } from './session.js';
export const ActivityTypeSchema = z.union([ export const ActivityTypeSchema = z.union([
z.literal('sent'), z.literal('sent'),
@@ -17,6 +18,7 @@ export const ActivityItemSchema = z.strictObject({
imageUrl: z.string().optional(), imageUrl: z.string().optional(),
postTitle: z.string().optional(), postTitle: z.string().optional(),
postUrl: z.string().optional(), postUrl: z.string().optional(),
owner: RedditAccountOwnerSchema.optional(),
}); });
export const ActivityResponseSchema = z.array(ActivityItemSchema); export const ActivityResponseSchema = z.array(ActivityItemSchema);
+3
View File
@@ -1,5 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { ErrorResponseSchema, RejectedActionResponseSchema } from './common.js'; import { ErrorResponseSchema, RejectedActionResponseSchema } from './common.js';
import { RedditAccountOwnerSchema } from './session.js';
export const ChatMediaSchema = z.strictObject({ export const ChatMediaSchema = z.strictObject({
originalUrl: z.string(), originalUrl: z.string(),
@@ -25,6 +26,7 @@ export const ActiveChatSchema = z.strictObject({
messages: z.array(ChatMessageSchema).optional(), messages: z.array(ChatMessageSchema).optional(),
originMessage: z.string().optional(), originMessage: z.string().optional(),
hasReceivedImages: z.boolean().optional(), hasReceivedImages: z.boolean().optional(),
owner: RedditAccountOwnerSchema.optional(),
}); });
export const ChatsResponseSchema = z.array(ActiveChatSchema); export const ChatsResponseSchema = z.array(ActiveChatSchema);
export const ConversationsResponseSchema = ChatsResponseSchema; export const ConversationsResponseSchema = ChatsResponseSchema;
@@ -75,6 +77,7 @@ export const ConversationDetailResponseSchema = ActiveChatSchema.extend({
export const SuggestedReplyResponseSchema = z.strictObject({ suggestedReply: z.string() }); export const SuggestedReplyResponseSchema = z.strictObject({ suggestedReply: z.string() });
export const SendReplyRequestSchema = z.strictObject({ message: z.string().min(1) }); export const SendReplyRequestSchema = z.strictObject({ message: z.string().min(1) });
export const StartConversationRequestSchema = z.strictObject({ export const StartConversationRequestSchema = z.strictObject({
accountId: z.string().min(1),
username: z.string().min(1), username: z.string().min(1),
message: z.string().min(1), message: z.string().min(1),
}); });
+2
View File
@@ -1,5 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { RejectedActionResponseSchema, SuccessResponseSchema } from './common.js'; import { RejectedActionResponseSchema, SuccessResponseSchema } from './common.js';
import { RedditAccountOwnerSchema } from './session.js';
export const MediaItemSchema = z.strictObject({ export const MediaItemSchema = z.strictObject({
id: z.string(), id: z.string(),
@@ -12,6 +13,7 @@ export const MediaItemSchema = z.strictObject({
relativePath: z.string(), relativePath: z.string(),
createdAt: z.number(), createdAt: z.number(),
urlPath: z.string(), urlPath: z.string(),
owner: RedditAccountOwnerSchema.optional(),
}); });
export const MediaResponseSchema = z.array(MediaItemSchema); export const MediaResponseSchema = z.array(MediaItemSchema);
export const MediaSyncStatusSchema = z.strictObject({ export const MediaSyncStatusSchema = z.strictObject({
+3
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { RedditAccountOwnerSchema } from './session.js';
export const SentMessageSchema = z.strictObject({ export const SentMessageSchema = z.strictObject({
id: z.number(), id: z.number(),
@@ -17,6 +18,7 @@ export const SentMessageSchema = z.strictObject({
postUrl: z.string().optional(), postUrl: z.string().optional(),
postImageUrl: z.string().optional(), postImageUrl: z.string().optional(),
postImageAnalysis: z.string().optional(), postImageAnalysis: z.string().optional(),
owner: RedditAccountOwnerSchema.optional(),
}); });
export const MessagesResponseSchema = z.array(SentMessageSchema); export const MessagesResponseSchema = z.array(SentMessageSchema);
@@ -32,6 +34,7 @@ export const ProcessedPostSchema = z.strictObject({
processedAt: z.number(), processedAt: z.number(),
messageSent: z.boolean(), messageSent: z.boolean(),
error: z.string().nullable(), error: z.string().nullable(),
owner: RedditAccountOwnerSchema.optional(),
}); });
export const ProcessedPostsResponseSchema = z.array(ProcessedPostSchema); export const ProcessedPostsResponseSchema = z.array(ProcessedPostSchema);
+27 -1
View File
@@ -6,12 +6,28 @@ export const RedditSessionStateSchema = z.union([
z.literal('login-required'), z.literal('login-required'),
z.literal('unreachable'), z.literal('unreachable'),
]); ]);
export const RedditAccountSummarySchema = z.strictObject({
accountId: z.string().min(1),
redditUsername: z.string().min(1),
matrixUserId: z.string().min(1),
isRunning: z.boolean(),
status: z.union([z.literal('authenticated'), z.literal('login-required')]),
createdAt: z.number(),
updatedAt: z.number(),
lastImportedAt: z.number(),
});
export const RedditAccountOwnerSchema = RedditAccountSummarySchema.pick({
accountId: true,
redditUsername: true,
});
export const RedditAccountsResponseSchema = z.array(RedditAccountSummarySchema);
export const LoginStatusResponseSchema = z.union([ export const LoginStatusResponseSchema = z.union([
z.strictObject({ ready: z.literal(false), error: z.string() }), z.strictObject({ ready: z.literal(false), error: z.string() }),
z.strictObject({ z.strictObject({
ready: z.literal(true), ready: z.literal(true),
loggedIn: z.boolean(), loggedIn: z.boolean(),
status: RedditSessionStateSchema, status: RedditSessionStateSchema,
accountId: z.string().min(1).nullable().optional(),
username: z.string().min(1).nullable().optional(), username: z.string().min(1).nullable().optional(),
}), }),
z.strictObject({ z.strictObject({
@@ -39,10 +55,20 @@ export const RedditCookieImportRequestSchema = z.strictObject({
}), }),
}); });
export const RedditCookieImportResponseSchema = z.union([ export const RedditCookieImportResponseSchema = z.union([
z.strictObject({ ok: z.literal(true), status: z.literal('authenticated') }), z.strictObject({
ok: z.literal(true),
status: z.literal('authenticated'),
account: RedditAccountSummarySchema,
}),
z.strictObject({ ok: z.literal(false), error: z.string() }),
]);
export const RedditAccountActivationResponseSchema = z.union([
z.strictObject({ ok: z.literal(true), account: RedditAccountSummarySchema }),
z.strictObject({ ok: z.literal(false), error: z.string() }), z.strictObject({ ok: z.literal(false), error: z.string() }),
]); ]);
export type RedditSessionState = z.infer<typeof RedditSessionStateSchema>; export type RedditSessionState = z.infer<typeof RedditSessionStateSchema>;
export type LoginStatusResponse = z.infer<typeof LoginStatusResponseSchema>; export type LoginStatusResponse = z.infer<typeof LoginStatusResponseSchema>;
export type RedditCookieImportRequest = z.infer<typeof RedditCookieImportRequestSchema>; export type RedditCookieImportRequest = z.infer<typeof RedditCookieImportRequestSchema>;
export type RedditAccountSummary = z.infer<typeof RedditAccountSummarySchema>;
export type RedditAccountOwner = z.infer<typeof RedditAccountOwnerSchema>;
+89 -11
View File
@@ -39,6 +39,13 @@ 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'; import { ConfigStore } from './config/persistence.js';
import { RedditAccountsRepository, AccountContentRepository } from './database/account-repositories.js';
import { RedditAccountsService } from './services/reddit-accounts.js';
import type { Cookie } from 'playwright';
import { ChatCache } from './services/chat/cache.js';
import { RedditAccountRuntimeManager } from './services/reddit-account-runtime.js';
import { createConcreteRedditAccountRuntimeFactory } from './services/reddit-account-runtime-builder.js';
import { importLegacyAccountContent } from './database/legacy-content-import.js';
export interface AppContainer { export interface AppContainer {
config: Config; config: Config;
@@ -47,6 +54,8 @@ export interface AppContainer {
browserClient: BrowserClient; browserClient: BrowserClient;
accountAuth: AccountAuthContext; accountAuth: AccountAuthContext;
redditAccountsRepo: RedditAccountsRepository;
accountRuntimeManager: RedditAccountRuntimeManager;
postsRepo: PostsRepository; postsRepo: PostsRepository;
messagesRepo: MessagesRepository; messagesRepo: MessagesRepository;
@@ -97,14 +106,25 @@ export async function bootstrap(
// Database // Database
const db = existingDatabase ?? createDatabase(config); const db = existingDatabase ?? createDatabase(config);
runMigrations(db); runMigrations(db);
const postsRepo = new PostsRepository(db); const bootstrapAccountsRepo = new RedditAccountsRepository(db);
const messagesRepo = new MessagesRepository(db); const bootstrapAccount = bootstrapAccountsRepo.listPublic()
const successfulMessagesRepo = new SuccessfulMessagesRepository(db); .filter((account) => account.status === 'verified')
const activityLogRepo = new ActivityLogRepository(db); .sort((left, right) => right.updatedAt - left.updatedAt)[0];
const bootstrapAccountId = bootstrapAccount?.accountId;
const postsRepo = new PostsRepository(db, bootstrapAccountId);
const messagesRepo = new MessagesRepository(db, bootstrapAccountId);
const successfulMessagesRepo = new SuccessfulMessagesRepository(db, bootstrapAccountId);
const activityLogRepo = new ActivityLogRepository(db, bootstrapAccountId);
const redditUsersRepo = new RedditUsersRepository(db); const redditUsersRepo = new RedditUsersRepository(db);
const deliveryOutboxRepo = new DeliveryOutboxRepository(db); const deliveryOutboxRepo = new DeliveryOutboxRepository(db, bootstrapAccountId);
const conversationStateRepo = new ConversationStateRepository(db); const conversationStateRepo = new ConversationStateRepository(db, bootstrapAccountId);
const conversationOutcomesRepo = new ConversationOutcomesRepository(db); const conversationOutcomesRepo = new ConversationOutcomesRepository(db, bootstrapAccountId);
const redditAccountsRepo = bootstrapAccountsRepo;
const accountContentRepo = new AccountContentRepository(db);
const legacyImport = importLegacyAccountContent(db);
if (legacyImport.conversations > 0 || legacyImport.media > 0) {
logger.info(`Imported legacy unassigned content into SQLite (${legacyImport.conversations} conversations, ${legacyImport.media} media items)`);
}
logger.info('Database initialized'); logger.info('Database initialized');
// Browser // Browser
@@ -112,6 +132,18 @@ export async function bootstrap(
const accountAuth = new AccountAuthContext(logger); const accountAuth = new AccountAuthContext(logger);
await browserClient.initialize(); await browserClient.initialize();
const storedAccount = bootstrapAccount;
if (storedAccount) {
const privateAccount = redditAccountsRepo.get(storedAccount.accountId);
if (privateAccount) {
try {
await browserClient.importRedditCookies(JSON.parse(privateAccount.cookiesJson) as Cookie[]);
} catch (error) {
logger.warn(`Could not restore stored Reddit cookies for u/${storedAccount.redditUsername}:`, error);
}
}
}
// The extension import is the only session acquisition path. A missing // The extension import is the only session acquisition path. A missing
// session is recoverable: the dashboard remains available while all // session is recoverable: the dashboard remains available while all
// account-bound work waits for a verified dump. // account-bound work waits for a verified dump.
@@ -151,7 +183,10 @@ export async function bootstrap(
// Matrix credentials come only from a verified extension dump (or its // Matrix credentials come only from a verified extension dump (or its
// persisted cache on restart). The backend never opens Reddit Chat to // persisted cache on restart). The backend never opens Reddit Chat to
// sniff or refresh a token. // sniff or refresh a token.
let matrixCreds: MatrixCredentials | null = await loadCachedCredentials(); const storedPrivateAccount = storedAccount ? redditAccountsRepo.get(storedAccount.accountId) : null;
let matrixCreds: MatrixCredentials | null = storedPrivateAccount
? JSON.parse(storedPrivateAccount.matrixCredentialsJson) as MatrixCredentials
: await loadCachedCredentials();
if (matrixCreds) { if (matrixCreds) {
logger.info(`Loaded cached Matrix credentials (user=${matrixCreds.userId})`); logger.info(`Loaded cached Matrix credentials (user=${matrixCreds.userId})`);
} }
@@ -185,7 +220,13 @@ export async function bootstrap(
// 2. Build the media downloader + service on top of the Matrix client so // 2. Build the media downloader + service on top of the Matrix client so
// `mxc://` URIs resolve through the same auth + rate-limiter pipeline. // `mxc://` URIs resolve through the same auth + rate-limiter pipeline.
const mediaDownloader = new MatrixMediaDownloader(activeMatrixClient, logger); const mediaDownloader = new MatrixMediaDownloader(activeMatrixClient, logger);
const mediaService = new MediaService(mediaDownloader, logger); const mediaService = new MediaService(
mediaDownloader,
logger,
undefined,
accountContentRepo,
() => accountAuth.getSnapshot().accountId,
);
const indexedMedia = await mediaService.getMediaItems(); const indexedMedia = await mediaService.getMediaItems();
const receivedImageRooms = new Set( const receivedImageRooms = new Set(
@@ -224,7 +265,7 @@ export async function bootstrap(
mediaService, mediaService,
tokenRefresher, tokenRefresher,
usernameResolver, usernameResolver,
undefined, new ChatCache(logger, undefined, accountContentRepo, storedAccount?.accountId ?? null),
messagesRepo, messagesRepo,
redditUsersRepo, redditUsersRepo,
accountAuth, accountAuth,
@@ -247,13 +288,42 @@ export async function bootstrap(
mediaDownloader.setMatrixClient(client); mediaDownloader.setMatrixClient(client);
matrixUsernameResolver.setMatrixClient(client); matrixUsernameResolver.setMatrixClient(client);
}, },
switchAccount(accountId) {
return chatService.switchAccount(accountId);
},
}, },
(credentials) => new MatrixClient(credentials, logger), (credentials) => new MatrixClient(credentials, logger),
saveCredentials, saveCredentials,
accountAuth, accountAuth,
{
save(input) {
redditAccountsRepo.upsertVerified({
accountId: input.accountId,
redditUsername: input.redditUsername,
matrixUserId: input.matrixUserId,
cookiesJson: JSON.stringify(input.cookies),
matrixCredentialsJson: JSON.stringify(input.credentials),
});
},
},
); );
let webServer: WebServer | null = null; let webServer: WebServer | null = null;
const accountRuntimeManager = new RedditAccountRuntimeManager(
redditAccountsRepo,
createConcreteRedditAccountRuntimeFactory({
config,
logger,
db,
isBotRunning: () => webServer?.isRunning() ?? true,
}),
);
const redditAccounts = new RedditAccountsService(
redditAccountsRepo,
sessionImporter,
accountAuth,
accountRuntimeManager,
browserClient,
);
if (config.web.enabled) { if (config.web.enabled) {
webServer = new WebServer( webServer = new WebServer(
config.web.port, config.web.port,
@@ -330,6 +400,8 @@ export async function bootstrap(
webServer.setBrowserClient(browserClient); webServer.setBrowserClient(browserClient);
webServer.setSessionImporter(sessionImporter); webServer.setSessionImporter(sessionImporter);
webServer.setAccountAuth(accountAuth); webServer.setAccountAuth(accountAuth);
webServer.setRedditAccountsService(redditAccounts);
webServer.setAccountRuntimeManager(accountRuntimeManager);
webServer.setConversationStateRepository(conversationStateRepo); webServer.setConversationStateRepository(conversationStateRepo);
webServer.setConfigStore(new ConfigStore(db)); webServer.setConfigStore(new ConfigStore(db));
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard')); webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
@@ -337,6 +409,10 @@ export async function bootstrap(
// Start background services // Start background services
if (webServer) webServer.start(); if (webServer) webServer.start();
await accountRuntimeManager.startEnabled().catch((error) => {
logger.error('One or more Reddit account runtimes failed to start:', error);
});
if (accountRuntimeManager.list().length > 0) await browserClient.close();
// Prime processor with successful examples already on disk. // Prime processor with successful examples already on disk.
const existingExamples = responseChecker.getSuccessfulExamples(3); const existingExamples = responseChecker.getSuccessfulExamples(3);
@@ -352,6 +428,8 @@ export async function bootstrap(
db, db,
browserClient, browserClient,
accountAuth, accountAuth,
redditAccountsRepo,
accountRuntimeManager,
postsRepo, postsRepo,
messagesRepo, messagesRepo,
successfulMessagesRepo, successfulMessagesRepo,
+16 -3
View File
@@ -88,6 +88,9 @@ export class BrowserClient {
if (!this.context) { if (!this.context) {
await this.initialize(); await this.initialize();
} }
if (typeof this.context!.clearCookies === 'function') {
await this.context!.clearCookies();
}
await this.context!.addCookies(cookies); await this.context!.addCookies(cookies);
this.sessionState = 'unknown'; this.sessionState = 'unknown';
} }
@@ -113,7 +116,16 @@ export class BrowserClient {
/** Returns the account name from Reddit's authenticated identity endpoint. */ /** Returns the account name from Reddit's authenticated identity endpoint. */
async getAuthenticatedUsername(): Promise<string | null> { async getAuthenticatedUsername(): Promise<string | null> {
try { try {
return await this.getRedditIdentity(await this.getPage()); return (await this.getRedditIdentity(await this.getPage()))?.username ?? null;
} catch {
return null;
}
}
async getAuthenticatedIdentity(): Promise<{ username: string; accountId: string } | null> {
try {
const identity = await this.getRedditIdentity(await this.getPage());
return identity?.accountId ? { username: identity.username, accountId: identity.accountId } : null;
} catch { } catch {
return null; return null;
} }
@@ -168,7 +180,7 @@ export class BrowserClient {
return (await this.getRedditIdentity(page)) !== null; return (await this.getRedditIdentity(page)) !== null;
} }
private async getRedditIdentity(page: Page): Promise<string | null> { private async getRedditIdentity(page: Page): Promise<{ username: string; accountId: string | null } | null> {
try { try {
return await page.evaluate(async () => { return await page.evaluate(async () => {
try { try {
@@ -181,7 +193,8 @@ export class BrowserClient {
: data; : data;
if (!('name' in account) || typeof account.name !== 'string') return null; if (!('name' in account) || typeof account.name !== 'string') return null;
const username = account.name.trim(); const username = account.name.trim();
return username || null; const accountId = 'id' in account && typeof account.id === 'string' ? account.id.trim() : '';
return username ? { username, accountId: accountId || null } : null;
} catch { } catch {
return null; return null;
} }
+375
View File
@@ -0,0 +1,375 @@
import type Database from 'better-sqlite3';
export type RedditAccountStatus = 'verified' | 'login-required' | 'disabled' | 'legacy-unassigned';
export interface RedditAccountPublicRecord {
accountId: string;
redditUsername: string;
matrixUserId: string | null;
status: RedditAccountStatus;
isActive: boolean;
createdAt: number;
updatedAt: number;
lastVerifiedAt: number | null;
lastImportedAt: number | null;
}
/** Private credential-bearing record. Never return this from a web API. */
export interface RedditAccountPrivateRecord extends RedditAccountPublicRecord {
cookiesJson: string;
matrixCredentialsJson: string;
}
export interface VerifiedRedditAccountInput {
accountId: string;
redditUsername: string;
matrixUserId: string;
cookiesJson: string;
matrixCredentialsJson: string;
now?: number;
}
type AccountRow = {
account_id: string;
reddit_username: string;
matrix_user_id: string | null;
status: RedditAccountStatus;
is_active: number;
cookies_json: string;
matrix_credentials_json: string;
created_at: number;
updated_at: number;
last_verified_at: number | null;
last_imported_at: number | null;
};
export class RedditAccountsRepository {
constructor(private readonly db: Database.Database) {}
listPublic(): RedditAccountPublicRecord[] {
const rows = this.db.prepare(`
SELECT account_id, reddit_username, matrix_user_id, status, is_active,
created_at, updated_at, last_verified_at, last_imported_at
FROM reddit_accounts
ORDER BY is_active DESC, reddit_username COLLATE NOCASE, account_id
`).all() as AccountRow[];
return rows.map(mapPublicAccount);
}
/** Private lookup for the runtime/session factory. Contains credentials. */
get(accountId: string): RedditAccountPrivateRecord | null {
const row = this.db.prepare('SELECT * FROM reddit_accounts WHERE account_id = ?')
.get(accountId) as AccountRow | undefined;
return row ? mapPrivateAccount(row) : null;
}
getActive(): RedditAccountPublicRecord[] {
const rows = this.db.prepare(`
SELECT account_id, reddit_username, matrix_user_id, status, is_active,
created_at, updated_at, last_verified_at, last_imported_at
FROM reddit_accounts
WHERE is_active = 1 AND status != 'legacy-unassigned'
ORDER BY reddit_username COLLATE NOCASE, account_id
`).all() as AccountRow[];
return rows.map(mapPublicAccount);
}
upsertVerified(input: VerifiedRedditAccountInput): RedditAccountPublicRecord {
requireText(input.accountId, 'accountId');
requireText(input.redditUsername, 'redditUsername');
requireText(input.matrixUserId, 'matrixUserId');
requireJson(input.cookiesJson, 'cookiesJson');
requireJson(input.matrixCredentialsJson, 'matrixCredentialsJson');
const now = input.now ?? Date.now();
this.db.prepare(`
INSERT INTO reddit_accounts (
account_id, reddit_username, matrix_user_id, status, is_active,
cookies_json, matrix_credentials_json, created_at, updated_at,
last_verified_at, last_imported_at
) VALUES (
@accountId, @redditUsername, @matrixUserId, 'verified', 1,
@cookiesJson, @matrixCredentialsJson, @now, @now, @now, @now
)
ON CONFLICT(account_id) DO UPDATE SET
reddit_username = excluded.reddit_username,
matrix_user_id = excluded.matrix_user_id,
status = 'verified',
is_active = 1,
cookies_json = excluded.cookies_json,
matrix_credentials_json = excluded.matrix_credentials_json,
updated_at = excluded.updated_at,
last_verified_at = excluded.last_verified_at,
last_imported_at = excluded.last_imported_at
`).run({ ...input, now });
return publicFromPrivate(this.getRequired(input.accountId));
}
setActive(accountId: string, active = true, now: number = Date.now()): boolean {
const status = active ? 'verified' : 'disabled';
return this.db.prepare(`
UPDATE reddit_accounts
SET is_active = ?, status = ?, updated_at = ?
WHERE account_id = ? AND status != 'legacy-unassigned'
`).run(active ? 1 : 0, status, now, accountId).changes === 1;
}
markLoginRequired(accountId: string, now: number = Date.now()): boolean {
return this.db.prepare(`
UPDATE reddit_accounts
SET status = 'login-required', updated_at = ?
WHERE account_id = ? AND status != 'legacy-unassigned'
`).run(now, accountId).changes === 1;
}
private getRequired(accountId: string): RedditAccountPrivateRecord {
const account = this.get(accountId);
if (!account) throw new Error(`Reddit account ${accountId} was not persisted`);
return account;
}
}
function mapPublicAccount(row: AccountRow): RedditAccountPublicRecord {
return {
accountId: row.account_id,
redditUsername: row.reddit_username,
matrixUserId: row.matrix_user_id,
status: row.status,
isActive: row.is_active === 1,
createdAt: row.created_at,
updatedAt: row.updated_at,
lastVerifiedAt: row.last_verified_at,
lastImportedAt: row.last_imported_at,
};
}
function mapPrivateAccount(row: AccountRow): RedditAccountPrivateRecord {
return {
...mapPublicAccount(row),
cookiesJson: row.cookies_json,
matrixCredentialsJson: row.matrix_credentials_json,
};
}
function publicFromPrivate(record: RedditAccountPrivateRecord): RedditAccountPublicRecord {
const { cookiesJson: _cookies, matrixCredentialsJson: _matrix, ...publicRecord } = record;
return publicRecord;
}
export interface AccountConversationRecord {
accountId: string;
conversationId: string;
username: string;
roomName: string | null;
payloadJson: string;
updatedAt: number;
}
export interface AccountSyncStateRecord {
accountId: string;
nextBatch: string | null;
lastFullSyncAt: number | null;
lastDeltaSyncAt: number | null;
cooldownJson: string | null;
updatedAt: number;
}
export interface AccountMediaRecord {
accountId: string;
mediaId: string;
chatUsername: string | null;
chatRoomId: string | null;
sender: string;
timestamp: number;
originalUrl: string;
fileName: string;
relativePath: string;
createdAt: number;
}
type ConversationRow = {
account_id: string;
conversation_id: string;
username: string;
room_name: string | null;
payload_json: string;
updated_at: number;
};
type SyncStateRow = {
account_id: string;
next_batch: string | null;
last_full_sync_at: number | null;
last_delta_sync_at: number | null;
cooldown_json: string | null;
updated_at: number;
};
type MediaRow = {
account_id: string;
media_id: string;
chat_username: string | null;
chat_room_id: string | null;
sender: string;
timestamp: number;
original_url: string;
file_name: string;
relative_path: string;
created_at: number;
};
/** Account-scoped DB persistence for chat cache state and downloaded-media metadata. */
export class AccountContentRepository {
constructor(private readonly db: Database.Database) {}
upsertConversation(input: Omit<AccountConversationRecord, 'updatedAt'> & { updatedAt?: number }): void {
requireText(input.accountId, 'accountId');
requireText(input.conversationId, 'conversationId');
requireText(input.username, 'username');
requireJson(input.payloadJson, 'payloadJson');
this.db.prepare(`
INSERT INTO reddit_conversations (
account_id, conversation_id, username, room_name, payload_json, updated_at
) VALUES (@accountId, @conversationId, @username, @roomName, @payloadJson, @updatedAt)
ON CONFLICT(account_id, conversation_id) DO UPDATE SET
username = excluded.username,
room_name = excluded.room_name,
payload_json = excluded.payload_json,
updated_at = excluded.updated_at
`).run({ ...input, roomName: input.roomName ?? null, updatedAt: input.updatedAt ?? Date.now() });
}
getConversation(accountId: string, conversationId: string): AccountConversationRecord | null {
const row = this.db.prepare(`
SELECT * FROM reddit_conversations WHERE account_id = ? AND conversation_id = ?
`).get(accountId, conversationId) as ConversationRow | undefined;
return row ? mapConversation(row) : null;
}
listConversations(accountId: string): AccountConversationRecord[] {
const rows = this.db.prepare(`
SELECT * FROM reddit_conversations WHERE account_id = ?
ORDER BY updated_at DESC, conversation_id
`).all(accountId) as ConversationRow[];
return rows.map(mapConversation);
}
deleteConversation(accountId: string, conversationId: string): boolean {
return this.db.prepare(`
DELETE FROM reddit_conversations WHERE account_id = ? AND conversation_id = ?
`).run(accountId, conversationId).changes === 1;
}
saveSyncState(input: Omit<AccountSyncStateRecord, 'updatedAt'> & { updatedAt?: number }): void {
if (input.cooldownJson !== null) requireJson(input.cooldownJson, 'cooldownJson');
this.db.prepare(`
INSERT INTO reddit_account_sync_state (
account_id, next_batch, last_full_sync_at, last_delta_sync_at, cooldown_json, updated_at
) VALUES (@accountId, @nextBatch, @lastFullSyncAt, @lastDeltaSyncAt, @cooldownJson, @updatedAt)
ON CONFLICT(account_id) DO UPDATE SET
next_batch = excluded.next_batch,
last_full_sync_at = excluded.last_full_sync_at,
last_delta_sync_at = excluded.last_delta_sync_at,
cooldown_json = excluded.cooldown_json,
updated_at = excluded.updated_at
`).run({ ...input, updatedAt: input.updatedAt ?? Date.now() });
}
getSyncState(accountId: string): AccountSyncStateRecord | null {
const row = this.db.prepare('SELECT * FROM reddit_account_sync_state WHERE account_id = ?')
.get(accountId) as SyncStateRow | undefined;
return row ? mapSyncState(row) : null;
}
upsertMedia(input: AccountMediaRecord): void {
requireText(input.accountId, 'accountId');
requireText(input.mediaId, 'mediaId');
this.db.prepare(`
INSERT INTO reddit_media (
account_id, media_id, chat_username, chat_room_id, sender, timestamp,
original_url, file_name, relative_path, created_at
) VALUES (
@accountId, @mediaId, @chatUsername, @chatRoomId, @sender, @timestamp,
@originalUrl, @fileName, @relativePath, @createdAt
)
ON CONFLICT(account_id, media_id) DO UPDATE SET
chat_username = excluded.chat_username,
chat_room_id = excluded.chat_room_id,
sender = excluded.sender,
timestamp = excluded.timestamp,
original_url = excluded.original_url,
file_name = excluded.file_name,
relative_path = excluded.relative_path,
created_at = excluded.created_at
`).run(input);
}
getMedia(accountId: string, mediaId: string): AccountMediaRecord | null {
const row = this.db.prepare(`
SELECT * FROM reddit_media WHERE account_id = ? AND media_id = ?
`).get(accountId, mediaId) as MediaRow | undefined;
return row ? mapMedia(row) : null;
}
listMedia(accountId: string): AccountMediaRecord[] {
const rows = this.db.prepare(`
SELECT * FROM reddit_media WHERE account_id = ?
ORDER BY timestamp DESC, media_id
`).all(accountId) as MediaRow[];
return rows.map(mapMedia);
}
deleteMedia(accountId: string, mediaId: string): boolean {
return this.db.prepare(`
DELETE FROM reddit_media WHERE account_id = ? AND media_id = ?
`).run(accountId, mediaId).changes === 1;
}
}
function mapConversation(row: ConversationRow): AccountConversationRecord {
return {
accountId: row.account_id,
conversationId: row.conversation_id,
username: row.username,
roomName: row.room_name,
payloadJson: row.payload_json,
updatedAt: row.updated_at,
};
}
function mapSyncState(row: SyncStateRow): AccountSyncStateRecord {
return {
accountId: row.account_id,
nextBatch: row.next_batch,
lastFullSyncAt: row.last_full_sync_at,
lastDeltaSyncAt: row.last_delta_sync_at,
cooldownJson: row.cooldown_json,
updatedAt: row.updated_at,
};
}
function mapMedia(row: MediaRow): AccountMediaRecord {
return {
accountId: row.account_id,
mediaId: row.media_id,
chatUsername: row.chat_username,
chatRoomId: row.chat_room_id,
sender: row.sender,
timestamp: row.timestamp,
originalUrl: row.original_url,
fileName: row.file_name,
relativePath: row.relative_path,
createdAt: row.created_at,
};
}
function requireText(value: string, name: string): void {
if (!value.trim()) throw new Error(`${name} must not be empty`);
}
function requireJson(value: string, name: string): void {
try {
JSON.parse(value);
} catch {
throw new Error(`${name} must be valid JSON`);
}
}
+260
View File
@@ -3,6 +3,8 @@ import type { Config } from '../config/index.js';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
export const LEGACY_UNASSIGNED_ACCOUNT_ID = '__legacy_unassigned__';
export function createDatabase(config: Pick<Config, 'database'>): 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:'
@@ -198,6 +200,7 @@ export function runMigrations(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_sent_messages_image ON sent_messages(got_image); CREATE INDEX IF NOT EXISTS idx_sent_messages_image ON sent_messages(got_image);
`); `);
}, },
() => migrateAccountOwnership(db),
]; ];
let currentVersion = db.pragma('user_version', { simple: true }) as number; let currentVersion = db.pragma('user_version', { simple: true }) as number;
@@ -207,14 +210,271 @@ export function runMigrations(db: Database.Database): void {
for (let index = currentVersion; index < migrations.length; index += 1) { for (let index = currentVersion; index < migrations.length; index += 1) {
const version = index + 1; const version = index + 1;
// SQLite cannot rebuild a referenced table while FK enforcement is on.
// Migration 11 replaces the account-produced table graph atomically, then
// validates it before foreign-key enforcement is restored.
const rebuildsForeignKeyGraph = version === 11;
if (rebuildsForeignKeyGraph) db.pragma('foreign_keys = OFF');
try {
db.transaction(() => { db.transaction(() => {
migrations[index](); migrations[index]();
if (rebuildsForeignKeyGraph) {
const violations = db.pragma('foreign_key_check') as unknown[];
if (violations.length > 0) {
throw new Error(`Account ownership migration left ${violations.length} foreign-key violation(s)`);
}
}
db.pragma(`user_version = ${version}`); db.pragma(`user_version = ${version}`);
})(); })();
} finally {
if (rebuildsForeignKeyGraph) db.pragma('foreign_keys = ON');
}
currentVersion = version; currentVersion = version;
} }
} }
function migrateAccountOwnership(db: Database.Database): void {
const legacy = LEGACY_UNASSIGNED_ACCOUNT_ID.replaceAll("'", "''");
db.exec(`
CREATE TABLE reddit_accounts (
account_id TEXT PRIMARY KEY,
reddit_username TEXT NOT NULL,
matrix_user_id TEXT UNIQUE,
status TEXT NOT NULL CHECK(status IN ('verified', 'login-required', 'disabled', 'legacy-unassigned')),
is_active INTEGER NOT NULL DEFAULT 1 CHECK(is_active IN (0, 1)),
cookies_json TEXT NOT NULL,
matrix_credentials_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_verified_at INTEGER,
last_imported_at INTEGER
);
CREATE INDEX idx_reddit_accounts_username ON reddit_accounts(reddit_username COLLATE NOCASE);
CREATE INDEX idx_reddit_accounts_active ON reddit_accounts(is_active, status);
INSERT INTO reddit_accounts (
account_id, reddit_username, matrix_user_id, status, is_active,
cookies_json, matrix_credentials_json, created_at, updated_at
) VALUES (
'${legacy}', 'Legacy / unassigned', NULL, 'legacy-unassigned', 0,
'[]', '{}', 0, 0
);
CREATE TABLE processed_posts_account_scoped (
account_id TEXT NOT NULL DEFAULT '${legacy}',
id TEXT NOT NULL,
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,
post_url TEXT,
PRIMARY KEY (account_id, id),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id)
);
INSERT INTO processed_posts_account_scoped (
account_id, id, subreddit, author, title, image_url, image_analysis,
generated_message, processed_at, message_sent, error, post_url
) SELECT '${legacy}', id, subreddit, author, title, image_url, image_analysis,
generated_message, processed_at, message_sent, error, post_url
FROM processed_posts;
CREATE TABLE sent_messages_account_scoped (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT NOT NULL DEFAULT '${legacy}',
post_id TEXT,
recipient TEXT NOT NULL,
subject TEXT NOT NULL,
message TEXT NOT NULL,
sent_at INTEGER NOT NULL,
got_response INTEGER DEFAULT 0,
got_image INTEGER NOT NULL DEFAULT 0,
response_checked_at INTEGER,
strategy_id TEXT,
chat_room_id TEXT,
UNIQUE (account_id, id),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id),
FOREIGN KEY (account_id, post_id) REFERENCES processed_posts_account_scoped(account_id, id)
);
INSERT INTO sent_messages_account_scoped (
id, account_id, post_id, recipient, subject, message, sent_at,
got_response, got_image, response_checked_at, strategy_id, chat_room_id
) SELECT id, '${legacy}', post_id, recipient, subject, message, sent_at,
got_response, got_image, response_checked_at, strategy_id, chat_room_id
FROM sent_messages;
CREATE TABLE successful_messages_account_scoped (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT NOT NULL DEFAULT '${legacy}',
sent_message_id INTEGER,
original_message TEXT NOT NULL,
image_analysis TEXT,
post_title TEXT,
response_snippet TEXT,
created_at INTEGER NOT NULL,
subreddit TEXT,
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id),
FOREIGN KEY (account_id, sent_message_id) REFERENCES sent_messages_account_scoped(account_id, id)
);
INSERT INTO successful_messages_account_scoped (
id, account_id, sent_message_id, original_message, image_analysis,
post_title, response_snippet, created_at, subreddit
) SELECT id, '${legacy}', sent_message_id, original_message, image_analysis,
post_title, response_snippet, created_at, subreddit
FROM successful_messages;
CREATE TABLE activity_log_account_scoped (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT NOT NULL DEFAULT '${legacy}',
timestamp INTEGER NOT NULL,
type TEXT NOT NULL,
message TEXT NOT NULL,
recipient TEXT,
image_url TEXT,
post_title TEXT,
post_url TEXT,
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id)
);
INSERT INTO activity_log_account_scoped (
id, account_id, timestamp, type, message, recipient, image_url, post_title, post_url
) SELECT id, '${legacy}', timestamp, type, message, recipient, image_url, post_title, post_url
FROM activity_log;
CREATE TABLE delivery_outbox_account_scoped (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT NOT NULL DEFAULT '${legacy}',
post_id TEXT NOT NULL,
recipient TEXT NOT NULL,
subject TEXT NOT NULL,
message TEXT NOT NULL,
strategy_id TEXT,
idempotency_key TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('discovered', 'prepared', 'sending', 'sent', 'failed')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
lease_owner TEXT,
lease_expires_at INTEGER,
attempt_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
sent_message_id INTEGER,
UNIQUE (account_id, post_id),
UNIQUE (account_id, idempotency_key),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id),
FOREIGN KEY (account_id, post_id) REFERENCES processed_posts_account_scoped(account_id, id),
FOREIGN KEY (account_id, sent_message_id) REFERENCES sent_messages_account_scoped(account_id, id)
);
INSERT INTO delivery_outbox_account_scoped (
id, account_id, post_id, recipient, subject, message, strategy_id,
idempotency_key, status, created_at, updated_at, lease_owner,
lease_expires_at, attempt_count, last_error, sent_message_id
) SELECT id, '${legacy}', post_id, recipient, subject, message, strategy_id,
idempotency_key, status, created_at, updated_at, lease_owner,
lease_expires_at, attempt_count, last_error, sent_message_id
FROM delivery_outbox;
CREATE TABLE conversation_states_account_scoped (
account_id TEXT NOT NULL DEFAULT '${legacy}',
conversation_id TEXT NOT NULL,
state_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (account_id, conversation_id),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id)
);
INSERT INTO conversation_states_account_scoped (account_id, conversation_id, state_json, updated_at)
SELECT '${legacy}', conversation_id, state_json, updated_at FROM conversation_states;
CREATE TABLE conversation_outcomes_account_scoped (
account_id TEXT NOT NULL DEFAULT '${legacy}',
sent_message_id INTEGER NOT NULL,
outcome TEXT NOT NULL CHECK(outcome IN ('engaged', 'declined', 'boundary', 'upsell', 'unknown')),
response_snippet TEXT,
recorded_at INTEGER NOT NULL,
PRIMARY KEY (account_id, sent_message_id),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id),
FOREIGN KEY (account_id, sent_message_id) REFERENCES sent_messages_account_scoped(account_id, id)
);
INSERT INTO conversation_outcomes_account_scoped (
account_id, sent_message_id, outcome, response_snippet, recorded_at
) SELECT '${legacy}', sent_message_id, outcome, response_snippet, recorded_at
FROM conversation_outcomes;
DROP TABLE conversation_outcomes;
DROP TABLE conversation_states;
DROP TABLE delivery_outbox;
DROP TABLE successful_messages;
DROP TABLE sent_messages;
DROP TABLE processed_posts;
DROP TABLE activity_log;
ALTER TABLE processed_posts_account_scoped RENAME TO processed_posts;
ALTER TABLE sent_messages_account_scoped RENAME TO sent_messages;
ALTER TABLE successful_messages_account_scoped RENAME TO successful_messages;
ALTER TABLE activity_log_account_scoped RENAME TO activity_log;
ALTER TABLE delivery_outbox_account_scoped RENAME TO delivery_outbox;
ALTER TABLE conversation_states_account_scoped RENAME TO conversation_states;
ALTER TABLE conversation_outcomes_account_scoped RENAME TO conversation_outcomes;
CREATE INDEX idx_posts_subreddit ON processed_posts(account_id, subreddit);
CREATE INDEX idx_posts_processed_at ON processed_posts(account_id, processed_at);
CREATE INDEX idx_posts_message_sent ON processed_posts(account_id, message_sent);
CREATE INDEX idx_posts_author_history
ON processed_posts(author COLLATE NOCASE, processed_at DESC, id DESC);
CREATE INDEX idx_sent_messages_response ON sent_messages(account_id, got_response);
CREATE INDEX idx_sent_messages_image ON sent_messages(account_id, got_image);
CREATE INDEX idx_sent_messages_recipient ON sent_messages(account_id, recipient);
CREATE INDEX idx_sent_messages_chat_room ON sent_messages(account_id, chat_room_id);
CREATE INDEX idx_activity_log_timestamp ON activity_log(account_id, timestamp);
CREATE INDEX idx_delivery_outbox_claim ON delivery_outbox(account_id, status, lease_expires_at, created_at);
CREATE TABLE reddit_conversations (
account_id TEXT NOT NULL,
conversation_id TEXT NOT NULL,
username TEXT NOT NULL,
room_name TEXT,
payload_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (account_id, conversation_id),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
);
CREATE INDEX idx_reddit_conversations_username
ON reddit_conversations(account_id, username COLLATE NOCASE);
CREATE INDEX idx_reddit_conversations_updated
ON reddit_conversations(account_id, updated_at DESC);
CREATE TABLE reddit_account_sync_state (
account_id TEXT PRIMARY KEY,
next_batch TEXT,
last_full_sync_at INTEGER,
last_delta_sync_at INTEGER,
cooldown_json TEXT,
updated_at INTEGER NOT NULL,
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
);
CREATE TABLE reddit_media (
account_id TEXT NOT NULL,
media_id TEXT NOT NULL,
chat_username TEXT,
chat_room_id TEXT,
sender TEXT NOT NULL,
timestamp INTEGER NOT NULL,
original_url TEXT NOT NULL,
file_name TEXT NOT NULL,
relative_path TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (account_id, media_id),
UNIQUE (account_id, relative_path),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
);
CREATE INDEX idx_reddit_media_timestamp ON reddit_media(account_id, timestamp DESC);
CREATE INDEX idx_reddit_media_room ON reddit_media(account_id, chat_room_id);
`);
}
function addColumnIfNotExists(db: Database.Database, table: string, column: string, definition: string): void { function addColumnIfNotExists(db: Database.Database, table: string, column: string, definition: string): void {
const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
const exists = columns.some((c) => c.name === column); const exists = columns.some((c) => c.name === column);
+95
View File
@@ -0,0 +1,95 @@
import fs from 'node:fs';
import path from 'node:path';
import type Database from 'better-sqlite3';
import { z } from 'zod';
import { LEGACY_UNASSIGNED_ACCOUNT_ID } from './client.js';
import { AccountContentRepository } from './account-repositories.js';
const ChatCacheSchema = z.object({
chats: z.array(z.object({
username: z.string().min(1),
roomName: z.string(),
roomId: z.string().optional(),
}).passthrough()),
nextBatch: z.string().nullable().optional(),
lastFullSyncAt: z.number().nullable().optional(),
lastDeltaSyncAt: z.number().nullable().optional(),
cooldown: z.unknown().nullable().optional(),
});
const MediaIndexSchema = z.object({
items: z.array(z.object({
id: z.string().min(1),
chatUsername: z.string().nullable(),
chatRoomId: z.string().nullable(),
sender: z.string(),
timestamp: z.number(),
originalUrl: z.string(),
fileName: z.string(),
relativePath: z.string(),
createdAt: z.number(),
})),
});
export interface LegacyContentImportResult { conversations: number; media: number; skipped: boolean }
/** Imports pre-account JSON indexes once, preserving them as explicitly unassigned content. */
export function importLegacyAccountContent(
db: Database.Database,
dataDirectory = path.join(process.cwd(), 'data'),
): LegacyContentImportResult {
db.exec(`CREATE TABLE IF NOT EXISTS legacy_content_imports (
import_key TEXT PRIMARY KEY,
imported_at INTEGER NOT NULL
)`);
const importKey = 'account-ownership-json-v1';
if (db.prepare('SELECT 1 FROM legacy_content_imports WHERE import_key = ?').get(importKey)) {
return { conversations: 0, media: 0, skipped: true };
}
const chatPath = path.join(dataDirectory, 'chat-cache.json');
const mediaPath = path.join(dataDirectory, 'media', 'index.json');
const hasChat = fs.existsSync(chatPath);
const hasMedia = fs.existsSync(mediaPath);
if (!hasChat && !hasMedia) return { conversations: 0, media: 0, skipped: false };
const chat = hasChat ? ChatCacheSchema.parse(JSON.parse(fs.readFileSync(chatPath, 'utf8'))) : null;
const media = hasMedia ? MediaIndexSchema.parse(JSON.parse(fs.readFileSync(mediaPath, 'utf8'))) : null;
const content = new AccountContentRepository(db);
db.transaction(() => {
for (const conversation of chat?.chats ?? []) {
content.upsertConversation({
accountId: LEGACY_UNASSIGNED_ACCOUNT_ID,
conversationId: conversation.roomId ?? conversation.username,
username: conversation.username,
roomName: conversation.roomName,
payloadJson: JSON.stringify(conversation),
});
}
if (chat) {
content.saveSyncState({
accountId: LEGACY_UNASSIGNED_ACCOUNT_ID,
nextBatch: chat.nextBatch ?? null,
lastFullSyncAt: chat.lastFullSyncAt ?? null,
lastDeltaSyncAt: chat.lastDeltaSyncAt ?? null,
cooldownJson: chat.cooldown == null ? null : JSON.stringify(chat.cooldown),
});
}
for (const item of media?.items ?? []) {
content.upsertMedia({
accountId: LEGACY_UNASSIGNED_ACCOUNT_ID,
mediaId: item.id,
chatUsername: item.chatUsername,
chatRoomId: item.chatRoomId,
sender: item.sender,
timestamp: item.timestamp,
originalUrl: item.originalUrl,
fileName: item.fileName,
relativePath: item.relativePath,
createdAt: item.createdAt,
});
}
db.prepare('INSERT INTO legacy_content_imports (import_key, imported_at) VALUES (?, ?)').run(importKey, Date.now());
})();
return { conversations: chat?.chats.length ?? 0, media: media?.items.length ?? 0, skipped: false };
}
+144 -101
View File
@@ -5,6 +5,7 @@ import {
type ConversationState, type ConversationState,
} from '../services/conversation-state.js'; } from '../services/conversation-state.js';
import type { ConversationOutcome } from '../services/conversation-outcome.js'; import type { ConversationOutcome } from '../services/conversation-outcome.js';
import { LEGACY_UNASSIGNED_ACCOUNT_ID } from './client.js';
export interface ProcessedPostRecord { export interface ProcessedPostRecord {
id: string; id: string;
@@ -114,7 +115,10 @@ export interface DeliveryOutboxRecord {
* lease owner. A crashed worker's lease can safely be recovered on startup. * lease owner. A crashed worker's lease can safely be recovered on startup.
*/ */
export class DeliveryOutboxRepository { export class DeliveryOutboxRepository {
constructor(private readonly db: Database.Database) {} constructor(
private readonly db: Database.Database,
private readonly accountId: string = LEGACY_UNASSIGNED_ACCOUNT_ID,
) {}
discover(input: { discover(input: {
postId: string; postId: string;
@@ -128,14 +132,14 @@ export class DeliveryOutboxRepository {
const now = input.now ?? Date.now(); const now = input.now ?? Date.now();
const insert = this.db.prepare(` const insert = this.db.prepare(`
INSERT INTO delivery_outbox INSERT INTO delivery_outbox
(post_id, recipient, subject, message, strategy_id, idempotency_key, status, created_at, updated_at) (account_id, post_id, recipient, subject, message, strategy_id, idempotency_key, status, created_at, updated_at)
VALUES (@postId, @recipient, @subject, @message, @strategyId, @idempotencyKey, 'discovered', @now, @now) VALUES (@accountId, @postId, @recipient, @subject, @message, @strategyId, @idempotencyKey, 'discovered', @now, @now)
ON CONFLICT(idempotency_key) DO NOTHING ON CONFLICT(account_id, idempotency_key) DO NOTHING
`); `);
const select = this.db.prepare('SELECT * FROM delivery_outbox WHERE idempotency_key = ?'); const select = this.db.prepare('SELECT * FROM delivery_outbox WHERE account_id = ? AND idempotency_key = ?');
const transaction = this.db.transaction(() => { const transaction = this.db.transaction(() => {
insert.run({ ...input, strategyId: input.strategyId ?? null, now }); insert.run({ ...input, accountId: this.accountId, strategyId: input.strategyId ?? null, now });
const row = select.get(input.idempotencyKey) as Record<string, unknown> | undefined; const row = select.get(this.accountId, input.idempotencyKey) as Record<string, unknown> | undefined;
if (!row) throw new Error('Outbox delivery was not persisted'); if (!row) throw new Error('Outbox delivery was not persisted');
return this.mapRow(row); return this.mapRow(row);
}); });
@@ -143,19 +147,21 @@ export class DeliveryOutboxRepository {
} }
get(id: number): DeliveryOutboxRecord | null { get(id: number): DeliveryOutboxRecord | null {
const row = this.db.prepare('SELECT * FROM delivery_outbox WHERE id = ?').get(id) as Record<string, unknown> | undefined; const row = this.db.prepare('SELECT * FROM delivery_outbox WHERE account_id = ? AND id = ?')
.get(this.accountId, id) as Record<string, unknown> | undefined;
return row ? this.mapRow(row) : null; return row ? this.mapRow(row) : null;
} }
count(): number { count(): number {
return (this.db.prepare('SELECT COUNT(*) AS count FROM delivery_outbox').get() as { count: number }).count; return (this.db.prepare('SELECT COUNT(*) AS count FROM delivery_outbox WHERE account_id = ?')
.get(this.accountId) as { count: number }).count;
} }
markPrepared(id: number, now: number = Date.now()): boolean { markPrepared(id: number, now: number = Date.now()): boolean {
return this.db.prepare(` return this.db.prepare(`
UPDATE delivery_outbox SET status = 'prepared', updated_at = ?, last_error = NULL UPDATE delivery_outbox SET status = 'prepared', updated_at = ?, last_error = NULL
WHERE id = ? AND status IN ('discovered', 'failed') WHERE account_id = ? AND id = ? AND status IN ('discovered', 'failed')
`).run(now, id).changes === 1; `).run(now, this.accountId, id).changes === 1;
} }
claimNext(workerId: string, leaseMs: number, now: number = Date.now()): DeliveryOutboxRecord | null { claimNext(workerId: string, leaseMs: number, now: number = Date.now()): DeliveryOutboxRecord | null {
@@ -164,16 +170,16 @@ export class DeliveryOutboxRepository {
const transaction = this.db.transaction(() => { const transaction = this.db.transaction(() => {
const row = this.db.prepare(` const row = this.db.prepare(`
SELECT id FROM delivery_outbox SELECT id FROM delivery_outbox
WHERE status = 'prepared' WHERE account_id = ? AND status = 'prepared'
ORDER BY created_at ASC, id ASC ORDER BY created_at ASC, id ASC
LIMIT 1 LIMIT 1
`).get() as { id: number } | undefined; `).get(this.accountId) as { id: number } | undefined;
if (!row) return null; if (!row) return null;
const changed = this.db.prepare(` const changed = this.db.prepare(`
UPDATE delivery_outbox UPDATE delivery_outbox
SET status = 'sending', lease_owner = ?, lease_expires_at = ?, updated_at = ?, attempt_count = attempt_count + 1 SET status = 'sending', lease_owner = ?, lease_expires_at = ?, updated_at = ?, attempt_count = attempt_count + 1
WHERE id = ? AND status = 'prepared' WHERE account_id = ? AND id = ? AND status = 'prepared'
`).run(workerId, now + leaseMs, now, row.id).changes; `).run(workerId, now + leaseMs, now, this.accountId, row.id).changes;
if (changed !== 1) return null; if (changed !== 1) return null;
return this.get(row.id); return this.get(row.id);
}); });
@@ -187,8 +193,8 @@ export class DeliveryOutboxRepository {
const changed = this.db.prepare(` const changed = this.db.prepare(`
UPDATE delivery_outbox UPDATE delivery_outbox
SET status = 'sending', lease_owner = ?, lease_expires_at = ?, updated_at = ?, attempt_count = attempt_count + 1 SET status = 'sending', lease_owner = ?, lease_expires_at = ?, updated_at = ?, attempt_count = attempt_count + 1
WHERE id = ? AND status = 'prepared' WHERE account_id = ? AND id = ? AND status = 'prepared'
`).run(workerId, now + leaseMs, now, id).changes; `).run(workerId, now + leaseMs, now, this.accountId, id).changes;
return changed === 1 ? this.get(id) : null; return changed === 1 ? this.get(id) : null;
} }
@@ -196,24 +202,24 @@ export class DeliveryOutboxRepository {
return this.db.prepare(` return this.db.prepare(`
UPDATE delivery_outbox UPDATE delivery_outbox
SET status = 'prepared', lease_owner = NULL, lease_expires_at = NULL, updated_at = ? SET status = 'prepared', lease_owner = NULL, lease_expires_at = NULL, updated_at = ?
WHERE status = 'sending' AND lease_expires_at <= ? WHERE account_id = ? AND status = 'sending' AND lease_expires_at <= ?
`).run(now, now).changes; `).run(now, this.accountId, now).changes;
} }
markSent(id: number, workerId: string, sentMessageId: number, now: number = Date.now()): boolean { markSent(id: number, workerId: string, sentMessageId: number, now: number = Date.now()): boolean {
return this.db.prepare(` return this.db.prepare(`
UPDATE delivery_outbox UPDATE delivery_outbox
SET status = 'sent', sent_message_id = ?, lease_owner = NULL, lease_expires_at = NULL, updated_at = ?, last_error = NULL SET status = 'sent', sent_message_id = ?, lease_owner = NULL, lease_expires_at = NULL, updated_at = ?, last_error = NULL
WHERE id = ? AND status = 'sending' AND lease_owner = ? WHERE account_id = ? AND id = ? AND status = 'sending' AND lease_owner = ?
`).run(sentMessageId, now, id, workerId).changes === 1; `).run(sentMessageId, now, this.accountId, id, workerId).changes === 1;
} }
markFailed(id: number, workerId: string, error: string, now: number = Date.now()): boolean { markFailed(id: number, workerId: string, error: string, now: number = Date.now()): boolean {
return this.db.prepare(` return this.db.prepare(`
UPDATE delivery_outbox UPDATE delivery_outbox
SET status = 'failed', last_error = ?, lease_owner = NULL, lease_expires_at = NULL, updated_at = ? SET status = 'failed', last_error = ?, lease_owner = NULL, lease_expires_at = NULL, updated_at = ?
WHERE id = ? AND status = 'sending' AND lease_owner = ? WHERE account_id = ? AND id = ? AND status = 'sending' AND lease_owner = ?
`).run(error, now, id, workerId).changes === 1; `).run(error, now, this.accountId, id, workerId).changes === 1;
} }
private mapRow(row: Record<string, unknown>): DeliveryOutboxRecord { private mapRow(row: Record<string, unknown>): DeliveryOutboxRecord {
@@ -243,26 +249,34 @@ export class ActivityLogRepository {
private pruneStmt: Database.Statement; private pruneStmt: Database.Statement;
private countSentSkippedStmt: Database.Statement; private countSentSkippedStmt: Database.Statement;
constructor(private db: Database.Database) { constructor(
private db: Database.Database,
private readonly accountId: string = LEGACY_UNASSIGNED_ACCOUNT_ID,
) {
this.insertStmt = db.prepare(` this.insertStmt = db.prepare(`
INSERT INTO activity_log (timestamp, type, message, recipient, image_url, post_title, post_url) INSERT INTO activity_log (account_id, 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, post_title, post_url SELECT timestamp, type, message, recipient, image_url, post_title, post_url
FROM activity_log FROM activity_log
WHERE account_id = ?
ORDER BY id DESC ORDER BY id DESC
LIMIT ? LIMIT ?
`); `);
this.pruneStmt = db.prepare(` this.pruneStmt = db.prepare(`
DELETE FROM activity_log DELETE FROM activity_log
WHERE id NOT IN (SELECT id FROM activity_log ORDER BY id DESC LIMIT ?) WHERE account_id = ?
AND id NOT IN (
SELECT id FROM activity_log WHERE account_id = ? ORDER BY id DESC LIMIT ?
)
`); `);
this.countSentSkippedStmt = db.prepare(` this.countSentSkippedStmt = db.prepare(`
SELECT COUNT(*) AS count FROM activity_log WHERE type IN ('sent', 'skipped') SELECT COUNT(*) AS count FROM activity_log
WHERE account_id = ? AND type IN ('sent', 'skipped')
`); `);
} }
@@ -276,6 +290,7 @@ export class ActivityLogRepository {
postUrl?: string; postUrl?: string;
}): void { }): void {
this.insertStmt.run( this.insertStmt.run(
this.accountId,
item.timestamp, item.timestamp,
item.type, item.type,
item.message, item.message,
@@ -287,7 +302,7 @@ export class ActivityLogRepository {
} }
getRecent(limit: number): ActivityLogRecord[] { getRecent(limit: number): ActivityLogRecord[] {
const rows = this.getRecentStmt.all(limit) as Record<string, unknown>[]; const rows = this.getRecentStmt.all(this.accountId, limit) as Record<string, unknown>[];
return rows.map((row) => ({ return rows.map((row) => ({
timestamp: row.timestamp as number, timestamp: row.timestamp as number,
type: row.type as string, type: row.type as string,
@@ -300,11 +315,11 @@ export class ActivityLogRepository {
} }
prune(keep: number): void { prune(keep: number): void {
this.pruneStmt.run(keep); this.pruneStmt.run(this.accountId, this.accountId, keep);
} }
countSentSkipped(): number { countSentSkipped(): number {
const row = this.countSentSkippedStmt.get() as { count: number }; const row = this.countSentSkippedStmt.get(this.accountId) as { count: number };
return row.count; return row.count;
} }
} }
@@ -318,33 +333,38 @@ export class PostsRepository {
private getByIdStmt: Database.Statement; private getByIdStmt: Database.Statement;
private getByAuthorStmt: Database.Statement; private getByAuthorStmt: Database.Statement;
constructor(private db: Database.Database) { constructor(
private db: Database.Database,
private readonly accountId: string = LEGACY_UNASSIGNED_ACCOUNT_ID,
) {
this.insertStmt = db.prepare(` this.insertStmt = db.prepare(`
INSERT INTO processed_posts (id, subreddit, author, title, post_url, image_url, processed_at) INSERT INTO processed_posts (account_id, 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 account_id = ? AND id = ?`);
this.markSentStmt = db.prepare(`UPDATE processed_posts SET message_sent = 1 WHERE id = ?`); this.markSentStmt = db.prepare(`UPDATE processed_posts SET message_sent = 1 WHERE account_id = ? AND id = ?`);
this.updateAnalysisStmt = db.prepare(` this.updateAnalysisStmt = db.prepare(`
UPDATE processed_posts UPDATE processed_posts
SET image_analysis = ?, generated_message = ? SET image_analysis = ?, generated_message = ?
WHERE id = ? WHERE account_id = ? AND id = ?
`); `);
this.setErrorStmt = db.prepare(`UPDATE processed_posts SET error = ? WHERE id = ?`); this.setErrorStmt = db.prepare(`UPDATE processed_posts SET error = ? WHERE account_id = ? AND id = ?`);
this.getByIdStmt = db.prepare(`SELECT * FROM processed_posts WHERE id = ?`); this.getByIdStmt = db.prepare(`SELECT * FROM processed_posts WHERE account_id = ? AND id = ?`);
this.getByAuthorStmt = db.prepare(` this.getByAuthorStmt = db.prepare(`
SELECT * FROM processed_posts WHERE author = ? COLLATE NOCASE ORDER BY processed_at DESC LIMIT 10 SELECT * FROM processed_posts
WHERE account_id = ? AND author = ? COLLATE NOCASE
ORDER BY processed_at DESC, id DESC LIMIT 10
`); `);
} }
isProcessed(postId: string): boolean { isProcessed(postId: string): boolean {
return this.existsStmt.get(postId) !== undefined; return this.existsStmt.get(this.accountId, postId) !== undefined;
} }
insert(post: { insert(post: {
@@ -357,6 +377,7 @@ export class PostsRepository {
processedAt: number; processedAt: number;
}): void { }): void {
this.insertStmt.run( this.insertStmt.run(
this.accountId,
post.id, post.id,
post.subreddit, post.subreddit,
post.author, post.author,
@@ -368,19 +389,19 @@ export class PostsRepository {
} }
updateAnalysis(postId: string, analysis: string, message: string): void { updateAnalysis(postId: string, analysis: string, message: string): void {
this.updateAnalysisStmt.run(analysis, message, postId); this.updateAnalysisStmt.run(analysis, message, this.accountId, postId);
} }
markMessageSent(postId: string): void { markMessageSent(postId: string): void {
this.markSentStmt.run(postId); this.markSentStmt.run(this.accountId, postId);
} }
setError(postId: string, error: string): void { setError(postId: string, error: string): void {
this.setErrorStmt.run(error, postId); this.setErrorStmt.run(error, this.accountId, postId);
} }
getById(postId: string): ProcessedPostRecord | undefined { getById(postId: string): ProcessedPostRecord | undefined {
const row = this.getByIdStmt.get(postId) as Record<string, unknown> | undefined; const row = this.getByIdStmt.get(this.accountId, postId) as Record<string, unknown> | undefined;
if (!row) return undefined; if (!row) return undefined;
return { return {
@@ -399,7 +420,7 @@ export class PostsRepository {
} }
getByAuthor(author: string): ProcessedPostRecord[] { getByAuthor(author: string): ProcessedPostRecord[] {
const rows = this.getByAuthorStmt.all(author) as Record<string, unknown>[]; const rows = this.getByAuthorStmt.all(this.accountId, author) as Record<string, unknown>[];
return rows.map((row) => ({ return rows.map((row) => ({
id: row.id as string, id: row.id as string,
subreddit: row.subreddit as string, subreddit: row.subreddit as string,
@@ -429,35 +450,38 @@ export class MessagesRepository {
private markCheckedStmt: Database.Statement; private markCheckedStmt: Database.Statement;
private hasSentToStmt: Database.Statement; private hasSentToStmt: Database.Statement;
constructor(private db: Database.Database) { constructor(
private db: Database.Database,
private readonly accountId: string = LEGACY_UNASSIGNED_ACCOUNT_ID,
) {
this.insertStmt = db.prepare(` this.insertStmt = db.prepare(`
INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at, strategy_id, chat_room_id) INSERT INTO sent_messages (account_id, post_id, recipient, subject, message, sent_at, strategy_id, chat_room_id)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`); `);
this.insertChatStmt = db.prepare(` this.insertChatStmt = db.prepare(`
INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at, chat_room_id) INSERT INTO sent_messages (account_id, post_id, recipient, subject, message, sent_at, chat_room_id)
VALUES (NULL, ?, ?, ?, ?, ?) VALUES (?, NULL, ?, ?, ?, ?, ?)
`); `);
this.getByChatRoomIdStmt = db.prepare(` this.getByChatRoomIdStmt = db.prepare(`
SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.post_url, 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.account_id = pp.account_id AND sm.post_id = pp.id
WHERE sm.chat_room_id = ? WHERE sm.account_id = ? AND sm.chat_room_id = ?
ORDER BY sm.post_id IS NULL ASC, sm.sent_at DESC 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 account_id = ? AND post_id = ?`);
this.getLatestByRecipientStmt = db.prepare(` this.getLatestByRecipientStmt = db.prepare(`
SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.post_url, 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.account_id = pp.account_id AND sm.post_id = pp.id
WHERE sm.recipient = ? COLLATE NOCASE WHERE sm.account_id = ? AND sm.recipient = ? COLLATE NOCASE
ORDER BY sm.sent_at DESC ORDER BY sm.sent_at DESC
LIMIT 1 LIMIT 1
`); `);
@@ -466,14 +490,15 @@ export class MessagesRepository {
SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.post_url, 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.account_id = pp.account_id AND sm.post_id = pp.id
WHERE sm.account_id = ?
ORDER BY sm.sent_at DESC ORDER BY sm.sent_at DESC
LIMIT ? LIMIT ?
`); `);
this.getUncheckedStmt = db.prepare(` this.getUncheckedStmt = db.prepare(`
SELECT * FROM sent_messages SELECT * FROM sent_messages
WHERE got_response = 0 WHERE account_id = ? AND got_response = 0
AND (response_checked_at IS NULL OR response_checked_at < ?) AND (response_checked_at IS NULL OR response_checked_at < ?)
AND sent_at < ? AND sent_at < ?
ORDER BY sent_at ASC ORDER BY sent_at ASC
@@ -481,7 +506,7 @@ export class MessagesRepository {
`); `);
this.markResponseStmt = db.prepare(` this.markResponseStmt = db.prepare(`
UPDATE sent_messages SET got_response = 1, response_checked_at = ? WHERE id = ? UPDATE sent_messages SET got_response = 1, response_checked_at = ? WHERE account_id = ? AND id = ?
`); `);
this.markImageStmt = db.prepare(` this.markImageStmt = db.prepare(`
@@ -491,11 +516,11 @@ export class MessagesRepository {
`); `);
this.markCheckedStmt = db.prepare(` this.markCheckedStmt = db.prepare(`
UPDATE sent_messages SET response_checked_at = ? WHERE id = ? UPDATE sent_messages SET response_checked_at = ? WHERE account_id = ? AND id = ?
`); `);
this.hasSentToStmt = db.prepare(` this.hasSentToStmt = db.prepare(`
SELECT 1 FROM sent_messages WHERE recipient = ? LIMIT 1 SELECT 1 FROM sent_messages WHERE account_id = ? AND recipient = ? LIMIT 1
`); `);
} }
@@ -509,6 +534,7 @@ export class MessagesRepository {
chatRoomId?: string | null; chatRoomId?: string | null;
}): number { }): number {
const result = this.insertStmt.run( const result = this.insertStmt.run(
this.accountId,
message.postId, message.postId,
message.recipient, message.recipient,
message.subject, message.subject,
@@ -528,6 +554,7 @@ export class MessagesRepository {
chatRoomId: string; chatRoomId: string;
}): number { }): number {
const result = this.insertChatStmt.run( const result = this.insertChatStmt.run(
this.accountId,
message.recipient, message.recipient,
message.subject, message.subject,
message.message, message.message,
@@ -538,33 +565,34 @@ export class MessagesRepository {
} }
getByPostId(postId: string): SentMessageRecord | undefined { getByPostId(postId: string): SentMessageRecord | undefined {
const row = this.getByPostIdStmt.get(postId) as Record<string, unknown> | undefined; const row = this.getByPostIdStmt.get(this.accountId, postId) as Record<string, unknown> | undefined;
if (!row) return undefined; if (!row) return undefined;
return this.mapRow(row); return this.mapRow(row);
} }
getByChatRoomId(chatRoomId: string): SentMessageRecord | undefined { getByChatRoomId(chatRoomId: string): SentMessageRecord | undefined {
const row = this.getByChatRoomIdStmt.get(chatRoomId) as Record<string, unknown> | undefined; const row = this.getByChatRoomIdStmt.get(this.accountId, chatRoomId) as Record<string, unknown> | undefined;
if (!row) return undefined; if (!row) return undefined;
return this.mapRow(row); return this.mapRow(row);
} }
getLatestByRecipient(recipient: string): SentMessageRecord | undefined { getLatestByRecipient(recipient: string): SentMessageRecord | undefined {
const row = this.getLatestByRecipientStmt.get(recipient) as Record<string, unknown> | undefined; const row = this.getLatestByRecipientStmt.get(this.accountId, recipient) as Record<string, unknown> | undefined;
if (!row) return undefined; if (!row) return undefined;
return this.mapRow(row); return this.mapRow(row);
} }
getRecent(limit: number = 10): SentMessageRecord[] { getRecent(limit: number = 10): SentMessageRecord[] {
const rows = this.getRecentStmt.all(limit) as Record<string, unknown>[]; const rows = this.getRecentStmt.all(this.accountId, limit) as Record<string, unknown>[];
return rows.map((row) => this.mapRow(row)); return rows.map((row) => this.mapRow(row));
} }
/** Successful-send history adapter for durable rolling rate limits. */ /** Successful-send history adapter for durable rolling rate limits. */
getSentTimestampsSince(since: number): number[] { getSentTimestampsSince(since: number): number[] {
const rows = this.db.prepare(` const rows = this.db.prepare(`
SELECT sent_at FROM sent_messages WHERE sent_at > ? ORDER BY sent_at ASC SELECT sent_at FROM sent_messages
`).all(since) as Array<{ sent_at: number }>; WHERE account_id = ? AND sent_at > ? ORDER BY sent_at ASC
`).all(this.accountId, since) as Array<{ sent_at: number }>;
return rows.map((row) => row.sent_at); return rows.map((row) => row.sent_at);
} }
@@ -572,12 +600,12 @@ export class MessagesRepository {
const now = Date.now(); const now = Date.now();
const minSentAt = now - minAgeMs; const minSentAt = now - minAgeMs;
const recheckBefore = now - recheckAfterMs; const recheckBefore = now - recheckAfterMs;
const rows = this.getUncheckedStmt.all(recheckBefore, minSentAt, limit) as Record<string, unknown>[]; const rows = this.getUncheckedStmt.all(this.accountId, recheckBefore, minSentAt, limit) as Record<string, unknown>[];
return rows.map((row) => this.mapRow(row)); return rows.map((row) => this.mapRow(row));
} }
markGotResponse(messageId: number): void { markGotResponse(messageId: number): void {
this.markResponseStmt.run(Date.now(), messageId); this.markResponseStmt.run(Date.now(), this.accountId, messageId);
} }
markGotImage(messageId: number): void { markGotImage(messageId: number): void {
@@ -585,11 +613,11 @@ export class MessagesRepository {
} }
markChecked(messageId: number): void { markChecked(messageId: number): void {
this.markCheckedStmt.run(Date.now(), messageId); this.markCheckedStmt.run(Date.now(), this.accountId, messageId);
} }
hasSentTo(recipient: string): boolean { hasSentTo(recipient: string): boolean {
return this.hasSentToStmt.get(recipient) !== undefined; return this.hasSentToStmt.get(this.accountId, recipient) !== undefined;
} }
getStrategyStats(): StrategyStats[] { getStrategyStats(): StrategyStats[] {
@@ -600,9 +628,9 @@ 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,
SUM(CASE WHEN got_image = 1 THEN 1 ELSE 0 END) as total_images SUM(CASE WHEN got_image = 1 THEN 1 ELSE 0 END) as total_images
FROM sent_messages FROM sent_messages
WHERE strategy_id IS NOT NULL WHERE account_id = ? AND strategy_id IS NOT NULL
GROUP BY strategy_id GROUP BY strategy_id
`).all() as Record<string, unknown>[]; `).all(this.accountId) as Record<string, unknown>[];
return rows.map(row => ({ return rows.map(row => ({
strategyId: row.strategy_id as string, strategyId: row.strategy_id as string,
@@ -626,10 +654,11 @@ export class MessagesRepository {
SUM(CASE WHEN sm.got_response = 1 THEN 1 ELSE 0 END) as total_responses, SUM(CASE WHEN sm.got_response = 1 THEN 1 ELSE 0 END) as total_responses,
SUM(CASE WHEN sm.got_image = 1 THEN 1 ELSE 0 END) as total_images SUM(CASE WHEN sm.got_image = 1 THEN 1 ELSE 0 END) as total_images
FROM sent_messages sm FROM sent_messages sm
JOIN processed_posts pp ON sm.post_id = pp.id JOIN processed_posts pp ON sm.account_id = pp.account_id AND sm.post_id = pp.id
WHERE sm.account_id = ?
GROUP BY pp.subreddit GROUP BY pp.subreddit
ORDER BY total_sent DESC ORDER BY total_sent DESC
`).all() as Record<string, unknown>[]; `).all(this.accountId) as Record<string, unknown>[];
return rows.map(row => ({ return rows.map(row => ({
subreddit: row.subreddit as string, subreddit: row.subreddit as string,
@@ -654,11 +683,11 @@ 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,
SUM(CASE WHEN got_image = 1 THEN 1 ELSE 0 END) as total_images SUM(CASE WHEN got_image = 1 THEN 1 ELSE 0 END) as total_images
FROM sent_messages FROM sent_messages
WHERE sent_at > ? WHERE account_id = ? AND sent_at > ?
AND (post_id IS NOT NULL OR strategy_id IS NOT NULL) 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(this.accountId, cutoff) as Record<string, unknown>[];
return rows.map(row => ({ return rows.map(row => ({
date: row.day as string, date: row.day as string,
@@ -704,14 +733,18 @@ export class SuccessfulMessagesRepository {
private countStmt: Database.Statement; private countStmt: Database.Statement;
private existsByMessageStmt: Database.Statement; private existsByMessageStmt: Database.Statement;
constructor(private db: Database.Database) { constructor(
private db: Database.Database,
private readonly accountId: string = LEGACY_UNASSIGNED_ACCOUNT_ID,
) {
this.insertStmt = db.prepare(` this.insertStmt = db.prepare(`
INSERT INTO successful_messages (sent_message_id, original_message, image_analysis, post_title, response_snippet, subreddit, created_at) INSERT INTO successful_messages (account_id, sent_message_id, original_message, image_analysis, post_title, response_snippet, subreddit, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`); `);
this.getRecentStmt = db.prepare(` this.getRecentStmt = db.prepare(`
SELECT * FROM successful_messages ORDER BY created_at DESC, id DESC LIMIT ? SELECT * FROM successful_messages
WHERE account_id = ? ORDER BY created_at DESC, id DESC LIMIT ?
`); `);
this.getAttributedRecentStmt = db.prepare(` this.getAttributedRecentStmt = db.prepare(`
@@ -730,14 +763,15 @@ export class SuccessfulMessagesRepository {
SELECT *, SELECT *,
CASE WHEN subreddit = ? THEN 1 ELSE 0 END as subreddit_match CASE WHEN subreddit = ? THEN 1 ELSE 0 END as subreddit_match
FROM successful_messages FROM successful_messages
WHERE account_id = ?
ORDER BY subreddit_match DESC, created_at DESC ORDER BY subreddit_match DESC, created_at DESC
LIMIT ? LIMIT ?
`); `);
this.countStmt = db.prepare(`SELECT COUNT(*) as count FROM successful_messages`); this.countStmt = db.prepare(`SELECT COUNT(*) as count FROM successful_messages WHERE account_id = ?`);
this.existsByMessageStmt = db.prepare(` this.existsByMessageStmt = db.prepare(`
SELECT 1 FROM successful_messages WHERE original_message = ? LIMIT 1 SELECT 1 FROM successful_messages WHERE account_id = ? AND original_message = ? LIMIT 1
`); `);
} }
@@ -750,6 +784,7 @@ export class SuccessfulMessagesRepository {
subreddit: string | null; subreddit: string | null;
}): void { }): void {
this.insertStmt.run( this.insertStmt.run(
this.accountId,
data.sentMessageId, data.sentMessageId,
data.originalMessage, data.originalMessage,
data.imageAnalysis, data.imageAnalysis,
@@ -761,11 +796,11 @@ export class SuccessfulMessagesRepository {
} }
existsByMessage(originalMessage: string): boolean { existsByMessage(originalMessage: string): boolean {
return this.existsByMessageStmt.get(originalMessage) !== undefined; return this.existsByMessageStmt.get(this.accountId, originalMessage) !== undefined;
} }
getRecent(limit: number = 5): SuccessfulMessageRecord[] { getRecent(limit: number = 5): SuccessfulMessageRecord[] {
const rows = this.getRecentStmt.all(limit) as Record<string, unknown>[]; const rows = this.getRecentStmt.all(this.accountId, limit) as Record<string, unknown>[];
return rows.map((row) => this.mapRow(row)); return rows.map((row) => this.mapRow(row));
} }
@@ -776,14 +811,14 @@ export class SuccessfulMessagesRepository {
getWeightedExamples(subreddit: string | null, limit: number = 3): SuccessfulMessageRecord[] { getWeightedExamples(subreddit: string | null, limit: number = 3): SuccessfulMessageRecord[] {
if (subreddit) { if (subreddit) {
const rows = this.getBySubredditStmt.all(subreddit, limit) as Record<string, unknown>[]; const rows = this.getBySubredditStmt.all(subreddit, this.accountId, limit) as Record<string, unknown>[];
return rows.map((row) => this.mapRow(row)); return rows.map((row) => this.mapRow(row));
} }
return this.getRecent(limit); return this.getRecent(limit);
} }
count(): number { count(): number {
const result = this.countStmt.get() as { count: number }; const result = this.countStmt.get(this.accountId) as { count: number };
return result.count; return result.count;
} }
@@ -807,47 +842,55 @@ export class ConversationStateRepository {
private upsertStmt: Database.Statement; private upsertStmt: Database.Statement;
private getStmt: Database.Statement; private getStmt: Database.Statement;
constructor(private db: Database.Database) { constructor(
private db: Database.Database,
private readonly accountId: string = LEGACY_UNASSIGNED_ACCOUNT_ID,
) {
this.upsertStmt = db.prepare(` this.upsertStmt = db.prepare(`
INSERT INTO conversation_states (conversation_id, state_json, updated_at) INSERT INTO conversation_states (account_id, conversation_id, state_json, updated_at)
VALUES (?, ?, ?) VALUES (?, ?, ?, ?)
ON CONFLICT(conversation_id) DO UPDATE SET ON CONFLICT(account_id, conversation_id) DO UPDATE SET
state_json = excluded.state_json, state_json = excluded.state_json,
updated_at = excluded.updated_at updated_at = excluded.updated_at
`); `);
this.getStmt = db.prepare(` this.getStmt = db.prepare(`
SELECT state_json FROM conversation_states WHERE conversation_id = ? SELECT state_json FROM conversation_states WHERE account_id = ? AND conversation_id = ?
`); `);
} }
save(conversationId: string, state: ConversationState): void { save(conversationId: string, state: ConversationState): void {
this.upsertStmt.run(conversationId, serializeConversationState(state), Date.now()); this.upsertStmt.run(this.accountId, conversationId, serializeConversationState(state), Date.now());
} }
get(conversationId: string): ConversationState | undefined { get(conversationId: string): ConversationState | undefined {
const row = this.getStmt.get(conversationId) as { state_json: string } | undefined; const row = this.getStmt.get(this.accountId, conversationId) as { state_json: string } | undefined;
return row ? parseConversationState(row.state_json) ?? undefined : undefined; return row ? parseConversationState(row.state_json) ?? undefined : undefined;
} }
} }
/** Outcome labels are recorded independently from a raw inbox response. */ /** Outcome labels are recorded independently from a raw inbox response. */
export class ConversationOutcomesRepository { export class ConversationOutcomesRepository {
constructor(private db: Database.Database) {} constructor(
private db: Database.Database,
private readonly accountId: string = LEGACY_UNASSIGNED_ACCOUNT_ID,
) {}
record(sentMessageId: number, outcome: ConversationOutcome, responseSnippet: string | null): void { record(sentMessageId: number, outcome: ConversationOutcome, responseSnippet: string | null): void {
this.db.prepare(` this.db.prepare(`
INSERT INTO conversation_outcomes (sent_message_id, outcome, response_snippet, recorded_at) INSERT INTO conversation_outcomes (account_id, sent_message_id, outcome, response_snippet, recorded_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(sent_message_id) DO UPDATE SET ON CONFLICT(account_id, sent_message_id) DO UPDATE SET
outcome = excluded.outcome, outcome = excluded.outcome,
response_snippet = excluded.response_snippet, response_snippet = excluded.response_snippet,
recorded_at = excluded.recorded_at recorded_at = excluded.recorded_at
`).run(sentMessageId, outcome, responseSnippet, Date.now()); `).run(this.accountId, sentMessageId, outcome, responseSnippet, Date.now());
} }
get(sentMessageId: number): { outcome: ConversationOutcome; responseSnippet: string | null } | undefined { get(sentMessageId: number): { outcome: ConversationOutcome; responseSnippet: string | null } | undefined {
const row = this.db.prepare(`SELECT outcome, response_snippet FROM conversation_outcomes WHERE sent_message_id = ?`) const row = this.db.prepare(`
.get(sentMessageId) as { outcome: ConversationOutcome; response_snippet: string | null } | undefined; SELECT outcome, response_snippet FROM conversation_outcomes
WHERE account_id = ? AND sent_message_id = ?
`).get(this.accountId, sentMessageId) as { outcome: ConversationOutcome; response_snippet: string | null } | undefined;
return row ? { outcome: row.outcome, responseSnippet: row.response_snippet } : undefined; return row ? { outcome: row.outcome, responseSnippet: row.response_snippet } : undefined;
} }
} }
+28
View File
@@ -74,6 +74,10 @@ const ERROR_BACKOFF_MS = 60_000;
* SIGINT/SIGTERM. Resolves once shutdown completes. * SIGINT/SIGTERM. Resolves once shutdown completes.
*/ */
export async function runMainLoop(app: AppContainer): Promise<void> { export async function runMainLoop(app: AppContainer): Promise<void> {
if (app.accountRuntimeManager) {
await runConcurrentAccountRuntimes(app);
return;
}
const { const {
config, config,
logger, logger,
@@ -407,3 +411,27 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
} }
} }
} }
async function runConcurrentAccountRuntimes(app: AppContainer): Promise<void> {
const { logger, accountRuntimeManager, browserClient, db, webServer } = app;
logger.info(`Running ${accountRuntimeManager.list().length} independent Reddit account runtime(s)`);
await new Promise<void>((resolve) => {
let shuttingDown = false;
const shutdown = async (): Promise<void> => {
if (shuttingDown) return;
shuttingDown = true;
logger.info('Stopping all Reddit account runtimes...');
await accountRuntimeManager.stopAll();
webServer?.stop();
await browserClient.close();
db.close();
process.off('SIGINT', onSignal);
process.off('SIGTERM', onSignal);
logger.info('Goodbye!');
resolve();
};
const onSignal = (): void => { void shutdown(); };
process.on('SIGINT', onSignal);
process.on('SIGTERM', onSignal);
});
}
+70 -2
View File
@@ -2,6 +2,7 @@ import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import type { Logger } from '../../utils/logger.js'; import type { Logger } from '../../utils/logger.js';
import type { ActiveChat, ChatCacheFile, InvitationCooldown } from './types.js'; import type { ActiveChat, ChatCacheFile, InvitationCooldown } from './types.js';
import type { AccountContentRepository } from '../../database/account-repositories.js';
export interface SyncMarkers { export interface SyncMarkers {
nextBatch: string | null; nextBatch: string | null;
@@ -24,8 +25,13 @@ export class ChatCache {
private lastDeltaSyncAt: number | null = null; private lastDeltaSyncAt: number | null = null;
private cooldown: InvitationCooldown | null = null; private cooldown: InvitationCooldown | null = null;
constructor(private logger: Logger, filePath?: string) { constructor(
if (!filePath && process.env.NODE_ENV === 'test') { private logger: Logger,
filePath?: string,
private readonly databaseStore?: AccountContentRepository,
private accountId: string | null = null,
) {
if (!filePath && !databaseStore && process.env.NODE_ENV === 'test') {
// Without this guard a test that constructs a ChatCache (or a // Without this guard a test that constructs a ChatCache (or a
// ChatService that builds one) would silently write to the prod // ChatService that builds one) would silently write to the prod
// ./data/chat-cache.json — concurrent test runs have already torn // ./data/chat-cache.json — concurrent test runs have already torn
@@ -37,6 +43,18 @@ export class ChatCache {
this.filePath = filePath ?? path.join(process.cwd(), 'data', 'chat-cache.json'); this.filePath = filePath ?? path.join(process.cwd(), 'data', 'chat-cache.json');
} }
async switchAccount(accountId: string): Promise<void> {
if (this.accountId === accountId) return;
this.accountId = accountId;
this.chats = [];
this.lastFetchTime = 0;
this.nextBatch = null;
this.lastFullSyncAt = null;
this.lastDeltaSyncAt = null;
this.cooldown = null;
await this.load();
}
get(): ActiveChat[] { get(): ActiveChat[] {
return this.chats; return this.chats;
} }
@@ -80,6 +98,20 @@ export class ChatCache {
} }
async load(): Promise<void> { async load(): Promise<void> {
if (this.databaseStore && this.accountId) {
const rows = this.databaseStore.listConversations(this.accountId);
this.chats = rows.flatMap((row) => {
try { return [JSON.parse(row.payloadJson) as ActiveChat]; } catch { return []; }
});
const state = this.databaseStore.getSyncState(this.accountId);
this.nextBatch = state?.nextBatch ?? null;
this.lastFullSyncAt = state?.lastFullSyncAt ?? null;
this.lastDeltaSyncAt = state?.lastDeltaSyncAt ?? null;
try { this.cooldown = state?.cooldownJson ? JSON.parse(state.cooldownJson) as InvitationCooldown : null; } catch { this.cooldown = null; }
this.lastFetchTime = Date.now();
this.logger.info(`Loaded ${this.chats.length} account-owned chats from the database`);
return;
}
try { try {
const raw = await fs.readFile(this.filePath, 'utf-8'); const raw = await fs.readFile(this.filePath, 'utf-8');
const parsed = JSON.parse(raw) as ChatCacheFile; const parsed = JSON.parse(raw) as ChatCacheFile;
@@ -115,6 +147,10 @@ export class ChatCache {
* sync with `chats: []` and null markers. * sync with `chats: []` and null markers.
*/ */
async persistCooldown(): Promise<void> { async persistCooldown(): Promise<void> {
if (this.databaseStore && this.accountId) {
this.persistDatabaseState();
return;
}
let existing: ChatCacheFile | null = null; let existing: ChatCacheFile | null = null;
try { try {
const raw = await fs.readFile(this.filePath, 'utf-8'); const raw = await fs.readFile(this.filePath, 'utf-8');
@@ -143,6 +179,27 @@ export class ChatCache {
} }
async save(chats: ActiveChat[] = this.chats): Promise<void> { async save(chats: ActiveChat[] = this.chats): Promise<void> {
if (this.databaseStore && this.accountId) {
const retained = new Set<string>();
for (const chat of chats) {
const conversationId = chat.roomId ?? chat.username;
retained.add(conversationId);
this.databaseStore.upsertConversation({
accountId: this.accountId,
conversationId,
username: chat.username,
roomName: chat.roomName ?? null,
payloadJson: JSON.stringify(chat),
});
}
for (const existing of this.databaseStore.listConversations(this.accountId)) {
if (!retained.has(existing.conversationId)) {
this.databaseStore.deleteConversation(this.accountId, existing.conversationId);
}
}
this.persistDatabaseState();
return;
}
const payload: ChatCacheFile = { const payload: ChatCacheFile = {
version: 1, version: 1,
savedAt: Date.now(), savedAt: Date.now(),
@@ -159,6 +216,17 @@ export class ChatCache {
} }
} }
private persistDatabaseState(): void {
if (!this.databaseStore || !this.accountId) return;
this.databaseStore.saveSyncState({
accountId: this.accountId,
nextBatch: this.nextBatch,
lastFullSyncAt: this.lastFullSyncAt,
lastDeltaSyncAt: this.lastDeltaSyncAt,
cooldownJson: this.cooldown ? JSON.stringify(this.cooldown) : null,
});
}
/** /**
* Write the cache file via a temp+rename so a crashed/interrupted/concurrent * Write the cache file via a temp+rename so a crashed/interrupted/concurrent
* writer can never leave the on-disk file half-written. rename(2) is atomic * writer can never leave the on-disk file half-written. rename(2) is atomic
+9
View File
@@ -256,6 +256,15 @@ export class ChatService {
} }
} }
async switchAccount(accountId: string): Promise<void> {
await this.enqueue(async () => {
await this.cache.switchAccount(accountId);
this.t2ToUsername.clear();
this.unresolvableRooms.clear();
this.invitationCooldown = this.cache.getCooldown();
});
}
async saveCacheExternal(): Promise<void> { async saveCacheExternal(): Promise<void> {
await this.cache.save(); await this.cache.save();
} }
+59 -7
View File
@@ -4,6 +4,7 @@ import crypto from 'crypto';
import { z } from 'zod'; import { z } from 'zod';
import type { Logger } from '../utils/logger.js'; import type { Logger } from '../utils/logger.js';
import type { ActiveChat, ChatMedia, ChatMessage } from './chat.js'; import type { ActiveChat, ChatMedia, ChatMessage } from './chat.js';
import type { AccountContentRepository } from '../database/account-repositories.js';
export interface MediaDownloadResult { export interface MediaDownloadResult {
buffer: Buffer; buffer: Buffer;
@@ -15,6 +16,7 @@ export interface MediaDownloader {
} }
export interface MediaRecord { export interface MediaRecord {
accountId?: string;
id: string; id: string;
chatUsername: string | null; chatUsername: string | null;
chatRoomId: string | null; chatRoomId: string | null;
@@ -59,13 +61,16 @@ export class MediaService {
private downloader: MediaDownloader, private downloader: MediaDownloader,
private logger: Logger, private logger: Logger,
mediaRoot?: string, mediaRoot?: string,
private readonly databaseStore?: AccountContentRepository,
private readonly getAccountId: () => string | null = () => null,
) { ) {
this.mediaRoot = mediaRoot ?? path.join(process.cwd(), 'data', 'media'); this.mediaRoot = mediaRoot ?? path.join(process.cwd(), 'data', 'media');
this.indexPath = path.join(this.mediaRoot, 'index.json'); this.indexPath = path.join(this.mediaRoot, 'index.json');
} }
async attachMedia(chat: ActiveChat, messages: ScrapedChatMessage[]): Promise<ChatMessage[]> { async attachMedia(chat: ActiveChat, messages: ScrapedChatMessage[]): Promise<ChatMessage[]> {
await this.loadIndex(); const databaseBacked = Boolean(this.databaseStore && this.getAccountId());
if (!databaseBacked) await this.loadIndex();
const updated: ChatMessage[] = []; const updated: ChatMessage[] = [];
for (const message of messages) { for (const message of messages) {
@@ -79,11 +84,26 @@ export class MediaService {
}); });
} }
await this.saveIndex(); if (!databaseBacked) await this.saveIndex();
return updated; return updated;
} }
async getMediaItems(): Promise<MediaRecord[]> { async getMediaItems(): Promise<MediaRecord[]> {
const accountId = this.getAccountId();
if (this.databaseStore && accountId) {
return this.databaseStore.listMedia(accountId).map((item) => ({
accountId: item.accountId,
id: item.mediaId,
chatUsername: item.chatUsername,
chatRoomId: item.chatRoomId,
sender: item.sender,
timestamp: item.timestamp,
originalUrl: item.originalUrl,
fileName: item.fileName,
relativePath: item.relativePath,
createdAt: item.createdAt,
}));
}
await this.loadIndex(); await this.loadIndex();
return (this.index?.items || []).slice().sort((a, b) => b.timestamp - a.timestamp); return (this.index?.items || []).slice().sort((a, b) => b.timestamp - a.timestamp);
} }
@@ -133,11 +153,23 @@ export class MediaService {
// Try the full-resolution URL first, fall back to original // Try the full-resolution URL first, fall back to original
const fullUrl = this.resolveFullMediaUrl(url); const fullUrl = this.resolveFullMediaUrl(url);
const id = crypto.createHash('sha1').update(fullUrl).digest('hex'); const accountId = this.getAccountId();
const id = crypto.createHash('sha1').update(`${accountId ?? 'legacy'}\0${fullUrl}`).digest('hex');
if (this.databaseStore && accountId) {
const existingRecord = this.databaseStore.getMedia(accountId, id);
if (existingRecord) {
try {
await fs.access(path.join(this.mediaRoot, existingRecord.relativePath));
return { ...existingRecord, id: existingRecord.mediaId };
} catch {
this.databaseStore.deleteMedia(accountId, id);
}
}
}
const existing = this.index?.items.find((item) => item.id === id); const existing = this.index?.items.find((item) => item.id === id);
if (existing) { if (existing) {
// Verify the file still exists on disk // Verify the file still exists on disk
const existingPath = path.join(this.mediaRoot, existing.fileName); const existingPath = path.join(this.mediaRoot, existing.relativePath);
try { try {
await fs.access(existingPath); await fs.access(existingPath);
return existing; return existing;
@@ -165,13 +197,17 @@ export class MediaService {
} }
const fileName = `${id}${extension}`; const fileName = `${id}${extension}`;
const relativePath = fileName; const accountDirectory = accountId
const fullPath = path.join(this.mediaRoot, fileName); ? crypto.createHash('sha256').update(accountId).digest('hex').slice(0, 16)
: '';
const relativePath = accountDirectory ? path.join(accountDirectory, fileName) : fileName;
const fullPath = path.join(this.mediaRoot, relativePath);
await fs.mkdir(this.mediaRoot, { recursive: true }); await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, download.buffer); await fs.writeFile(fullPath, download.buffer);
const record: MediaRecord = { const record: MediaRecord = {
accountId: accountId ?? undefined,
id, id,
chatUsername: chat.username || null, chatUsername: chat.username || null,
chatRoomId: chat.roomId || null, chatRoomId: chat.roomId || null,
@@ -183,6 +219,22 @@ export class MediaService {
createdAt: Date.now(), createdAt: Date.now(),
}; };
if (this.databaseStore && accountId) {
this.databaseStore.upsertMedia({
accountId,
mediaId: id,
chatUsername: record.chatUsername,
chatRoomId: record.chatRoomId,
sender: record.sender,
timestamp: record.timestamp,
originalUrl: record.originalUrl,
fileName: record.fileName,
relativePath: record.relativePath,
createdAt: record.createdAt,
});
return record;
}
if (!this.index) { if (!this.index) {
this.index = { version: 1, savedAt: Date.now(), items: [record] }; this.index = { version: 1, savedAt: Date.now(), items: [record] };
} else { } else {
@@ -0,0 +1,290 @@
import crypto from 'node:crypto';
import path from 'node:path';
import type Database from 'better-sqlite3';
import type { Config } from '../config/index.js';
import type { Logger } from '../utils/logger.js';
import { BrowserClient } from '../browser/client.js';
import {
PostsRepository, MessagesRepository, SuccessfulMessagesRepository,
DeliveryOutboxRepository, ConversationStateRepository,
ConversationOutcomesRepository, RedditUsersRepository,
ActivityLogRepository,
} from '../database/repositories.js';
import { AccountContentRepository } from '../database/account-repositories.js';
import { RedditApiClient } from '../reddit/api.js';
import { OpenRouterClient } from '../ai/openrouter.js';
import { MonitorService } from './monitor.js';
import { ProcessorService, isSkip } from './processor.js';
import { ResponseCheckerService } from './response-checker.js';
import { StrategySelector } from './strategy-selector.js';
import { AccountAuthContext } from './account-auth-context.js';
import { ChatCache } from './chat/cache.js';
import {
ChatService, MatrixClient, MatrixMediaDownloader, MatrixTokenRefresher,
MatrixUsernameResolver, type UsernameResolver,
} from './chat/index.js';
import { MediaService } from './media.js';
import { MediaSyncService } from './media-sync.js';
import { RateLimiter } from '../utils/rate-limiter.js';
import {
RedditAccountRuntime, RedditAccountRuntimeFactory,
accountBrowserProfileDir, type RedditAccountRuntimeResources,
type VerifiedAccountSession,
} from './reddit-account-runtime.js';
export interface ConcreteRuntimeFactoryOptions {
config: Config;
logger: Logger;
db: Database.Database;
isBotRunning?: () => boolean;
}
export function createConcreteRedditAccountRuntimeFactory(
options: ConcreteRuntimeFactoryOptions,
): RedditAccountRuntimeFactory {
return new RedditAccountRuntimeFactory((session) => buildRuntime(options, session));
}
async function buildRuntime(
{ config, logger, db, isBotRunning = () => true }: ConcreteRuntimeFactoryOptions,
session: VerifiedAccountSession,
): Promise<RedditAccountRuntime> {
const browserProfileDir = accountBrowserProfileDir(
path.resolve(process.cwd(), config.browser.sessionDir),
session.accountId,
);
const accountConfig: Config = {
...config,
browser: { ...config.browser, sessionDir: browserProfileDir },
web: { ...config.web, enabled: false },
};
const browserClient = new BrowserClient(accountConfig, logger);
await browserClient.initialize();
try {
await browserClient.importRedditCookies(session.cookies);
const identity = await browserClient.getAuthenticatedIdentity();
const expectedRedditId = matrixRedditId(session.matrixUserId);
if (!identity || !expectedRedditId || identity.accountId.toLowerCase().replace(/^t2_/, '') !== expectedRedditId) {
throw new Error(`Stored Reddit cookies no longer verify for u/${session.redditUsername}`);
}
const matrixClient = new MatrixClient(session.matrixCredentials, logger);
if (await matrixClient.whoami() !== session.matrixUserId) {
throw new Error(`Stored Matrix session no longer verifies for u/${session.redditUsername}`);
}
const accountAuth = new AccountAuthContext(logger);
accountAuth.markAuthenticated({
accountId: session.accountId,
redditUsername: identity.username,
matrixUserId: session.matrixUserId,
});
accountAuth.setAuthenticator(async () => {
const current = await browserClient.getAuthenticatedIdentity();
if (!current || matrixRedditId(session.matrixUserId) !== current.accountId.toLowerCase().replace(/^t2_/, '')) return null;
return await matrixClient.whoami() === session.matrixUserId
? { accountId: session.accountId, redditUsername: current.username, matrixUserId: session.matrixUserId }
: null;
});
const postsRepo = new PostsRepository(db, session.accountId);
const messagesRepo = new MessagesRepository(db, session.accountId);
const successfulMessagesRepo = new SuccessfulMessagesRepository(db, session.accountId);
const deliveryOutboxRepo = new DeliveryOutboxRepository(db, session.accountId);
const conversationStateRepo = new ConversationStateRepository(db, session.accountId);
const conversationOutcomesRepo = new ConversationOutcomesRepository(db, session.accountId);
const redditUsersRepo = new RedditUsersRepository(db);
const accountContentRepo = new AccountContentRepository(db);
const activityLogRepo = new ActivityLogRepository(db, session.accountId);
const redditApi = new RedditApiClient(accountConfig, logger);
redditApi.setBrowserContext(browserClient.getContext()!);
const chatPage = await browserClient.getContext()!.newPage();
const aiClient = new OpenRouterClient(accountConfig, logger);
const monitorService = new MonitorService(redditApi, postsRepo, messagesRepo, accountConfig, logger);
const processorService = new ProcessorService(redditApi, aiClient, accountConfig, logger);
processorService.setStrategySelector(new StrategySelector(accountConfig, messagesRepo, logger));
processorService.setChatStrategySelector(new StrategySelector(accountConfig, messagesRepo, logger, 'chatStrategies'));
const mediaDownloader = new MatrixMediaDownloader(matrixClient, logger);
const mediaService = new MediaService(
mediaDownloader,
logger,
undefined,
accountContentRepo,
() => session.accountId,
);
const usernameResolverImpl = new MatrixUsernameResolver(matrixClient);
const usernameResolver: UsernameResolver = {
resolveT2Ids: (ids) => usernameResolverImpl.resolveT2Ids(ids),
resolveUsernameToT2: (username) => redditApi.resolveUsernameToT2(username),
};
const chatCache = new ChatCache(logger, undefined, accountContentRepo, session.accountId);
const chatService = new ChatService(
matrixClient, logger, successfulMessagesRepo, mediaService,
new MatrixTokenRefresher(), usernameResolver, chatCache,
messagesRepo, redditUsersRepo, accountAuth,
);
await chatService.loadCache();
const mediaSyncService = new MediaSyncService(chatService, logger, accountConfig.media.syncIntervalMs, accountAuth);
const responseChecker = new ResponseCheckerService(
chatPage, messagesRepo, successfulMessagesRepo, postsRepo, logger,
accountAuth, conversationOutcomesRepo,
);
const rateLimiter = new RateLimiter(accountConfig, logger, undefined, {
sentMessageHistory: async (since) => messagesRepo.getSentTimestampsSince(since),
});
await rateLimiter.initialize();
const resources: RedditAccountRuntimeResources = {
browserProfileDir, browserClient, accountAuth, matrixClient, chatCache, redditApi,
postsRepo, messagesRepo, successfulMessagesRepo, deliveryOutboxRepo,
conversationStateRepo, conversationOutcomesRepo, redditUsersRepo, accountContentRepo,
activityLogRepo,
monitorService, processorService, responseChecker, chatService, mediaService,
mediaSyncService, rateLimiter,
};
const lifecycle = new AccountRuntimeLoop(resources, accountConfig, logger, isBotRunning);
return new RedditAccountRuntime(session, resources, lifecycle);
} catch (error) {
await browserClient.close();
throw error;
}
}
class AccountRuntimeLoop {
private controller: AbortController | null = null;
private loop: Promise<void> | null = null;
constructor(
private readonly resources: RedditAccountRuntimeResources,
private readonly config: Config,
private readonly logger: Logger,
private readonly isBotRunning: () => boolean,
) {}
start(): void {
if (this.loop) return;
this.controller = new AbortController();
this.resources.mediaSyncService.start();
void this.resources.chatService.runStartupSync()
.then(() => this.resources.chatService.enrichUsernames())
.catch((error) => this.logger.error(`[account ${this.resources.accountAuth.getSnapshot().redditUsername}] startup sync failed:`, error));
this.loop = this.run(this.controller.signal).finally(() => { this.loop = null; });
}
async stop(): Promise<void> {
this.controller?.abort();
this.resources.mediaSyncService.stop();
await this.loop?.catch((error) => {
if (!(error instanceof DOMException && error.name === 'AbortError')) throw error;
});
await this.resources.browserClient.close();
}
private async run(signal: AbortSignal): Promise<void> {
let nextResponseCheckAt = 0;
const responseInterval = Math.max(this.config.reddit.pollIntervalMs * 5, 60_000);
while (!signal.aborted) {
try {
if (!this.isBotRunning()) {
await delay(1_000, signal);
continue;
}
const auth = this.resources.accountAuth;
if (!auth.isFullyAuthenticated() && !(await auth.ensureFullyAuthenticated())) {
await delay(1_000, signal);
continue;
}
if (Date.now() >= nextResponseCheckAt) {
nextResponseCheckAt = Date.now() + responseInterval;
await this.resources.responseChecker.checkForResponses();
}
if (!this.resources.chatService.getInvitationCooldown()) {
await this.processPosts(signal);
}
await delay(this.config.reddit.pollIntervalMs, signal);
} catch (error) {
if (signal.aborted) return;
this.logger.error(`Account runtime loop failed for ${this.resources.accountAuth.getSnapshot().redditUsername}:`, error);
await delay(60_000, signal);
}
}
}
private async processPosts(signal: AbortSignal): Promise<void> {
const { monitorService, postsRepo, messagesRepo, redditApi, processorService, rateLimiter, chatService, responseChecker } = this.resources;
const posts = await monitorService.getUnprocessedPosts();
for (const post of posts) {
if (signal.aborted) return;
const subreddit = this.config.reddit.subreddits.find((entry) => entry.name.toLowerCase() === post.subreddit.toLowerCase());
const isChat = subreddit?.type === 'chat';
const imageUrl = isChat ? null : redditApi.getImageUrl(post);
postsRepo.insert({
id: post.id, subreddit: post.subreddit, author: post.author, title: post.title,
postUrl: redditApi.getPostUrl(post), imageUrl, processedAt: Date.now(),
});
try {
if (
this.config.reddit.filterSellers
&& (this.config.reddit.sellerKeywords.length > 0 || this.config.reddit.sellerDomains.length > 0)
&& await redditApi.isSellerProfile(post.author, this.config.reddit.sellerKeywords, this.config.reddit.sellerDomains)
) {
postsRepo.setError(post.id, 'Seller profile detected');
this.resources.activityLogRepo.insert({ timestamp: Date.now(), type: 'skipped', message: `Skipped post by u/${post.author} (seller profile)`, recipient: post.author });
continue;
}
processorService.setSuccessfulExamples(responseChecker.getSuccessfulExamples(3, post.subreddit));
const processed = await processorService.processPost(post);
if (isSkip(processed)) {
postsRepo.setError(post.id, processed.skipReason);
this.resources.activityLogRepo.insert({ timestamp: Date.now(), type: 'skipped', message: `Skipped post by u/${post.author} (${processed.skipReason})`, recipient: post.author });
continue;
}
postsRepo.updateAnalysis(post.id, processed.imageAnalysis, processed.generatedMessage);
await rateLimiter.throttle(async () => {
const chat = await chatService.startConversation(post.author, processed.generatedMessage, post.author_fullname);
if (!chat) return false;
postsRepo.markMessageSent(post.id);
messagesRepo.insert({
postId: post.id, recipient: post.author, subject: post.author,
message: processed.generatedMessage, sentAt: Date.now(),
strategyId: processed.strategyId, chatRoomId: chat.roomId,
});
this.resources.activityLogRepo.insert({
timestamp: Date.now(), type: 'sent', message: `Sent message to u/${post.author} (r/${post.subreddit})`,
recipient: post.author, imageUrl: imageUrl ?? undefined, postTitle: post.title, postUrl: redditApi.getPostUrl(post),
});
void chatService.refreshTopChats(5);
return true;
}, (success) => success, signal);
} catch (error) {
if (signal.aborted) return;
postsRepo.setError(post.id, error instanceof Error ? error.message : String(error));
this.resources.activityLogRepo.insert({ timestamp: Date.now(), type: 'error', message: `Failed processing post by u/${post.author}`, recipient: post.author });
this.logger.error(`Account runtime failed processing post ${post.id}:`, error);
}
}
}
}
function matrixRedditId(matrixUserId: string): string | null {
return matrixUserId.match(/^@t2_([^:]+):reddit\.com$/i)?.[1]?.toLowerCase() ?? null;
}
function delay(ms: number, signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.reject(new DOMException('Operation aborted', 'AbortError'));
return new Promise((resolve, reject) => {
const onAbort = () => {
clearTimeout(timer);
signal.removeEventListener('abort', onAbort);
reject(new DOMException('Operation aborted', 'AbortError'));
};
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal.addEventListener('abort', onAbort, { once: true });
});
}
+378
View File
@@ -0,0 +1,378 @@
import path from 'node:path';
import type { Cookie } from 'playwright';
import { z } from 'zod';
import type { BrowserClient } from '../browser/client.js';
import type {
RedditAccountPrivateRecord,
RedditAccountsRepository,
AccountContentRepository,
} from '../database/account-repositories.js';
import type {
ConversationOutcomesRepository,
ActivityLogRepository,
ConversationStateRepository,
DeliveryOutboxRepository,
MessagesRepository,
PostsRepository,
RedditUsersRepository,
SuccessfulMessagesRepository,
} from '../database/repositories.js';
import type { RedditApiClient } from '../reddit/api.js';
import type { RateLimiter } from '../utils/rate-limiter.js';
import type { AccountAuthContext } from './account-auth-context.js';
import type { ChatCache } from './chat/cache.js';
import type { MatrixClient } from './chat/matrix/client.js';
import type { MatrixCredentials } from './chat/matrix/types.js';
import type { ChatService } from './chat/service.js';
import type { MediaService } from './media.js';
import type { MediaSyncService } from './media-sync.js';
import type { MonitorService } from './monitor.js';
import type { ProcessorService } from './processor.js';
import type { ResponseCheckerService } from './response-checker.js';
/** Credentials passed to a factory. They must never be exposed by list/snapshot APIs. */
export interface VerifiedAccountSession {
accountId: string;
redditUsername: string;
matrixUserId: string;
cookies: Cookie[];
matrixCredentials: MatrixCredentials;
updatedAt: number;
}
/**
* The complete set of stateful dependencies owned by one account runtime.
*
* Nothing in this object may be shared with another account runtime except the
* underlying SQLite connection and stateless configuration/AI clients. In
* particular, BrowserClient, AccountAuthContext, ChatCache, MatrixClient,
* RedditApiClient and RateLimiter are intentionally per-account instances.
*/
export interface RedditAccountRuntimeResources {
browserProfileDir: string;
browserClient: BrowserClient;
accountAuth: AccountAuthContext;
matrixClient: MatrixClient;
chatCache: ChatCache;
redditApi: RedditApiClient;
postsRepo: PostsRepository;
messagesRepo: MessagesRepository;
successfulMessagesRepo: SuccessfulMessagesRepository;
deliveryOutboxRepo: DeliveryOutboxRepository;
conversationStateRepo: ConversationStateRepository;
conversationOutcomesRepo: ConversationOutcomesRepository;
redditUsersRepo: RedditUsersRepository;
accountContentRepo: AccountContentRepository;
activityLogRepo: ActivityLogRepository;
monitorService: MonitorService;
processorService: ProcessorService;
responseChecker: ResponseCheckerService;
chatService: ChatService;
mediaService: MediaService;
mediaSyncService: MediaSyncService;
rateLimiter: RateLimiter;
}
export interface RedditAccountRuntimeLifecycle {
/** Start this account's main loop, response schedule and media sync. */
start(): void | Promise<void>;
/** Stop and await this account's schedules/loop, then close its browser. */
stop(): void | Promise<void>;
}
export type RedditAccountRuntimeState =
| 'created'
| 'starting'
| 'running'
| 'stopping'
| 'stopped'
| 'failed';
export interface RedditAccountRuntimeSnapshot {
accountId: string;
redditUsername: string;
matrixUserId: string;
state: RedditAccountRuntimeState;
updatedAt: number;
failure: string | null;
}
/**
* One immutable Reddit identity and every mutable service associated with it.
* There is deliberately no switchAccount method: a session refresh replaces
* the matching runtime instead of mutating shared live state.
*/
export class RedditAccountRuntime {
private state: RedditAccountRuntimeState = 'created';
private failure: string | null = null;
private transition: Promise<void> = Promise.resolve();
constructor(
readonly session: VerifiedAccountSession,
readonly resources: RedditAccountRuntimeResources,
private readonly lifecycle: RedditAccountRuntimeLifecycle,
) {
if (session.accountId !== session.matrixUserId) {
throw new Error('Runtime account ID must match the verified Matrix user ID');
}
if (!session.accountId || !session.redditUsername) {
throw new Error('Runtime requires a verified Reddit and Matrix identity');
}
}
start(): Promise<void> {
return this.enqueue(async () => {
if (this.state === 'running') return;
this.state = 'starting';
this.failure = null;
try {
await this.lifecycle.start();
this.state = 'running';
} catch (error) {
this.state = 'failed';
this.failure = errorMessage(error);
try {
await this.lifecycle.stop();
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], 'Account runtime failed to start and clean up');
}
throw error;
}
});
}
stop(): Promise<void> {
return this.enqueue(async () => {
if (this.state === 'created' || this.state === 'stopped') {
this.state = 'stopped';
return;
}
this.state = 'stopping';
try {
await this.lifecycle.stop();
this.state = 'stopped';
} catch (error) {
this.state = 'failed';
this.failure = errorMessage(error);
throw error;
}
});
}
snapshot(): RedditAccountRuntimeSnapshot {
return {
accountId: this.session.accountId,
redditUsername: this.session.redditUsername,
matrixUserId: this.session.matrixUserId,
state: this.state,
updatedAt: this.session.updatedAt,
failure: this.failure,
};
}
private enqueue(operation: () => Promise<void>): Promise<void> {
const result = this.transition.then(operation, operation);
this.transition = result.catch(() => undefined);
return result;
}
}
export type RedditAccountRuntimeBuilder = (
session: VerifiedAccountSession,
) => Promise<RedditAccountRuntime>;
/** Narrow factory wrapper used as the bootstrap integration seam. */
export class RedditAccountRuntimeFactory {
constructor(private readonly buildRuntime: RedditAccountRuntimeBuilder) {}
async create(record: RedditAccountPrivateRecord): Promise<RedditAccountRuntime> {
return this.buildRuntime(parseStoredSession(record));
}
async createSession(session: VerifiedAccountSession): Promise<RedditAccountRuntime> {
return this.buildRuntime(session);
}
}
export interface RedditAccountRuntimeManagerSnapshot {
runtimes: RedditAccountRuntimeSnapshot[];
}
/**
* Owns all enabled account runtimes. Work for different account IDs runs in
* parallel; operations for the same ID are serialized to avoid duplicate
* loops and browser-profile contention.
*/
export class RedditAccountRuntimeManager {
private readonly runtimes = new Map<string, RedditAccountRuntime>();
private readonly accountOperations = new Map<string, Promise<void>>();
constructor(
private readonly accounts: Pick<RedditAccountsRepository, 'listPublic' | 'get'>,
private readonly factory: RedditAccountRuntimeFactory,
) {}
async startEnabled(): Promise<RedditAccountRuntimeManagerSnapshot> {
const enabled = this.accounts.listPublic().filter(isEnabledAccount);
await Promise.all(enabled.map((account) => this.start(account.accountId)));
return this.snapshot();
}
start(accountId: string): Promise<void> {
return this.forAccount(accountId, async () => {
const existing = this.runtimes.get(accountId);
if (existing?.snapshot().state === 'running') return;
const account = this.requireEnabledAccount(accountId);
const runtime = existing ?? await this.factory.create(account);
await runtime.start();
this.runtimes.set(accountId, runtime);
});
}
/**
* Rebuild only the refreshed account. Other account loops are untouched.
* The old runtime is stopped before concrete construction because a builder
* may open the account's persistent browser profile immediately.
*/
replace(accountId: string): Promise<void> {
return this.forAccount(accountId, async () => {
const account = this.requireEnabledAccount(accountId);
const previous = this.runtimes.get(accountId);
if (previous) await previous.stop();
try {
// Concrete construction opens the account's persistent browser
// profile, so the prior owner must be closed before factory.create.
const replacement = await this.factory.create(account);
await replacement.start();
this.runtimes.set(accountId, replacement);
} catch (error) {
if (previous) {
try {
const restored = await this.factory.createSession(previous.session);
await restored.start();
this.runtimes.set(accountId, restored);
} catch (rollbackError) {
this.runtimes.delete(accountId);
throw new AggregateError(
[error, rollbackError],
`Could not replace or restore Reddit account runtime ${accountId}`,
);
}
} else {
this.runtimes.delete(accountId);
}
throw error;
}
});
}
stop(accountId: string): Promise<void> {
return this.forAccount(accountId, async () => {
const runtime = this.runtimes.get(accountId);
if (!runtime) return;
await runtime.stop();
this.runtimes.delete(accountId);
});
}
async stopAll(): Promise<void> {
await Promise.all([...this.runtimes.keys()].map((accountId) => this.stop(accountId)));
}
get(accountId: string): RedditAccountRuntime | null {
return this.runtimes.get(accountId) ?? null;
}
list(): RedditAccountRuntimeSnapshot[] {
return this.snapshot().runtimes;
}
snapshot(): RedditAccountRuntimeManagerSnapshot {
return {
runtimes: [...this.runtimes.values()]
.map((runtime) => runtime.snapshot())
.sort((left, right) => left.redditUsername.localeCompare(right.redditUsername)),
};
}
private requireEnabledAccount(accountId: string): RedditAccountPrivateRecord {
const account = this.accounts.get(accountId);
if (!account || !isEnabledAccount(account)) {
throw new Error(`Reddit account ${accountId} is not enabled with a verified session`);
}
return account;
}
private forAccount(accountId: string, operation: () => Promise<void>): Promise<void> {
const previous = this.accountOperations.get(accountId) ?? Promise.resolve();
const result = previous.then(operation, operation);
const settled = result.catch(() => undefined).finally(() => {
if (this.accountOperations.get(accountId) === settled) {
this.accountOperations.delete(accountId);
}
});
this.accountOperations.set(accountId, settled);
return result;
}
}
/**
* Deterministic, filesystem-safe profile path for one immutable account ID.
* Bootstrap should clone config and set browser.sessionDir to this value
* before constructing that account's BrowserClient.
*/
export function accountBrowserProfileDir(baseSessionDir: string, accountId: string): string {
if (!accountId) throw new Error('Browser profile requires an account ID');
const encodedAccountId = Buffer.from(accountId, 'utf8').toString('base64url');
return path.join(baseSessionDir, 'accounts', encodedAccountId);
}
function parseStoredSession(record: RedditAccountPrivateRecord): VerifiedAccountSession {
const cookies = StoredCookiesSchema.parse(JSON.parse(record.cookiesJson)) as Cookie[];
const matrixCredentials = StoredMatrixCredentialsSchema.parse(
JSON.parse(record.matrixCredentialsJson),
) as MatrixCredentials;
if (record.matrixUserId !== record.accountId || matrixCredentials.userId !== record.accountId) {
throw new Error('Stored Reddit account and Matrix identities do not match');
}
return {
accountId: record.accountId,
redditUsername: record.redditUsername,
matrixUserId: record.accountId,
cookies,
matrixCredentials,
updatedAt: record.updatedAt,
};
}
const StoredCookiesSchema = z.array(z.object({
name: z.string().min(1),
value: z.string(),
domain: z.string().optional(),
path: z.string().optional(),
expires: z.number().optional(),
httpOnly: z.boolean().optional(),
secure: z.boolean().optional(),
sameSite: z.enum(['Strict', 'Lax', 'None']).optional(),
url: z.string().url().optional(),
}));
const StoredMatrixCredentialsSchema = z.object({
homeserver: z.string().url(),
accessToken: z.string().min(1),
userId: z.string().min(1),
capturedAt: z.number(),
});
function isEnabledAccount(account: {
status: string;
isActive: boolean;
}): boolean {
return account.status === 'verified' && account.isActive;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+96
View File
@@ -0,0 +1,96 @@
import { z } from 'zod';
import type { Cookie } from 'playwright';
import type { RedditAccountsRepository, RedditAccountPublicRecord } from '../database/account-repositories.js';
import type { AccountAuthContext } from './account-auth-context.js';
import type { RedditSessionImporter } from './reddit-session-importer.js';
import type { MatrixCredentials } from './chat/matrix/types.js';
import type { RedditAccountRuntimeManager } from './reddit-account-runtime.js';
import type { BrowserClient } from '../browser/client.js';
const StoredCookiesSchema = z.array(z.object({
name: z.string(), value: z.string(), domain: z.string(), path: z.string(),
expires: z.number(), httpOnly: z.boolean(), secure: z.boolean(),
sameSite: z.enum(['Strict', 'Lax', 'None']),
}));
const StoredMatrixCredentialsSchema = z.object({
homeserver: z.string().url(),
accessToken: z.string().min(1),
userId: z.string().min(1),
capturedAt: z.number(),
});
export interface RedditAccountSummary {
accountId: string;
redditUsername: string;
matrixUserId: string;
isRunning: boolean;
status: 'authenticated' | 'login-required';
createdAt: number;
updatedAt: number;
lastImportedAt: number;
}
export class RedditAccountsService {
constructor(
private readonly accounts: RedditAccountsRepository,
private readonly importer: RedditSessionImporter,
private readonly auth: AccountAuthContext,
private readonly runtimes?: RedditAccountRuntimeManager,
private readonly verificationBrowser?: BrowserClient,
) {}
list(): RedditAccountSummary[] {
return this.accounts.listPublic()
.filter((account) => account.status !== 'legacy-unassigned')
.map((account) => this.toSummary(account));
}
get(accountId: string): RedditAccountSummary | null {
return this.list().find((account) => account.accountId === accountId) ?? null;
}
async activate(accountId: string): Promise<RedditAccountSummary> {
const stored = this.accounts.get(accountId);
if (!stored || stored.status === 'legacy-unassigned') {
throw new Error('Reddit account not found');
}
const cookies = StoredCookiesSchema.parse(JSON.parse(stored.cookiesJson)) as Cookie[];
const credentials = StoredMatrixCredentialsSchema.parse(
JSON.parse(stored.matrixCredentialsJson),
) as MatrixCredentials;
return this.importSession(cookies, credentials);
}
async importSession(cookies: Cookie[], credentials: MatrixCredentials): Promise<RedditAccountSummary> {
try {
const verified = await this.importer.import(cookies, credentials);
if (this.runtimes) {
const existing = this.runtimes.get(verified.accountId);
if (existing) await this.runtimes.replace(verified.accountId);
else await this.runtimes.start(verified.accountId);
}
const account = this.get(verified.accountId);
if (!account) throw new Error('Activated account was not persisted');
return account;
} finally {
await this.verificationBrowser?.close();
}
}
private toSummary(account: RedditAccountPublicRecord): RedditAccountSummary {
const runtime = this.runtimes?.get(account.accountId);
const running = runtime?.snapshot().state === 'running';
return {
accountId: account.accountId,
redditUsername: account.redditUsername,
matrixUserId: account.matrixUserId ?? account.accountId,
isRunning: running ?? (account.accountId === this.auth.getSnapshot().accountId),
status: account.status === 'verified' && (running ?? account.accountId === this.auth.getSnapshot().accountId)
? 'authenticated'
: 'login-required',
createdAt: account.createdAt,
updatedAt: account.updatedAt,
lastImportedAt: account.lastImportedAt ?? account.updatedAt,
};
}
}
+39 -4
View File
@@ -7,15 +7,32 @@ interface BrowserSessionTarget {
importRedditCookies(cookies: Cookie[]): Promise<void>; importRedditCookies(cookies: Cookie[]): Promise<void>;
isSessionValid(): Promise<boolean>; isSessionValid(): Promise<boolean>;
getAuthenticatedUsername(): Promise<string | null>; getAuthenticatedUsername(): Promise<string | null>;
getAuthenticatedIdentity?(): Promise<{ username: string; accountId: string } | null>;
} }
interface ChatSessionTarget { interface ChatSessionTarget {
setMatrixClient(client: MatrixClient): void; setMatrixClient(client: MatrixClient): void;
switchAccount?(accountId: string): Promise<void>;
} }
type MatrixClientFactory = (credentials: MatrixCredentials) => MatrixClient; type MatrixClientFactory = (credentials: MatrixCredentials) => MatrixClient;
type CredentialStore = (credentials: MatrixCredentials) => void; type CredentialStore = (credentials: MatrixCredentials) => void;
export interface VerifiedRedditSessionStore {
save(input: {
accountId: string;
redditUsername: string;
matrixUserId: string;
cookies: Cookie[];
credentials: MatrixCredentials;
}): void;
}
function matrixRedditAccountId(matrixUserId: string): string | null {
const match = matrixUserId.match(/^@t2_([^:]+):reddit\.com$/i);
return match?.[1]?.toLowerCase() ?? null;
}
export class RedditSessionImporter { export class RedditSessionImporter {
constructor( constructor(
private readonly browser: BrowserSessionTarget, private readonly browser: BrowserSessionTarget,
@@ -23,9 +40,14 @@ export class RedditSessionImporter {
private readonly createMatrixClient: MatrixClientFactory, private readonly createMatrixClient: MatrixClientFactory,
private readonly saveCredentials: CredentialStore, private readonly saveCredentials: CredentialStore,
private readonly accountAuth: AccountAuthContext, private readonly accountAuth: AccountAuthContext,
private readonly sessionStore?: VerifiedRedditSessionStore,
) {} ) {}
async import(cookies: Cookie[], credentials: MatrixCredentials): Promise<void> { async import(cookies: Cookie[], credentials: MatrixCredentials): Promise<{
accountId: string;
redditUsername: string;
matrixUserId: string;
}> {
const homeserver = new URL(credentials.homeserver); const homeserver = new URL(credentials.homeserver);
if ( if (
homeserver.protocol !== 'https:' || homeserver.protocol !== 'https:' ||
@@ -44,15 +66,28 @@ export class RedditSessionImporter {
if (!(await this.browser.isSessionValid())) { if (!(await this.browser.isSessionValid())) {
throw new Error('Imported cookies did not authenticate with Reddit'); throw new Error('Imported cookies did not authenticate with Reddit');
} }
const redditUsername = await this.browser.getAuthenticatedUsername(); const identity = this.browser.getAuthenticatedIdentity
? await this.browser.getAuthenticatedIdentity()
: null;
const redditUsername = identity?.username ?? await this.browser.getAuthenticatedUsername();
if (!redditUsername) throw new Error('Reddit did not return an authenticated account identity'); if (!redditUsername) throw new Error('Reddit did not return an authenticated account identity');
if (identity) {
const matrixAccountId = matrixRedditAccountId(userId);
if (!matrixAccountId || matrixAccountId !== identity.accountId.toLowerCase().replace(/^t2_/, '')) {
throw new Error('Reddit cookies and Matrix credentials belong to different accounts');
}
}
this.saveCredentials(credentials); this.saveCredentials(credentials);
this.chat.setMatrixClient(matrixClient); this.chat.setMatrixClient(matrixClient);
this.accountAuth.markAuthenticated({ const account = {
accountId: userId, accountId: userId,
redditUsername, redditUsername,
matrixUserId: userId, matrixUserId: userId,
}); };
this.sessionStore?.save({ ...account, cookies, credentials });
await this.chat.switchAccount?.(account.accountId);
this.accountAuth.markAuthenticated(account);
return account;
} }
} }
+4
View File
@@ -20,6 +20,8 @@ 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'; import type { ConfigStore } from '../config/persistence.js';
import type { RedditAccountsService } from '../services/reddit-accounts.js';
import type { RedditAccountRuntimeManager } from '../services/reddit-account-runtime.js';
/** Bag of everything the route modules might need. */ /** Bag of everything the route modules might need. */
export interface WebContext { export interface WebContext {
@@ -48,6 +50,8 @@ export interface WebContext {
accountAuth?: AccountAuthContext; accountAuth?: AccountAuthContext;
conversationState?: ConversationStateRepository; conversationState?: ConversationStateRepository;
configStore?: ConfigStore; configStore?: ConfigStore;
redditAccounts?: RedditAccountsService;
accountRuntimes?: RedditAccountRuntimeManager;
}; };
/** Bot pause/resume toggle read by the main loop. */ /** Bot pause/resume toggle read by the main loop. */
+66 -1
View File
@@ -12,7 +12,15 @@ export function createChatsRouter(ctx: WebContext): Router {
return; return;
} }
try { try {
const all = await ctx.services.chat.getCachedConversations(); const runtimes = runningRuntimes(ctx);
const all = runtimes.length > 0
? (await Promise.all(runtimes.map(async (runtime) =>
(await runtime.resources.chatService.getCachedConversations()).map((chat) => ({
...chat,
owner: { accountId: runtime.session.accountId, redditUsername: runtime.session.redditUsername },
})),
))).flat()
: await ctx.services.chat.getCachedConversations();
const cutoff = Date.now() - ACTIVE_WINDOW_MS; const cutoff = Date.now() - ACTIVE_WINDOW_MS;
res.json(all.filter((c) => c.lastMessageTime >= cutoff)); res.json(all.filter((c) => c.lastMessageTime >= cutoff));
} catch (error) { } catch (error) {
@@ -23,6 +31,21 @@ export function createChatsRouter(ctx: WebContext): Router {
/** Status snapshot for the dashboard's sync controls. */ /** Status snapshot for the dashboard's sync controls. */
router.get('/chats/sync', (_req, res) => { router.get('/chats/sync', (_req, res) => {
const runtimes = runningRuntimes(ctx);
if (runtimes.length > 0) {
const statuses = runtimes.map((runtime) => runtime.resources.chatService.getSyncStatus());
const active = statuses.find((status) => status.syncing);
res.json({
syncing: Boolean(active),
syncingKind: active?.syncingKind ?? null,
lastFullSyncAt: newest(statuses.map((status) => status.lastFullSyncAt)),
lastDeltaSyncAt: newest(statuses.map((status) => status.lastDeltaSyncAt)),
hasEverFullSynced: statuses.every((status) => status.hasEverFullSynced),
cachedChats: statuses.reduce((total, status) => total + status.cachedChats, 0),
progress: active?.progress ?? null,
});
return;
}
if (!ctx.services.chat) { if (!ctx.services.chat) {
res.status(503).json({ error: 'Chat service not initialized' }); res.status(503).json({ error: 'Chat service not initialized' });
return; return;
@@ -35,6 +58,17 @@ export function createChatsRouter(ctx: WebContext): Router {
* Use this for routine refreshes. * Use this for routine refreshes.
*/ */
router.post('/chats/sync/delta', async (_req, res) => { router.post('/chats/sync/delta', async (_req, res) => {
const runtimes = runningRuntimes(ctx);
if (runtimes.length > 0) {
const available = runtimes.filter((runtime) => !runtime.resources.chatService.getSyncStatus().syncing);
for (const runtime of available) {
void runtime.resources.chatService.refreshTopChats().catch((error) =>
ctx.logger.error(`Delta sync failed for u/${runtime.session.redditUsername}:`, error),
);
}
res.json(available.length > 0 ? { success: true, kind: 'delta' } : { success: false, reason: 'All account syncs are already running' });
return;
}
if (!ctx.services.chat) { if (!ctx.services.chat) {
res.status(500).json({ error: 'Chat service not initialized' }); res.status(500).json({ error: 'Chat service not initialized' });
return; return;
@@ -64,6 +98,17 @@ export function createChatsRouter(ctx: WebContext): Router {
* appear in /sync. * appear in /sync.
*/ */
router.post('/chats/sync/full', async (_req, res) => { router.post('/chats/sync/full', async (_req, res) => {
const runtimes = runningRuntimes(ctx);
if (runtimes.length > 0) {
const available = runtimes.filter((runtime) => !runtime.resources.chatService.getSyncStatus().syncing);
for (const runtime of available) {
void runtime.resources.chatService.fetchAllMessages().catch((error) =>
ctx.logger.error(`Full sync failed for u/${runtime.session.redditUsername}:`, error),
);
}
res.json(available.length > 0 ? { success: true, kind: 'full' } : { success: false, reason: 'All account syncs are already running' });
return;
}
if (!ctx.services.chat) { if (!ctx.services.chat) {
res.status(500).json({ error: 'Chat service not initialized' }); res.status(500).json({ error: 'Chat service not initialized' });
return; return;
@@ -87,6 +132,12 @@ export function createChatsRouter(ctx: WebContext): Router {
/** Back-compat alias for the old "fetch-all" endpoint. */ /** Back-compat alias for the old "fetch-all" endpoint. */
router.post('/chats/fetch-all', async (_req, res) => { router.post('/chats/fetch-all', async (_req, res) => {
const runtimes = runningRuntimes(ctx);
if (runtimes.length > 0) {
const chats = (await Promise.all(runtimes.map((runtime) => runtime.resources.chatService.fetchAllMessages()))).flat();
res.json({ success: true, count: chats.length });
return;
}
if (!ctx.services.chat) { if (!ctx.services.chat) {
res.status(500).json({ error: 'Chat service not initialized' }); res.status(500).json({ error: 'Chat service not initialized' });
return; return;
@@ -102,3 +153,17 @@ export function createChatsRouter(ctx: WebContext): Router {
return router; return router;
} }
function runningRuntimes(ctx: WebContext) {
return (ctx.services.accountRuntimes?.list() ?? [])
.filter((snapshot) => snapshot.state === 'running')
.flatMap((snapshot) => {
const runtime = ctx.services.accountRuntimes?.get(snapshot.accountId);
return runtime ? [runtime] : [];
});
}
function newest(values: Array<number | null>): number | null {
const present = values.filter((value): value is number => value !== null);
return present.length > 0 ? Math.max(...present) : null;
}
+126 -43
View File
@@ -7,26 +7,60 @@ import {
mergeConversationState, mergeConversationState,
} from '../../services/conversation-state.js'; } from '../../services/conversation-state.js';
type OwnedActiveChat = ActiveChat & { owner?: { accountId: string; redditUsername: string } };
export function createConversationsRouter(ctx: WebContext): Router { export function createConversationsRouter(ctx: WebContext): Router {
const router = Router(); const router = Router();
async function findConversation(id: string): Promise<ActiveChat | undefined> { function currentOwner() {
if (!ctx.services.chat) return undefined; const snapshot = ctx.services.accountAuth?.getSnapshot();
const conversations = await ctx.services.chat.getCachedConversations(); return snapshot?.accountId && snapshot.redditUsername
return conversations.find( ? { accountId: snapshot.accountId, redditUsername: snapshot.redditUsername }
(c) => c.roomId === id || c.username === id || c.roomName === id, : undefined;
);
} }
function getConversationIntelligence(conversation: ActiveChat, requestedIntent?: 'media-ask') { function parseOwnedId(id: string): { accountId: string; conversationId: string } | null {
const separator = id.indexOf('~');
if (separator < 1) return null;
try {
return {
accountId: decodeURIComponent(id.slice(0, separator)),
conversationId: decodeURIComponent(id.slice(separator + 1)),
};
} catch { return null; }
}
async function findConversation(id: string): Promise<{
conversation: ActiveChat;
runtime: NonNullable<ReturnType<NonNullable<WebContext['services']['accountRuntimes']>['get']>> | null;
} | undefined> {
const owned = parseOwnedId(id);
if (!owned && (ctx.services.accountRuntimes?.list().length ?? 0) > 0) return undefined;
const runtime = owned ? ctx.services.accountRuntimes?.get(owned.accountId) ?? null : null;
if (owned && (!runtime || runtime.snapshot().state !== 'running')) return undefined;
const chat = runtime?.resources.chatService ?? ctx.services.chat;
if (!chat) return undefined;
const conversations = await chat.getCachedConversations();
const conversationId = owned?.conversationId ?? id;
const conversation = conversations.find(
(c) => c.roomId === conversationId || c.username === conversationId || c.roomName === conversationId,
);
return conversation ? { conversation, runtime } : undefined;
}
function getConversationIntelligence(
conversation: ActiveChat,
stateRepo = ctx.services.conversationState,
requestedIntent?: 'media-ask',
) {
const messages = (conversation.messages ?? []).map((message) => ({ const messages = (conversation.messages ?? []).map((message) => ({
sender: message.sender, sender: message.sender,
text: message.text, text: message.text,
isSelf: message.isSelf, isSelf: message.isSelf,
})); }));
const saved = conversation.roomId ? ctx.services.conversationState?.get(conversation.roomId) : undefined; const saved = conversation.roomId ? stateRepo?.get(conversation.roomId) : undefined;
const state = mergeConversationState(saved, buildConversationState(messages)); const state = mergeConversationState(saved, buildConversationState(messages));
if (conversation.roomId) ctx.services.conversationState?.save(conversation.roomId, state); if (conversation.roomId) stateRepo?.save(conversation.roomId, state);
return { messages, state, intent: chooseConversationIntent(messages, state, requestedIntent) }; return { messages, state, intent: chooseConversationIntent(messages, state, requestedIntent) };
} }
@@ -36,9 +70,18 @@ export function createConversationsRouter(ctx: WebContext): Router {
return; return;
} }
try { try {
const conversations = await ctx.services.chat.getCachedConversations(); const runtimes = ctx.services.accountRuntimes?.list().filter((runtime) => runtime.state === 'running') ?? [];
const conversations: OwnedActiveChat[] = runtimes.length > 0
? (await Promise.all(runtimes.map(async (snapshot) => {
const runtime = ctx.services.accountRuntimes!.get(snapshot.accountId)!;
return (await runtime.resources.chatService.getCachedConversations()).map((conversation) => ({
...conversation,
owner: { accountId: snapshot.accountId, redditUsername: snapshot.redditUsername },
}));
}))).flat()
: await ctx.services.chat.getCachedConversations();
if (req.query.includeImageMetadata !== 'true') { if (req.query.includeImageMetadata !== 'true') {
res.json(conversations); res.json(conversations.map((conversation) => ({ ...conversation, owner: conversation.owner ?? currentOwner() })));
return; return;
} }
const receivedMediaRooms = new Set<string>(); const receivedMediaRooms = new Set<string>();
@@ -46,11 +89,18 @@ export function createConversationsRouter(ctx: WebContext): Router {
if (ctx.services.media) { if (ctx.services.media) {
try { try {
const mediaItems = await ctx.services.media.getMediaItems(); const mediaByAccount = runtimes.length > 0
for (const item of mediaItems) { ? await Promise.all(runtimes.map(async (snapshot) => ({
accountId: snapshot.accountId,
items: await ctx.services.accountRuntimes!.get(snapshot.accountId)!.resources.mediaService.getMediaItems(),
})))
: [{ accountId: currentOwner()?.accountId ?? 'legacy', items: await ctx.services.media.getMediaItems() }];
for (const source of mediaByAccount) {
for (const item of source.items) {
if (item.sender.toLowerCase() === 'me') continue; if (item.sender.toLowerCase() === 'me') continue;
if (item.chatRoomId) receivedMediaRooms.add(item.chatRoomId); if (item.chatRoomId) receivedMediaRooms.add(`${source.accountId}:${item.chatRoomId}`);
if (item.chatUsername) receivedMediaUsers.add(item.chatUsername.toLowerCase()); if (item.chatUsername) receivedMediaUsers.add(`${source.accountId}:${item.chatUsername.toLowerCase()}`);
}
} }
} catch (error) { } catch (error) {
ctx.logger.warn('Failed to read media index for conversation markers:', error); ctx.logger.warn('Failed to read media index for conversation markers:', error);
@@ -59,9 +109,10 @@ export function createConversationsRouter(ctx: WebContext): Router {
res.json(conversations.map((conversation) => ({ res.json(conversations.map((conversation) => ({
...conversation, ...conversation,
owner: conversation.owner ?? currentOwner(),
hasReceivedImages: hasReceivedImages:
receivedMediaRooms.has(conversation.roomId ?? '') receivedMediaRooms.has(`${conversation.owner?.accountId ?? currentOwner()?.accountId ?? 'legacy'}:${conversation.roomId ?? ''}`)
|| receivedMediaUsers.has(conversation.username.toLowerCase()) || receivedMediaUsers.has(`${conversation.owner?.accountId ?? currentOwner()?.accountId ?? 'legacy'}:${conversation.username.toLowerCase()}`)
|| (conversation.messages ?? []).some( || (conversation.messages ?? []).some(
(message) => !message.isSelf && (message.media?.length ?? 0) > 0, (message) => !message.isSelf && (message.media?.length ?? 0) > 0,
), ),
@@ -80,13 +131,15 @@ export function createConversationsRouter(ctx: WebContext): Router {
try { try {
// Conversation reads never initiate a sync. Sync lifecycle events can // Conversation reads never initiate a sync. Sync lifecycle events can
// invalidate this query, so starting a sync here would create a loop. // invalidate this query, so starting a sync here would create a loop.
const syncStatus = ctx.services.chat.getSyncStatus(); const resolved = await findConversation(decodeURIComponent(req.params.id));
const syncInFlight = syncStatus.syncing; if (!resolved) {
const conversation = await findConversation(decodeURIComponent(req.params.id));
if (!conversation) {
res.status(404).json({ error: 'Conversation not found' }); res.status(404).json({ error: 'Conversation not found' });
return; return;
} }
const chatService = resolved.runtime?.resources.chatService ?? ctx.services.chat;
const syncStatus = chatService.getSyncStatus();
const syncInFlight = syncStatus.syncing;
const conversation = resolved.conversation;
// Delta sync only populates room summaries — full message history is // Delta sync only populates room summaries — full message history is
// fetched on demand the first time a conversation is opened, and // fetched on demand the first time a conversation is opened, and
// re-fetched when the summary's last-activity timestamp is newer than // re-fetched when the summary's last-activity timestamp is newer than
@@ -99,8 +152,8 @@ export function createConversationsRouter(ctx: WebContext): Router {
!conversation.messages || conversation.messages.length === 0 || hasNewerActivity; !conversation.messages || conversation.messages.length === 0 || hasNewerActivity;
if (needsLazyLoad && !syncInFlight) { if (needsLazyLoad && !syncInFlight) {
try { try {
await ctx.services.chat.loadConversationDetails(conversation); await chatService.loadConversationDetails(conversation);
await ctx.services.chat.saveCacheExternal(); await chatService.saveCacheExternal();
} catch (err) { } catch (err) {
ctx.logger.warn(`Lazy-load failed for conversation ${conversation.roomId}:`, err); ctx.logger.warn(`Lazy-load failed for conversation ${conversation.roomId}:`, err);
} }
@@ -116,10 +169,11 @@ export function createConversationsRouter(ctx: WebContext): Router {
sentAt: number; sentAt: number;
} | null = null; } | null = null;
try { try {
const messagesRepo = resolved.runtime?.resources.messagesRepo ?? ctx.repos.messages;
const sent = conversation.roomId const sent = conversation.roomId
? ctx.repos.messages.getByChatRoomId(conversation.roomId) ? messagesRepo.getByChatRoomId(conversation.roomId)
?? ctx.repos.messages.getLatestByRecipient(conversation.username) ?? messagesRepo.getLatestByRecipient(conversation.username)
: ctx.repos.messages.getLatestByRecipient(conversation.username); : messagesRepo.getLatestByRecipient(conversation.username);
if (sent && sent.postId) { if (sent && sent.postId) {
const url = sent.postUrl ?? (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}/`
@@ -139,6 +193,9 @@ export function createConversationsRouter(ctx: WebContext): Router {
res.json({ res.json({
...conversation, ...conversation,
owner: resolved.runtime
? { accountId: resolved.runtime.session.accountId, redditUsername: resolved.runtime.session.redditUsername }
: currentOwner(),
triggeringPost, triggeringPost,
syncInProgress: syncInFlight syncInProgress: syncInFlight
? { ? {
@@ -164,11 +221,12 @@ export function createConversationsRouter(ctx: WebContext): Router {
return; return;
} }
try { try {
const conversation = await findConversation(decodeURIComponent(req.params.id)); const resolved = await findConversation(decodeURIComponent(req.params.id));
if (!conversation) { if (!resolved) {
res.status(404).json({ error: 'Conversation not found' }); res.status(404).json({ error: 'Conversation not found' });
return; return;
} }
const conversation = resolved.conversation;
if (!conversation.messages || conversation.messages.length === 0) { if (!conversation.messages || conversation.messages.length === 0) {
res.status(400).json({ error: 'No messages in conversation to generate a reply from' }); res.status(400).json({ error: 'No messages in conversation to generate a reply from' });
return; return;
@@ -178,7 +236,7 @@ export function createConversationsRouter(ctx: WebContext): Router {
return; return;
} }
const intelligence = getConversationIntelligence(conversation); const intelligence = getConversationIntelligence(conversation, resolved.runtime?.resources.conversationStateRepo);
const suggestedReply = await ctx.services.ai.generateReply( const suggestedReply = await ctx.services.ai.generateReply(
intelligence.messages, intelligence.messages,
conversation.username, conversation.username,
@@ -202,11 +260,12 @@ export function createConversationsRouter(ctx: WebContext): Router {
return; return;
} }
try { try {
const conversation = await findConversation(decodeURIComponent(req.params.id)); const resolved = await findConversation(decodeURIComponent(req.params.id));
if (!conversation) { if (!resolved) {
res.status(404).json({ error: 'Conversation not found' }); res.status(404).json({ error: 'Conversation not found' });
return; return;
} }
const conversation = resolved.conversation;
if (!conversation.messages || conversation.messages.length === 0) { if (!conversation.messages || conversation.messages.length === 0) {
res.status(400).json({ error: 'No messages in conversation to generate from' }); res.status(400).json({ error: 'No messages in conversation to generate from' });
return; return;
@@ -216,7 +275,7 @@ export function createConversationsRouter(ctx: WebContext): Router {
return; return;
} }
const intelligence = getConversationIntelligence(conversation, 'media-ask'); const intelligence = getConversationIntelligence(conversation, resolved.runtime?.resources.conversationStateRepo, 'media-ask');
if (intelligence.intent.kind !== 'media-ask') { if (intelligence.intent.kind !== 'media-ask') {
res.status(409).json({ error: intelligence.intent.reason, intent: intelligence.intent, conversationState: intelligence.state }); res.status(409).json({ error: intelligence.intent.reason, intent: intelligence.intent, conversationState: intelligence.state });
return; return;
@@ -246,17 +305,20 @@ export function createConversationsRouter(ctx: WebContext): Router {
return; return;
} }
const conversation = await findConversation(decodeURIComponent(req.params.id)); const resolved = await findConversation(decodeURIComponent(req.params.id));
if (!conversation) { if (!resolved) {
res.status(404).json({ error: 'Conversation not found' }); res.status(404).json({ error: 'Conversation not found' });
return; return;
} }
const conversation = resolved.conversation;
const chatService = resolved.runtime?.resources.chatService ?? ctx.services.chat;
const stateRepo = resolved.runtime?.resources.conversationStateRepo ?? ctx.services.conversationState;
if (!conversation.url) { if (!conversation.url) {
res.status(400).json({ error: 'No chat URL for this conversation' }); res.status(400).json({ error: 'No chat URL for this conversation' });
return; return;
} }
const success = await ctx.services.chat.sendChatMessage(conversation.url, message.trim()); const success = await chatService.sendChatMessage(conversation.url, message.trim());
if (success) { if (success) {
if (conversation.roomId) { if (conversation.roomId) {
const currentMessages = (conversation.messages ?? []).map((item) => ({ const currentMessages = (conversation.messages ?? []).map((item) => ({
@@ -264,18 +326,26 @@ export function createConversationsRouter(ctx: WebContext): Router {
text: item.text, text: item.text,
isSelf: item.isSelf, isSelf: item.isSelf,
})); }));
const saved = ctx.services.conversationState?.get(conversation.roomId); const saved = stateRepo?.get(conversation.roomId);
const updatedState = mergeConversationState( const updatedState = mergeConversationState(
saved, saved,
buildConversationState([...currentMessages, { sender: 'Me', text: message.trim(), isSelf: true }]), buildConversationState([...currentMessages, { sender: 'Me', text: message.trim(), isSelf: true }]),
); );
ctx.services.conversationState?.save(conversation.roomId, updatedState); stateRepo?.save(conversation.roomId, updatedState);
} }
if (resolved.runtime) {
resolved.runtime.resources.activityLogRepo.insert({
timestamp: Date.now(), type: 'sent',
message: `Sent chat reply to u/${conversation.username}`,
recipient: conversation.username,
});
} else {
ctx.activity.push('sent', `Sent chat reply to u/${conversation.username}`, conversation.username); ctx.activity.push('sent', `Sent chat reply to u/${conversation.username}`, conversation.username);
}
ctx.broadcaster.push('info', `Chat reply sent to u/${conversation.username}`); ctx.broadcaster.push('info', `Chat reply sent to u/${conversation.username}`);
res.json({ success: true }); res.json({ success: true });
ctx.services.chat.refreshTopChats(5).catch((err) => chatService.refreshTopChats(5).catch((err) =>
ctx.logger.error('Failed to refresh chats after send-reply:', err), ctx.logger.error('Failed to refresh chats after send-reply:', err),
); );
} else { } else {
@@ -297,7 +367,11 @@ export function createConversationsRouter(ctx: WebContext): Router {
return; return;
} }
try { try {
const { username, message } = req.body ?? {}; const { accountId, username, message } = req.body ?? {};
if (!accountId || typeof accountId !== 'string') {
res.status(400).json({ error: 'accountId is required' });
return;
}
if (!username || typeof username !== 'string') { if (!username || typeof username !== 'string') {
res.status(400).json({ error: 'username is required' }); res.status(400).json({ error: 'username is required' });
return; return;
@@ -306,20 +380,29 @@ export function createConversationsRouter(ctx: WebContext): Router {
res.status(400).json({ error: 'message is required' }); res.status(400).json({ error: 'message is required' });
return; return;
} }
const chat = await ctx.services.chat.startConversation(username, message.trim()); const runtime = ctx.services.accountRuntimes?.get(accountId);
if (!runtime || runtime.snapshot().state !== 'running') {
res.status(409).json({ error: 'Selected Reddit account is not running' });
return;
}
const chat = await runtime.resources.chatService.startConversation(username, message.trim());
if (!chat) { if (!chat) {
res.status(500).json({ error: 'Failed to start conversation (unknown user or send failed)' }); res.status(500).json({ error: 'Failed to start conversation (unknown user or send failed)' });
return; return;
} }
ctx.activity.push('sent', `Started chat with u/${chat.username}`, chat.username); runtime.resources.activityLogRepo.insert({
timestamp: Date.now(), type: 'sent',
message: `Started chat with u/${chat.username}`,
recipient: chat.username,
});
if (chat.roomId) { if (chat.roomId) {
ctx.services.conversationState?.save( runtime.resources.conversationStateRepo.save(
chat.roomId, chat.roomId,
buildConversationState(chat.messages ?? [{ sender: 'Me', text: message.trim(), isSelf: true }]), buildConversationState(chat.messages ?? [{ sender: 'Me', text: message.trim(), isSelf: true }]),
); );
} }
ctx.broadcaster.push('info', `Started new chat with u/${chat.username}`); ctx.broadcaster.push('info', `Started new chat with u/${chat.username}`);
res.json({ success: true, chat }); res.json({ success: true, chat: { ...chat, owner: { accountId, redditUsername: runtime.session.redditUsername } } });
} catch (error) { } catch (error) {
ctx.logger.error('Failed to start conversation:', error); ctx.logger.error('Failed to start conversation:', error);
res.status(500).json({ error: String(error) }); res.status(500).json({ error: String(error) });
+53 -2
View File
@@ -46,6 +46,18 @@ export function createLoginRouter(ctx: WebContext): Router {
const router = Router(); const router = Router();
router.get('/login/status', async (_req, res) => { router.get('/login/status', async (_req, res) => {
const runningAccounts = ctx.services.redditAccounts?.list().filter((account) => account.isRunning) ?? [];
if (runningAccounts.length > 0) {
const only = runningAccounts.length === 1 ? runningAccounts[0] : null;
res.json({
ready: true,
loggedIn: true,
status: 'authenticated',
accountId: only?.accountId ?? null,
username: only?.redditUsername ?? null,
});
return;
}
const browser = ctx.services.browser; const browser = ctx.services.browser;
if (!browser) { if (!browser) {
res.status(503).json({ ready: false, error: 'browser not initialised' }); res.status(503).json({ ready: false, error: 'browser not initialised' });
@@ -57,9 +69,16 @@ export function createLoginRouter(ctx: WebContext): Router {
? await accountAuth.ensureFullyAuthenticated() ? await accountAuth.ensureFullyAuthenticated()
: await browser.isSessionValid(); : await browser.isSessionValid();
if (fullyAuthenticated) { if (fullyAuthenticated) {
const username = accountAuth?.getSnapshot().redditUsername const snapshot = accountAuth?.getSnapshot();
const username = snapshot?.redditUsername
?? await browser.getAuthenticatedUsername(); ?? await browser.getAuthenticatedUsername();
res.json({ ready: true, loggedIn: true, status: 'authenticated', username }); res.json({
ready: true,
loggedIn: true,
status: 'authenticated',
...(snapshot?.accountId ? { accountId: snapshot.accountId } : {}),
username,
});
return; return;
} }
@@ -97,9 +116,16 @@ export function createLoginRouter(ctx: WebContext): Router {
} }
try { try {
const accounts = ctx.services.redditAccounts;
if (!accounts) {
await importer.import(cookies.map(toPlaywrightCookie), payload.data.matrix); await importer.import(cookies.map(toPlaywrightCookie), payload.data.matrix);
ctx.logger.info(`Imported ${cookies.length} Reddit session cookies and verified the browser session`); ctx.logger.info(`Imported ${cookies.length} Reddit session cookies and verified the browser session`);
res.json({ ok: true, status: 'authenticated' }); res.json({ ok: true, status: 'authenticated' });
return;
}
const account = await accounts.importSession(cookies.map(toPlaywrightCookie), payload.data.matrix);
ctx.logger.info(`Imported ${cookies.length} Reddit session cookies and verified the browser session`);
res.json({ ok: true, status: 'authenticated', account });
} catch (error) { } catch (error) {
ctx.logger.error('Failed to import Reddit session cookies:', error); ctx.logger.error('Failed to import Reddit session cookies:', error);
const message = error instanceof Error ? error.message : 'Could not import Reddit session'; const message = error instanceof Error ? error.message : 'Could not import Reddit session';
@@ -107,5 +133,30 @@ export function createLoginRouter(ctx: WebContext): Router {
} }
}); });
router.get('/accounts', (_req, res) => {
const accounts = ctx.services.redditAccounts;
if (!accounts) {
res.status(503).json({ error: 'Reddit account service not initialised' });
return;
}
res.json(accounts.list());
});
router.post('/accounts/:accountId/activate', async (req, res) => {
const accounts = ctx.services.redditAccounts;
if (!accounts) {
res.status(503).json({ ok: false, error: 'Reddit account service not initialised' });
return;
}
try {
const account = await accounts.activate(decodeURIComponent(req.params.accountId));
ctx.logger.info(`Activated Reddit account u/${account.redditUsername}`);
res.json({ ok: true, account });
} catch (error) {
ctx.logger.error('Failed to activate Reddit account:', error);
res.status(401).json({ ok: false, error: error instanceof Error ? error.message : 'Could not activate Reddit account' });
}
});
return router; return router;
} }
+56 -7
View File
@@ -11,19 +11,27 @@ export function createMediaRouter(ctx: WebContext): Router {
return; return;
} }
try { try {
const items = await ctx.services.media.getMediaItems(); const runtimes = ctx.services.accountRuntimes?.list().filter((runtime) => runtime.state === 'running') ?? [];
const items = runtimes.length > 0
? (await Promise.all(runtimes.map((snapshot) =>
ctx.services.accountRuntimes!.get(snapshot.accountId)!.resources.mediaService.getMediaItems(),
))).flat()
: await ctx.services.media.getMediaItems();
// Build a roomId → current chat lookup so we can override stale // Build a roomId → current chat lookup so we can override stale
// stored usernames (e.g. items captured before username enrichment ran) // stored usernames (e.g. items captured before username enrichment ran)
// and surface the recipient even for rooms that were originally // and surface the recipient even for rooms that were originally
// ingested as `unknown`. // ingested as `unknown`.
const liveChats = ctx.services.chat const liveChats = runtimes.length > 0
? await ctx.services.chat.getCachedConversations() ? (await Promise.all(runtimes.map((snapshot) =>
: []; ctx.services.accountRuntimes!.get(snapshot.accountId)!.resources.chatService.getCachedConversations()
const byRoom = new Map(liveChats.map((c) => [c.roomId ?? '', c] as const)); .then((chats) => chats.map((chat) => ({ ...chat, accountId: snapshot.accountId }))),
))).flat()
: (ctx.services.chat ? await ctx.services.chat.getCachedConversations() : []).map((chat) => ({ ...chat, accountId: '' }));
const byRoom = new Map(liveChats.map((c) => [`${c.accountId}:${c.roomId ?? ''}`, c] as const));
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.accountId ?? ''}:${item.chatRoomId}`) : undefined;
const username = resolveRedditUsername( const username = resolveRedditUsername(
live?.username ?? item.chatUsername, live?.username ?? item.chatUsername,
ctx.services.chat, ctx.services.chat,
@@ -31,11 +39,15 @@ export function createMediaRouter(ctx: WebContext): Router {
const sender = /^(me|you)$/i.test(item.sender) const sender = /^(me|you)$/i.test(item.sender)
? 'You' ? 'You'
: resolveRedditUsername(item.sender, ctx.services.chat) ?? username ?? 'Unknown Reddit user'; : resolveRedditUsername(item.sender, ctx.services.chat) ?? username ?? 'Unknown Reddit user';
const account = item.accountId
? ctx.services.redditAccounts?.get(item.accountId)
: null;
return { return {
...item, ...item,
chatUsername: username, chatUsername: username,
sender, sender,
urlPath: `/media/${encodeURIComponent(item.relativePath)}`, urlPath: `/media/${item.relativePath.split(/[\\/]/).map(encodeURIComponent).join('/')}`,
owner: account ? { accountId: account.accountId, redditUsername: account.redditUsername } : undefined,
}; };
})); }));
} catch (error) { } catch (error) {
@@ -45,6 +57,21 @@ export function createMediaRouter(ctx: WebContext): Router {
}); });
router.get('/media/sync', (_req, res) => { router.get('/media/sync', (_req, res) => {
const runtimes = ctx.services.accountRuntimes?.list().filter((runtime) => runtime.state === 'running') ?? [];
if (runtimes.length > 0) {
const statuses = runtimes.map((snapshot) =>
ctx.services.accountRuntimes!.get(snapshot.accountId)!.resources.mediaSyncService.getStatus(),
);
res.json({
isSyncing: statuses.some((status) => status.isSyncing),
lastSyncAt: newest(statuses.map((status) => status.lastSyncAt)),
lastSyncDurationMs: newest(statuses.map((status) => status.lastSyncDurationMs)),
lastSyncChats: statuses.reduce((total, status) => total + (status.lastSyncChats ?? 0), 0) || null,
lastError: statuses.find((status) => status.lastError)?.lastError ?? null,
nextSyncAt: newest(statuses.map((status) => status.nextSyncAt)),
});
return;
}
if (!ctx.services.mediaSync) { if (!ctx.services.mediaSync) {
res.status(503).json({ error: 'Media sync service not initialized' }); res.status(503).json({ error: 'Media sync service not initialized' });
return; return;
@@ -53,6 +80,15 @@ export function createMediaRouter(ctx: WebContext): Router {
}); });
router.post('/media/sync', async (req, res) => { router.post('/media/sync', async (req, res) => {
const runtimes = ctx.services.accountRuntimes?.list().filter((runtime) => runtime.state === 'running') ?? [];
if (runtimes.length > 0) {
const full = req.body?.full === true;
const results = await Promise.all(runtimes.map((snapshot) =>
ctx.services.accountRuntimes!.get(snapshot.accountId)!.resources.mediaSyncService.triggerSync(full),
));
res.json({ success: results.some(Boolean), reason: results.some(Boolean) ? undefined : 'Sync already in progress' });
return;
}
if (!ctx.services.mediaSync) { if (!ctx.services.mediaSync) {
res.status(503).json({ error: 'Media sync service not initialized' }); res.status(503).json({ error: 'Media sync service not initialized' });
return; return;
@@ -73,6 +109,14 @@ export function createMediaRouter(ctx: WebContext): Router {
}); });
router.post('/media/sync/full', async (_req, res) => { router.post('/media/sync/full', async (_req, res) => {
const runtimes = ctx.services.accountRuntimes?.list().filter((runtime) => runtime.state === 'running') ?? [];
if (runtimes.length > 0) {
const results = await Promise.all(runtimes.map((snapshot) =>
ctx.services.accountRuntimes!.get(snapshot.accountId)!.resources.mediaSyncService.triggerSync(true),
));
res.json({ success: results.some(Boolean), reason: results.some(Boolean) ? undefined : 'Sync already in progress' });
return;
}
if (!ctx.services.mediaSync) { if (!ctx.services.mediaSync) {
res.status(503).json({ error: 'Media sync service not initialized' }); res.status(503).json({ error: 'Media sync service not initialized' });
return; return;
@@ -93,3 +137,8 @@ export function createMediaRouter(ctx: WebContext): Router {
return router; return router;
} }
function newest(values: Array<number | null>): number | null {
const present = values.filter((value): value is number => value !== null);
return present.length > 0 ? Math.max(...present) : null;
}
+57 -11
View File
@@ -1,31 +1,55 @@
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 { ActivityItem, BotStats } from '../types.js';
import { presentActivityItem } from '../reddit-identities.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 const recentActivity = (limit: number) => {
.recent(limit) const runtimes = runningRuntimes(ctx);
.map((item) => presentActivityItem(item, ctx.services.chat)); if (runtimes.length === 0) {
return ctx.activity.recent(limit).map((item) => presentActivityItem(item, ctx.services.chat));
}
return runtimes.flatMap((runtime) => runtime.resources.activityLogRepo.getRecent(limit).map((item) => ({
...presentActivityItem({
...item,
type: item.type as ActivityItem['type'],
recipient: item.recipient ?? undefined,
imageUrl: item.imageUrl ?? undefined,
postTitle: item.postTitle ?? undefined,
postUrl: item.postUrl ?? undefined,
}, runtime.resources.chatService),
owner: { accountId: runtime.session.accountId, redditUsername: runtime.session.redditUsername },
}))).sort((left, right) => right.timestamp - left.timestamp).slice(0, limit);
};
router.get('/stats', (_req, res) => { router.get('/stats', (_req, res) => {
const messages = ctx.repos.messages.getRecent(1000); const runtimes = runningRuntimes(ctx);
const messages = runtimes.length > 0
? runtimes.flatMap((runtime) => runtime.resources.messagesRepo.getRecent(1000))
: ctx.repos.messages.getRecent(1000);
const totalSent = messages.length; const totalSent = messages.length;
const totalResponses = messages.filter((m) => m.gotResponse).length; const totalResponses = messages.filter((m) => m.gotResponse).length;
const cooldown = ctx.services.chat?.getInvitationCooldown() ?? null; const cooldown = runtimes.map((runtime) => runtime.resources.chatService.getInvitationCooldown()).find(Boolean)
?? ctx.services.chat?.getInvitationCooldown()
?? null;
const messagesThisHour = runtimes.length > 0
? runtimes.reduce((total, runtime) => total + runtime.resources.rateLimiter.getStats().messagesThisHour, 0)
: ctx.getMessagesThisHour();
const stats: BotStats = { const stats: BotStats = {
isRunning: ctx.getBotRunning(), isRunning: ctx.getBotRunning(),
dryRunMode: ctx.config.browser.dryRun, dryRunMode: ctx.config.browser.dryRun,
totalProcessed: ctx.activity.totalProcessed(), totalProcessed: runtimes.length > 0
? runtimes.reduce((total, runtime) => total + runtime.resources.activityLogRepo.countSentSkipped(), 0)
: ctx.activity.totalProcessed(),
totalMessagesSent: totalSent, totalMessagesSent: totalSent,
totalResponses, totalResponses,
responseRate: totalSent > 0 ? Math.round((totalResponses / totalSent) * 100) : 0, responseRate: totalSent > 0 ? Math.round((totalResponses / totalSent) * 100) : 0,
messagesThisHour: ctx.getMessagesThisHour(), messagesThisHour,
maxMessagesPerHour: ctx.config.rateLimit.maxMessagesPerHour, maxMessagesPerHour: ctx.config.rateLimit.maxMessagesPerHour * Math.max(1, runtimes.length),
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: recentActivity(10), recentActivity: recentActivity(10),
@@ -42,11 +66,24 @@ export function createStatsRouter(ctx: WebContext): Router {
}); });
router.get('/posts/by-author/:username', (req, res) => { router.get('/posts/by-author/:username', (req, res) => {
res.json(ctx.repos.posts.getByAuthor(req.params.username)); const runtimes = runningRuntimes(ctx);
res.json(runtimes.length > 0
? runtimes.flatMap((runtime) => runtime.resources.postsRepo.getByAuthor(req.params.username).map((post) => ({
...post,
owner: { accountId: runtime.session.accountId, redditUsername: runtime.session.redditUsername },
})))
: ctx.repos.posts.getByAuthor(req.params.username));
}); });
router.get('/messages', (_req, res) => { router.get('/messages', (_req, res) => {
res.json(ctx.repos.messages.getRecent(20)); const runtimes = runningRuntimes(ctx);
const messages = runtimes.length > 0
? runtimes.flatMap((runtime) => runtime.resources.messagesRepo.getRecent(20).map((message) => ({
...message,
owner: { accountId: runtime.session.accountId, redditUsername: runtime.session.redditUsername },
})))
: ctx.repos.messages.getRecent(20);
res.json(messages.sort((left, right) => right.sentAt - left.sentAt).slice(0, 20));
}); });
router.get('/successful', (_req, res) => { router.get('/successful', (_req, res) => {
@@ -55,3 +92,12 @@ export function createStatsRouter(ctx: WebContext): Router {
return router; return router;
} }
function runningRuntimes(ctx: WebContext) {
return (ctx.services.accountRuntimes?.list() ?? [])
.filter((snapshot) => snapshot.state === 'running')
.flatMap((snapshot) => {
const runtime = ctx.services.accountRuntimes?.get(snapshot.accountId);
return runtime ? [runtime] : [];
});
}
+10
View File
@@ -22,6 +22,8 @@ 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 type { ConfigStore } from '../config/persistence.js';
import type { RedditAccountsService } from '../services/reddit-accounts.js';
import type { RedditAccountRuntimeManager } from '../services/reddit-account-runtime.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';
@@ -219,6 +221,14 @@ export class WebServer {
this.services.configStore = configStore; this.services.configStore = configStore;
} }
setRedditAccountsService(accounts: RedditAccountsService): void {
this.services.redditAccounts = accounts;
}
setAccountRuntimeManager(runtimes: RedditAccountRuntimeManager): void {
this.services.accountRuntimes = runtimes;
}
logActivity( logActivity(
type: ActivityItem['type'], type: ActivityItem['type'],
message: string, message: string,
+1
View File
@@ -26,6 +26,7 @@ export interface ActivityItem {
imageUrl?: string; imageUrl?: string;
postTitle?: string; postTitle?: string;
postUrl?: string; postUrl?: string;
owner?: { accountId: string; redditUsername: string };
} }
export interface LogEntry { export interface LogEntry {
+183
View File
@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import Database from 'better-sqlite3';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
AccountContentRepository,
RedditAccountsRepository,
} from '../src/database/account-repositories.js';
import { LEGACY_UNASSIGNED_ACCOUNT_ID, runMigrations } from '../src/database/client.js';
import { importLegacyAccountContent } from '../src/database/legacy-content-import.js';
describe('multi-account database ownership', () => {
let db: Database.Database;
let accounts: RedditAccountsRepository;
let content: AccountContentRepository;
beforeEach(() => {
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
accounts = new RedditAccountsRepository(db);
content = new AccountContentRepository(db);
});
afterEach(() => db.close());
function addAccount(accountId: string, username: string, now: number) {
return accounts.upsertVerified({
accountId,
redditUsername: username,
matrixUserId: `@${accountId}:reddit.com`,
cookiesJson: JSON.stringify([{ name: 'reddit_session', value: `${username}-secret` }]),
matrixCredentialsJson: JSON.stringify({ accessToken: `${username}-matrix-secret` }),
now,
});
}
it('stores private verified session material without exposing it in public listings', () => {
const publicAccount = addAccount('t2_alice', 'alice-bot', 100);
expect(publicAccount).toMatchObject({
accountId: 't2_alice', redditUsername: 'alice-bot', status: 'verified', isActive: true,
createdAt: 100, updatedAt: 100, lastVerifiedAt: 100, lastImportedAt: 100,
});
expect(publicAccount).not.toHaveProperty('cookiesJson');
expect(publicAccount).not.toHaveProperty('matrixCredentialsJson');
expect(accounts.listPublic().find((item) => item.accountId === 't2_alice'))
.not.toHaveProperty('cookiesJson');
expect(accounts.get('t2_alice')).toMatchObject({
cookiesJson: expect.stringContaining('alice-bot-secret'),
matrixCredentialsJson: expect.stringContaining('alice-bot-matrix-secret'),
});
});
it('re-imports one account in place and supports multiple active accounts', () => {
addAccount('t2_alice', 'alice-bot', 100);
addAccount('t2_bob', 'bob-bot', 110);
addAccount('t2_alice', 'alice-renamed', 120);
expect(accounts.getActive().map((account) => account.accountId)).toEqual(['t2_alice', 't2_bob']);
expect(accounts.get('t2_alice')).toMatchObject({
redditUsername: 'alice-renamed', createdAt: 100, updatedAt: 120, lastImportedAt: 120,
});
expect(accounts.setActive('t2_bob', false, 130)).toBe(true);
expect(accounts.getActive().map((account) => account.accountId)).toEqual(['t2_alice']);
expect(accounts.get('t2_bob')).toMatchObject({ status: 'disabled', isActive: false, updatedAt: 130 });
});
it('partitions conversations, sync state and media even when remote IDs match', () => {
addAccount('t2_alice', 'alice-bot', 100);
addAccount('t2_bob', 'bob-bot', 110);
for (const [accountId, username, updatedAt] of [
['t2_alice', 'recipient-a', 200],
['t2_bob', 'recipient-b', 300],
] as const) {
content.upsertConversation({
accountId, conversationId: 'shared-room', username, roomName: username,
payloadJson: JSON.stringify({ username }), updatedAt,
});
content.saveSyncState({
accountId, nextBatch: `cursor-${accountId}`, lastFullSyncAt: updatedAt,
lastDeltaSyncAt: updatedAt + 1, cooldownJson: JSON.stringify({ until: updatedAt + 100 }), updatedAt,
});
content.upsertMedia({
accountId, mediaId: 'shared-media', chatUsername: username, chatRoomId: 'shared-room',
sender: username, timestamp: updatedAt, originalUrl: 'mxc://reddit.com/shared',
fileName: `${accountId}.jpg`, relativePath: `${accountId}/shared.jpg`, createdAt: updatedAt,
});
}
expect(content.getConversation('t2_alice', 'shared-room')?.username).toBe('recipient-a');
expect(content.getConversation('t2_bob', 'shared-room')?.username).toBe('recipient-b');
expect(content.getSyncState('t2_alice')?.nextBatch).toBe('cursor-t2_alice');
expect(content.getSyncState('t2_bob')?.nextBatch).toBe('cursor-t2_bob');
expect(content.getMedia('t2_alice', 'shared-media')?.fileName).toBe('t2_alice.jpg');
expect(content.getMedia('t2_bob', 'shared-media')?.fileName).toBe('t2_bob.jpg');
});
it('enforces account ownership foreign keys', () => {
expect(() => content.upsertConversation({
accountId: 'missing', conversationId: 'room', username: 'someone', roomName: null,
payloadJson: '{}', updatedAt: 1,
})).toThrow(/FOREIGN KEY/);
expect(db.pragma('foreign_key_check')).toEqual([]);
});
it('scopes legacy account-produced table uniqueness by account', () => {
addAccount('t2_alice', 'alice-bot', 100);
addAccount('t2_bob', 'bob-bot', 110);
const insertPost = db.prepare(`
INSERT INTO processed_posts (account_id, id, subreddit, author, title, processed_at)
VALUES (?, 'same-post', 'test', 'recipient', 'title', 1)
`);
insertPost.run('t2_alice');
insertPost.run('t2_bob');
const insertOutbox = db.prepare(`
INSERT INTO delivery_outbox (
account_id, post_id, recipient, subject, message, idempotency_key,
status, created_at, updated_at
) VALUES (?, 'same-post', 'recipient', 'subject', 'message', 'same-key', 'prepared', 1, 1)
`);
insertOutbox.run('t2_alice');
insertOutbox.run('t2_bob');
expect(() => insertOutbox.run('t2_alice')).toThrow(/UNIQUE/);
expect(db.prepare('SELECT COUNT(*) count FROM delivery_outbox').get()).toEqual({ count: 2 });
});
it('migrates existing rows to the explicit legacy account without guessing ownership', () => {
const legacyDb = new Database(':memory:');
legacyDb.pragma('foreign_keys = ON');
legacyDb.exec(`
CREATE TABLE processed_posts (id TEXT PRIMARY KEY, subreddit TEXT NOT NULL, author TEXT NOT NULL, title TEXT NOT NULL, image_url TEXT, image_analysis TEXT, generated_message TEXT, processed_at INTEGER NOT NULL, message_sent INTEGER DEFAULT 0, error TEXT);
CREATE TABLE sent_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, post_id TEXT NOT NULL, recipient TEXT NOT NULL, subject TEXT NOT NULL, message TEXT NOT NULL, sent_at INTEGER NOT NULL);
CREATE TABLE activity_log (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER NOT NULL, type TEXT NOT NULL, message TEXT NOT NULL, recipient TEXT, image_url TEXT);
INSERT INTO processed_posts VALUES ('legacy-post', 'test', 'author', 'title', NULL, NULL, NULL, 1, 1, NULL);
INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at) VALUES ('legacy-post', 'recipient', 'subject', 'message', 2);
INSERT INTO activity_log (timestamp, type, message, recipient) VALUES (2, 'sent', 'legacy activity', 'recipient');
`);
runMigrations(legacyDb);
expect(legacyDb.prepare('SELECT account_id, id FROM processed_posts').get()).toEqual({
account_id: LEGACY_UNASSIGNED_ACCOUNT_ID, id: 'legacy-post',
});
expect(legacyDb.prepare('SELECT account_id FROM sent_messages').get()).toEqual({
account_id: LEGACY_UNASSIGNED_ACCOUNT_ID,
});
expect(legacyDb.prepare('SELECT account_id FROM activity_log').get()).toEqual({
account_id: LEGACY_UNASSIGNED_ACCOUNT_ID,
});
expect(legacyDb.prepare('SELECT status, is_active FROM reddit_accounts WHERE account_id = ?')
.get(LEGACY_UNASSIGNED_ACCOUNT_ID)).toEqual({ status: 'legacy-unassigned', is_active: 0 });
expect(legacyDb.pragma('foreign_key_check')).toEqual([]);
legacyDb.close();
});
it('imports legacy chat and media JSON once as explicitly unassigned content', () => {
const dataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'redditbot-legacy-content-'));
try {
fs.mkdirSync(path.join(dataDirectory, 'media'));
fs.writeFileSync(path.join(dataDirectory, 'chat-cache.json'), JSON.stringify({
chats: [{ username: 'alice', roomName: 'Alice', roomId: 'room-alice', lastMessageTime: 1 }],
nextBatch: 'cursor-one', lastFullSyncAt: 10, lastDeltaSyncAt: 11, cooldown: null,
}));
fs.writeFileSync(path.join(dataDirectory, 'media', 'index.json'), JSON.stringify({
items: [{ id: 'media-one', chatUsername: 'alice', chatRoomId: 'room-alice', sender: 'alice', timestamp: 12, originalUrl: 'mxc://one', fileName: 'one.jpg', relativePath: 'one.jpg', createdAt: 13 }],
}));
expect(importLegacyAccountContent(db, dataDirectory)).toEqual({ conversations: 1, media: 1, skipped: false });
expect(content.listConversations(LEGACY_UNASSIGNED_ACCOUNT_ID)).toHaveLength(1);
expect(content.listMedia(LEGACY_UNASSIGNED_ACCOUNT_ID)).toHaveLength(1);
expect(content.getSyncState(LEGACY_UNASSIGNED_ACCOUNT_ID)?.nextBatch).toBe('cursor-one');
expect(importLegacyAccountContent(db, dataDirectory).skipped).toBe(true);
} finally {
fs.rmSync(dataDirectory, { recursive: true, force: true });
}
});
});
+4 -1
View File
@@ -54,7 +54,10 @@ function detailFixture(chat = alice) {
}; };
} }
const server = setupServer(); const server = setupServer(http.get('/api/accounts', () => HttpResponse.json([{
accountId: '@t2_bot:reddit.com', redditUsername: 'bot-account', matrixUserId: '@t2_bot:reddit.com',
isRunning: true, status: 'authenticated', createdAt: 1, updatedAt: 2, lastImportedAt: 2,
}])));
function installConversationList() { function installConversationList() {
server.use( server.use(
+12 -5
View File
@@ -19,7 +19,7 @@ const config = {
advanced: { openrouter: { chatModel: 'example/chat-model' } }, advanced: { openrouter: { chatModel: 'example/chat-model' } },
}; };
const server = setupServer(); const server = setupServer(http.get('/api/accounts', () => HttpResponse.json([])));
function installConfigApi() { function installConfigApi() {
server.use(http.get('/api/config', () => HttpResponse.json(config))); server.use(http.get('/api/config', () => HttpResponse.json(config)));
@@ -277,13 +277,20 @@ describe('settings and access', () => {
? { ready: true, loggedIn: false, status: 'login-required' } ? { ready: true, loggedIn: false, status: 'login-required' }
: { ready: true, loggedIn: true, status: 'authenticated', username: 'example-account' }); : { ready: true, loggedIn: true, status: 'authenticated', username: 'example-account' });
}), }),
http.get('/api/accounts', () => HttpResponse.json([{
accountId: '@t2_bot:reddit.com', redditUsername: 'example-account', matrixUserId: '@t2_bot:reddit.com',
isRunning: true, status: 'authenticated', createdAt: 1, updatedAt: 2, lastImportedAt: 2,
}])),
http.post('/api/login/session-import', async ({ request }) => { http.post('/api/login/session-import', async ({ request }) => {
expect(request.credentials).toBe('same-origin'); expect(request.credentials).toBe('same-origin');
const body = await request.json() as { cookies: unknown[]; matrix: { accessToken: string } }; const body = await request.json() as { cookies: unknown[]; matrix: { accessToken: string } };
expect(body.cookies).toHaveLength(1); expect(body.cookies).toHaveLength(1);
expect(body.matrix.accessToken).toBe('matrix-secret'); expect(body.matrix.accessToken).toBe('matrix-secret');
importRequests += 1; importRequests += 1;
return HttpResponse.json({ ok: true, status: 'authenticated' }); return HttpResponse.json({ ok: true, status: 'authenticated', account: {
accountId: '@t2_bot:reddit.com', redditUsername: 'example-account', matrixUserId: '@t2_bot:reddit.com',
isRunning: true, status: 'authenticated', createdAt: 1, updatedAt: 2, lastImportedAt: 2,
} });
}), }),
); );
const user = userEvent.setup(); const user = userEvent.setup();
@@ -306,13 +313,13 @@ describe('settings and access', () => {
expect(await screen.findByText('Reddit session imported and verified.')).toBeInTheDocument(); expect(await screen.findByText('Reddit session imported and verified.')).toBeInTheDocument();
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.getAllByText(/u\/example-account/)).toHaveLength(2);
expect(screen.getByRole('link', { name: 'u/example-account' })).toHaveAttribute( expect(screen.getAllByRole('link', { name: 'u/example-account' })[0]).toHaveAttribute(
'href', 'href',
'https://www.reddit.com/u/example-account', 'https://www.reddit.com/u/example-account',
); );
await user.click(screen.getByRole('button', { name: 'Recheck session' })); await user.click(screen.getByRole('button', { name: 'Recheck accounts' }));
expect(statusRequests).toBeGreaterThanOrEqual(2); expect(statusRequests).toBeGreaterThanOrEqual(2);
}); });
}); });
+4 -3
View File
@@ -106,8 +106,8 @@ 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 }[];
// Eight domain tables plus the singleton application configuration table. // Existing domain/config tables plus account, conversation, sync and media ownership stores.
expect(tables.length).toBe(9); expect(tables.length).toBe(13);
}); });
it('tracks schema version and migrates a legacy sent_messages table transactionally', () => { it('tracks schema version and migrates a legacy sent_messages table transactionally', () => {
@@ -138,7 +138,8 @@ describe('Database Client', () => {
const columns = db.prepare('PRAGMA table_info(processed_posts)').all() as { name: string; notnull: number }[]; const columns = db.prepare('PRAGMA table_info(processed_posts)').all() as { name: string; notnull: number }[];
const columnMap = new Map(columns.map(c => [c.name, c])); const columnMap = new Map(columns.map(c => [c.name, c]));
expect(columnMap.get('id')?.notnull).toBe(0); // TEXT is not NOT NULL by default expect(columnMap.get('account_id')?.notnull).toBe(1);
expect(columnMap.get('id')?.notnull).toBe(1); // Composite account-scoped primary key
expect(columnMap.get('subreddit')?.notnull).toBe(1); expect(columnMap.get('subreddit')?.notnull).toBe(1);
expect(columnMap.get('author')?.notnull).toBe(1); expect(columnMap.get('author')?.notnull).toBe(1);
expect(columnMap.get('title')?.notnull).toBe(1); expect(columnMap.get('title')?.notnull).toBe(1);
+239
View File
@@ -0,0 +1,239 @@
import { describe, expect, it, vi } from 'vitest';
import type {
RedditAccountPrivateRecord,
RedditAccountPublicRecord,
} from '../src/database/account-repositories.js';
import {
accountBrowserProfileDir,
RedditAccountRuntime,
RedditAccountRuntimeFactory,
RedditAccountRuntimeManager,
type RedditAccountRuntimeResources,
} from '../src/services/reddit-account-runtime.js';
function account(
key: string,
overrides: Partial<RedditAccountPrivateRecord> = {},
): RedditAccountPrivateRecord {
const accountId = `@t2_${key}:reddit.com`;
return {
accountId,
redditUsername: key,
matrixUserId: accountId,
status: 'verified',
isActive: true,
cookiesJson: JSON.stringify([{ name: 'reddit_session', value: key }]),
matrixCredentialsJson: JSON.stringify({
homeserver: 'https://matrix.redditspace.com',
accessToken: `token-${key}`,
userId: accountId,
capturedAt: 1,
}),
createdAt: 1,
updatedAt: 2,
lastVerifiedAt: 2,
lastImportedAt: 2,
...overrides,
};
}
function accountStore(initial: RedditAccountPrivateRecord[]) {
const records = new Map(initial.map((record) => [record.accountId, record]));
return {
records,
repository: {
listPublic: (): RedditAccountPublicRecord[] => [...records.values()],
get: (accountId: string): RedditAccountPrivateRecord | null => records.get(accountId) ?? null,
},
};
}
const resources = {} as RedditAccountRuntimeResources;
describe('RedditAccountRuntimeManager', () => {
it('starts enabled accounts concurrently as independent runtimes', async () => {
const first = account('first');
const second = account('second');
const store = accountStore([first, second]);
const starts: string[] = [];
const releases = new Map<string, () => void>();
const factory = new RedditAccountRuntimeFactory(async (session) => new RedditAccountRuntime(
session,
{ ...resources },
{
start: () => new Promise<void>((resolve) => {
starts.push(session.accountId);
releases.set(session.accountId, resolve);
}),
stop: vi.fn(),
},
));
const manager = new RedditAccountRuntimeManager(store.repository, factory);
const starting = manager.startEnabled();
await vi.waitFor(() => expect(starts).toHaveLength(2));
expect(new Set(starts)).toEqual(new Set([first.accountId, second.accountId]));
releases.get(first.accountId)!();
releases.get(second.accountId)!();
await starting;
expect(manager.list()).toEqual([
expect.objectContaining({ accountId: first.accountId, state: 'running' }),
expect.objectContaining({ accountId: second.accountId, state: 'running' }),
]);
expect(manager.get(first.accountId)?.resources).not.toBe(manager.get(second.accountId)?.resources);
});
it('does not start disabled or login-required accounts', async () => {
const enabled = account('enabled');
const disabled = account('disabled', { isActive: false, status: 'disabled' });
const expired = account('expired', { status: 'login-required' });
const store = accountStore([enabled, disabled, expired]);
const built: string[] = [];
const factory = new RedditAccountRuntimeFactory(async (session) => {
built.push(session.accountId);
return new RedditAccountRuntime(session, { ...resources }, { start: vi.fn(), stop: vi.fn() });
});
const manager = new RedditAccountRuntimeManager(store.repository, factory);
await manager.startEnabled();
expect(built).toEqual([enabled.accountId]);
expect(manager.list()).toHaveLength(1);
});
it('replaces only the refreshed account while other accounts keep running', async () => {
const first = account('first');
const second = account('second');
const store = accountStore([first, second]);
const events: string[] = [];
const generations = new Map<string, number>();
const factory = new RedditAccountRuntimeFactory(async (session) => {
const generation = (generations.get(session.accountId) ?? 0) + 1;
generations.set(session.accountId, generation);
return new RedditAccountRuntime(session, { ...resources }, {
start: () => { events.push(`start:${session.accountId}:${generation}`); },
stop: () => { events.push(`stop:${session.accountId}:${generation}`); },
});
});
const manager = new RedditAccountRuntimeManager(store.repository, factory);
await manager.startEnabled();
const secondRuntime = manager.get(second.accountId);
events.length = 0;
store.records.set(first.accountId, account('first', { updatedAt: 99 }));
await manager.replace(first.accountId);
expect(events).toEqual([
`stop:${first.accountId}:1`,
`start:${first.accountId}:2`,
]);
expect(manager.get(first.accountId)?.snapshot().updatedAt).toBe(99);
expect(manager.get(second.accountId)).toBe(secondRuntime);
expect(manager.get(second.accountId)?.snapshot().state).toBe('running');
});
it('serializes repeated refreshes for one account', async () => {
const stored = account('same');
const store = accountStore([stored]);
const events: string[] = [];
let generation = 0;
const factory = new RedditAccountRuntimeFactory(async (session) => {
generation += 1;
const ownGeneration = generation;
return new RedditAccountRuntime(session, { ...resources }, {
start: async () => { events.push(`start:${ownGeneration}`); },
stop: async () => { events.push(`stop:${ownGeneration}`); },
});
});
const manager = new RedditAccountRuntimeManager(store.repository, factory);
await manager.startEnabled();
events.length = 0;
await Promise.all([manager.replace(stored.accountId), manager.replace(stored.accountId)]);
expect(events).toEqual(['stop:1', 'start:2', 'stop:2', 'start:3']);
expect(manager.get(stored.accountId)?.snapshot().state).toBe('running');
});
it('rebuilds the previous session when a refreshed runtime fails to start', async () => {
const stored = account('recoverable');
const store = accountStore([stored]);
let generation = 0;
const starts: number[] = [];
const stops: number[] = [];
const factory = new RedditAccountRuntimeFactory(async (session) => {
generation += 1;
const ownGeneration = generation;
return new RedditAccountRuntime(session, { ...resources }, {
start: () => {
starts.push(ownGeneration);
if (ownGeneration === 2) throw new Error('replacement failed');
},
stop: () => { stops.push(ownGeneration); },
});
});
const manager = new RedditAccountRuntimeManager(store.repository, factory);
await manager.startEnabled();
await expect(manager.replace(stored.accountId)).rejects.toThrow('replacement failed');
expect(generation).toBe(3);
expect(starts).toEqual([1, 2, 3]);
expect(stops).toEqual([1, 2]);
expect(manager.get(stored.accountId)?.snapshot().state).toBe('running');
expect(manager.get(stored.accountId)?.session).toMatchObject({
accountId: stored.accountId,
updatedAt: stored.updatedAt,
});
});
it('stops different accounts concurrently', async () => {
const first = account('first');
const second = account('second');
const store = accountStore([first, second]);
const stops: string[] = [];
const releases = new Map<string, () => void>();
const factory = new RedditAccountRuntimeFactory(async (session) => new RedditAccountRuntime(
session,
{ ...resources },
{
start: vi.fn(),
stop: () => new Promise<void>((resolve) => {
stops.push(session.accountId);
releases.set(session.accountId, resolve);
}),
},
));
const manager = new RedditAccountRuntimeManager(store.repository, factory);
await manager.startEnabled();
const stopping = manager.stopAll();
await vi.waitFor(() => expect(stops).toHaveLength(2));
releases.get(first.accountId)!();
releases.get(second.accountId)!();
await stopping;
expect(manager.list()).toEqual([]);
});
});
describe('account runtime isolation', () => {
it('derives distinct deterministic browser profiles from account IDs', () => {
const first = accountBrowserProfileDir('./data/browser', '@t2_first:reddit.com');
const second = accountBrowserProfileDir('./data/browser', '@t2_second:reddit.com');
expect(first).not.toBe(second);
expect(first).toBe(accountBrowserProfileDir('./data/browser', '@t2_first:reddit.com'));
expect(first).toContain('accounts');
});
it('rejects a stored session whose Reddit account and Matrix identity differ', async () => {
const mismatched = account('first', { matrixUserId: '@t2_other:reddit.com' });
const factory = new RedditAccountRuntimeFactory(vi.fn());
await expect(factory.create(mismatched)).rejects.toThrow(
'Stored Reddit account and Matrix identities do not match',
);
});
});
+155
View File
@@ -0,0 +1,155 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import Database from 'better-sqlite3';
import { RedditAccountsRepository } from '../src/database/account-repositories.js';
import { runMigrations } from '../src/database/client.js';
import {
ActivityLogRepository,
ConversationOutcomesRepository,
ConversationStateRepository,
DeliveryOutboxRepository,
MessagesRepository,
PostsRepository,
SuccessfulMessagesRepository,
} from '../src/database/repositories.js';
import { buildConversationState } from '../src/services/conversation-state.js';
const ACCOUNT_A = '@t2_account_a:reddit.com';
const ACCOUNT_B = '@t2_account_b:reddit.com';
describe('account-bound domain repositories', () => {
let db: Database.Database;
beforeEach(() => {
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
runMigrations(db);
const accounts = new RedditAccountsRepository(db);
for (const [accountId, username] of [[ACCOUNT_A, 'account-a'], [ACCOUNT_B, 'account-b']] as const) {
accounts.upsertVerified({
accountId,
redditUsername: username,
matrixUserId: accountId,
cookiesJson: '[]',
matrixCredentialsJson: '{}',
now: 1,
});
}
});
afterEach(() => db.close());
function insertSamePost(posts: PostsRepository, title: string): void {
posts.insert({
id: 'same-post',
subreddit: 'same-subreddit',
author: 'same-recipient',
title,
imageUrl: null,
processedAt: 1,
});
}
it('isolates posts and messages with the same Reddit IDs and recipients', () => {
const postsA = new PostsRepository(db, ACCOUNT_A);
const postsB = new PostsRepository(db, ACCOUNT_B);
const messagesA = new MessagesRepository(db, ACCOUNT_A);
const messagesB = new MessagesRepository(db, ACCOUNT_B);
insertSamePost(postsA, 'owned by A');
insertSamePost(postsB, 'owned by B');
postsA.markMessageSent('same-post');
expect(postsA.getById('same-post')).toMatchObject({ title: 'owned by A', messageSent: true });
expect(postsB.getById('same-post')).toMatchObject({ title: 'owned by B', messageSent: false });
const messageA = messagesA.insert({
postId: 'same-post', recipient: 'same-recipient', subject: 'A', message: 'from A', sentAt: 10,
});
const messageB = messagesB.insert({
postId: 'same-post', recipient: 'same-recipient', subject: 'B', message: 'from B', sentAt: 20,
});
messagesA.insertChat({
recipient: 'only-a', subject: 'A only', message: 'private', sentAt: 30, chatRoomId: 'same-room',
});
expect(messagesA.getByPostId('same-post')).toMatchObject({ id: messageA, message: 'from A' });
expect(messagesB.getByPostId('same-post')).toMatchObject({ id: messageB, message: 'from B' });
expect(messagesA.hasSentTo('only-a')).toBe(true);
expect(messagesB.hasSentTo('only-a')).toBe(false);
expect(messagesA.getByChatRoomId('same-room')?.recipient).toBe('only-a');
expect(messagesB.getByChatRoomId('same-room')).toBeUndefined();
messagesA.markGotResponse(messageB);
expect(messagesB.getByPostId('same-post')?.gotResponse).toBe(false);
expect(messagesA.getRecent()).toHaveLength(2);
expect(messagesB.getRecent()).toHaveLength(1);
});
it('isolates conversation state with the same conversation ID', () => {
const statesA = new ConversationStateRepository(db, ACCOUNT_A);
const statesB = new ConversationStateRepository(db, ACCOUNT_B);
statesA.save('same-room', buildConversationState([
{ sender: 'account-a', text: 'hello from A', isSelf: true },
]));
statesB.save('same-room', buildConversationState([
{ sender: 'recipient', text: 'stop', isSelf: false },
]));
expect(statesA.get('same-room')?.boundaries).toEqual([]);
expect(statesB.get('same-room')?.boundaries).toContain('does not want further sexual escalation');
});
it('isolates outbox claims, successful examples, activity and outcomes', () => {
const postsA = new PostsRepository(db, ACCOUNT_A);
const postsB = new PostsRepository(db, ACCOUNT_B);
insertSamePost(postsA, 'A');
insertSamePost(postsB, 'B');
const messagesA = new MessagesRepository(db, ACCOUNT_A);
const messagesB = new MessagesRepository(db, ACCOUNT_B);
const messageA = messagesA.insert({
postId: 'same-post', recipient: 'same-recipient', subject: 'A', message: 'A', sentAt: 1,
});
const messageB = messagesB.insert({
postId: 'same-post', recipient: 'same-recipient', subject: 'B', message: 'B', sentAt: 2,
});
const outboxA = new DeliveryOutboxRepository(db, ACCOUNT_A);
const outboxB = new DeliveryOutboxRepository(db, ACCOUNT_B);
const items = [outboxA, outboxB].map((outbox) =>
outbox.discover({
postId: 'same-post', recipient: 'same-recipient', subject: 'same', message: 'same',
idempotencyKey: 'same-key', now: 1,
}),
);
expect(outboxA.count()).toBe(1);
expect(outboxB.count()).toBe(1);
expect(outboxA.markPrepared(items[0].id)).toBe(true);
expect(outboxB.markPrepared(items[1].id)).toBe(true);
expect(outboxA.claimNext('worker-a', 10, 1)?.id).toBe(items[0].id);
expect(outboxB.get(items[1].id)?.status).toBe('prepared');
expect(outboxB.claimNext('worker-b', 10, 1)?.id).toBe(items[1].id);
const examplesA = new SuccessfulMessagesRepository(db, ACCOUNT_A);
const examplesB = new SuccessfulMessagesRepository(db, ACCOUNT_B);
examplesA.insert({ sentMessageId: messageA, originalMessage: 'same text', imageAnalysis: null, postTitle: null, responseSnippet: null, subreddit: null });
expect(examplesA.existsByMessage('same text')).toBe(true);
expect(examplesB.existsByMessage('same text')).toBe(false);
examplesB.insert({ sentMessageId: messageB, originalMessage: 'same text', imageAnalysis: null, postTitle: null, responseSnippet: null, subreddit: null });
expect(examplesA.count()).toBe(1);
expect(examplesB.count()).toBe(1);
const activityA = new ActivityLogRepository(db, ACCOUNT_A);
const activityB = new ActivityLogRepository(db, ACCOUNT_B);
activityA.insert({ timestamp: 1, type: 'sent', message: 'A activity' });
activityB.insert({ timestamp: 2, type: 'sent', message: 'B activity' });
expect(activityA.getRecent(10).map((item) => item.message)).toEqual(['A activity']);
expect(activityB.getRecent(10).map((item) => item.message)).toEqual(['B activity']);
const outcomesA = new ConversationOutcomesRepository(db, ACCOUNT_A);
const outcomesB = new ConversationOutcomesRepository(db, ACCOUNT_B);
outcomesA.record(messageA, 'engaged', 'A response');
outcomesB.record(messageB, 'declined', 'B response');
expect(outcomesA.get(messageA)?.outcome).toBe('engaged');
expect(outcomesA.get(messageB)).toBeUndefined();
expect(outcomesB.get(messageB)?.outcome).toBe('declined');
});
});
+125 -2
View File
@@ -6,12 +6,16 @@ import { createConversationsRouter } from '../../src/web/routes/conversations.js
const servers: Array<ReturnType<express.Express['listen']>> = []; const servers: Array<ReturnType<express.Express['listen']>> = [];
async function request(app: express.Express, path: string): Promise<Response> { async function request(
app: express.Express,
path: string,
init?: RequestInit,
): Promise<Response> {
const server = app.listen(0); const server = app.listen(0);
servers.push(server); servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve)); await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo; const { port } = server.address() as AddressInfo;
return fetch(`http://127.0.0.1:${port}${path}`); return fetch(`http://127.0.0.1:${port}${path}`, init);
} }
afterEach(async () => { afterEach(async () => {
@@ -19,6 +23,125 @@ afterEach(async () => {
}); });
describe('conversations route', () => { describe('conversations route', () => {
it('aggregates simultaneous account runtimes and labels identical room IDs with their owner', async () => {
const chat = (username: string) => ({
username, roomName: username, roomId: 'same-room', lastMessageTime: 1,
lastMessageSnippet: `from ${username}`, isFromUs: false, unread: false,
});
const runtime = (accountId: string, redditUsername: string, conversationUsername: string) => ({
session: { accountId, redditUsername },
resources: { chatService: { getCachedConversations: vi.fn().mockResolvedValue([chat(conversationUsername)]) } },
});
const runtimes = new Map([
['@t2_one:reddit.com', runtime('@t2_one:reddit.com', 'owner-one', 'alice')],
['@t2_two:reddit.com', runtime('@t2_two:reddit.com', 'owner-two', 'bob')],
]);
const context = {
logger: { error: vi.fn() },
services: {
chat: { getCachedConversations: vi.fn() },
accountRuntimes: {
list: () => [...runtimes].map(([accountId, item]) => ({ accountId, redditUsername: item.session.redditUsername, state: 'running' })),
get: (accountId: string) => runtimes.get(accountId) ?? null,
},
},
} as unknown as WebContext;
const app = express().use('/api', createConversationsRouter(context));
const response = await request(app, '/api/conversations');
const body = await response.json();
expect(body).toHaveLength(2);
expect(body).toEqual(expect.arrayContaining([
expect.objectContaining({ roomId: 'same-room', username: 'alice', owner: { accountId: '@t2_one:reddit.com', redditUsername: 'owner-one' } }),
expect.objectContaining({ roomId: 'same-room', username: 'bob', owner: { accountId: '@t2_two:reddit.com', redditUsername: 'owner-two' } }),
]));
});
it('sends an owned conversation reply only through its account runtime', async () => {
const firstAccountId = '@t2_one:reddit.com';
const secondAccountId = '@t2_two:reddit.com';
const conversation = {
username: 'alice', roomName: 'alice', roomId: 'same-room', url: '/room/same-room',
lastMessageTime: 1, lastMessageSnippet: 'hello', isFromUs: false, unread: false,
messages: [{ sender: 'alice', text: 'hello', timestamp: 1, isSelf: false }],
};
const runtime = (accountId: string, redditUsername: string) => ({
session: { accountId, redditUsername },
snapshot: () => ({ state: 'running' }),
resources: {
chatService: {
getCachedConversations: vi.fn().mockResolvedValue([conversation]),
sendChatMessage: vi.fn().mockResolvedValue(true),
refreshTopChats: vi.fn().mockResolvedValue(undefined),
},
conversationStateRepo: { get: vi.fn(), save: vi.fn() },
activityLogRepo: { insert: vi.fn() },
},
});
const first = runtime(firstAccountId, 'owner-one');
const second = runtime(secondAccountId, 'owner-two');
const singletonSend = vi.fn();
const runtimes = new Map([[firstAccountId, first], [secondAccountId, second]]);
const context = {
logger: { error: vi.fn() },
activity: { push: vi.fn() },
broadcaster: { push: vi.fn() },
services: {
chat: { sendChatMessage: singletonSend },
accountRuntimes: {
list: () => [...runtimes.values()].map((item) => ({
accountId: item.session.accountId,
redditUsername: item.session.redditUsername,
state: 'running',
})),
get: (accountId: string) => runtimes.get(accountId) ?? null,
},
},
} as unknown as WebContext;
const app = express()
.use(express.json())
.use('/api', createConversationsRouter(context));
const ownedId = `${encodeURIComponent(secondAccountId)}~${encodeURIComponent('same-room')}`;
const response = await request(app, `/api/conversations/${encodeURIComponent(ownedId)}/send-reply`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ message: 'reply from account two' }),
});
expect(response.status).toBe(200);
expect(second.resources.chatService.sendChatMessage).toHaveBeenCalledWith(
'/room/same-room',
'reply from account two',
);
expect(first.resources.chatService.sendChatMessage).not.toHaveBeenCalled();
expect(singletonSend).not.toHaveBeenCalled();
expect(second.resources.activityLogRepo.insert).toHaveBeenCalledWith(expect.objectContaining({
type: 'sent', recipient: 'alice',
}));
expect(first.resources.activityLogRepo.insert).not.toHaveBeenCalled();
});
it('rejects an unowned conversation ID while account runtimes are active', async () => {
const context = {
logger: { error: vi.fn() },
services: {
chat: { getCachedConversations: vi.fn().mockResolvedValue([]) },
accountRuntimes: {
list: () => [{ accountId: '@t2_one:reddit.com', redditUsername: 'owner-one', state: 'running' }],
get: vi.fn(),
},
},
} as unknown as WebContext;
const app = express().use('/api', createConversationsRouter(context));
const response = await request(app, '/api/conversations/same-room');
expect(response.status).toBe(404);
expect(context.services.chat.getCachedConversations).not.toHaveBeenCalled();
});
it('reports an unavailable chat service instead of presenting it as an empty inbox', async () => { it('reports an unavailable chat service instead of presenting it as an empty inbox', async () => {
const context = { const context = {
logger: { error: vi.fn() }, logger: { error: vi.fn() },