Gate bot logic on full account authentication
This commit is contained in:
1 parent
4db6de8bf5
commit
2fd92bf82c
22 files changed
+635
-65
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user