Retire removed accounts instead of deleting them
Removing an account had no path at all, so a dead session could only be left running or hand-edited out of the database. The first cut deleted the account and everything it owned, which threw away conversations and media that are worth keeping. Retire instead: keep the row and every row it produced, destroy the stored cookies and Matrix token. Wiping the credentials is what makes sending and syncing impossible rather than merely disabled — isEnabledAccount requires a verified status, so no runtime ever starts, and no session survives for anything to use. setActive, markLoginRequired and the background credential refresh now skip retired accounts so none of them quietly revives one; re-importing a session deliberately does. Keeping the content visible needed more than keeping the rows. Conversations and media are only served from running runtimes, so a retired account's history would have disappeared from the dashboard regardless. Media already reads the account content table for every listed account. Conversations now have an archive path that reads the stored ActiveChat payloads directly. Every write path refuses a retired conversation with a 409: send-reply, both suggest endpoints, share-media, shareable-media and all read-state writes. Lazy-loading detail is skipped too — the fallback chat service belongs to a different account, so it would sync the wrong session and write the wrong cache. Archived detail derives conversation state from the transcript alone for the same reason. Migration 32 rebuilds reddit_accounts to admit the new status, with foreign keys off and a foreign_key_check before they are restored, as migration 11 does. Two migration tests rewound user_version by counting back from the head, which silently breaks whenever a migration is appended; they now target absolute versions. Conversations never opened before removal keep only their summary. Message history is lazy-loaded on first open and there is no session left to fetch the rest with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3f60f7f665
commit
a9cea7ed18
16 files changed
+433
-97
No files matched your search
@@ -98,6 +98,7 @@ function establishmentPill(establishment: AccountEstablishmentSummary | null | u
|
||||
function AccountRow({ account }: { account: RedditAccountSummary }) {
|
||||
const removeMutation = useRemoveRedditAccountMutation();
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
const isRemoved = account.status === 'removed';
|
||||
|
||||
useEffect(() => {
|
||||
if (!confirmRemove) return;
|
||||
@@ -105,24 +106,31 @@ function AccountRow({ account }: { account: RedditAccountSummary }) {
|
||||
return () => globalThis.clearTimeout(timer);
|
||||
}, [confirmRemove]);
|
||||
|
||||
const rowClass = isRemoved ? 'account-row is-removed' : account.isRunning ? 'account-row is-active' : 'account-row';
|
||||
return (
|
||||
<div className={account.isRunning ? 'account-row is-active' : 'account-row'}>
|
||||
<div className={rowClass}>
|
||||
<div>
|
||||
<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 className="account-row-pills">
|
||||
{establishmentPill(account.establishment)}
|
||||
<span className={account.isRunning ? 'pill success' : 'pill warning'}>
|
||||
{account.isRunning ? 'Running' : 'Paused'}
|
||||
{isRemoved ? null : establishmentPill(account.establishment)}
|
||||
<span className={isRemoved ? 'pill neutral' : account.isRunning ? 'pill success' : 'pill warning'}>
|
||||
{isRemoved ? 'Removed' : account.isRunning ? 'Running' : 'Paused'}
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
Removes u/{account.redditUsername}, its stored session and all of its messages,
|
||||
conversations and activity. This cannot be undone.
|
||||
Stops u/{account.redditUsername} and deletes its stored session. Conversations,
|
||||
messages and media it collected are kept, but become read-only.
|
||||
</span>
|
||||
<button className="button secondary" onClick={() => setConfirmRemove(false)} type="button">
|
||||
Cancel
|
||||
@@ -151,7 +159,7 @@ function AccountRow({ account }: { account: RedditAccountSummary }) {
|
||||
{removeMutation.isError ? (
|
||||
<strong className="save-error" role="alert">{errorMessage(removeMutation.error)}</strong>
|
||||
) : null}
|
||||
{account.establishment && account.establishment.state !== 'unknown' ? (
|
||||
{!isRemoved && account.establishment && account.establishment.state !== 'unknown' ? (
|
||||
<EstablishmentDetail establishment={account.establishment} />
|
||||
) : null}
|
||||
</div>
|
||||
@@ -203,8 +211,10 @@ export function AccountsPage() {
|
||||
? session.username ?? null
|
||||
: null;
|
||||
const accounts = accountsQuery.data ?? [];
|
||||
const pausedCount = accounts.filter((account) => !account.isRunning).length;
|
||||
const heldAccounts = accounts.filter((account) => account.establishment?.sendingHeld);
|
||||
// Removed accounts are not paused work — nothing about them needs attention.
|
||||
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 (
|
||||
<div className="page accounts-page">
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
.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.is-active { border-color: var(--accent); }
|
||||
.account-row.is-removed { opacity: .72; }
|
||||
.account-row > div { display: grid; gap: .2rem; min-width: 0; }
|
||||
.account-row span { color: var(--muted); font-size: .82rem; }
|
||||
.account-row .pill { font-size: .7rem; }
|
||||
|
||||
@@ -536,7 +536,8 @@ function useAutoMarkRead(conversationId: string, conversation: ConversationDetai
|
||||
const mutate = markRead.mutate;
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversation || newestTimestamp <= lastReadMessageTs) return;
|
||||
if (!conversation || conversation.readOnly) return;
|
||||
if (newestTimestamp <= lastReadMessageTs) return;
|
||||
const key = `${conversationId}:${newestTimestamp}`;
|
||||
if (markedRef.current === key) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
@@ -585,11 +586,13 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
|
||||
</span>
|
||||
</div>
|
||||
<div className="conversation-detail-controls">
|
||||
<ConversationTriageActions
|
||||
conversation={conversation}
|
||||
conversationId={conversationId}
|
||||
onResolved={onClose}
|
||||
/>
|
||||
{conversation.readOnly ? null : (
|
||||
<ConversationTriageActions
|
||||
conversation={conversation}
|
||||
conversationId={conversationId}
|
||||
onResolved={onClose}
|
||||
/>
|
||||
)}
|
||||
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -617,7 +620,19 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
|
||||
</div>
|
||||
) : null}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -127,6 +127,7 @@ function ConversationRow({
|
||||
if (isSelected) itemClasses.push('is-selected');
|
||||
if (unread) itemClasses.push('is-unread');
|
||||
if (isChecked) itemClasses.push('is-checked');
|
||||
if (conversation.readOnly) itemClasses.push('is-read-only');
|
||||
|
||||
const descriptors = [
|
||||
redditUsernameLabel(conversation.username),
|
||||
@@ -134,6 +135,7 @@ function ConversationRow({
|
||||
relativeTime,
|
||||
...(conversation.sellerDetected ? ['Seller'] : []),
|
||||
...(conversation.hasReceivedImages ? ['Image received'] : []),
|
||||
...(conversation.readOnly ? ['Removed account, read-only'] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -179,6 +181,11 @@ function ConversationRow({
|
||||
</span>
|
||||
))}
|
||||
{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.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null}
|
||||
<span className="conversation-list-profile"><RedditUsernameLink username={conversation.username} /></span>
|
||||
|
||||
@@ -96,6 +96,11 @@
|
||||
.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-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 strong, .conversation-detail-state strong { color: #e9f1ec; }
|
||||
/* Column layout: history takes the slack, the composer stays reachable. */
|
||||
|
||||
@@ -71,6 +71,11 @@ export const ActiveChatSchema = z.strictObject({
|
||||
automationPolicyMode: ConversationPolicyModeSchema.optional(),
|
||||
/** Why automation handed over, when it has. */
|
||||
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 ConversationsResponseSchema = ChatsResponseSchema;
|
||||
|
||||
@@ -31,10 +31,12 @@ export const RedditAccountSummarySchema = z.strictObject({
|
||||
redditUsername: z.string().min(1),
|
||||
matrixUserId: z.string().min(1),
|
||||
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(),
|
||||
updatedAt: 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. */
|
||||
establishment: AccountEstablishmentSummarySchema.nullable().optional(),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
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 {
|
||||
accountId: string;
|
||||
@@ -12,6 +18,7 @@ export interface RedditAccountPublicRecord {
|
||||
updatedAt: number;
|
||||
lastVerifiedAt: number | null;
|
||||
lastImportedAt: number | null;
|
||||
removedAt: number | null;
|
||||
}
|
||||
|
||||
/** Private credential-bearing record. Never return this from a web API. */
|
||||
@@ -41,29 +48,16 @@ type AccountRow = {
|
||||
updated_at: number;
|
||||
last_verified_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 {
|
||||
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
|
||||
created_at, updated_at, last_verified_at, last_imported_at, removed_at
|
||||
FROM reddit_accounts
|
||||
ORDER BY is_active DESC, reddit_username COLLATE NOCASE, account_id
|
||||
`).all() as AccountRow[];
|
||||
@@ -80,7 +74,7 @@ export class RedditAccountsRepository {
|
||||
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
|
||||
created_at, updated_at, last_verified_at, last_imported_at, removed_at
|
||||
FROM reddit_accounts
|
||||
WHERE is_active = 1 AND status != 'legacy-unassigned'
|
||||
ORDER BY reddit_username COLLATE NOCASE, account_id
|
||||
@@ -113,7 +107,8 @@ export class RedditAccountsRepository {
|
||||
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
|
||||
last_imported_at = excluded.last_imported_at,
|
||||
removed_at = NULL
|
||||
`).run({ ...input, now });
|
||||
return publicFromPrivate(this.getRequired(input.accountId));
|
||||
}
|
||||
@@ -129,7 +124,7 @@ export class RedditAccountsRepository {
|
||||
return this.db.prepare(`
|
||||
UPDATE reddit_accounts
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -138,32 +133,31 @@ export class RedditAccountsRepository {
|
||||
return this.db.prepare(`
|
||||
UPDATE reddit_accounts
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the account and everything it owns. Account-scoped tables added
|
||||
* after the ownership migration cascade on their own, but the tables that
|
||||
* migration created reference `reddit_accounts` with no delete action, so
|
||||
* their rows are cleared explicitly — children before parents — or SQLite
|
||||
* would refuse the delete.
|
||||
* Retire the account. Everything it collected stays — the row survives so
|
||||
* conversations and media keep an owner — but the stored credentials are
|
||||
* destroyed, which is what makes a later send or sync impossible rather
|
||||
* than merely disabled.
|
||||
*/
|
||||
delete(accountId: string): boolean {
|
||||
retire(accountId: string, now: number = Date.now()): boolean {
|
||||
requireText(accountId, 'accountId');
|
||||
return this.db.transaction((id: string) => {
|
||||
for (const table of ACCOUNT_OWNED_TABLES) {
|
||||
this.db.prepare(`DELETE FROM ${table} WHERE account_id = ?`).run(id);
|
||||
}
|
||||
return this.db.prepare('DELETE FROM reddit_accounts WHERE account_id = ?').run(id).changes === 1;
|
||||
})(accountId);
|
||||
return this.db.prepare(`
|
||||
UPDATE reddit_accounts
|
||||
SET status = 'removed', is_active = 0, cookies_json = '[]', matrix_credentials_json = '{}',
|
||||
removed_at = ?, updated_at = ?
|
||||
WHERE account_id = ? AND status NOT IN ('legacy-unassigned', 'removed')
|
||||
`).run(now, 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'
|
||||
WHERE account_id = ? AND status NOT IN ('legacy-unassigned', 'removed')
|
||||
`).run(now, accountId).changes === 1;
|
||||
}
|
||||
|
||||
@@ -185,6 +179,7 @@ function mapPublicAccount(row: AccountRow): RedditAccountPublicRecord {
|
||||
updatedAt: row.updated_at,
|
||||
lastVerifiedAt: row.last_verified_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__';
|
||||
|
||||
/** 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 {
|
||||
const dbPath = config.database.path === ':memory:'
|
||||
? ':memory:'
|
||||
@@ -772,6 +775,40 @@ export function runMigrations(db: Database.Database): void {
|
||||
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;
|
||||
@@ -782,9 +819,10 @@ export function runMigrations(db: Database.Database): void {
|
||||
for (let index = currentVersion; index < migrations.length; 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;
|
||||
// Migration 11 replaces the account-produced table graph atomically and
|
||||
// migration 32 rebuilds `reddit_accounts` itself; both validate the graph
|
||||
// before foreign-key enforcement is restored.
|
||||
const rebuildsForeignKeyGraph = FOREIGN_KEY_REBUILD_VERSIONS.has(version);
|
||||
if (rebuildsForeignKeyGraph) db.pragma('foreign_keys = OFF');
|
||||
try {
|
||||
db.transaction(() => {
|
||||
|
||||
@@ -50,10 +50,13 @@ export interface RedditAccountSummary {
|
||||
redditUsername: string;
|
||||
matrixUserId: string;
|
||||
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;
|
||||
updatedAt: 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. */
|
||||
establishment: RedditAccountEstablishmentSummary | null;
|
||||
}
|
||||
@@ -78,6 +81,11 @@ export class RedditAccountsService {
|
||||
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> {
|
||||
const stored = this.accounts.get(accountId);
|
||||
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
|
||||
* the database. Downloaded media files stay on disk: the media library can
|
||||
* hand an image to another account, so the files are not ours alone to bin.
|
||||
* Retire the account: stop its loop and destroy its credentials, but keep
|
||||
* every conversation, message and media item it collected. The content stays
|
||||
* readable and attributed; with no session left there is nothing to sync and
|
||||
* nothing to send with.
|
||||
*/
|
||||
async remove(accountId: string): Promise<RedditAccountSummary> {
|
||||
const account = this.get(accountId);
|
||||
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);
|
||||
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) {
|
||||
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> {
|
||||
@@ -124,18 +136,23 @@ export class RedditAccountsService {
|
||||
|
||||
private toSummary(account: RedditAccountPublicRecord): RedditAccountSummary {
|
||||
const runtime = this.runtimes?.get(account.accountId);
|
||||
const running = runtime?.snapshot().state === 'running';
|
||||
const running = account.status === 'removed' ? false : 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',
|
||||
isRunning: account.status === 'removed'
|
||||
? false
|
||||
: running ?? (account.accountId === this.auth.getSnapshot().accountId),
|
||||
status: account.status === 'removed'
|
||||
? 'removed'
|
||||
: account.status === 'verified' && (running ?? account.accountId === this.auth.getSnapshot().accountId)
|
||||
? 'authenticated'
|
||||
: 'login-required',
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt,
|
||||
lastImportedAt: account.lastImportedAt ?? account.updatedAt,
|
||||
removedAt: account.removedAt,
|
||||
establishment: this.toEstablishmentSummary(account.accountId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,11 +39,16 @@ const BulkMarkReadRequestSchema = z.strictObject({
|
||||
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 & {
|
||||
owner?: { accountId: string; redditUsername: string };
|
||||
automationStatus?: ConversationAutomationStatus;
|
||||
automationPolicyMode?: ConversationPolicyMode;
|
||||
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. */
|
||||
@@ -119,12 +124,48 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
||||
} 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<{
|
||||
conversation: ActiveChat;
|
||||
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> {
|
||||
const owned = parseOwnedId(id);
|
||||
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;
|
||||
if (owned && (!runtime || runtime.snapshot().state !== 'running')) return undefined;
|
||||
const chat = runtime?.resources.chatService ?? ctx.services.chat;
|
||||
@@ -137,6 +178,21 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
||||
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(
|
||||
conversation: ActiveChat,
|
||||
stateRepo = ctx.services.conversationState,
|
||||
@@ -231,6 +287,28 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
||||
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') {
|
||||
res.json(conversations.map((conversation) => ({ ...conversation, owner: conversation.owner ?? currentOwner() })));
|
||||
return;
|
||||
@@ -299,8 +377,11 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
||||
? conversation.messages[conversation.messages.length - 1].timestamp
|
||||
: 0;
|
||||
const hasNewerActivity = (conversation.lastMessageTime ?? 0) > cachedLastTs;
|
||||
const needsLazyLoad =
|
||||
!conversation.messages || conversation.messages.length === 0 || hasNewerActivity;
|
||||
// A retired conversation is whatever was captured before removal. There
|
||||
// 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) {
|
||||
try {
|
||||
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);
|
||||
}
|
||||
|
||||
const stateRepo = resolved.runtime?.resources.conversationStateRepo ?? ctx.services.conversationState;
|
||||
const intelligence = getConversationIntelligence(conversation, stateRepo);
|
||||
const stateRepo = resolved.readOnly
|
||||
? undefined
|
||||
: resolved.runtime?.resources.conversationStateRepo ?? ctx.services.conversationState;
|
||||
const intelligence = resolved.readOnly
|
||||
? archivedIntelligence(conversation)
|
||||
: getConversationIntelligence(conversation, stateRepo);
|
||||
const receivedImage = (conversation.messages ?? []).some(
|
||||
(message) => !message.isSelf && (message.media?.length ?? 0) > 0,
|
||||
);
|
||||
@@ -375,7 +460,9 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
||||
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();
|
||||
const triage = annotateTriage(
|
||||
conversation,
|
||||
@@ -394,9 +481,11 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
||||
recipientAccountStatusCheckedAt: triage.recipientAccountStatusCheckedAt,
|
||||
sellerDetected: intelligence.state.sellerDetected,
|
||||
...(annotatedMessages ? { messages: annotatedMessages } : {}),
|
||||
owner: resolved.runtime
|
||||
? { accountId: resolved.runtime.session.accountId, redditUsername: resolved.runtime.session.redditUsername }
|
||||
: currentOwner(),
|
||||
owner: resolved.readOnly
|
||||
?? (resolved.runtime
|
||||
? { accountId: resolved.runtime.session.accountId, redditUsername: resolved.runtime.session.redditUsername }
|
||||
: currentOwner()),
|
||||
...(resolved.readOnly ? { readOnly: true } : {}),
|
||||
triggeringPost,
|
||||
automation: {
|
||||
turnsUsed: intelligence.state.automation.turnsUsed,
|
||||
@@ -433,6 +522,10 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
||||
res.status(404).json({ error: 'Conversation not found' });
|
||||
return;
|
||||
}
|
||||
if (resolved.readOnly) {
|
||||
res.status(409).json({ error: READ_ONLY_MESSAGE });
|
||||
return;
|
||||
}
|
||||
const conversation = resolved.conversation;
|
||||
if (!conversation.messages || conversation.messages.length === 0) {
|
||||
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' });
|
||||
return;
|
||||
}
|
||||
if (resolved.readOnly) {
|
||||
res.status(409).json({ error: READ_ONLY_MESSAGE });
|
||||
return;
|
||||
}
|
||||
const conversation = resolved.conversation;
|
||||
if (!conversation.messages || conversation.messages.length === 0) {
|
||||
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' });
|
||||
return;
|
||||
}
|
||||
if (resolved.readOnly) {
|
||||
res.status(409).json({ error: READ_ONLY_MESSAGE });
|
||||
return;
|
||||
}
|
||||
const conversation = resolved.conversation;
|
||||
const chatService = resolved.runtime?.resources.chatService ?? ctx.services.chat;
|
||||
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));
|
||||
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;
|
||||
if (!repo) return { error: { status: 503, message: 'Conversation read state is not initialized' } };
|
||||
if (!resolved.conversation.roomId) {
|
||||
@@ -828,6 +930,10 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
||||
if (!conversation.roomId) {
|
||||
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
|
||||
// regardless of what (if anything) the latest message asked for. Safety
|
||||
// flags surface as a non-blocking warning, never a gate.
|
||||
@@ -853,6 +959,7 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
||||
try {
|
||||
const resolved = await findConversation(decodeURIComponent(req.params.id));
|
||||
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 exchange = runtime?.resources.mediaExchange;
|
||||
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:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
const version = db.pragma('user_version', { simple: true }) as number;
|
||||
db.exec('DROP TABLE account_browsing_decisions; DROP TABLE account_browsing_settings;');
|
||||
// Five browsing migrations plus the later conversation-learning migration.
|
||||
db.pragma(`user_version = ${version - 6}`);
|
||||
// Absolute, not counted back from the head: browsing's first migration is
|
||||
// 26, and a relative rewind breaks whenever a migration is appended.
|
||||
db.pragma('user_version = 25');
|
||||
|
||||
runMigrations(db);
|
||||
insertAccount(db, ACCOUNT, 'owner');
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('multi-account database ownership', () => {
|
||||
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_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();
|
||||
expect(accounts.listPublic().map((account) => account.accountId)).toContain('t2_bob');
|
||||
// The content the account collected is the point of retiring rather than
|
||||
// deleting: every row it produced must survive untouched.
|
||||
for (const table of [
|
||||
'processed_posts', 'sent_messages', 'successful_messages', 'delivery_outbox',
|
||||
'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 })
|
||||
.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 });
|
||||
}
|
||||
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([]);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
expect(accounts.delete('t2_missing')).toBe(false);
|
||||
expect(() => accounts.delete(' ')).toThrow(/accountId/);
|
||||
expect(accounts.get('t2_alice')).not.toBeNull();
|
||||
expect(accounts.retire('t2_missing')).toBe(false);
|
||||
expect(() => accounts.retire(' ')).toThrow(/accountId/);
|
||||
expect(accounts.get('t2_alice')).toMatchObject({ status: 'verified' });
|
||||
});
|
||||
|
||||
it('scopes legacy account-produced table uniqueness by account', () => {
|
||||
|
||||
@@ -13,6 +13,8 @@ const OTHER_ACCOUNT = '@t2_other:reddit.com';
|
||||
// Browsing migrations depend on the growth tables, so a pre-growth fixture
|
||||
// must rewind past both the growth sequence and the later browsing additions.
|
||||
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 {
|
||||
return {
|
||||
@@ -507,14 +509,16 @@ describe('AccountGrowthRepository', () => {
|
||||
});
|
||||
|
||||
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 {
|
||||
const version = db.pragma('user_version', { simple: true }) as number;
|
||||
db.exec('DROP TABLE account_growth_comments');
|
||||
db.exec('DROP TABLE account_growth_subreddits');
|
||||
db.exec('DROP TABLE account_growth_settings');
|
||||
// Also rewind the later conversation-learning migration.
|
||||
db.pragma(`user_version = ${version - GROWTH_MIGRATION_COUNT - 1}`);
|
||||
db.pragma(`user_version = ${FIRST_GROWTH_MIGRATION - 1}`);
|
||||
}
|
||||
|
||||
it('upgrades a database created before growth existed', () => {
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('removing a Reddit account', () => {
|
||||
|
||||
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');
|
||||
|
||||
expect(stopped).toEqual(['t2_alice']);
|
||||
@@ -56,10 +56,22 @@ describe('removing a Reddit account', () => {
|
||||
accountId: 't2_alice',
|
||||
redditUsername: 'alice-bot',
|
||||
isRunning: false,
|
||||
status: 'login-required',
|
||||
status: 'removed',
|
||||
});
|
||||
expect(accounts.get('t2_alice')).toBeNull();
|
||||
expect(service.list()).toEqual([]);
|
||||
// Retired, not deleted: the row survives so its content keeps an owner.
|
||||
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 () => {
|
||||
@@ -93,12 +105,15 @@ describe('removing a Reddit account', () => {
|
||||
await service.remove('t2_alice');
|
||||
|
||||
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 () => {
|
||||
await expect(service.remove('t2_missing')).rejects.toThrow('Reddit account not found');
|
||||
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