Gate bot logic on full account authentication

This commit is contained in:
jjbrunton committed 2026-08-08 16:01:06 +01:00
1 parent 4db6de8bf5
commit 2fd92bf82c
22 files changed
+635 -65

No files matched your search

+157
View File
@@ -0,0 +1,157 @@
import type { Logger } from '../utils/logger.js';
export type AccountAuthStatus =
| 'login-required'
| 'checking'
| 'authenticated';
export interface AuthenticatedAccount {
accountId: string;
redditUsername: string;
matrixUserId: string;
}
export interface AccountAuthSnapshot {
status: AccountAuthStatus;
accountId: string | null;
redditUsername: string | null;
matrixUserId: string | null;
reason: string | null;
}
export interface AccountAuthGate {
isFullyAuthenticated(): boolean;
invalidate(reason: string): void;
}
export type AccountAuthenticator = () => Promise<AuthenticatedAccount | null>;
export type AccountAuthenticatedListener = (account: AuthenticatedAccount) => void | Promise<void>;
export interface AccountAuthContextOptions {
leaseMs?: number;
retryDelayMs?: number;
now?: () => number;
}
export const denyAccountAuth: AccountAuthGate = {
isFullyAuthenticated: () => false,
invalidate: () => undefined,
};
export class AccountAuthContext implements AccountAuthGate {
private snapshot: AccountAuthSnapshot = {
status: 'login-required',
accountId: null,
redditUsername: null,
matrixUserId: null,
reason: 'Full Reddit and Matrix authentication has not been verified',
};
private authenticator: AccountAuthenticator | null = null;
private authenticationInFlight: Promise<boolean> | null = null;
private readonly listeners = new Set<AccountAuthenticatedListener>();
private authenticatedUntil = 0;
private nextAuthenticationAttemptAt = 0;
private readonly leaseMs: number;
private readonly retryDelayMs: number;
private readonly now: () => number;
constructor(
private readonly logger: Logger,
options: AccountAuthContextOptions = {},
) {
this.leaseMs = options.leaseMs ?? 30 * 60 * 1000;
this.retryDelayMs = options.retryDelayMs ?? 60 * 1000;
this.now = options.now ?? Date.now;
}
setAuthenticator(authenticator: AccountAuthenticator): void {
this.authenticator = authenticator;
}
isFullyAuthenticated(): boolean {
if (this.snapshot.status !== 'authenticated') return false;
if (this.now() < this.authenticatedUntil) return true;
this.invalidate('Full account authentication lease expired');
return false;
}
getSnapshot(): AccountAuthSnapshot {
this.isFullyAuthenticated();
return { ...this.snapshot };
}
markAuthenticated(account: AuthenticatedAccount): void {
if (!account.accountId || !account.redditUsername || !account.matrixUserId) {
throw new Error('A fully authenticated account requires Reddit and Matrix identities');
}
if (account.accountId !== account.matrixUserId) {
throw new Error('Authenticated account ID must match the Matrix user ID');
}
const changedAccount = this.snapshot.accountId !== account.accountId;
const wasAuthenticated = this.isFullyAuthenticated();
this.snapshot = {
status: 'authenticated',
accountId: account.accountId,
redditUsername: account.redditUsername,
matrixUserId: account.matrixUserId,
reason: null,
};
this.authenticatedUntil = this.now() + this.leaseMs;
this.nextAuthenticationAttemptAt = 0;
if (!wasAuthenticated || changedAccount) {
for (const listener of this.listeners) {
void Promise.resolve(listener(account)).catch((error) => {
this.logger.error('Authenticated account listener failed:', error);
});
}
}
}
invalidate(reason: string): void {
this.authenticatedUntil = 0;
this.snapshot = {
status: 'login-required',
accountId: null,
redditUsername: null,
matrixUserId: null,
reason,
};
}
onAuthenticated(listener: AccountAuthenticatedListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
ensureFullyAuthenticated(): Promise<boolean> {
if (this.isFullyAuthenticated()) return Promise.resolve(true);
if (this.authenticationInFlight) return this.authenticationInFlight;
if (!this.authenticator) return Promise.resolve(false);
if (this.now() < this.nextAuthenticationAttemptAt) return Promise.resolve(false);
this.snapshot = { ...this.snapshot, status: 'checking', reason: null };
this.authenticationInFlight = this.authenticate().finally(() => {
this.authenticationInFlight = null;
});
return this.authenticationInFlight;
}
private async authenticate(): Promise<boolean> {
try {
const account = await this.authenticator!();
if (!account) {
this.invalidate('Full Reddit and Matrix authentication is required');
this.nextAuthenticationAttemptAt = this.now() + this.retryDelayMs;
return false;
}
this.markAuthenticated(account);
return true;
} catch (error) {
this.invalidate(error instanceof Error ? error.message : String(error));
this.nextAuthenticationAttemptAt = this.now() + this.retryDelayMs;
this.logger.warn('Full account authentication failed:', error);
return false;
}
}
}
+22 -2
View File
@@ -12,6 +12,7 @@ import type { ActiveChat, ChatMessage, ScrapedChatMessage, InvitationCooldown }
export type { InvitationCooldown } from './types.js';
import { fetchWithTimeout } from '../../utils/fetch-with-timeout.js';
import type { AccountAuthGate } from '../account-auth-context.js';
const ACTIVE_WINDOW_MS = 96 * 60 * 60 * 1000;
const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/';
@@ -129,6 +130,7 @@ export class ChatService {
cache?: ChatCache,
private messagesRepo?: MessagesRepository,
private redditUsersRepo?: RedditUsersRepository,
private accountAuth?: AccountAuthGate,
) {
this.cache = cache ?? new ChatCache(logger);
if (this.redditUsersRepo) {
@@ -176,6 +178,11 @@ export class ChatService {
* refresh itself fails, we propagate.
*/
private async withAuth<T>(fn: () => Promise<T>): Promise<T> {
if (!this.accountAuth?.isFullyAuthenticated()) {
throw new MatrixAuthenticationUnavailableError(
new Error('Full account authentication is required'),
);
}
try {
return await fn();
} catch (err) {
@@ -184,9 +191,15 @@ export class ChatService {
try {
await this.tokenRefresher.refresh();
} catch (refreshError) {
this.accountAuth?.invalidate('Matrix authentication could not be restored');
throw new MatrixAuthenticationUnavailableError(refreshError);
}
return await fn();
try {
return await fn();
} catch (retryError) {
this.accountAuth?.invalidate('Matrix authentication was rejected after refresh');
throw retryError;
}
}
throw err;
}
@@ -459,6 +472,7 @@ export class ChatService {
* `chat.messages`.
*/
async loadConversationDetails(chat: ActiveChat): Promise<void> {
if (!this.accountAuth?.isFullyAuthenticated()) return;
return this.loadDetails(chat);
}
@@ -468,6 +482,7 @@ export class ChatService {
* a raw Matrix room ID like `!abc:reddit.com`.
*/
async sendChatMessage(chatUrlOrRoomId: string, message: string): Promise<boolean> {
if (!this.accountAuth?.isFullyAuthenticated()) return false;
const active = this.getInvitationCooldown();
if (active) {
const secondsLeft = Math.ceil((active.until - Date.now()) / 1000);
@@ -508,6 +523,7 @@ export class ChatService {
* line. Returns the new chat (or null on failure).
*/
async startConversation(username: string, message: string): Promise<ActiveChat | null> {
if (!this.accountAuth?.isFullyAuthenticated()) return null;
const active = this.getInvitationCooldown();
if (active) {
const secondsLeft = Math.ceil((active.until - Date.now()) / 1000);
@@ -571,7 +587,7 @@ export class ChatService {
* old Playwright sender; always returns true.
*/
async verifyMessageSent(_recipient: string, _messageText: string): Promise<boolean> {
return true;
return this.accountAuth?.isFullyAuthenticated() ?? false;
}
/**
@@ -1026,6 +1042,10 @@ export class ChatService {
* cache. Best-effort; safe to await or fire-and-forget.
*/
async enrichUsernames(): Promise<void> {
if (!this.accountAuth?.isFullyAuthenticated()) {
this.logger.debug('[chat] username enrichment skipped until full account authentication');
return;
}
if (!this.usernameResolver) return;
let dirty = false;
+11 -1
View File
@@ -1,5 +1,6 @@
import type { Logger } from '../utils/logger.js';
import type { ChatService } from './chat.js';
import { denyAccountAuth, type AccountAuthGate } from './account-auth-context.js';
export interface MediaSyncStatus {
isSyncing: boolean;
@@ -22,7 +23,8 @@ export class MediaSyncService {
constructor(
private chatService: ChatService,
private logger: Logger,
intervalMs: number = 30 * 60 * 1000 // default 30 minutes
intervalMs: number = 30 * 60 * 1000, // default 30 minutes
private accountAuth: AccountAuthGate = denyAccountAuth,
) {
this.intervalMs = intervalMs;
}
@@ -64,6 +66,10 @@ export class MediaSyncService {
* Returns immediately if a sync is already in progress.
*/
async triggerSync(full = false): Promise<boolean> {
if (!this.accountAuth.isFullyAuthenticated()) {
this.logger.warn('Media sync: blocked until the account is fully authenticated');
return false;
}
if (this.syncing) {
this.logger.info('Media sync: sync already in progress, skipping trigger');
return false;
@@ -86,6 +92,10 @@ export class MediaSyncService {
}
private async runSync(full = false): Promise<void> {
if (!this.accountAuth.isFullyAuthenticated()) {
this.logger.debug('Media sync: skipped because the account is not fully authenticated');
return;
}
if (this.syncing) return;
this.syncing = true;
this.lastError = null;
+7 -1
View File
@@ -2,6 +2,7 @@ import type { Page } from 'playwright';
import type { Logger } from '../utils/logger.js';
import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord } from '../database/repositories.js';
import type { PostsRepository } from '../database/repositories.js';
import type { AccountAuthGate } from './account-auth-context.js';
export interface ResponseCheckResult {
messageId: number;
@@ -16,11 +17,16 @@ export class ResponseCheckerService {
private messagesRepo: MessagesRepository,
private successfulMessagesRepo: SuccessfulMessagesRepository,
private postsRepo: PostsRepository,
private logger: Logger
private logger: Logger,
private accountAuth?: AccountAuthGate,
) { }
async checkForResponses(): Promise<ResponseCheckResult[]> {
const results: ResponseCheckResult[] = [];
if (!this.accountAuth?.isFullyAuthenticated()) {
this.logger.debug('Response check skipped until full account authentication');
return results;
}
// Get messages that haven't been checked recently (at least 1 hour old, recheck every 6 hours)
const minAgeMs = 60 * 60 * 1000; // 1 hour