Compare commits
2
Commits
142378df33
...
a9cea7ed18
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9cea7ed18 | ||
|
|
3f60f7f665 |
No files matched your search
@@ -98,6 +98,7 @@ function establishmentPill(establishment: AccountEstablishmentSummary | null | u
|
|||||||
function AccountRow({ account }: { account: RedditAccountSummary }) {
|
function AccountRow({ account }: { account: RedditAccountSummary }) {
|
||||||
const removeMutation = useRemoveRedditAccountMutation();
|
const removeMutation = useRemoveRedditAccountMutation();
|
||||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||||
|
const isRemoved = account.status === 'removed';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!confirmRemove) return;
|
if (!confirmRemove) return;
|
||||||
@@ -105,24 +106,31 @@ function AccountRow({ account }: { account: RedditAccountSummary }) {
|
|||||||
return () => globalThis.clearTimeout(timer);
|
return () => globalThis.clearTimeout(timer);
|
||||||
}, [confirmRemove]);
|
}, [confirmRemove]);
|
||||||
|
|
||||||
|
const rowClass = isRemoved ? 'account-row is-removed' : account.isRunning ? 'account-row is-active' : 'account-row';
|
||||||
return (
|
return (
|
||||||
<div className={account.isRunning ? 'account-row is-active' : 'account-row'}>
|
<div className={rowClass}>
|
||||||
<div>
|
<div>
|
||||||
<strong><RedditUsernameLink username={account.redditUsername} /></strong>
|
<strong><RedditUsernameLink username={account.redditUsername} /></strong>
|
||||||
<span>{account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}</span>
|
<span>
|
||||||
|
{isRemoved
|
||||||
|
? 'Removed — its conversations and media are kept, read-only'
|
||||||
|
: account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="account-row-pills">
|
<div className="account-row-pills">
|
||||||
{establishmentPill(account.establishment)}
|
{isRemoved ? null : establishmentPill(account.establishment)}
|
||||||
<span className={account.isRunning ? 'pill success' : 'pill warning'}>
|
<span className={isRemoved ? 'pill neutral' : account.isRunning ? 'pill success' : 'pill warning'}>
|
||||||
{account.isRunning ? 'Running' : 'Paused'}
|
{isRemoved ? 'Removed' : account.isRunning ? 'Running' : 'Paused'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="account-row-actions">
|
<div className="account-row-actions">
|
||||||
{confirmRemove ? (
|
{isRemoved ? (
|
||||||
|
<span className="field-hint">Import a session for this account to bring it back.</span>
|
||||||
|
) : confirmRemove ? (
|
||||||
<>
|
<>
|
||||||
<span className="account-remove-warning">
|
<span className="account-remove-warning">
|
||||||
Removes u/{account.redditUsername}, its stored session and all of its messages,
|
Stops u/{account.redditUsername} and deletes its stored session. Conversations,
|
||||||
conversations and activity. This cannot be undone.
|
messages and media it collected are kept, but become read-only.
|
||||||
</span>
|
</span>
|
||||||
<button className="button secondary" onClick={() => setConfirmRemove(false)} type="button">
|
<button className="button secondary" onClick={() => setConfirmRemove(false)} type="button">
|
||||||
Cancel
|
Cancel
|
||||||
@@ -151,7 +159,7 @@ function AccountRow({ account }: { account: RedditAccountSummary }) {
|
|||||||
{removeMutation.isError ? (
|
{removeMutation.isError ? (
|
||||||
<strong className="save-error" role="alert">{errorMessage(removeMutation.error)}</strong>
|
<strong className="save-error" role="alert">{errorMessage(removeMutation.error)}</strong>
|
||||||
) : null}
|
) : null}
|
||||||
{account.establishment && account.establishment.state !== 'unknown' ? (
|
{!isRemoved && account.establishment && account.establishment.state !== 'unknown' ? (
|
||||||
<EstablishmentDetail establishment={account.establishment} />
|
<EstablishmentDetail establishment={account.establishment} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -203,8 +211,10 @@ export function AccountsPage() {
|
|||||||
? session.username ?? null
|
? session.username ?? null
|
||||||
: null;
|
: null;
|
||||||
const accounts = accountsQuery.data ?? [];
|
const accounts = accountsQuery.data ?? [];
|
||||||
const pausedCount = accounts.filter((account) => !account.isRunning).length;
|
// Removed accounts are not paused work — nothing about them needs attention.
|
||||||
const heldAccounts = accounts.filter((account) => account.establishment?.sendingHeld);
|
const liveAccounts = accounts.filter((account) => account.status !== 'removed');
|
||||||
|
const pausedCount = liveAccounts.filter((account) => !account.isRunning).length;
|
||||||
|
const heldAccounts = liveAccounts.filter((account) => account.establishment?.sendingHeld);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page accounts-page">
|
<div className="page accounts-page">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
.account-list { display: grid; gap: .6rem; }
|
.account-list { display: grid; gap: .6rem; }
|
||||||
.account-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: .85rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; }
|
.account-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: .85rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; }
|
||||||
.account-row.is-active { border-color: var(--accent); }
|
.account-row.is-active { border-color: var(--accent); }
|
||||||
|
.account-row.is-removed { opacity: .72; }
|
||||||
.account-row > div { display: grid; gap: .2rem; min-width: 0; }
|
.account-row > div { display: grid; gap: .2rem; min-width: 0; }
|
||||||
.account-row span { color: var(--muted); font-size: .82rem; }
|
.account-row span { color: var(--muted); font-size: .82rem; }
|
||||||
.account-row .pill { font-size: .7rem; }
|
.account-row .pill { font-size: .7rem; }
|
||||||
|
|||||||
@@ -536,7 +536,8 @@ function useAutoMarkRead(conversationId: string, conversation: ConversationDetai
|
|||||||
const mutate = markRead.mutate;
|
const mutate = markRead.mutate;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!conversation || newestTimestamp <= lastReadMessageTs) return;
|
if (!conversation || conversation.readOnly) return;
|
||||||
|
if (newestTimestamp <= lastReadMessageTs) return;
|
||||||
const key = `${conversationId}:${newestTimestamp}`;
|
const key = `${conversationId}:${newestTimestamp}`;
|
||||||
if (markedRef.current === key) return;
|
if (markedRef.current === key) return;
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
@@ -585,11 +586,13 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="conversation-detail-controls">
|
<div className="conversation-detail-controls">
|
||||||
<ConversationTriageActions
|
{conversation.readOnly ? null : (
|
||||||
conversation={conversation}
|
<ConversationTriageActions
|
||||||
conversationId={conversationId}
|
conversation={conversation}
|
||||||
onResolved={onClose}
|
conversationId={conversationId}
|
||||||
/>
|
onResolved={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
|
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -617,7 +620,19 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<MessageHistory conversation={conversation} now={now} />
|
<MessageHistory conversation={conversation} now={now} />
|
||||||
<ConversationComposer conversation={conversation} conversationId={conversationId} />
|
{conversation.readOnly ? (
|
||||||
|
<section aria-labelledby="read-only-heading" className="conversation-read-only" role="status">
|
||||||
|
<h3 id="read-only-heading">Read-only</h3>
|
||||||
|
<p>
|
||||||
|
{conversation.owner?.redditUsername
|
||||||
|
? `u/${conversation.owner.redditUsername} was removed from the app.`
|
||||||
|
: 'The account that owned this conversation was removed from the app.'}
|
||||||
|
{' '}Its history is kept here, but nothing can be sent or synced from it.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<ConversationComposer conversation={conversation} conversationId={conversationId} />
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -127,6 +127,7 @@ function ConversationRow({
|
|||||||
if (isSelected) itemClasses.push('is-selected');
|
if (isSelected) itemClasses.push('is-selected');
|
||||||
if (unread) itemClasses.push('is-unread');
|
if (unread) itemClasses.push('is-unread');
|
||||||
if (isChecked) itemClasses.push('is-checked');
|
if (isChecked) itemClasses.push('is-checked');
|
||||||
|
if (conversation.readOnly) itemClasses.push('is-read-only');
|
||||||
|
|
||||||
const descriptors = [
|
const descriptors = [
|
||||||
redditUsernameLabel(conversation.username),
|
redditUsernameLabel(conversation.username),
|
||||||
@@ -134,6 +135,7 @@ function ConversationRow({
|
|||||||
relativeTime,
|
relativeTime,
|
||||||
...(conversation.sellerDetected ? ['Seller'] : []),
|
...(conversation.sellerDetected ? ['Seller'] : []),
|
||||||
...(conversation.hasReceivedImages ? ['Image received'] : []),
|
...(conversation.hasReceivedImages ? ['Image received'] : []),
|
||||||
|
...(conversation.readOnly ? ['Removed account, read-only'] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -179,6 +181,11 @@ function ConversationRow({
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{showOwner ? <AccountOwnerBadge owner={conversation.owner} /> : null}
|
{showOwner ? <AccountOwnerBadge owner={conversation.owner} /> : null}
|
||||||
|
{conversation.readOnly ? (
|
||||||
|
<span className="conversation-read-only-marker" title="The account that owned this conversation was removed. History is kept, but nothing can be sent.">
|
||||||
|
Read-only
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{conversation.sellerDetected ? <span className="conversation-seller-marker">Seller</span> : null}
|
{conversation.sellerDetected ? <span className="conversation-seller-marker">Seller</span> : null}
|
||||||
{conversation.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null}
|
{conversation.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null}
|
||||||
<span className="conversation-list-profile"><RedditUsernameLink username={conversation.username} /></span>
|
<span className="conversation-list-profile"><RedditUsernameLink username={conversation.username} /></span>
|
||||||
|
|||||||
@@ -96,6 +96,11 @@
|
|||||||
.conversation-shortcut-hint { margin-left: auto; opacity: .8; }
|
.conversation-shortcut-hint { margin-left: auto; opacity: .8; }
|
||||||
.conversation-image-marker { justify-self: start; padding: .18rem .45rem; color: #d8e8ff !important; background: #18324d; border: 1px solid #315d85; border-radius: 999px; font-size: .68rem !important; font-weight: 750; }
|
.conversation-image-marker { justify-self: start; padding: .18rem .45rem; color: #d8e8ff !important; background: #18324d; border: 1px solid #315d85; border-radius: 999px; font-size: .68rem !important; font-weight: 750; }
|
||||||
.conversation-seller-marker { display: inline-flex; justify-self: start; padding: .18rem .45rem; margin-right: .35rem; color: #ffd6d0 !important; background: #4b1e1a; border: 1px solid #874139; border-radius: 999px; font-size: .68rem !important; font-weight: 800; }
|
.conversation-seller-marker { display: inline-flex; justify-self: start; padding: .18rem .45rem; margin-right: .35rem; color: #ffd6d0 !important; background: #4b1e1a; border: 1px solid #874139; border-radius: 999px; font-size: .68rem !important; font-weight: 800; }
|
||||||
|
.conversation-read-only-marker { display: inline-flex; justify-self: start; padding: .18rem .45rem; margin-right: .35rem; color: var(--muted) !important; background: var(--surface-raised); border: 1px solid var(--border); border-radius: 999px; font-size: .68rem !important; font-weight: 800; }
|
||||||
|
.conversation-list-item.is-read-only .conversation-list-snippet { opacity: .8; }
|
||||||
|
.conversation-read-only { padding: 1rem; margin: 0 1rem 1rem; color: var(--muted); background: var(--surface-raised); border: 1px dashed var(--border); border-radius: .65rem; }
|
||||||
|
.conversation-read-only h3 { margin: 0 0 .3rem; color: var(--text); font-size: .9rem; }
|
||||||
|
.conversation-read-only p { margin: 0; font-size: .82rem; }
|
||||||
.conversation-list-state, .conversation-detail-state { display: grid; place-items: center; align-content: center; gap: .5rem; min-height: 16rem; padding: 1.5rem; color: var(--muted); text-align: center; }
|
.conversation-list-state, .conversation-detail-state { display: grid; place-items: center; align-content: center; gap: .5rem; min-height: 16rem; padding: 1.5rem; color: var(--muted); text-align: center; }
|
||||||
.conversation-list-state strong, .conversation-detail-state strong { color: #e9f1ec; }
|
.conversation-list-state strong, .conversation-detail-state strong { color: #e9f1ec; }
|
||||||
/* Column layout: history takes the slack, the composer stays reachable. */
|
/* Column layout: history takes the slack, the composer stays reachable. */
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { createOwnedConversationId } from '../conversations/conversation-identity.js';
|
||||||
import { learningApi } from './learning-api.js';
|
import { learningApi } from './learning-api.js';
|
||||||
import './learning.css';
|
import './learning.css';
|
||||||
|
|
||||||
@@ -13,7 +15,19 @@ function TrajectoryDetail({ id }: { id: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="trajectory-detail">
|
<div className="trajectory-detail">
|
||||||
<dl className="trajectory-facts">
|
<dl className="trajectory-facts">
|
||||||
<div><dt>Conversation</dt><dd>{trajectory.conversationId}</dd></div>
|
<div>
|
||||||
|
<dt>Conversation</dt>
|
||||||
|
<dd>
|
||||||
|
{/* The conversations route resolves owned ids only, so the account
|
||||||
|
that produced the trajectory has to travel with the room id. */}
|
||||||
|
<Link
|
||||||
|
className="trajectory-conversation-link"
|
||||||
|
to={`/conversations/${encodeURIComponent(createOwnedConversationId(trajectory.accountId, trajectory.conversationId))}`}
|
||||||
|
>
|
||||||
|
{trajectory.conversationId}
|
||||||
|
</Link>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
<div><dt>Policy mode</dt><dd>{trajectory.policyMode ?? 'Unknown'}</dd></div>
|
<div><dt>Policy mode</dt><dd>{trajectory.policyMode ?? 'Unknown'}</dd></div>
|
||||||
<div><dt>Outcome</dt><dd>{trajectory.outcomeType.replaceAll('-', ' ')}</dd></div>
|
<div><dt>Outcome</dt><dd>{trajectory.outcomeType.replaceAll('-', ' ')}</dd></div>
|
||||||
<div><dt>Matures</dt><dd>{new Date(trajectory.maturityAt).toLocaleString()}</dd></div>
|
<div><dt>Matures</dt><dd>{new Date(trajectory.maturityAt).toLocaleString()}</dd></div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
.trajectory-facts { display: flex; flex-wrap: wrap; gap: .35rem 1.4rem; margin: 0; }
|
.trajectory-facts { display: flex; flex-wrap: wrap; gap: .35rem 1.4rem; margin: 0; }
|
||||||
.trajectory-facts dt { color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; }
|
.trajectory-facts dt { color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
.trajectory-facts dd { margin: 0; font-size: .85rem; word-break: break-all; }
|
.trajectory-facts dd { margin: 0; font-size: .85rem; word-break: break-all; }
|
||||||
|
.trajectory-conversation-link { color: var(--accent); text-decoration: underline; text-underline-offset: .15em; }
|
||||||
.decision-list { display: grid; gap: .75rem; list-style: none; margin: 0; padding: 0; }
|
.decision-list { display: grid; gap: .75rem; list-style: none; margin: 0; padding: 0; }
|
||||||
.decision-card { border: 1px solid var(--border); border-radius: .6rem; display: grid; gap: .5rem; padding: .7rem .8rem; }
|
.decision-card { border: 1px solid var(--border); border-radius: .6rem; display: grid; gap: .5rem; padding: .7rem .8rem; }
|
||||||
.decision-heading { align-items: center; display: flex; flex-wrap: wrap; gap: .5rem; }
|
.decision-heading { align-items: center; display: flex; flex-wrap: wrap; gap: .5rem; }
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ export const ActiveChatSchema = z.strictObject({
|
|||||||
automationPolicyMode: ConversationPolicyModeSchema.optional(),
|
automationPolicyMode: ConversationPolicyModeSchema.optional(),
|
||||||
/** Why automation handed over, when it has. */
|
/** Why automation handed over, when it has. */
|
||||||
automationHandoffReason: z.string().nullable().optional(),
|
automationHandoffReason: z.string().nullable().optional(),
|
||||||
|
/**
|
||||||
|
* The owning account was removed. Its history is kept and readable, but the
|
||||||
|
* app can no longer send into the conversation or sync it.
|
||||||
|
*/
|
||||||
|
readOnly: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
export const ChatsResponseSchema = z.array(ActiveChatSchema);
|
export const ChatsResponseSchema = z.array(ActiveChatSchema);
|
||||||
export const ConversationsResponseSchema = ChatsResponseSchema;
|
export const ConversationsResponseSchema = ChatsResponseSchema;
|
||||||
|
|||||||
@@ -31,10 +31,12 @@ export const RedditAccountSummarySchema = z.strictObject({
|
|||||||
redditUsername: z.string().min(1),
|
redditUsername: z.string().min(1),
|
||||||
matrixUserId: z.string().min(1),
|
matrixUserId: z.string().min(1),
|
||||||
isRunning: z.boolean(),
|
isRunning: z.boolean(),
|
||||||
status: z.union([z.literal('authenticated'), z.literal('login-required')]),
|
status: z.union([z.literal('authenticated'), z.literal('login-required'), z.literal('removed')]),
|
||||||
createdAt: z.number(),
|
createdAt: z.number(),
|
||||||
updatedAt: z.number(),
|
updatedAt: z.number(),
|
||||||
lastImportedAt: z.number(),
|
lastImportedAt: z.number(),
|
||||||
|
/** Set when the operator retired the account; its content is kept read-only. */
|
||||||
|
removedAt: z.number().nullable().optional(),
|
||||||
/** Absent on responses from a build that predates establishment tracking. */
|
/** Absent on responses from a build that predates establishment tracking. */
|
||||||
establishment: AccountEstablishmentSummarySchema.nullable().optional(),
|
establishment: AccountEstablishmentSummarySchema.nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import type Database from 'better-sqlite3';
|
import type Database from 'better-sqlite3';
|
||||||
|
|
||||||
export type RedditAccountStatus = 'verified' | 'login-required' | 'disabled' | 'legacy-unassigned';
|
export type RedditAccountStatus =
|
||||||
|
| 'verified'
|
||||||
|
| 'login-required'
|
||||||
|
| 'disabled'
|
||||||
|
| 'legacy-unassigned'
|
||||||
|
/** Retired by the operator: content is kept and readable, credentials are gone. */
|
||||||
|
| 'removed';
|
||||||
|
|
||||||
export interface RedditAccountPublicRecord {
|
export interface RedditAccountPublicRecord {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
@@ -12,6 +18,7 @@ export interface RedditAccountPublicRecord {
|
|||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
lastVerifiedAt: number | null;
|
lastVerifiedAt: number | null;
|
||||||
lastImportedAt: number | null;
|
lastImportedAt: number | null;
|
||||||
|
removedAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Private credential-bearing record. Never return this from a web API. */
|
/** Private credential-bearing record. Never return this from a web API. */
|
||||||
@@ -41,29 +48,16 @@ type AccountRow = {
|
|||||||
updated_at: number;
|
updated_at: number;
|
||||||
last_verified_at: number | null;
|
last_verified_at: number | null;
|
||||||
last_imported_at: number | null;
|
last_imported_at: number | null;
|
||||||
|
removed_at: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Tables whose foreign key to `reddit_accounts` predates cascading deletes,
|
|
||||||
* ordered children first so each delete leaves no dangling composite reference.
|
|
||||||
*/
|
|
||||||
const ACCOUNT_OWNED_TABLES = [
|
|
||||||
'conversation_outcomes',
|
|
||||||
'successful_messages',
|
|
||||||
'delivery_outbox',
|
|
||||||
'sent_messages',
|
|
||||||
'conversation_states',
|
|
||||||
'activity_log',
|
|
||||||
'processed_posts',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export class RedditAccountsRepository {
|
export class RedditAccountsRepository {
|
||||||
constructor(private readonly db: Database.Database) {}
|
constructor(private readonly db: Database.Database) {}
|
||||||
|
|
||||||
listPublic(): RedditAccountPublicRecord[] {
|
listPublic(): RedditAccountPublicRecord[] {
|
||||||
const rows = this.db.prepare(`
|
const rows = this.db.prepare(`
|
||||||
SELECT account_id, reddit_username, matrix_user_id, status, is_active,
|
SELECT account_id, reddit_username, matrix_user_id, status, is_active,
|
||||||
created_at, updated_at, last_verified_at, last_imported_at
|
created_at, updated_at, last_verified_at, last_imported_at, removed_at
|
||||||
FROM reddit_accounts
|
FROM reddit_accounts
|
||||||
ORDER BY is_active DESC, reddit_username COLLATE NOCASE, account_id
|
ORDER BY is_active DESC, reddit_username COLLATE NOCASE, account_id
|
||||||
`).all() as AccountRow[];
|
`).all() as AccountRow[];
|
||||||
@@ -80,7 +74,7 @@ export class RedditAccountsRepository {
|
|||||||
getActive(): RedditAccountPublicRecord[] {
|
getActive(): RedditAccountPublicRecord[] {
|
||||||
const rows = this.db.prepare(`
|
const rows = this.db.prepare(`
|
||||||
SELECT account_id, reddit_username, matrix_user_id, status, is_active,
|
SELECT account_id, reddit_username, matrix_user_id, status, is_active,
|
||||||
created_at, updated_at, last_verified_at, last_imported_at
|
created_at, updated_at, last_verified_at, last_imported_at, removed_at
|
||||||
FROM reddit_accounts
|
FROM reddit_accounts
|
||||||
WHERE is_active = 1 AND status != 'legacy-unassigned'
|
WHERE is_active = 1 AND status != 'legacy-unassigned'
|
||||||
ORDER BY reddit_username COLLATE NOCASE, account_id
|
ORDER BY reddit_username COLLATE NOCASE, account_id
|
||||||
@@ -113,7 +107,8 @@ export class RedditAccountsRepository {
|
|||||||
matrix_credentials_json = excluded.matrix_credentials_json,
|
matrix_credentials_json = excluded.matrix_credentials_json,
|
||||||
updated_at = excluded.updated_at,
|
updated_at = excluded.updated_at,
|
||||||
last_verified_at = excluded.last_verified_at,
|
last_verified_at = excluded.last_verified_at,
|
||||||
last_imported_at = excluded.last_imported_at
|
last_imported_at = excluded.last_imported_at,
|
||||||
|
removed_at = NULL
|
||||||
`).run({ ...input, now });
|
`).run({ ...input, now });
|
||||||
return publicFromPrivate(this.getRequired(input.accountId));
|
return publicFromPrivate(this.getRequired(input.accountId));
|
||||||
}
|
}
|
||||||
@@ -129,7 +124,7 @@ export class RedditAccountsRepository {
|
|||||||
return this.db.prepare(`
|
return this.db.prepare(`
|
||||||
UPDATE reddit_accounts
|
UPDATE reddit_accounts
|
||||||
SET matrix_credentials_json = ?, updated_at = ?, last_verified_at = ?
|
SET matrix_credentials_json = ?, updated_at = ?, last_verified_at = ?
|
||||||
WHERE account_id = ? AND status != 'legacy-unassigned'
|
WHERE account_id = ? AND status NOT IN ('legacy-unassigned', 'removed')
|
||||||
`).run(matrixCredentialsJson, now, now, accountId).changes === 1;
|
`).run(matrixCredentialsJson, now, now, accountId).changes === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,32 +133,31 @@ export class RedditAccountsRepository {
|
|||||||
return this.db.prepare(`
|
return this.db.prepare(`
|
||||||
UPDATE reddit_accounts
|
UPDATE reddit_accounts
|
||||||
SET is_active = ?, status = ?, updated_at = ?
|
SET is_active = ?, status = ?, updated_at = ?
|
||||||
WHERE account_id = ? AND status != 'legacy-unassigned'
|
WHERE account_id = ? AND status NOT IN ('legacy-unassigned', 'removed')
|
||||||
`).run(active ? 1 : 0, status, now, accountId).changes === 1;
|
`).run(active ? 1 : 0, status, now, accountId).changes === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove the account and everything it owns. Account-scoped tables added
|
* Retire the account. Everything it collected stays — the row survives so
|
||||||
* after the ownership migration cascade on their own, but the tables that
|
* conversations and media keep an owner — but the stored credentials are
|
||||||
* migration created reference `reddit_accounts` with no delete action, so
|
* destroyed, which is what makes a later send or sync impossible rather
|
||||||
* their rows are cleared explicitly — children before parents — or SQLite
|
* than merely disabled.
|
||||||
* would refuse the delete.
|
|
||||||
*/
|
*/
|
||||||
delete(accountId: string): boolean {
|
retire(accountId: string, now: number = Date.now()): boolean {
|
||||||
requireText(accountId, 'accountId');
|
requireText(accountId, 'accountId');
|
||||||
return this.db.transaction((id: string) => {
|
return this.db.prepare(`
|
||||||
for (const table of ACCOUNT_OWNED_TABLES) {
|
UPDATE reddit_accounts
|
||||||
this.db.prepare(`DELETE FROM ${table} WHERE account_id = ?`).run(id);
|
SET status = 'removed', is_active = 0, cookies_json = '[]', matrix_credentials_json = '{}',
|
||||||
}
|
removed_at = ?, updated_at = ?
|
||||||
return this.db.prepare('DELETE FROM reddit_accounts WHERE account_id = ?').run(id).changes === 1;
|
WHERE account_id = ? AND status NOT IN ('legacy-unassigned', 'removed')
|
||||||
})(accountId);
|
`).run(now, now, accountId).changes === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
markLoginRequired(accountId: string, now: number = Date.now()): boolean {
|
markLoginRequired(accountId: string, now: number = Date.now()): boolean {
|
||||||
return this.db.prepare(`
|
return this.db.prepare(`
|
||||||
UPDATE reddit_accounts
|
UPDATE reddit_accounts
|
||||||
SET status = 'login-required', updated_at = ?
|
SET status = 'login-required', updated_at = ?
|
||||||
WHERE account_id = ? AND status != 'legacy-unassigned'
|
WHERE account_id = ? AND status NOT IN ('legacy-unassigned', 'removed')
|
||||||
`).run(now, accountId).changes === 1;
|
`).run(now, accountId).changes === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +179,7 @@ function mapPublicAccount(row: AccountRow): RedditAccountPublicRecord {
|
|||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
lastVerifiedAt: row.last_verified_at,
|
lastVerifiedAt: row.last_verified_at,
|
||||||
lastImportedAt: row.last_imported_at,
|
lastImportedAt: row.last_imported_at,
|
||||||
|
removedAt: row.removed_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+41
-3
@@ -5,6 +5,9 @@ import fs from 'fs';
|
|||||||
|
|
||||||
export const LEGACY_UNASSIGNED_ACCOUNT_ID = '__legacy_unassigned__';
|
export const LEGACY_UNASSIGNED_ACCOUNT_ID = '__legacy_unassigned__';
|
||||||
|
|
||||||
|
/** Migrations that replace a table other tables reference by foreign key. */
|
||||||
|
const FOREIGN_KEY_REBUILD_VERSIONS = new Set([11, 32]);
|
||||||
|
|
||||||
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:'
|
||||||
@@ -772,6 +775,40 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
ON learning_evaluation_runs(created_at DESC);
|
ON learning_evaluation_runs(created_at DESC);
|
||||||
`);
|
`);
|
||||||
},
|
},
|
||||||
|
// Removing an account retires it rather than deleting it: the content it
|
||||||
|
// collected stays readable and attributed, so the row has to survive with
|
||||||
|
// a status that keeps it out of every runtime and sending path.
|
||||||
|
() => db.exec(`
|
||||||
|
CREATE TABLE reddit_accounts_with_removed (
|
||||||
|
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', 'removed'
|
||||||
|
)),
|
||||||
|
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,
|
||||||
|
removed_at INTEGER
|
||||||
|
);
|
||||||
|
INSERT INTO reddit_accounts_with_removed (
|
||||||
|
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, removed_at
|
||||||
|
) SELECT
|
||||||
|
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, NULL
|
||||||
|
FROM reddit_accounts;
|
||||||
|
DROP TABLE reddit_accounts;
|
||||||
|
ALTER TABLE reddit_accounts_with_removed RENAME TO reddit_accounts;
|
||||||
|
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);
|
||||||
|
`),
|
||||||
];
|
];
|
||||||
|
|
||||||
let currentVersion = db.pragma('user_version', { simple: true }) as number;
|
let currentVersion = db.pragma('user_version', { simple: true }) as number;
|
||||||
@@ -782,9 +819,10 @@ 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.
|
// SQLite cannot rebuild a referenced table while FK enforcement is on.
|
||||||
// Migration 11 replaces the account-produced table graph atomically, then
|
// Migration 11 replaces the account-produced table graph atomically and
|
||||||
// validates it before foreign-key enforcement is restored.
|
// migration 32 rebuilds `reddit_accounts` itself; both validate the graph
|
||||||
const rebuildsForeignKeyGraph = version === 11;
|
// before foreign-key enforcement is restored.
|
||||||
|
const rebuildsForeignKeyGraph = FOREIGN_KEY_REBUILD_VERSIONS.has(version);
|
||||||
if (rebuildsForeignKeyGraph) db.pragma('foreign_keys = OFF');
|
if (rebuildsForeignKeyGraph) db.pragma('foreign_keys = OFF');
|
||||||
try {
|
try {
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
|
|||||||
@@ -50,10 +50,13 @@ export interface RedditAccountSummary {
|
|||||||
redditUsername: string;
|
redditUsername: string;
|
||||||
matrixUserId: string;
|
matrixUserId: string;
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
status: 'authenticated' | 'login-required';
|
/** `removed` is terminal until a fresh session is imported for the account. */
|
||||||
|
status: 'authenticated' | 'login-required' | 'removed';
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
lastImportedAt: number;
|
lastImportedAt: number;
|
||||||
|
/** When the operator retired the account. Null while it is in use. */
|
||||||
|
removedAt: number | null;
|
||||||
/** Null until the establishment check has run for this account. */
|
/** Null until the establishment check has run for this account. */
|
||||||
establishment: RedditAccountEstablishmentSummary | null;
|
establishment: RedditAccountEstablishmentSummary | null;
|
||||||
}
|
}
|
||||||
@@ -78,6 +81,11 @@ export class RedditAccountsService {
|
|||||||
return this.list().find((account) => account.accountId === accountId) ?? null;
|
return this.list().find((account) => account.accountId === accountId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Retired accounts, whose collected content is still served read-only. */
|
||||||
|
listRemoved(): RedditAccountSummary[] {
|
||||||
|
return this.list().filter((account) => account.status === 'removed');
|
||||||
|
}
|
||||||
|
|
||||||
async activate(accountId: string): Promise<RedditAccountSummary> {
|
async activate(accountId: string): Promise<RedditAccountSummary> {
|
||||||
const stored = this.accounts.get(accountId);
|
const stored = this.accounts.get(accountId);
|
||||||
if (!stored || stored.status === 'legacy-unassigned') {
|
if (!stored || stored.status === 'legacy-unassigned') {
|
||||||
@@ -91,19 +99,23 @@ export class RedditAccountsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop the account's loop and drop it, along with the content it owns, from
|
* Retire the account: stop its loop and destroy its credentials, but keep
|
||||||
* the database. Downloaded media files stay on disk: the media library can
|
* every conversation, message and media item it collected. The content stays
|
||||||
* hand an image to another account, so the files are not ours alone to bin.
|
* readable and attributed; with no session left there is nothing to sync and
|
||||||
|
* nothing to send with.
|
||||||
*/
|
*/
|
||||||
async remove(accountId: string): Promise<RedditAccountSummary> {
|
async remove(accountId: string): Promise<RedditAccountSummary> {
|
||||||
const account = this.get(accountId);
|
const account = this.get(accountId);
|
||||||
if (!account) throw new Error('Reddit account not found');
|
if (!account) throw new Error('Reddit account not found');
|
||||||
|
if (account.status === 'removed') throw new Error('Reddit account was already removed');
|
||||||
await this.runtimes?.stop(accountId);
|
await this.runtimes?.stop(accountId);
|
||||||
if (!this.accounts.delete(accountId)) throw new Error('Reddit account not found');
|
if (!this.accounts.retire(accountId)) throw new Error('Reddit account not found');
|
||||||
if (this.auth.getSnapshot().accountId === accountId) {
|
if (this.auth.getSnapshot().accountId === accountId) {
|
||||||
this.auth.invalidate(`Reddit account u/${account.redditUsername} was removed`);
|
this.auth.invalidate(`Reddit account u/${account.redditUsername} was removed`);
|
||||||
}
|
}
|
||||||
return { ...account, isRunning: false, status: 'login-required' };
|
const retired = this.get(accountId);
|
||||||
|
if (!retired) throw new Error('Removed Reddit account was not persisted');
|
||||||
|
return retired;
|
||||||
}
|
}
|
||||||
|
|
||||||
async importSession(cookies: Cookie[], credentials: MatrixCredentials): Promise<RedditAccountSummary> {
|
async importSession(cookies: Cookie[], credentials: MatrixCredentials): Promise<RedditAccountSummary> {
|
||||||
@@ -124,18 +136,23 @@ export class RedditAccountsService {
|
|||||||
|
|
||||||
private toSummary(account: RedditAccountPublicRecord): RedditAccountSummary {
|
private toSummary(account: RedditAccountPublicRecord): RedditAccountSummary {
|
||||||
const runtime = this.runtimes?.get(account.accountId);
|
const runtime = this.runtimes?.get(account.accountId);
|
||||||
const running = runtime?.snapshot().state === 'running';
|
const running = account.status === 'removed' ? false : runtime?.snapshot().state === 'running';
|
||||||
return {
|
return {
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
redditUsername: account.redditUsername,
|
redditUsername: account.redditUsername,
|
||||||
matrixUserId: account.matrixUserId ?? account.accountId,
|
matrixUserId: account.matrixUserId ?? account.accountId,
|
||||||
isRunning: running ?? (account.accountId === this.auth.getSnapshot().accountId),
|
isRunning: account.status === 'removed'
|
||||||
status: account.status === 'verified' && (running ?? account.accountId === this.auth.getSnapshot().accountId)
|
? false
|
||||||
? 'authenticated'
|
: running ?? (account.accountId === this.auth.getSnapshot().accountId),
|
||||||
: 'login-required',
|
status: account.status === 'removed'
|
||||||
|
? 'removed'
|
||||||
|
: account.status === 'verified' && (running ?? account.accountId === this.auth.getSnapshot().accountId)
|
||||||
|
? 'authenticated'
|
||||||
|
: 'login-required',
|
||||||
createdAt: account.createdAt,
|
createdAt: account.createdAt,
|
||||||
updatedAt: account.updatedAt,
|
updatedAt: account.updatedAt,
|
||||||
lastImportedAt: account.lastImportedAt ?? account.updatedAt,
|
lastImportedAt: account.lastImportedAt ?? account.updatedAt,
|
||||||
|
removedAt: account.removedAt,
|
||||||
establishment: this.toEstablishmentSummary(account.accountId),
|
establishment: this.toEstablishmentSummary(account.accountId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,11 +39,16 @@ const BulkMarkReadRequestSchema = z.strictObject({
|
|||||||
conversationIds: z.array(z.string().min(1)).min(1).max(500),
|
conversationIds: z.array(z.string().min(1)).min(1).max(500),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const READ_ONLY_MESSAGE =
|
||||||
|
'This conversation belongs to a removed account. Its history is kept, but nothing can be sent or synced from it.';
|
||||||
|
|
||||||
type OwnedActiveChat = ActiveChat & {
|
type OwnedActiveChat = ActiveChat & {
|
||||||
owner?: { accountId: string; redditUsername: string };
|
owner?: { accountId: string; redditUsername: string };
|
||||||
automationStatus?: ConversationAutomationStatus;
|
automationStatus?: ConversationAutomationStatus;
|
||||||
automationPolicyMode?: ConversationPolicyMode;
|
automationPolicyMode?: ConversationPolicyMode;
|
||||||
automationHandoffReason?: string | null;
|
automationHandoffReason?: string | null;
|
||||||
|
/** Owned by a retired account: readable, but nothing can be sent into it. */
|
||||||
|
readOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** An owned conversation with operator triage state projected onto it. */
|
/** An owned conversation with operator triage state projected onto it. */
|
||||||
@@ -119,12 +124,48 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conversations collected by retired accounts. There is no runtime to read
|
||||||
|
* them from, so they come straight from the account content table — the same
|
||||||
|
* payloads a running account serves out of its own cache.
|
||||||
|
*/
|
||||||
|
function retiredConversations(accountId: string): ActiveChat[] {
|
||||||
|
const store = ctx.repos.accountContent;
|
||||||
|
if (!store) return [];
|
||||||
|
return store.listConversations(accountId).flatMap((row) => {
|
||||||
|
try { return [JSON.parse(row.payloadJson) as ActiveChat]; } catch { return []; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function retiredAccount(accountId: string) {
|
||||||
|
return ctx.services.redditAccounts?.listRemoved().find((account) => account.accountId === accountId);
|
||||||
|
}
|
||||||
|
|
||||||
async function findConversation(id: string): Promise<{
|
async function findConversation(id: string): Promise<{
|
||||||
conversation: ActiveChat;
|
conversation: ActiveChat;
|
||||||
runtime: NonNullable<ReturnType<NonNullable<WebContext['services']['accountRuntimes']>['get']>> | null;
|
runtime: NonNullable<ReturnType<NonNullable<WebContext['services']['accountRuntimes']>['get']>> | null;
|
||||||
|
/** The owning account was retired: serve the content, refuse every write. */
|
||||||
|
readOnly?: { accountId: string; redditUsername: string };
|
||||||
} | undefined> {
|
} | undefined> {
|
||||||
const owned = parseOwnedId(id);
|
const owned = parseOwnedId(id);
|
||||||
if (!owned && (ctx.services.accountRuntimes?.list().length ?? 0) > 0) return undefined;
|
if (!owned && (ctx.services.accountRuntimes?.list().length ?? 0) > 0) return undefined;
|
||||||
|
// A retired account has no runtime by design, so it is resolved from the
|
||||||
|
// archive before the runtime requirement below would reject it.
|
||||||
|
const retired = owned ? retiredAccount(owned.accountId) : undefined;
|
||||||
|
if (owned && retired) {
|
||||||
|
const archived = retiredConversations(owned.accountId).find(
|
||||||
|
(chat) => chat.roomId === owned.conversationId
|
||||||
|
|| chat.username === owned.conversationId
|
||||||
|
|| chat.roomName === owned.conversationId,
|
||||||
|
);
|
||||||
|
return archived
|
||||||
|
? {
|
||||||
|
conversation: archived,
|
||||||
|
runtime: null,
|
||||||
|
readOnly: { accountId: retired.accountId, redditUsername: retired.redditUsername },
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
const runtime = owned ? ctx.services.accountRuntimes?.get(owned.accountId) ?? null : null;
|
const runtime = owned ? ctx.services.accountRuntimes?.get(owned.accountId) ?? null : null;
|
||||||
if (owned && (!runtime || runtime.snapshot().state !== 'running')) return undefined;
|
if (owned && (!runtime || runtime.snapshot().state !== 'running')) return undefined;
|
||||||
const chat = runtime?.resources.chatService ?? ctx.services.chat;
|
const chat = runtime?.resources.chatService ?? ctx.services.chat;
|
||||||
@@ -137,6 +178,21 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
return conversation ? { conversation, runtime } : undefined;
|
return conversation ? { conversation, runtime } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conversation state derived purely from the stored transcript. A retired
|
||||||
|
* account owns no state repository, and borrowing another account's would
|
||||||
|
* both read and write the wrong rows.
|
||||||
|
*/
|
||||||
|
function archivedIntelligence(conversation: ActiveChat) {
|
||||||
|
const messages = (conversation.messages ?? []).map((message) => ({
|
||||||
|
sender: message.sender,
|
||||||
|
text: message.text,
|
||||||
|
isSelf: message.isSelf,
|
||||||
|
}));
|
||||||
|
const state = buildConversationState(messages);
|
||||||
|
return { messages, state, intent: chooseConversationIntent(messages, state) };
|
||||||
|
}
|
||||||
|
|
||||||
function getConversationIntelligence(
|
function getConversationIntelligence(
|
||||||
conversation: ActiveChat,
|
conversation: ActiveChat,
|
||||||
stateRepo = ctx.services.conversationState,
|
stateRepo = ctx.services.conversationState,
|
||||||
@@ -231,6 +287,28 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
conversation.roomId ? readState.get(conversation.roomId) : undefined,
|
conversation.roomId ? readState.get(conversation.roomId) : undefined,
|
||||||
));
|
));
|
||||||
})();
|
})();
|
||||||
|
// Retired accounts keep everything they collected. Their rows are marked
|
||||||
|
// read-only and never flagged as needing a reply — the operator has no
|
||||||
|
// way to answer them, so they must not sit in the actionable queue.
|
||||||
|
for (const account of ctx.services.redditAccounts?.listRemoved() ?? []) {
|
||||||
|
for (const archived of retiredConversations(account.accountId)) {
|
||||||
|
// Built without the shared state repositories on purpose: those are
|
||||||
|
// scoped to a live account, and a retired one owns none of them.
|
||||||
|
conversations.push({
|
||||||
|
...archived,
|
||||||
|
owner: { accountId: account.accountId, redditUsername: account.redditUsername },
|
||||||
|
readOnly: true,
|
||||||
|
hasUnreadInbound: false,
|
||||||
|
needsReply: false,
|
||||||
|
// Deliberately left open rather than resolved: the default inbox
|
||||||
|
// filter hides resolved conversations, and archived history is
|
||||||
|
// meant to stay browsable there.
|
||||||
|
triageStatus: 'open',
|
||||||
|
snoozedUntil: null,
|
||||||
|
lastReadMessageTs: archived.lastMessageTime ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
if (req.query.includeImageMetadata !== 'true') {
|
if (req.query.includeImageMetadata !== 'true') {
|
||||||
res.json(conversations.map((conversation) => ({ ...conversation, owner: conversation.owner ?? currentOwner() })));
|
res.json(conversations.map((conversation) => ({ ...conversation, owner: conversation.owner ?? currentOwner() })));
|
||||||
return;
|
return;
|
||||||
@@ -299,8 +377,11 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
? conversation.messages[conversation.messages.length - 1].timestamp
|
? conversation.messages[conversation.messages.length - 1].timestamp
|
||||||
: 0;
|
: 0;
|
||||||
const hasNewerActivity = (conversation.lastMessageTime ?? 0) > cachedLastTs;
|
const hasNewerActivity = (conversation.lastMessageTime ?? 0) > cachedLastTs;
|
||||||
const needsLazyLoad =
|
// A retired conversation is whatever was captured before removal. There
|
||||||
!conversation.messages || conversation.messages.length === 0 || hasNewerActivity;
|
// is no session left to fetch more with, and the fallback chat service
|
||||||
|
// belongs to a different account, so lazy-loading is off the table.
|
||||||
|
const needsLazyLoad = !resolved.readOnly
|
||||||
|
&& (!conversation.messages || conversation.messages.length === 0 || hasNewerActivity);
|
||||||
if (needsLazyLoad && !syncInFlight) {
|
if (needsLazyLoad && !syncInFlight) {
|
||||||
try {
|
try {
|
||||||
await chatService.loadConversationDetails(conversation);
|
await chatService.loadConversationDetails(conversation);
|
||||||
@@ -346,8 +427,12 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
ctx.logger.warn(`Failed to look up triggering post for ${conversation.username}:`, err);
|
ctx.logger.warn(`Failed to look up triggering post for ${conversation.username}:`, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stateRepo = resolved.runtime?.resources.conversationStateRepo ?? ctx.services.conversationState;
|
const stateRepo = resolved.readOnly
|
||||||
const intelligence = getConversationIntelligence(conversation, stateRepo);
|
? undefined
|
||||||
|
: resolved.runtime?.resources.conversationStateRepo ?? ctx.services.conversationState;
|
||||||
|
const intelligence = resolved.readOnly
|
||||||
|
? archivedIntelligence(conversation)
|
||||||
|
: getConversationIntelligence(conversation, stateRepo);
|
||||||
const receivedImage = (conversation.messages ?? []).some(
|
const receivedImage = (conversation.messages ?? []).some(
|
||||||
(message) => !message.isSelf && (message.media?.length ?? 0) > 0,
|
(message) => !message.isSelf && (message.media?.length ?? 0) > 0,
|
||||||
);
|
);
|
||||||
@@ -375,7 +460,9 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
return { ...message, isAi: true };
|
return { ...message, isAi: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
const readStateRepo = resolved.runtime?.resources.conversationReadStateRepo ?? ctx.services.conversationReadState;
|
const readStateRepo = resolved.readOnly
|
||||||
|
? undefined
|
||||||
|
: resolved.runtime?.resources.conversationReadStateRepo ?? ctx.services.conversationReadState;
|
||||||
readStateRepo?.expireSnoozes();
|
readStateRepo?.expireSnoozes();
|
||||||
const triage = annotateTriage(
|
const triage = annotateTriage(
|
||||||
conversation,
|
conversation,
|
||||||
@@ -394,9 +481,11 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
recipientAccountStatusCheckedAt: triage.recipientAccountStatusCheckedAt,
|
recipientAccountStatusCheckedAt: triage.recipientAccountStatusCheckedAt,
|
||||||
sellerDetected: intelligence.state.sellerDetected,
|
sellerDetected: intelligence.state.sellerDetected,
|
||||||
...(annotatedMessages ? { messages: annotatedMessages } : {}),
|
...(annotatedMessages ? { messages: annotatedMessages } : {}),
|
||||||
owner: resolved.runtime
|
owner: resolved.readOnly
|
||||||
? { accountId: resolved.runtime.session.accountId, redditUsername: resolved.runtime.session.redditUsername }
|
?? (resolved.runtime
|
||||||
: currentOwner(),
|
? { accountId: resolved.runtime.session.accountId, redditUsername: resolved.runtime.session.redditUsername }
|
||||||
|
: currentOwner()),
|
||||||
|
...(resolved.readOnly ? { readOnly: true } : {}),
|
||||||
triggeringPost,
|
triggeringPost,
|
||||||
automation: {
|
automation: {
|
||||||
turnsUsed: intelligence.state.automation.turnsUsed,
|
turnsUsed: intelligence.state.automation.turnsUsed,
|
||||||
@@ -433,6 +522,10 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
res.status(404).json({ error: 'Conversation not found' });
|
res.status(404).json({ error: 'Conversation not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (resolved.readOnly) {
|
||||||
|
res.status(409).json({ error: READ_ONLY_MESSAGE });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const conversation = resolved.conversation;
|
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' });
|
||||||
@@ -472,6 +565,10 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
res.status(404).json({ error: 'Conversation not found' });
|
res.status(404).json({ error: 'Conversation not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (resolved.readOnly) {
|
||||||
|
res.status(409).json({ error: READ_ONLY_MESSAGE });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const conversation = resolved.conversation;
|
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' });
|
||||||
@@ -517,6 +614,10 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
res.status(404).json({ error: 'Conversation not found' });
|
res.status(404).json({ error: 'Conversation not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (resolved.readOnly) {
|
||||||
|
res.status(409).json({ error: READ_ONLY_MESSAGE });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const conversation = resolved.conversation;
|
const conversation = resolved.conversation;
|
||||||
const chatService = resolved.runtime?.resources.chatService ?? ctx.services.chat;
|
const chatService = resolved.runtime?.resources.chatService ?? ctx.services.chat;
|
||||||
const stateRepo = resolved.runtime?.resources.conversationStateRepo ?? ctx.services.conversationState;
|
const stateRepo = resolved.runtime?.resources.conversationStateRepo ?? ctx.services.conversationState;
|
||||||
@@ -605,6 +706,7 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
> {
|
> {
|
||||||
const resolved = await findConversation(decodeURIComponent(id));
|
const resolved = await findConversation(decodeURIComponent(id));
|
||||||
if (!resolved) return { error: { status: 404, message: 'Conversation not found' } };
|
if (!resolved) return { error: { status: 404, message: 'Conversation not found' } };
|
||||||
|
if (resolved.readOnly) return { error: { status: 409, message: READ_ONLY_MESSAGE } };
|
||||||
const repo = resolved.runtime?.resources.conversationReadStateRepo ?? ctx.services.conversationReadState;
|
const repo = resolved.runtime?.resources.conversationReadStateRepo ?? ctx.services.conversationReadState;
|
||||||
if (!repo) return { error: { status: 503, message: 'Conversation read state is not initialized' } };
|
if (!repo) return { error: { status: 503, message: 'Conversation read state is not initialized' } };
|
||||||
if (!resolved.conversation.roomId) {
|
if (!resolved.conversation.roomId) {
|
||||||
@@ -828,6 +930,10 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
if (!conversation.roomId) {
|
if (!conversation.roomId) {
|
||||||
return res.json({ requestKind: null, assets: [], reason: 'This conversation has no Matrix room.', warning: null });
|
return res.json({ requestKind: null, assets: [], reason: 'This conversation has no Matrix room.', warning: null });
|
||||||
}
|
}
|
||||||
|
if (resolved.readOnly) {
|
||||||
|
res.status(409).json({ error: READ_ONLY_MESSAGE });
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Full manual control: every enabled, unused library image is offered
|
// Full manual control: every enabled, unused library image is offered
|
||||||
// regardless of what (if anything) the latest message asked for. Safety
|
// regardless of what (if anything) the latest message asked for. Safety
|
||||||
// flags surface as a non-blocking warning, never a gate.
|
// flags surface as a non-blocking warning, never a gate.
|
||||||
@@ -853,6 +959,7 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
try {
|
try {
|
||||||
const resolved = await findConversation(decodeURIComponent(req.params.id));
|
const resolved = await findConversation(decodeURIComponent(req.params.id));
|
||||||
if (!resolved) return res.status(404).json({ error: 'Conversation not found' });
|
if (!resolved) return res.status(404).json({ error: 'Conversation not found' });
|
||||||
|
if (resolved.readOnly) return res.status(409).json({ error: READ_ONLY_MESSAGE });
|
||||||
const runtime = resolved.runtime;
|
const runtime = resolved.runtime;
|
||||||
const exchange = runtime?.resources.mediaExchange;
|
const exchange = runtime?.resources.mediaExchange;
|
||||||
if (!runtime || !exchange) return res.status(503).json({ error: 'Media sharing is not initialized for this account' });
|
if (!runtime || !exchange) return res.status(503).json({ error: 'Media sharing is not initialized for this account' });
|
||||||
|
|||||||
@@ -334,10 +334,10 @@ describe('browsing migration', () => {
|
|||||||
const db = new Database(':memory:');
|
const db = new Database(':memory:');
|
||||||
db.pragma('foreign_keys = ON');
|
db.pragma('foreign_keys = ON');
|
||||||
runMigrations(db);
|
runMigrations(db);
|
||||||
const version = db.pragma('user_version', { simple: true }) as number;
|
|
||||||
db.exec('DROP TABLE account_browsing_decisions; DROP TABLE account_browsing_settings;');
|
db.exec('DROP TABLE account_browsing_decisions; DROP TABLE account_browsing_settings;');
|
||||||
// Five browsing migrations plus the later conversation-learning migration.
|
// Absolute, not counted back from the head: browsing's first migration is
|
||||||
db.pragma(`user_version = ${version - 6}`);
|
// 26, and a relative rewind breaks whenever a migration is appended.
|
||||||
|
db.pragma('user_version = 25');
|
||||||
|
|
||||||
runMigrations(db);
|
runMigrations(db);
|
||||||
insertAccount(db, ACCOUNT, 'owner');
|
insertAccount(db, ACCOUNT, 'owner');
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ describe('multi-account database ownership', () => {
|
|||||||
expect(db.pragma('foreign_key_check')).toEqual([]);
|
expect(db.pragma('foreign_key_check')).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes an account with everything it owns and leaves other accounts intact', () => {
|
it('retires an account while keeping every row it collected', () => {
|
||||||
addAccount('t2_alice', 'alice-bot', 100);
|
addAccount('t2_alice', 'alice-bot', 100);
|
||||||
addAccount('t2_bob', 'bob-bot', 110);
|
addAccount('t2_bob', 'bob-bot', 110);
|
||||||
|
|
||||||
@@ -174,28 +174,51 @@ describe('multi-account database ownership', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(accounts.delete('t2_alice')).toBe(true);
|
expect(accounts.retire('t2_alice', 500)).toBe(true);
|
||||||
|
|
||||||
expect(accounts.get('t2_alice')).toBeNull();
|
// The content the account collected is the point of retiring rather than
|
||||||
expect(accounts.listPublic().map((account) => account.accountId)).toContain('t2_bob');
|
// deleting: every row it produced must survive untouched.
|
||||||
for (const table of [
|
for (const table of [
|
||||||
'processed_posts', 'sent_messages', 'successful_messages', 'delivery_outbox',
|
'processed_posts', 'sent_messages', 'successful_messages', 'delivery_outbox',
|
||||||
'activity_log', 'conversation_states', 'reddit_conversations', 'reddit_account_sync_state',
|
'activity_log', 'conversation_states', 'reddit_conversations', 'reddit_account_sync_state',
|
||||||
]) {
|
]) {
|
||||||
expect({ table, ...db.prepare(`SELECT COUNT(*) count FROM ${table} WHERE account_id = ?`).get('t2_alice') as object })
|
expect({ table, ...db.prepare(`SELECT COUNT(*) count FROM ${table} WHERE account_id = ?`).get('t2_alice') as object })
|
||||||
.toEqual({ table, count: 0 });
|
|
||||||
expect({ table, ...db.prepare(`SELECT COUNT(*) count FROM ${table} WHERE account_id = ?`).get('t2_bob') as object })
|
|
||||||
.toEqual({ table, count: 1 });
|
.toEqual({ table, count: 1 });
|
||||||
}
|
}
|
||||||
|
expect(accounts.get('t2_alice')).toMatchObject({
|
||||||
|
status: 'removed', isActive: false, removedAt: 500,
|
||||||
|
});
|
||||||
|
// Credentials are destroyed, so no session survives to sync or send with.
|
||||||
|
expect(accounts.get('t2_alice')).toMatchObject({ cookiesJson: '[]', matrixCredentialsJson: '{}' });
|
||||||
|
expect(accounts.getActive().map((account) => account.accountId)).toEqual(['t2_bob']);
|
||||||
expect(db.pragma('foreign_key_check')).toEqual([]);
|
expect(db.pragma('foreign_key_check')).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports an unknown account as not removed and rejects a blank id', () => {
|
it('refuses to reactivate or refresh a retired account', () => {
|
||||||
|
addAccount('t2_alice', 'alice-bot', 100);
|
||||||
|
expect(accounts.retire('t2_alice', 500)).toBe(true);
|
||||||
|
|
||||||
|
expect(accounts.setActive('t2_alice', true, 600)).toBe(false);
|
||||||
|
expect(accounts.markLoginRequired('t2_alice', 600)).toBe(false);
|
||||||
|
expect(accounts.updateMatrixCredentials('t2_alice', JSON.stringify({ accessToken: 'new' }), 600)).toBe(false);
|
||||||
|
expect(accounts.get('t2_alice')).toMatchObject({ status: 'removed', matrixCredentialsJson: '{}' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revives a retired account when a fresh session is imported for it', () => {
|
||||||
|
addAccount('t2_alice', 'alice-bot', 100);
|
||||||
|
accounts.retire('t2_alice', 500);
|
||||||
|
|
||||||
|
const revived = addAccount('t2_alice', 'alice-bot', 700);
|
||||||
|
|
||||||
|
expect(revived).toMatchObject({ status: 'verified', isActive: true, removedAt: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an unknown account as not retired and rejects a blank id', () => {
|
||||||
addAccount('t2_alice', 'alice-bot', 100);
|
addAccount('t2_alice', 'alice-bot', 100);
|
||||||
|
|
||||||
expect(accounts.delete('t2_missing')).toBe(false);
|
expect(accounts.retire('t2_missing')).toBe(false);
|
||||||
expect(() => accounts.delete(' ')).toThrow(/accountId/);
|
expect(() => accounts.retire(' ')).toThrow(/accountId/);
|
||||||
expect(accounts.get('t2_alice')).not.toBeNull();
|
expect(accounts.get('t2_alice')).toMatchObject({ status: 'verified' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('scopes legacy account-produced table uniqueness by account', () => {
|
it('scopes legacy account-produced table uniqueness by account', () => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
|||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { HttpResponse, http } from 'msw';
|
import { HttpResponse, http } from 'msw';
|
||||||
import { setupServer } from 'msw/node';
|
import { setupServer } from 'msw/node';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
import { LearningPage } from '../../apps/dashboard/src/features/learning/learning-page.js';
|
import { LearningPage } from '../../apps/dashboard/src/features/learning/learning-page.js';
|
||||||
|
|
||||||
@@ -62,7 +63,9 @@ function renderLearning() {
|
|||||||
});
|
});
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<LearningPage />
|
<MemoryRouter>
|
||||||
|
<LearningPage />
|
||||||
|
</MemoryRouter>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -96,6 +99,19 @@ describe('learning page trajectory detail', () => {
|
|||||||
await waitFor(() => expect(detailCalls).toEqual([TRAJECTORY_ID]));
|
await waitFor(() => expect(detailCalls).toEqual([TRAJECTORY_ID]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('links the evidence to the owning account\'s conversation', async () => {
|
||||||
|
renderLearning();
|
||||||
|
await screen.findByText(/human created success/i);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /show conversation evidence/i }));
|
||||||
|
|
||||||
|
const link = await screen.findByRole('link', { name: '!room:reddit.com' });
|
||||||
|
// The account id has to survive in the owned id or the route 404s.
|
||||||
|
expect(link).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
`/conversations/${encodeURIComponent('%40t2_learner%3Areddit.com~!room%3Areddit.com')}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('collapses the evidence again on a second click', async () => {
|
it('collapses the evidence again on a second click', async () => {
|
||||||
renderLearning();
|
renderLearning();
|
||||||
await screen.findByText(/human created success/i);
|
await screen.findByText(/human created success/i);
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const OTHER_ACCOUNT = '@t2_other:reddit.com';
|
|||||||
// Browsing migrations depend on the growth tables, so a pre-growth fixture
|
// Browsing migrations depend on the growth tables, so a pre-growth fixture
|
||||||
// must rewind past both the growth sequence and the later browsing additions.
|
// must rewind past both the growth sequence and the later browsing additions.
|
||||||
const GROWTH_MIGRATION_COUNT = 7;
|
const GROWTH_MIGRATION_COUNT = 7;
|
||||||
|
/** Schema version that first creates the growth tables. */
|
||||||
|
const FIRST_GROWTH_MIGRATION = 24;
|
||||||
|
|
||||||
function thread(id: string, overrides: Partial<RedditPost> = {}): RedditPost {
|
function thread(id: string, overrides: Partial<RedditPost> = {}): RedditPost {
|
||||||
return {
|
return {
|
||||||
@@ -507,14 +509,16 @@ describe('AccountGrowthRepository', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('growth migration', () => {
|
describe('growth migration', () => {
|
||||||
/** Rewinds past every growth migration so they replay from a pre-growth state. */
|
/**
|
||||||
|
* Rewinds past every growth migration so they replay from a pre-growth state.
|
||||||
|
* The target is absolute: growth's first migration is 24, and counting back
|
||||||
|
* from the head silently breaks every time a migration is appended.
|
||||||
|
*/
|
||||||
function rewindPastGrowth(db: Database.Database): void {
|
function rewindPastGrowth(db: Database.Database): void {
|
||||||
const version = db.pragma('user_version', { simple: true }) as number;
|
|
||||||
db.exec('DROP TABLE account_growth_comments');
|
db.exec('DROP TABLE account_growth_comments');
|
||||||
db.exec('DROP TABLE account_growth_subreddits');
|
db.exec('DROP TABLE account_growth_subreddits');
|
||||||
db.exec('DROP TABLE account_growth_settings');
|
db.exec('DROP TABLE account_growth_settings');
|
||||||
// Also rewind the later conversation-learning migration.
|
db.pragma(`user_version = ${FIRST_GROWTH_MIGRATION - 1}`);
|
||||||
db.pragma(`user_version = ${version - GROWTH_MIGRATION_COUNT - 1}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
it('upgrades a database created before growth existed', () => {
|
it('upgrades a database created before growth existed', () => {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ describe('removing a Reddit account', () => {
|
|||||||
|
|
||||||
afterEach(() => db.close());
|
afterEach(() => db.close());
|
||||||
|
|
||||||
it('stops the runtime before dropping the account and its stored credentials', async () => {
|
it('stops the runtime and destroys the session while keeping the account', async () => {
|
||||||
const removed = await service.remove('t2_alice');
|
const removed = await service.remove('t2_alice');
|
||||||
|
|
||||||
expect(stopped).toEqual(['t2_alice']);
|
expect(stopped).toEqual(['t2_alice']);
|
||||||
@@ -56,10 +56,22 @@ describe('removing a Reddit account', () => {
|
|||||||
accountId: 't2_alice',
|
accountId: 't2_alice',
|
||||||
redditUsername: 'alice-bot',
|
redditUsername: 'alice-bot',
|
||||||
isRunning: false,
|
isRunning: false,
|
||||||
status: 'login-required',
|
status: 'removed',
|
||||||
});
|
});
|
||||||
expect(accounts.get('t2_alice')).toBeNull();
|
// Retired, not deleted: the row survives so its content keeps an owner.
|
||||||
expect(service.list()).toEqual([]);
|
expect(accounts.get('t2_alice')).toMatchObject({
|
||||||
|
status: 'removed', cookiesJson: '[]', matrixCredentialsJson: '{}',
|
||||||
|
});
|
||||||
|
expect(service.list().map((account) => account.accountId)).toEqual(['t2_alice']);
|
||||||
|
expect(service.listRemoved().map((account) => account.accountId)).toEqual(['t2_alice']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to remove an account twice', async () => {
|
||||||
|
await service.remove('t2_alice');
|
||||||
|
stopped = [];
|
||||||
|
|
||||||
|
await expect(service.remove('t2_alice')).rejects.toThrow('Reddit account was already removed');
|
||||||
|
expect(stopped).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops the authenticated session when the removed account held it', async () => {
|
it('drops the authenticated session when the removed account held it', async () => {
|
||||||
@@ -93,12 +105,15 @@ describe('removing a Reddit account', () => {
|
|||||||
await service.remove('t2_alice');
|
await service.remove('t2_alice');
|
||||||
|
|
||||||
expect(auth.getSnapshot()).toMatchObject({ status: 'authenticated', accountId: 't2_bob' });
|
expect(auth.getSnapshot()).toMatchObject({ status: 'authenticated', accountId: 't2_bob' });
|
||||||
expect(service.list().map((account) => account.accountId)).toEqual(['t2_bob']);
|
expect(service.listRemoved().map((account) => account.accountId)).toEqual(['t2_alice']);
|
||||||
|
// Bob is untouched by Alice's removal — still listed, still not retired.
|
||||||
|
expect(service.list().find((account) => account.accountId === 't2_bob')?.status)
|
||||||
|
.not.toBe('removed');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refuses to remove an account it does not know, without stopping anything', async () => {
|
it('refuses to remove an account it does not know, without stopping anything', async () => {
|
||||||
await expect(service.remove('t2_missing')).rejects.toThrow('Reddit account not found');
|
await expect(service.remove('t2_missing')).rejects.toThrow('Reddit account not found');
|
||||||
expect(stopped).toEqual([]);
|
expect(stopped).toEqual([]);
|
||||||
expect(accounts.get('t2_alice')).not.toBeNull();
|
expect(accounts.get('t2_alice')).toMatchObject({ status: 'verified' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -880,4 +880,96 @@ describe('conversations route', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('removed accounts', () => {
|
||||||
|
const ARCHIVED = {
|
||||||
|
username: 'alice', roomName: 'alice', roomId: 'room-alice', lastMessageTime: 500,
|
||||||
|
lastMessageSnippet: 'thanks!', isFromUs: false, unread: false,
|
||||||
|
messages: [{ sender: 'alice', text: 'thanks!', timestamp: 500, isSelf: false }],
|
||||||
|
};
|
||||||
|
const OWNED_ID = `${encodeURIComponent('@t2_gone:reddit.com')}~${encodeURIComponent('room-alice')}`;
|
||||||
|
|
||||||
|
/** A retired account: listed by the service, but with no runtime at all. */
|
||||||
|
function retiredContext() {
|
||||||
|
return {
|
||||||
|
logger: { error: vi.fn(), warn: vi.fn() },
|
||||||
|
repos: {
|
||||||
|
accountContent: {
|
||||||
|
listConversations: vi.fn((accountId: string) => accountId === '@t2_gone:reddit.com'
|
||||||
|
? [{ conversationId: 'room-alice', payloadJson: JSON.stringify(ARCHIVED) }]
|
||||||
|
: []),
|
||||||
|
},
|
||||||
|
messages: { getByChatRoomId: vi.fn(), getLatestByRecipient: vi.fn() },
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
chat: {
|
||||||
|
getCachedConversations: vi.fn().mockResolvedValue([]),
|
||||||
|
getSyncStatus: () => ({ syncing: false }),
|
||||||
|
loadConversationDetails: vi.fn(),
|
||||||
|
saveCacheExternal: vi.fn(),
|
||||||
|
},
|
||||||
|
// Present so suggest-reply reaches the read-only guard rather than
|
||||||
|
// bailing out earlier on a missing AI client.
|
||||||
|
ai: { generateReply: vi.fn() },
|
||||||
|
accountRuntimes: { list: () => [], get: () => null },
|
||||||
|
redditAccounts: {
|
||||||
|
listRemoved: () => [{ accountId: '@t2_gone:reddit.com', redditUsername: 'gone-bot' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as WebContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps a retired account\'s conversations listed, read-only and out of the reply queue', async () => {
|
||||||
|
const app = express().use('/api', createConversationsRouter(retiredContext()));
|
||||||
|
|
||||||
|
const body = await (await request(app, '/api/conversations')).json();
|
||||||
|
|
||||||
|
expect(body).toEqual([expect.objectContaining({
|
||||||
|
roomId: 'room-alice',
|
||||||
|
readOnly: true,
|
||||||
|
needsReply: false,
|
||||||
|
hasUnreadInbound: false,
|
||||||
|
// Open, not resolved: the dashboard's default filter hides resolved
|
||||||
|
// conversations, and archived history has to stay browsable.
|
||||||
|
triageStatus: 'open',
|
||||||
|
owner: { accountId: '@t2_gone:reddit.com', redditUsername: 'gone-bot' },
|
||||||
|
})]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves the stored transcript without trying to sync more of it', async () => {
|
||||||
|
const context = retiredContext();
|
||||||
|
const app = express().use('/api', createConversationsRouter(context));
|
||||||
|
|
||||||
|
const body = await (await request(app, `/api/conversations/${encodeURIComponent(OWNED_ID)}`)).json();
|
||||||
|
|
||||||
|
expect(body).toMatchObject({ roomId: 'room-alice', readOnly: true });
|
||||||
|
expect(body.messages).toHaveLength(1);
|
||||||
|
// The fallback chat service belongs to another account: touching it would
|
||||||
|
// both sync the wrong session and mutate the wrong cache.
|
||||||
|
expect(context.services.chat!.loadConversationDetails).not.toHaveBeenCalled();
|
||||||
|
expect(context.services.chat!.saveCacheExternal).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to send, suggest or mark anything in a retired conversation', async () => {
|
||||||
|
const app = express().use(express.json()).use('/api', createConversationsRouter(retiredContext()));
|
||||||
|
const path = `/api/conversations/${encodeURIComponent(OWNED_ID)}`;
|
||||||
|
|
||||||
|
const responses = await Promise.all([
|
||||||
|
request(app, `${path}/send-reply`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message: 'hello again' }),
|
||||||
|
}),
|
||||||
|
request(app, `${path}/suggest-reply`, { method: 'POST' }),
|
||||||
|
request(app, `${path}/read`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
|
||||||
|
}),
|
||||||
|
request(app, `${path}/share-media`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ assetId: '00000000-0000-4000-8000-000000000000' }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(responses.map((response) => response.status)).toEqual([409, 409, 409, 409]);
|
||||||
|
expect((await responses[0].json()).error).toMatch(/removed account/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user