370 lines
13 KiB
TypeScript
370 lines
13 KiB
TypeScript
import type { Config } from './config/index.js';
|
|
import { attachLogSink, type Logger } from './utils/logger.js';
|
|
import { LogBroadcaster } from './web/log-broadcaster.js';
|
|
import type Database from 'better-sqlite3';
|
|
import { createDatabase, runMigrations } from './database/client.js';
|
|
import {
|
|
PostsRepository,
|
|
MessagesRepository,
|
|
SuccessfulMessagesRepository,
|
|
ActivityLogRepository,
|
|
RedditUsersRepository,
|
|
} from './database/repositories.js';
|
|
import { BrowserClient } from './browser/client.js';
|
|
import { RedditMessagePage } from './browser/message-page.js';
|
|
import { RedditApiClient } from './reddit/api.js';
|
|
import { OpenRouterClient } from './ai/openrouter.js';
|
|
import { MonitorService } from './services/monitor.js';
|
|
import { ProcessorService } from './services/processor.js';
|
|
import { ResponseCheckerService } from './services/response-checker.js';
|
|
import { StrategySelector } from './services/strategy-selector.js';
|
|
import {
|
|
ChatService,
|
|
MatrixClient,
|
|
MatrixMediaDownloader,
|
|
MatrixTokenRefresher,
|
|
type UsernameResolver,
|
|
type MatrixCredentials,
|
|
} from './services/chat/index.js';
|
|
import { MediaService } from './services/media.js';
|
|
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,
|
|
authenticateCurrentAccount,
|
|
type AuthenticatedAccount,
|
|
} from './services/account-auth-context.js';
|
|
import { ProcessNativeLoginHandoff } from './services/native-login-handoff.js';
|
|
|
|
export interface AppContainer {
|
|
config: Config;
|
|
logger: Logger;
|
|
db: Database.Database;
|
|
|
|
browserClient: BrowserClient;
|
|
messagePage: RedditMessagePage;
|
|
accountAuth: AccountAuthContext;
|
|
|
|
postsRepo: PostsRepository;
|
|
messagesRepo: MessagesRepository;
|
|
successfulMessagesRepo: SuccessfulMessagesRepository;
|
|
|
|
redditApi: RedditApiClient;
|
|
aiClient: OpenRouterClient;
|
|
monitorService: MonitorService;
|
|
processorService: ProcessorService;
|
|
strategySelector: StrategySelector;
|
|
responseChecker: ResponseCheckerService;
|
|
mediaService: MediaService;
|
|
chatService: ChatService;
|
|
mediaSyncService: MediaSyncService;
|
|
rateLimiter: RateLimiter;
|
|
|
|
webServer: WebServer | null;
|
|
}
|
|
|
|
/**
|
|
* Builds and wires every service required for the bot.
|
|
*
|
|
* Performs:
|
|
* 1. DB + repos
|
|
* 2. Browser + login
|
|
* 3. Three pages (compose / response-check / web-UI)
|
|
* 4. All services
|
|
* 5. Optional WebServer
|
|
*
|
|
* Returns a populated container. Caller owns lifecycle (start loop, handle
|
|
* shutdown).
|
|
*/
|
|
export async function bootstrap(config: Config, logger: Logger): Promise<AppContainer> {
|
|
// Wire winston → dashboard log buffer immediately so the matrix-token
|
|
// sniff and other early bootstrap diagnostics are visible in /api/logs.
|
|
const broadcaster = new LogBroadcaster();
|
|
if (config.web.enabled) {
|
|
attachLogSink(logger, broadcaster);
|
|
}
|
|
|
|
// Database
|
|
const db = createDatabase(config);
|
|
runMigrations(db);
|
|
const postsRepo = new PostsRepository(db);
|
|
const messagesRepo = new MessagesRepository(db);
|
|
const successfulMessagesRepo = new SuccessfulMessagesRepository(db);
|
|
const activityLogRepo = new ActivityLogRepository(db);
|
|
const redditUsersRepo = new RedditUsersRepository(db);
|
|
logger.info('Database initialized');
|
|
|
|
// Browser
|
|
const browserClient = new BrowserClient(config, logger);
|
|
await browserClient.initialize();
|
|
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
|
|
// can complete the login flow in the running headed browser.
|
|
logger.info('Verifying Reddit login status...');
|
|
let loggedIn = await messagePage.isLoggedIn();
|
|
if (!loggedIn) {
|
|
logger.warn('Session invalid or not logged in.');
|
|
|
|
if (!config.web.enabled && config.reddit.username && config.reddit.password) {
|
|
logger.info('Credentials found in config/env. Attempting automated login...');
|
|
loggedIn = await browserClient.login();
|
|
}
|
|
|
|
if (!loggedIn) {
|
|
if (config.web.enabled) {
|
|
logger.warn('Not logged in. Open the dashboard and click "Log in to Reddit" to authenticate via the noVNC console.');
|
|
logger.warn('The bot will stay paused until a valid session is detected.');
|
|
} else {
|
|
logger.error('Not logged in to Reddit and no recovery path (web dashboard disabled).');
|
|
logger.error('Run "npm run login" locally, OR set REDDIT_USERNAME and REDDIT_PASSWORD, OR enable the web dashboard.');
|
|
await browserClient.close();
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
if (loggedIn) {
|
|
logger.info('Reddit login verified');
|
|
}
|
|
|
|
// Reddit API client (shares browser context for auth)
|
|
const redditApi = new RedditApiClient(config, logger);
|
|
redditApi.setBrowserContext(browserClient.getContext()!);
|
|
|
|
// Dedicated page for the response checker (background scraping that
|
|
// doesn't conflict with the dashboard).
|
|
const chatPage = await browserClient.getContext()!.newPage();
|
|
|
|
// AI + services
|
|
const aiClient = new OpenRouterClient(config, logger);
|
|
const monitorService = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger);
|
|
const processorService = new ProcessorService(redditApi, aiClient, config, logger);
|
|
const strategySelector = new StrategySelector(config, messagesRepo, logger);
|
|
processorService.setStrategySelector(strategySelector);
|
|
const chatStrategySelector = new StrategySelector(config, messagesRepo, logger, 'chatStrategies');
|
|
processorService.setChatStrategySelector(chatStrategySelector);
|
|
|
|
const responseChecker = new ResponseCheckerService(
|
|
chatPage,
|
|
messagesRepo,
|
|
successfulMessagesRepo,
|
|
postsRepo,
|
|
logger,
|
|
accountAuth,
|
|
);
|
|
|
|
// ---- Matrix chat backend (replaces the Playwright scraper) ----
|
|
// 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
|
|
// `mxc://` URIs resolve through the same auth + rate-limiter pipeline.
|
|
const mediaDownloader = new MatrixMediaDownloader(matrixClient, logger);
|
|
const mediaService = new MediaService(mediaDownloader, logger);
|
|
|
|
// 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.
|
|
const credentialState: { latest: MatrixCredentials | null } = { latest: null };
|
|
const tokenRefresher = new MatrixTokenRefresher(
|
|
browserClient,
|
|
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 = {
|
|
async resolveT2Ids(ids) {
|
|
if (ids.length === 0) return {};
|
|
const out: Record<string, string> = {};
|
|
for (let i = 0; i < ids.length; i += 100) {
|
|
const chunk = ids.slice(i, i + 100).join(',');
|
|
try {
|
|
const res = await fetchWithTimeout(
|
|
// www.reddit.com serves this unauthenticated; the api.reddit.com
|
|
// host 404s the same path without an OAuth token.
|
|
`https://www.reddit.com/api/user_data_by_account_ids.json?ids=${encodeURIComponent(chunk)}`,
|
|
{ headers: { 'User-Agent': config.reddit.userAgent }, timeoutMs: 15_000 },
|
|
);
|
|
if (!res.ok) {
|
|
logger.debug(`user_data_by_account_ids → ${res.status}`);
|
|
continue;
|
|
}
|
|
const json = (await res.json()) as Record<string, { name?: string }>;
|
|
for (const [t2, info] of Object.entries(json)) {
|
|
if (info?.name) out[t2] = info.name;
|
|
}
|
|
} catch (err) {
|
|
logger.debug('user_data_by_account_ids failed:', err);
|
|
}
|
|
}
|
|
return out;
|
|
},
|
|
};
|
|
|
|
const chatService = new ChatService(
|
|
matrixClient,
|
|
logger,
|
|
successfulMessagesRepo,
|
|
mediaService,
|
|
tokenRefresher,
|
|
usernameResolver,
|
|
undefined,
|
|
messagesRepo,
|
|
redditUsersRepo,
|
|
accountAuth,
|
|
);
|
|
|
|
const mediaSyncService = new MediaSyncService(
|
|
chatService,
|
|
logger,
|
|
config.media.syncIntervalMs,
|
|
accountAuth,
|
|
);
|
|
const rateLimiter = new RateLimiter(config, logger);
|
|
|
|
await chatService.loadCache();
|
|
|
|
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();
|
|
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) {
|
|
webServer = new WebServer(
|
|
config.web.port,
|
|
config,
|
|
postsRepo,
|
|
messagesRepo,
|
|
successfulMessagesRepo,
|
|
activityLogRepo,
|
|
logger,
|
|
broadcaster,
|
|
);
|
|
|
|
webServer.setChatService(chatService);
|
|
// Stream sync-progress frames over the dashboard's SSE channel so the
|
|
// full-sync UI can show live phase + per-room progress.
|
|
chatService.setProgressListener((progress) => {
|
|
webServer!.broadcastSyncProgress(progress as Record<string, unknown> | null);
|
|
});
|
|
chatService.setNewReplyListener(({ username, replySnippet }) => {
|
|
webServer!.logActivity(
|
|
'response',
|
|
`Reply from u/${username}: ${replySnippet}`,
|
|
username,
|
|
);
|
|
webServer!.pushLog('info', `💬 Reply from u/${username}`);
|
|
});
|
|
webServer.setMediaService(mediaService);
|
|
webServer.setMediaSyncService(mediaSyncService);
|
|
webServer.setResponseChecker(responseChecker);
|
|
webServer.setRateLimiter(rateLimiter);
|
|
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'));
|
|
}
|
|
|
|
// Start background services
|
|
if (config.media.syncEnabled) mediaSyncService.start();
|
|
if (webServer) webServer.start();
|
|
|
|
// Prime processor with successful examples already on disk.
|
|
const existingExamples = responseChecker.getSuccessfulExamples(3);
|
|
processorService.setSuccessfulExamples(existingExamples);
|
|
const successCount = responseChecker.getSuccessCount();
|
|
if (successCount > 0) {
|
|
logger.info(`Loaded ${successCount} successful message(s) from history`);
|
|
}
|
|
|
|
return {
|
|
config,
|
|
logger,
|
|
db,
|
|
browserClient,
|
|
messagePage,
|
|
accountAuth,
|
|
postsRepo,
|
|
messagesRepo,
|
|
successfulMessagesRepo,
|
|
redditApi,
|
|
aiClient,
|
|
monitorService,
|
|
processorService,
|
|
strategySelector,
|
|
responseChecker,
|
|
mediaService,
|
|
chatService,
|
|
mediaSyncService,
|
|
rateLimiter,
|
|
webServer,
|
|
};
|
|
}
|