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:
commit
50985b52f1
28 files changed
+987
-124
No files matched your search
+83
-44
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user