New accounts cannot start chats until Reddit considers them established. Until now the bot would burn a candidate on every invitation Reddit was always going to reject, with nothing in the dashboard explaining why the account looked idle. Two signals feed the verdict. A periodic profile check reads the account's own age, karma and email verification from about.json and compares them against configurable thresholds — that is what flags a brand new account before it wastes anything. A rejected invitation is authoritative: Reddit has told us directly, so the account is held out of sending with a backoff that doubles per rejection, and a delivered invitation clears the flag outright. An unestablished account takes no new posts (canAcceptPost), raises an attention item, reports an `unestablished` sending state, and shows its age/karma evidence on the Accounts page. An unchecked account is always allowed through, so a first run is never blocked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
645 lines
22 KiB
TypeScript
645 lines
22 KiB
TypeScript
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));
|
|
}
|
|
|
|
/** Store a self-refreshed Matrix token without touching cookies or status. */
|
|
updateMatrixCredentials(
|
|
accountId: string,
|
|
matrixCredentialsJson: string,
|
|
now: number = Date.now(),
|
|
): boolean {
|
|
requireText(accountId, 'accountId');
|
|
requireJson(matrixCredentialsJson, 'matrixCredentialsJson');
|
|
return this.db.prepare(`
|
|
UPDATE reddit_accounts
|
|
SET matrix_credentials_json = ?, updated_at = ?, last_verified_at = ?
|
|
WHERE account_id = ? AND status != 'legacy-unassigned'
|
|
`).run(matrixCredentialsJson, now, now, accountId).changes === 1;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export type RedditRecipientAccountStatus = 'active' | 'suspended' | 'deleted';
|
|
|
|
export interface RedditRecipientAccountStatusRecord {
|
|
username: string;
|
|
status: RedditRecipientAccountStatus;
|
|
checkedAt: 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;
|
|
}
|
|
|
|
getMediaByOriginalUrl(accountId: string, originalUrl: string): AccountMediaRecord | null {
|
|
const row = this.db.prepare(`
|
|
SELECT * FROM reddit_media
|
|
WHERE account_id = ? AND original_url = ?
|
|
ORDER BY timestamp, media_id
|
|
LIMIT 1
|
|
`).get(accountId, originalUrl) 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;
|
|
}
|
|
}
|
|
|
|
type RecipientAccountStatusRow = {
|
|
username: string;
|
|
status: RedditRecipientAccountStatus;
|
|
checked_at: number;
|
|
};
|
|
|
|
/** Process-global Reddit profile availability, shared by conversations across owning accounts. */
|
|
export class RedditRecipientAccountStatusRepository {
|
|
constructor(private readonly db: Database.Database) {}
|
|
|
|
get(username: string): RedditRecipientAccountStatusRecord | null {
|
|
const row = this.db.prepare(`
|
|
SELECT username, status, checked_at
|
|
FROM reddit_recipient_account_status
|
|
WHERE username = ? COLLATE NOCASE
|
|
`).get(username) as RecipientAccountStatusRow | undefined;
|
|
return row ? { username: row.username, status: row.status, checkedAt: row.checked_at } : null;
|
|
}
|
|
|
|
upsert(username: string, status: RedditRecipientAccountStatus, checkedAt = Date.now()): void {
|
|
requireText(username, 'username');
|
|
this.db.prepare(`
|
|
INSERT INTO reddit_recipient_account_status (username, status, checked_at)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(username) DO UPDATE SET
|
|
status = excluded.status,
|
|
checked_at = excluded.checked_at
|
|
`).run(username, status, checkedAt);
|
|
}
|
|
}
|
|
|
|
export type AccountEstablishmentState = 'unknown' | 'established' | 'unestablished';
|
|
|
|
export interface AccountEstablishmentRecord {
|
|
accountId: string;
|
|
state: AccountEstablishmentState;
|
|
/** Why the account is not established yet. Empty when it is. */
|
|
reasons: string[];
|
|
accountCreatedAt: number | null;
|
|
linkKarma: number | null;
|
|
commentKarma: number | null;
|
|
totalKarma: number | null;
|
|
hasVerifiedEmail: boolean | null;
|
|
profileCheckedAt: number | null;
|
|
lastProbeAt: number | null;
|
|
lastProbeFailure: string | null;
|
|
probeFailureCount: number;
|
|
/** Earliest time the account may attempt an outbound invitation again. */
|
|
retryAt: number | null;
|
|
updatedAt: number;
|
|
}
|
|
|
|
export interface AccountProfileMetricsInput {
|
|
accountCreatedAt: number | null;
|
|
linkKarma: number | null;
|
|
commentKarma: number | null;
|
|
totalKarma: number | null;
|
|
hasVerifiedEmail: boolean | null;
|
|
}
|
|
|
|
type EstablishmentRow = {
|
|
account_id: string;
|
|
state: AccountEstablishmentState;
|
|
reasons_json: string;
|
|
account_created_at: number | null;
|
|
link_karma: number | null;
|
|
comment_karma: number | null;
|
|
total_karma: number | null;
|
|
has_verified_email: number | null;
|
|
profile_checked_at: number | null;
|
|
last_probe_at: number | null;
|
|
last_probe_failure: string | null;
|
|
probe_failure_count: number;
|
|
retry_at: number | null;
|
|
updated_at: number;
|
|
};
|
|
|
|
/**
|
|
* Whether each of our own Reddit accounts is established enough for Reddit to
|
|
* accept chat invitations from it, and the profile evidence behind that call.
|
|
*/
|
|
export class RedditAccountEstablishmentRepository {
|
|
constructor(private readonly db: Database.Database) {}
|
|
|
|
get(accountId: string): AccountEstablishmentRecord | null {
|
|
const row = this.db.prepare('SELECT * FROM reddit_account_establishment WHERE account_id = ?')
|
|
.get(accountId) as EstablishmentRow | undefined;
|
|
return row ? mapEstablishment(row) : null;
|
|
}
|
|
|
|
list(): AccountEstablishmentRecord[] {
|
|
const rows = this.db.prepare('SELECT * FROM reddit_account_establishment ORDER BY account_id')
|
|
.all() as EstablishmentRow[];
|
|
return rows.map(mapEstablishment);
|
|
}
|
|
|
|
/** Store a profile snapshot and the verdict derived from it. */
|
|
saveProfileVerdict(input: {
|
|
accountId: string;
|
|
state: AccountEstablishmentState;
|
|
reasons: string[];
|
|
metrics: AccountProfileMetricsInput;
|
|
checkedAt?: number;
|
|
retryAt?: number | null;
|
|
}): AccountEstablishmentRecord {
|
|
requireText(input.accountId, 'accountId');
|
|
const now = input.checkedAt ?? Date.now();
|
|
this.db.prepare(`
|
|
INSERT INTO reddit_account_establishment (
|
|
account_id, state, reasons_json, account_created_at, link_karma, comment_karma,
|
|
total_karma, has_verified_email, profile_checked_at, probe_failure_count, retry_at, updated_at
|
|
) VALUES (
|
|
@accountId, @state, @reasonsJson, @accountCreatedAt, @linkKarma, @commentKarma,
|
|
@totalKarma, @hasVerifiedEmail, @now, 0, @retryAt, @now
|
|
)
|
|
ON CONFLICT(account_id) DO UPDATE SET
|
|
state = excluded.state,
|
|
reasons_json = excluded.reasons_json,
|
|
account_created_at = excluded.account_created_at,
|
|
link_karma = excluded.link_karma,
|
|
comment_karma = excluded.comment_karma,
|
|
total_karma = excluded.total_karma,
|
|
has_verified_email = excluded.has_verified_email,
|
|
profile_checked_at = excluded.profile_checked_at,
|
|
retry_at = excluded.retry_at,
|
|
updated_at = excluded.updated_at
|
|
`).run({
|
|
accountId: input.accountId,
|
|
state: input.state,
|
|
reasonsJson: JSON.stringify(input.reasons),
|
|
accountCreatedAt: input.metrics.accountCreatedAt,
|
|
linkKarma: input.metrics.linkKarma,
|
|
commentKarma: input.metrics.commentKarma,
|
|
totalKarma: input.metrics.totalKarma,
|
|
hasVerifiedEmail: boolToInt(input.metrics.hasVerifiedEmail),
|
|
retryAt: input.retryAt ?? null,
|
|
now,
|
|
});
|
|
return this.getRequired(input.accountId);
|
|
}
|
|
|
|
/** Record that Reddit rejected an outbound invitation for being too new. */
|
|
recordProbeFailure(input: {
|
|
accountId: string;
|
|
reason: string;
|
|
retryAt: number;
|
|
at?: number;
|
|
}): AccountEstablishmentRecord {
|
|
requireText(input.accountId, 'accountId');
|
|
const now = input.at ?? Date.now();
|
|
this.db.prepare(`
|
|
INSERT INTO reddit_account_establishment (
|
|
account_id, state, reasons_json, last_probe_at, last_probe_failure,
|
|
probe_failure_count, retry_at, updated_at
|
|
) VALUES (
|
|
@accountId, 'unestablished', @reasonsJson, @now, @reason, 1, @retryAt, @now
|
|
)
|
|
ON CONFLICT(account_id) DO UPDATE SET
|
|
state = 'unestablished',
|
|
reasons_json = excluded.reasons_json,
|
|
last_probe_at = excluded.last_probe_at,
|
|
last_probe_failure = excluded.last_probe_failure,
|
|
probe_failure_count = reddit_account_establishment.probe_failure_count + 1,
|
|
retry_at = excluded.retry_at,
|
|
updated_at = excluded.updated_at
|
|
`).run({
|
|
accountId: input.accountId,
|
|
reasonsJson: JSON.stringify([input.reason]),
|
|
reason: input.reason,
|
|
retryAt: input.retryAt,
|
|
now,
|
|
});
|
|
return this.getRequired(input.accountId);
|
|
}
|
|
|
|
/**
|
|
* A delivered invitation is the only positive proof an account is
|
|
* established, so it clears the flag and the failure backoff outright.
|
|
*/
|
|
recordProbeSuccess(accountId: string, at: number = Date.now()): AccountEstablishmentRecord {
|
|
requireText(accountId, 'accountId');
|
|
this.db.prepare(`
|
|
INSERT INTO reddit_account_establishment (
|
|
account_id, state, reasons_json, last_probe_at, probe_failure_count, retry_at, updated_at
|
|
) VALUES (@accountId, 'established', '[]', @at, 0, NULL, @at)
|
|
ON CONFLICT(account_id) DO UPDATE SET
|
|
state = 'established',
|
|
reasons_json = '[]',
|
|
last_probe_at = excluded.last_probe_at,
|
|
last_probe_failure = NULL,
|
|
probe_failure_count = 0,
|
|
retry_at = NULL,
|
|
updated_at = excluded.updated_at
|
|
`).run({ accountId, at });
|
|
return this.getRequired(accountId);
|
|
}
|
|
|
|
private getRequired(accountId: string): AccountEstablishmentRecord {
|
|
const record = this.get(accountId);
|
|
if (!record) throw new Error(`Establishment state for ${accountId} was not persisted`);
|
|
return record;
|
|
}
|
|
}
|
|
|
|
function mapEstablishment(row: EstablishmentRow): AccountEstablishmentRecord {
|
|
return {
|
|
accountId: row.account_id,
|
|
state: row.state,
|
|
reasons: parseReasons(row.reasons_json),
|
|
accountCreatedAt: row.account_created_at,
|
|
linkKarma: row.link_karma,
|
|
commentKarma: row.comment_karma,
|
|
totalKarma: row.total_karma,
|
|
hasVerifiedEmail: row.has_verified_email === null ? null : row.has_verified_email === 1,
|
|
profileCheckedAt: row.profile_checked_at,
|
|
lastProbeAt: row.last_probe_at,
|
|
lastProbeFailure: row.last_probe_failure,
|
|
probeFailureCount: row.probe_failure_count,
|
|
retryAt: row.retry_at,
|
|
updatedAt: row.updated_at,
|
|
};
|
|
}
|
|
|
|
function parseReasons(json: string): string[] {
|
|
try {
|
|
const parsed = JSON.parse(json) as unknown;
|
|
return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function boolToInt(value: boolean | null): number | null {
|
|
return value === null ? null : value ? 1 : 0;
|
|
}
|
|
|
|
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`);
|
|
}
|
|
}
|