feat: support concurrent Reddit accounts
This commit is contained in:
1 parent
d819a0f1e6
commit
16a31335d0
51 files changed
+3241
-250
No files matched your search
@@ -0,0 +1,375 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export type RedditAccountStatus = 'verified' | 'login-required' | 'disabled' | 'legacy-unassigned';
|
||||
|
||||
export interface RedditAccountPublicRecord {
|
||||
accountId: string;
|
||||
redditUsername: string;
|
||||
matrixUserId: string | null;
|
||||
status: RedditAccountStatus;
|
||||
isActive: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastVerifiedAt: number | null;
|
||||
lastImportedAt: number | null;
|
||||
}
|
||||
|
||||
/** Private credential-bearing record. Never return this from a web API. */
|
||||
export interface RedditAccountPrivateRecord extends RedditAccountPublicRecord {
|
||||
cookiesJson: string;
|
||||
matrixCredentialsJson: string;
|
||||
}
|
||||
|
||||
export interface VerifiedRedditAccountInput {
|
||||
accountId: string;
|
||||
redditUsername: string;
|
||||
matrixUserId: string;
|
||||
cookiesJson: string;
|
||||
matrixCredentialsJson: string;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
type AccountRow = {
|
||||
account_id: string;
|
||||
reddit_username: string;
|
||||
matrix_user_id: string | null;
|
||||
status: RedditAccountStatus;
|
||||
is_active: number;
|
||||
cookies_json: string;
|
||||
matrix_credentials_json: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
last_verified_at: number | null;
|
||||
last_imported_at: number | null;
|
||||
};
|
||||
|
||||
export class RedditAccountsRepository {
|
||||
constructor(private readonly db: Database.Database) {}
|
||||
|
||||
listPublic(): RedditAccountPublicRecord[] {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT account_id, reddit_username, matrix_user_id, status, is_active,
|
||||
created_at, updated_at, last_verified_at, last_imported_at
|
||||
FROM reddit_accounts
|
||||
ORDER BY is_active DESC, reddit_username COLLATE NOCASE, account_id
|
||||
`).all() as AccountRow[];
|
||||
return rows.map(mapPublicAccount);
|
||||
}
|
||||
|
||||
/** Private lookup for the runtime/session factory. Contains credentials. */
|
||||
get(accountId: string): RedditAccountPrivateRecord | null {
|
||||
const row = this.db.prepare('SELECT * FROM reddit_accounts WHERE account_id = ?')
|
||||
.get(accountId) as AccountRow | undefined;
|
||||
return row ? mapPrivateAccount(row) : null;
|
||||
}
|
||||
|
||||
getActive(): RedditAccountPublicRecord[] {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT account_id, reddit_username, matrix_user_id, status, is_active,
|
||||
created_at, updated_at, last_verified_at, last_imported_at
|
||||
FROM reddit_accounts
|
||||
WHERE is_active = 1 AND status != 'legacy-unassigned'
|
||||
ORDER BY reddit_username COLLATE NOCASE, account_id
|
||||
`).all() as AccountRow[];
|
||||
return rows.map(mapPublicAccount);
|
||||
}
|
||||
|
||||
upsertVerified(input: VerifiedRedditAccountInput): RedditAccountPublicRecord {
|
||||
requireText(input.accountId, 'accountId');
|
||||
requireText(input.redditUsername, 'redditUsername');
|
||||
requireText(input.matrixUserId, 'matrixUserId');
|
||||
requireJson(input.cookiesJson, 'cookiesJson');
|
||||
requireJson(input.matrixCredentialsJson, 'matrixCredentialsJson');
|
||||
const now = input.now ?? Date.now();
|
||||
this.db.prepare(`
|
||||
INSERT INTO reddit_accounts (
|
||||
account_id, reddit_username, matrix_user_id, status, is_active,
|
||||
cookies_json, matrix_credentials_json, created_at, updated_at,
|
||||
last_verified_at, last_imported_at
|
||||
) VALUES (
|
||||
@accountId, @redditUsername, @matrixUserId, 'verified', 1,
|
||||
@cookiesJson, @matrixCredentialsJson, @now, @now, @now, @now
|
||||
)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
reddit_username = excluded.reddit_username,
|
||||
matrix_user_id = excluded.matrix_user_id,
|
||||
status = 'verified',
|
||||
is_active = 1,
|
||||
cookies_json = excluded.cookies_json,
|
||||
matrix_credentials_json = excluded.matrix_credentials_json,
|
||||
updated_at = excluded.updated_at,
|
||||
last_verified_at = excluded.last_verified_at,
|
||||
last_imported_at = excluded.last_imported_at
|
||||
`).run({ ...input, now });
|
||||
return publicFromPrivate(this.getRequired(input.accountId));
|
||||
}
|
||||
|
||||
setActive(accountId: string, active = true, now: number = Date.now()): boolean {
|
||||
const status = active ? 'verified' : 'disabled';
|
||||
return this.db.prepare(`
|
||||
UPDATE reddit_accounts
|
||||
SET is_active = ?, status = ?, updated_at = ?
|
||||
WHERE account_id = ? AND status != 'legacy-unassigned'
|
||||
`).run(active ? 1 : 0, status, now, accountId).changes === 1;
|
||||
}
|
||||
|
||||
markLoginRequired(accountId: string, now: number = Date.now()): boolean {
|
||||
return this.db.prepare(`
|
||||
UPDATE reddit_accounts
|
||||
SET status = 'login-required', updated_at = ?
|
||||
WHERE account_id = ? AND status != 'legacy-unassigned'
|
||||
`).run(now, accountId).changes === 1;
|
||||
}
|
||||
|
||||
private getRequired(accountId: string): RedditAccountPrivateRecord {
|
||||
const account = this.get(accountId);
|
||||
if (!account) throw new Error(`Reddit account ${accountId} was not persisted`);
|
||||
return account;
|
||||
}
|
||||
}
|
||||
|
||||
function mapPublicAccount(row: AccountRow): RedditAccountPublicRecord {
|
||||
return {
|
||||
accountId: row.account_id,
|
||||
redditUsername: row.reddit_username,
|
||||
matrixUserId: row.matrix_user_id,
|
||||
status: row.status,
|
||||
isActive: row.is_active === 1,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
lastVerifiedAt: row.last_verified_at,
|
||||
lastImportedAt: row.last_imported_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapPrivateAccount(row: AccountRow): RedditAccountPrivateRecord {
|
||||
return {
|
||||
...mapPublicAccount(row),
|
||||
cookiesJson: row.cookies_json,
|
||||
matrixCredentialsJson: row.matrix_credentials_json,
|
||||
};
|
||||
}
|
||||
|
||||
function publicFromPrivate(record: RedditAccountPrivateRecord): RedditAccountPublicRecord {
|
||||
const { cookiesJson: _cookies, matrixCredentialsJson: _matrix, ...publicRecord } = record;
|
||||
return publicRecord;
|
||||
}
|
||||
|
||||
export interface AccountConversationRecord {
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
username: string;
|
||||
roomName: string | null;
|
||||
payloadJson: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface AccountSyncStateRecord {
|
||||
accountId: string;
|
||||
nextBatch: string | null;
|
||||
lastFullSyncAt: number | null;
|
||||
lastDeltaSyncAt: number | null;
|
||||
cooldownJson: string | null;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface AccountMediaRecord {
|
||||
accountId: string;
|
||||
mediaId: string;
|
||||
chatUsername: string | null;
|
||||
chatRoomId: string | null;
|
||||
sender: string;
|
||||
timestamp: number;
|
||||
originalUrl: string;
|
||||
fileName: string;
|
||||
relativePath: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
type ConversationRow = {
|
||||
account_id: string;
|
||||
conversation_id: string;
|
||||
username: string;
|
||||
room_name: string | null;
|
||||
payload_json: string;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type SyncStateRow = {
|
||||
account_id: string;
|
||||
next_batch: string | null;
|
||||
last_full_sync_at: number | null;
|
||||
last_delta_sync_at: number | null;
|
||||
cooldown_json: string | null;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type MediaRow = {
|
||||
account_id: string;
|
||||
media_id: string;
|
||||
chat_username: string | null;
|
||||
chat_room_id: string | null;
|
||||
sender: string;
|
||||
timestamp: number;
|
||||
original_url: string;
|
||||
file_name: string;
|
||||
relative_path: string;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
/** Account-scoped DB persistence for chat cache state and downloaded-media metadata. */
|
||||
export class AccountContentRepository {
|
||||
constructor(private readonly db: Database.Database) {}
|
||||
|
||||
upsertConversation(input: Omit<AccountConversationRecord, 'updatedAt'> & { updatedAt?: number }): void {
|
||||
requireText(input.accountId, 'accountId');
|
||||
requireText(input.conversationId, 'conversationId');
|
||||
requireText(input.username, 'username');
|
||||
requireJson(input.payloadJson, 'payloadJson');
|
||||
this.db.prepare(`
|
||||
INSERT INTO reddit_conversations (
|
||||
account_id, conversation_id, username, room_name, payload_json, updated_at
|
||||
) VALUES (@accountId, @conversationId, @username, @roomName, @payloadJson, @updatedAt)
|
||||
ON CONFLICT(account_id, conversation_id) DO UPDATE SET
|
||||
username = excluded.username,
|
||||
room_name = excluded.room_name,
|
||||
payload_json = excluded.payload_json,
|
||||
updated_at = excluded.updated_at
|
||||
`).run({ ...input, roomName: input.roomName ?? null, updatedAt: input.updatedAt ?? Date.now() });
|
||||
}
|
||||
|
||||
getConversation(accountId: string, conversationId: string): AccountConversationRecord | null {
|
||||
const row = this.db.prepare(`
|
||||
SELECT * FROM reddit_conversations WHERE account_id = ? AND conversation_id = ?
|
||||
`).get(accountId, conversationId) as ConversationRow | undefined;
|
||||
return row ? mapConversation(row) : null;
|
||||
}
|
||||
|
||||
listConversations(accountId: string): AccountConversationRecord[] {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT * FROM reddit_conversations WHERE account_id = ?
|
||||
ORDER BY updated_at DESC, conversation_id
|
||||
`).all(accountId) as ConversationRow[];
|
||||
return rows.map(mapConversation);
|
||||
}
|
||||
|
||||
deleteConversation(accountId: string, conversationId: string): boolean {
|
||||
return this.db.prepare(`
|
||||
DELETE FROM reddit_conversations WHERE account_id = ? AND conversation_id = ?
|
||||
`).run(accountId, conversationId).changes === 1;
|
||||
}
|
||||
|
||||
saveSyncState(input: Omit<AccountSyncStateRecord, 'updatedAt'> & { updatedAt?: number }): void {
|
||||
if (input.cooldownJson !== null) requireJson(input.cooldownJson, 'cooldownJson');
|
||||
this.db.prepare(`
|
||||
INSERT INTO reddit_account_sync_state (
|
||||
account_id, next_batch, last_full_sync_at, last_delta_sync_at, cooldown_json, updated_at
|
||||
) VALUES (@accountId, @nextBatch, @lastFullSyncAt, @lastDeltaSyncAt, @cooldownJson, @updatedAt)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
next_batch = excluded.next_batch,
|
||||
last_full_sync_at = excluded.last_full_sync_at,
|
||||
last_delta_sync_at = excluded.last_delta_sync_at,
|
||||
cooldown_json = excluded.cooldown_json,
|
||||
updated_at = excluded.updated_at
|
||||
`).run({ ...input, updatedAt: input.updatedAt ?? Date.now() });
|
||||
}
|
||||
|
||||
getSyncState(accountId: string): AccountSyncStateRecord | null {
|
||||
const row = this.db.prepare('SELECT * FROM reddit_account_sync_state WHERE account_id = ?')
|
||||
.get(accountId) as SyncStateRow | undefined;
|
||||
return row ? mapSyncState(row) : null;
|
||||
}
|
||||
|
||||
upsertMedia(input: AccountMediaRecord): void {
|
||||
requireText(input.accountId, 'accountId');
|
||||
requireText(input.mediaId, 'mediaId');
|
||||
this.db.prepare(`
|
||||
INSERT INTO reddit_media (
|
||||
account_id, media_id, chat_username, chat_room_id, sender, timestamp,
|
||||
original_url, file_name, relative_path, created_at
|
||||
) VALUES (
|
||||
@accountId, @mediaId, @chatUsername, @chatRoomId, @sender, @timestamp,
|
||||
@originalUrl, @fileName, @relativePath, @createdAt
|
||||
)
|
||||
ON CONFLICT(account_id, media_id) DO UPDATE SET
|
||||
chat_username = excluded.chat_username,
|
||||
chat_room_id = excluded.chat_room_id,
|
||||
sender = excluded.sender,
|
||||
timestamp = excluded.timestamp,
|
||||
original_url = excluded.original_url,
|
||||
file_name = excluded.file_name,
|
||||
relative_path = excluded.relative_path,
|
||||
created_at = excluded.created_at
|
||||
`).run(input);
|
||||
}
|
||||
|
||||
getMedia(accountId: string, mediaId: string): AccountMediaRecord | null {
|
||||
const row = this.db.prepare(`
|
||||
SELECT * FROM reddit_media WHERE account_id = ? AND media_id = ?
|
||||
`).get(accountId, mediaId) as MediaRow | undefined;
|
||||
return row ? mapMedia(row) : null;
|
||||
}
|
||||
|
||||
listMedia(accountId: string): AccountMediaRecord[] {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT * FROM reddit_media WHERE account_id = ?
|
||||
ORDER BY timestamp DESC, media_id
|
||||
`).all(accountId) as MediaRow[];
|
||||
return rows.map(mapMedia);
|
||||
}
|
||||
|
||||
deleteMedia(accountId: string, mediaId: string): boolean {
|
||||
return this.db.prepare(`
|
||||
DELETE FROM reddit_media WHERE account_id = ? AND media_id = ?
|
||||
`).run(accountId, mediaId).changes === 1;
|
||||
}
|
||||
}
|
||||
|
||||
function mapConversation(row: ConversationRow): AccountConversationRecord {
|
||||
return {
|
||||
accountId: row.account_id,
|
||||
conversationId: row.conversation_id,
|
||||
username: row.username,
|
||||
roomName: row.room_name,
|
||||
payloadJson: row.payload_json,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapSyncState(row: SyncStateRow): AccountSyncStateRecord {
|
||||
return {
|
||||
accountId: row.account_id,
|
||||
nextBatch: row.next_batch,
|
||||
lastFullSyncAt: row.last_full_sync_at,
|
||||
lastDeltaSyncAt: row.last_delta_sync_at,
|
||||
cooldownJson: row.cooldown_json,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapMedia(row: MediaRow): AccountMediaRecord {
|
||||
return {
|
||||
accountId: row.account_id,
|
||||
mediaId: row.media_id,
|
||||
chatUsername: row.chat_username,
|
||||
chatRoomId: row.chat_room_id,
|
||||
sender: row.sender,
|
||||
timestamp: row.timestamp,
|
||||
originalUrl: row.original_url,
|
||||
fileName: row.file_name,
|
||||
relativePath: row.relative_path,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function requireText(value: string, name: string): void {
|
||||
if (!value.trim()) throw new Error(`${name} must not be empty`);
|
||||
}
|
||||
|
||||
function requireJson(value: string, name: string): void {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
} catch {
|
||||
throw new Error(`${name} must be valid JSON`);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user