Merge branch 'codex/native-login-handoff' into main

# Conflicts:
#	public/index.html
#	src/bootstrap.ts
#	src/services/chat/service.ts
#	tests/chat-service-username-persist.test.ts
This commit is contained in:
jjbrunton committed 2026-08-08 16:46:16 +01:00
commit 50985b52f1
28 files changed
+987 -124

No files matched your search

+83 -44
View File
@@ -23,8 +23,6 @@ import {
MatrixClient,
MatrixMediaDownloader,
MatrixTokenRefresher,
extractMatrixCredentials,
loadCachedCredentials,
type UsernameResolver,
type MatrixCredentials,
} from './services/chat/index.js';
@@ -34,6 +32,12 @@ import { RateLimiter } from './utils/rate-limiter.js';
import { fetchWithTimeout } from './utils/fetch-with-timeout.js';
import { WebServer } from './web/server.js';
import { reportReply } from './web/event-reporting.js';
import {
AccountAuthContext,
authenticateCurrentAccount,
type AuthenticatedAccount,
} from './services/account-auth-context.js';
import { ProcessNativeLoginHandoff } from './services/native-login-handoff.js';
export interface AppContainer {
config: Config;
@@ -42,6 +46,7 @@ export interface AppContainer {
browserClient: BrowserClient;
messagePage: RedditMessagePage;
accountAuth: AccountAuthContext;
postsRepo: PostsRepository;
messagesRepo: MessagesRepository;
@@ -98,6 +103,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
@@ -107,7 +113,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
if (!loggedIn) {
logger.warn('Session invalid or not logged in.');
if (config.reddit.username && config.reddit.password) {
if (!config.web.enabled && config.reddit.username && config.reddit.password) {
logger.info('Credentials found in config/env. Attempting automated login...');
loggedIn = await browserClient.login();
}
@@ -155,36 +161,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
@@ -195,13 +184,32 @@ 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(() => authenticateCurrentAccount({
getRedditUsername: () => browserClient.getAuthenticatedUsername(),
refreshMatrixCredentials: async () => {
credentialState.latest = null;
await tokenRefresher.refresh();
return credentialState.latest as MatrixCredentials | null;
},
}));
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 = {
@@ -233,7 +241,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
},
};
chatService = new ChatService(
const chatService = new ChatService(
matrixClient,
logger,
successfulMessagesRepo,
@@ -243,24 +251,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) {
@@ -327,6 +363,8 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
webServer.setAiClient(aiClient);
webServer.setStrategySelector(strategySelector);
webServer.setBrowserClient(browserClient);
webServer.setAccountAuthContext(accountAuth);
webServer.setNativeLoginHandoff(new ProcessNativeLoginHandoff());
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
}
@@ -348,6 +386,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
db,
browserClient,
messagePage,
accountAuth,
postsRepo,
messagesRepo,
successfulMessagesRepo,