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

+80 -43
View File
@@ -23,8 +23,6 @@ import {
MatrixClient,
MatrixMediaDownloader,
MatrixTokenRefresher,
extractMatrixCredentials,
loadCachedCredentials,
type UsernameResolver,
type MatrixCredentials,
} from './services/chat/index.js';
@@ -33,6 +31,7 @@ import { MediaSyncService } from './services/media-sync.js';
import { RateLimiter } from './utils/rate-limiter.js';
import { fetchWithTimeout } from './utils/fetch-with-timeout.js';
import { WebServer } from './web/server.js';
import { AccountAuthContext, type AuthenticatedAccount } from './services/account-auth-context.js';
export interface AppContainer {
config: Config;
@@ -41,6 +40,7 @@ export interface AppContainer {
browserClient: BrowserClient;
messagePage: RedditMessagePage;
accountAuth: AccountAuthContext;
postsRepo: PostsRepository;
messagesRepo: MessagesRepository;
@@ -97,6 +97,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
const page = await browserClient.getPage();
const messagePage = new RedditMessagePage(page, logger);
messagePage.setDryRun(config.browser.dryRun);
const accountAuth = new AccountAuthContext(logger);
// Login verification. When the web dashboard is enabled we treat a missing
// session as recoverable — the dashboard exposes a noVNC console so a human
@@ -150,36 +151,19 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
successfulMessagesRepo,
postsRepo,
logger,
accountAuth,
);
// ---- Matrix chat backend (replaces the Playwright scraper) ----
// 1. Get credentials: prefer cached, fall back to a fresh sniff via the
// persistent browser session that we just verified is logged in.
// If the sniff fails (e.g. egress blocked, chat.reddit.com refuses to
// load), we *don't* crash bootstrap — the post-monitoring/processor
// side of the bot doesn't need Matrix, and keeping the web UI up makes
// the situation diagnosable. Chat calls will fail with 401 until a
// subsequent token refresh succeeds.
let matrixCreds: MatrixCredentials | null = await loadCachedCredentials();
if (!matrixCreds) {
logger.info('No cached Matrix credentials — extracting via browser session...');
try {
matrixCreds = await extractMatrixCredentials(browserClient.getContext()!, logger);
} catch (err) {
logger.error(
'[matrix] credential extraction failed — chat features will be disabled until the next successful refresh:',
err,
);
matrixCreds = {
homeserver: 'https://matrix.redditspace.com',
accessToken: '',
userId: '',
capturedAt: Date.now(),
};
}
} else {
logger.info(`Loaded cached Matrix credentials (user=${matrixCreds.userId})`);
}
// A cached bearer token is not accepted as application authentication.
// The current Chrome profile must yield a fresh token and Matrix identity
// before the shared account context unlocks any bot behavior.
const matrixCreds: MatrixCredentials = {
homeserver: 'https://matrix.redditspace.com',
accessToken: '',
userId: '',
capturedAt: Date.now(),
};
const matrixClient = new MatrixClient(matrixCreds, logger);
// 2. Build the media downloader + service on top of the Matrix client so
@@ -190,13 +174,36 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
// 3. Token refresher — on 401, restore the persistent Reddit session when
// possible, re-sniff once, and swap credentials into the chat service.
// Concurrent callers share that refresh rather than opening extra pages.
let chatService: ChatService;
const credentialState: { latest: MatrixCredentials | null } = { latest: null };
const tokenRefresher = new MatrixTokenRefresher(
browserClient,
logger,
(fresh) => chatService.setMatrixClient(new MatrixClient(fresh, logger)),
(fresh) => {
credentialState.latest = fresh;
matrixClient.updateCredentials(fresh);
},
);
accountAuth.setAuthenticator(async (): Promise<AuthenticatedAccount | null> => {
credentialState.latest = null;
await tokenRefresher.refresh();
const redditUsername = await browserClient.getAuthenticatedUsername();
const freshCredentials = credentialState.latest as MatrixCredentials | null;
if (!redditUsername || !freshCredentials?.userId) return null;
return {
accountId: freshCredentials.userId,
redditUsername,
matrixUserId: freshCredentials.userId,
};
});
if (loggedIn) {
logger.info('Verifying full Reddit and Matrix account authentication...');
await accountAuth.ensureFullyAuthenticated();
} else {
accountAuth.invalidate('Interactive Reddit login is required');
}
// 4. Username resolver — turns t2 IDs into real Reddit usernames so the
// dashboard isn't full of `t2_abc123`. Uses Reddit's bulk endpoint.
const usernameResolver: UsernameResolver = {
@@ -228,7 +235,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
},
};
chatService = new ChatService(
const chatService = new ChatService(
matrixClient,
logger,
successfulMessagesRepo,
@@ -238,24 +245,52 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
undefined,
messagesRepo,
redditUsersRepo,
accountAuth,
);
const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs);
const mediaSyncService = new MediaSyncService(
chatService,
logger,
config.media.syncIntervalMs,
accountAuth,
);
const rateLimiter = new RateLimiter(config, logger);
await chatService.loadCache();
// Fire-and-forget startup sync: full if we've never backfilled, delta
// otherwise. Non-blocking so the rest of the bot can come up immediately;
// the dashboard's sync-status banner shows progress.
chatService
.runStartupSync()
.then(async ({ kind, chats }) => {
logger.info(`[chat] startup sync (${kind}) complete: ${chats} chats`);
// Resolve t2 IDs now that the full chat list is available.
let startupSyncAccountId: string | null = null;
let startupSyncInFlightAccountId: string | null = null;
const startAuthenticatedAccount = async (account: AuthenticatedAccount): Promise<void> => {
if (
startupSyncAccountId === account.accountId ||
startupSyncInFlightAccountId === account.accountId
) return;
startupSyncInFlightAccountId = account.accountId;
try {
const { kind, chats } = await chatService.runStartupSync();
await chatService.enrichUsernames();
})
.catch((err) => logger.error('[chat] startup sync failed:', err));
if (accountAuth.isFullyAuthenticated()) {
startupSyncAccountId = account.accountId;
logger.info(`[chat] startup sync (${kind}) complete for ${account.redditUsername}: ${chats} chats`);
}
} finally {
startupSyncInFlightAccountId = null;
}
};
accountAuth.onAuthenticated((account) => startAuthenticatedAccount(account));
const authenticatedAccount = accountAuth.getSnapshot();
if (
authenticatedAccount.status === 'authenticated' &&
authenticatedAccount.accountId &&
authenticatedAccount.redditUsername &&
authenticatedAccount.matrixUserId
) {
void startAuthenticatedAccount({
accountId: authenticatedAccount.accountId,
redditUsername: authenticatedAccount.redditUsername,
matrixUserId: authenticatedAccount.matrixUserId,
}).catch((err) => logger.error('[chat] startup sync failed:', err));
}
let webServer: WebServer | null = null;
if (config.web.enabled) {
@@ -291,6 +326,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
webServer.setAiClient(aiClient);
webServer.setStrategySelector(strategySelector);
webServer.setBrowserClient(browserClient);
webServer.setAccountAuthContext(accountAuth);
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
}
@@ -312,6 +348,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
db,
browserClient,
messagePage,
accountAuth,
postsRepo,
messagesRepo,
successfulMessagesRepo,
+18 -5
View File
@@ -112,6 +112,15 @@ export class BrowserClient {
return this.sessionState;
}
async getAuthenticatedUsername(): Promise<string | null> {
try {
const page = await this.getPage();
return await this.readAuthenticatedUsername(page);
} catch {
return null;
}
}
async close(): Promise<void> {
if (this.connectedBrowser) {
this.logger.info('Closing Google Chrome connection');
@@ -185,19 +194,23 @@ export class BrowserClient {
}
private async isLoggedInViaApi(page: Page): Promise<boolean> {
return (await this.readAuthenticatedUsername(page)) !== null;
}
private async readAuthenticatedUsername(page: Page): Promise<string | null> {
try {
return await page.evaluate(async () => {
try {
const res = await fetch('https://www.reddit.com/api/me.json', { credentials: 'include' });
if (!res.ok) return false;
const data = await res.json();
return Boolean(data && data.name);
if (!res.ok) return null;
const data = await res.json() as { name?: unknown };
return typeof data.name === 'string' && data.name ? data.name : null;
} catch {
return false;
return null;
}
});
} catch {
return false;
return null;
}
}
+16 -1
View File
@@ -19,6 +19,7 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
config,
logger,
browserClient,
accountAuth,
postsRepo,
messagesRepo,
redditApi,
@@ -65,7 +66,7 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
const RESPONSE_CHECK_INTERVAL_MS = Math.max(config.reddit.pollIntervalMs * 5, 60_000);
const checkResponses = async (): Promise<void> => {
if (isShuttingDown) return;
if (isShuttingDown || !accountAuth.isFullyAuthenticated()) return;
try {
logger.debug('Checking for responses to sent messages (Background Thread)...');
const responses = await responseChecker.checkForResponses();
@@ -92,11 +93,25 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
responseCheckInterval = setInterval(checkResponses, RESPONSE_CHECK_INTERVAL_MS);
let lastPausedLog = 0;
let lastAuthPausedLog = 0;
let lastCooldownLog = 0;
const COOLDOWN_LOG_INTERVAL_MS = 5 * 60_000;
while (!isShuttingDown) {
try {
if (!accountAuth.isFullyAuthenticated()) {
await accountAuth.ensureFullyAuthenticated();
}
if (!accountAuth.isFullyAuthenticated()) {
if (Date.now() - lastAuthPausedLog > PAUSED_LOG_INTERVAL_MS) {
logger.info('Bot logic is locked until full Reddit and Matrix authentication is verified.');
lastAuthPausedLog = Date.now();
}
await sleep(PAUSE_POLL_MS);
continue;
}
lastAuthPausedLog = 0;
if (webServer && !webServer.isRunning()) {
if (Date.now() - lastPausedLog > PAUSED_LOG_INTERVAL_MS) {
logger.info('Bot is paused. Use the web dashboard to start.');
+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
+2
View File
@@ -13,6 +13,7 @@ import type { RateLimiter } from '../utils/rate-limiter.js';
import type { OpenRouterClient } from '../ai/openrouter.js';
import type { StrategySelector } from '../services/strategy-selector.js';
import type { BrowserClient } from '../browser/client.js';
import type { AccountAuthContext } from '../services/account-auth-context.js';
import type { ActivityLog } from './activity-log.js';
import type { LogBroadcaster } from './log-broadcaster.js';
import type { RuntimeConfig } from './types.js';
@@ -40,6 +41,7 @@ export interface WebContext {
ai?: OpenRouterClient;
strategySelector?: StrategySelector;
browser?: BrowserClient;
accountAuth?: AccountAuthContext;
};
/** Bot pause/resume toggle read by the main loop. */
+5
View File
@@ -5,6 +5,11 @@ export function createControlRouter(ctx: WebContext): Router {
const router = Router();
router.post('/start', (_req, res) => {
if (!ctx.services.accountAuth?.isFullyAuthenticated()) {
ctx.logger.warn('Bot start blocked until full account authentication');
res.status(409).json({ success: false, running: false, authenticated: false });
return;
}
ctx.setBotRunning(true);
ctx.activity.push('sent', 'Bot started by user');
ctx.logger.info('Bot started via web UI');
+14 -3
View File
@@ -18,13 +18,23 @@ export function createLoginRouter(ctx: WebContext): Router {
router.get('/login/status', async (_req, res) => {
const browser = ctx.services.browser;
if (!browser) {
const accountAuth = ctx.services.accountAuth;
if (!browser || !accountAuth) {
res.status(503).json({ ready: false, error: 'browser not initialised' });
return;
}
try {
const loggedIn = await browser.isSessionValid();
res.json({ ready: true, loggedIn, status: browser.getSessionState() });
const loggedIn = await accountAuth.ensureFullyAuthenticated();
const browserState = browser.getSessionState();
res.json({
ready: true,
loggedIn,
status: loggedIn
? 'authenticated'
: browserState === 'unreachable'
? 'unreachable'
: 'login-required',
});
} catch (error) {
ctx.logger.error('Failed to check login status:', error);
res.status(500).json({ ready: true, loggedIn: false, error: 'check failed' });
@@ -40,6 +50,7 @@ export function createLoginRouter(ctx: WebContext): Router {
return;
}
try {
ctx.services.accountAuth?.invalidate('Interactive Reddit login requested');
const page = await browser.getPage();
await page.bringToFront().catch(() => undefined);
await page.goto('https://www.reddit.com/login', { waitUntil: 'domcontentloaded' });
+5
View File
@@ -17,6 +17,7 @@ import type { RateLimiter } from '../utils/rate-limiter.js';
import type { OpenRouterClient } from '../ai/openrouter.js';
import type { StrategySelector } from '../services/strategy-selector.js';
import type { BrowserClient } from '../browser/client.js';
import type { AccountAuthContext } from '../services/account-auth-context.js';
import { ActivityLog } from './activity-log.js';
import { LogBroadcaster } from './log-broadcaster.js';
import type { WebContext } from './context.js';
@@ -175,6 +176,10 @@ export class WebServer {
this.services.browser = browserClient;
}
setAccountAuthContext(accountAuth: AccountAuthContext): void {
this.services.accountAuth = accountAuth;
}
logActivity(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void {
this.activity.push(type, message, recipient, imageUrl);
}