feat: persist settings and enrich conversation context
This commit is contained in:
1 parent
a36723a325
commit
2c100d72b6
84 files changed
+2248
-644
No files matched your search
+33
-38
@@ -24,6 +24,7 @@ import {
|
||||
ChatService,
|
||||
MatrixClient,
|
||||
MatrixMediaDownloader,
|
||||
MatrixUsernameResolver,
|
||||
MatrixTokenRefresher,
|
||||
loadCachedCredentials,
|
||||
saveCredentials,
|
||||
@@ -33,11 +34,11 @@ import {
|
||||
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 { reportReply } from './web/event-reporting.js';
|
||||
import { RedditSessionImporter } from './services/reddit-session-importer.js';
|
||||
import { AccountAuthContext } from './services/account-auth-context.js';
|
||||
import { ConfigStore } from './config/persistence.js';
|
||||
|
||||
export interface AppContainer {
|
||||
config: Config;
|
||||
@@ -81,7 +82,11 @@ export interface AppContainer {
|
||||
* Returns a populated container. Caller owns lifecycle (start loop, handle
|
||||
* shutdown).
|
||||
*/
|
||||
export async function bootstrap(config: Config, logger: Logger): Promise<AppContainer> {
|
||||
export async function bootstrap(
|
||||
config: Config,
|
||||
logger: Logger,
|
||||
existingDatabase?: Database.Database,
|
||||
): 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();
|
||||
@@ -90,7 +95,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
}
|
||||
|
||||
// Database
|
||||
const db = createDatabase(config);
|
||||
const db = existingDatabase ?? createDatabase(config);
|
||||
runMigrations(db);
|
||||
const postsRepo = new PostsRepository(db);
|
||||
const messagesRepo = new MessagesRepository(db);
|
||||
@@ -186,35 +191,12 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
// or token-sniff flow.
|
||||
const tokenRefresher = new MatrixTokenRefresher();
|
||||
|
||||
// 4. Username resolver — turns t2 IDs into real Reddit usernames so the
|
||||
// dashboard isn't full of `t2_abc123`. Uses Reddit's bulk endpoint.
|
||||
// 4. Username resolver — Matrix profiles expose Reddit display names using
|
||||
// the same authenticated session that powers chat sync.
|
||||
const matrixUsernameResolver = new MatrixUsernameResolver(activeMatrixClient);
|
||||
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;
|
||||
},
|
||||
resolveT2Ids: (ids) => matrixUsernameResolver.resolveT2Ids(ids),
|
||||
resolveUsernameToT2: (username) => redditApi.resolveUsernameToT2(username),
|
||||
};
|
||||
|
||||
const chatService = new ChatService(
|
||||
@@ -244,6 +226,8 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
setMatrixClient(client) {
|
||||
activeMatrixClient = client;
|
||||
chatService.setMatrixClient(client);
|
||||
mediaDownloader.setMatrixClient(client);
|
||||
matrixUsernameResolver.setMatrixClient(client);
|
||||
},
|
||||
},
|
||||
(credentials) => new MatrixClient(credentials, logger),
|
||||
@@ -268,7 +252,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
// Stream sync-progress frames over the dashboard's SSE channel so the
|
||||
// full-sync UI can show live phase + per-room progress.
|
||||
let lastSyncProgress: { kind: 'full' | 'delta'; current: number; startedAt: number } | null = null;
|
||||
chatService.setProgressListener((progress) => {
|
||||
chatService.setProgressListener((progress, completion) => {
|
||||
webServer!.broadcastSyncProgress(progress as Record<string, unknown> | null);
|
||||
if (progress) {
|
||||
const syncId = `chat-${progress.startedAt}`;
|
||||
@@ -286,12 +270,22 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
}
|
||||
lastSyncProgress = progress;
|
||||
} else if (lastSyncProgress) {
|
||||
broadcaster.dashboardEvents.sync({
|
||||
phase: 'completed',
|
||||
id: `chat-${lastSyncProgress.startedAt}`,
|
||||
kind: lastSyncProgress.kind,
|
||||
processed: lastSyncProgress.current,
|
||||
});
|
||||
const id = `chat-${lastSyncProgress.startedAt}`;
|
||||
if (completion?.status === 'failed') {
|
||||
broadcaster.dashboardEvents.sync({
|
||||
phase: 'failed',
|
||||
id,
|
||||
kind: lastSyncProgress.kind,
|
||||
error: completion.error,
|
||||
});
|
||||
} else {
|
||||
broadcaster.dashboardEvents.sync({
|
||||
phase: 'completed',
|
||||
id,
|
||||
kind: lastSyncProgress.kind,
|
||||
processed: lastSyncProgress.current,
|
||||
});
|
||||
}
|
||||
lastSyncProgress = null;
|
||||
}
|
||||
});
|
||||
@@ -319,6 +313,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
||||
webServer.setSessionImporter(sessionImporter);
|
||||
webServer.setAccountAuth(accountAuth);
|
||||
webServer.setConversationStateRepository(conversationStateRepo);
|
||||
webServer.setConfigStore(new ConfigStore(db));
|
||||
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
|
||||
}
|
||||
|
||||
|
||||
@@ -175,8 +175,12 @@ export class BrowserClient {
|
||||
const res = await fetch('https://www.reddit.com/api/me.json', { credentials: 'include' });
|
||||
if (!res.ok) return null;
|
||||
const data: unknown = await res.json();
|
||||
if (!data || typeof data !== 'object' || !('name' in data) || typeof data.name !== 'string') return null;
|
||||
const username = data.name.trim();
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const account = 'data' in data && data.data && typeof data.data === 'object'
|
||||
? data.data
|
||||
: data;
|
||||
if (!('name' in account) || typeof account.name !== 'string') return null;
|
||||
const username = account.name.trim();
|
||||
return username || null;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
+20
-1
@@ -157,7 +157,20 @@ export const ConfigSchema = z.object({
|
||||
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
||||
export function loadConfig(): Config {
|
||||
export interface ConfigPersistence {
|
||||
load(environment?: NodeJS.ProcessEnv): Config | null;
|
||||
save(config: Config): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the database-backed configuration. A legacy YAML file is imported
|
||||
* exactly once when the database has not yet been seeded, which makes the
|
||||
* migration safe for existing installations.
|
||||
*/
|
||||
export function loadConfig(store?: ConfigPersistence): Config {
|
||||
const storedConfig = store?.load(process.env);
|
||||
if (storedConfig) return storedConfig;
|
||||
|
||||
const configPath = path.resolve(process.cwd(), 'config.yaml');
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
@@ -173,6 +186,11 @@ export function loadConfig(): Config {
|
||||
}
|
||||
rawConfig.openrouter.apiKey = process.env.OPENROUTER_API_KEY || rawConfig.openrouter.apiKey;
|
||||
|
||||
if (!rawConfig.database) rawConfig.database = {};
|
||||
// The database must be located before application settings can be read, so
|
||||
// its path is an environment-owned bootstrap value rather than app config.
|
||||
rawConfig.database.path = process.env.DATABASE_PATH ?? './data/reddit-bot.db';
|
||||
|
||||
if (process.env.WEB_DASHBOARD_TOKEN !== undefined) {
|
||||
if (!rawConfig.web) rawConfig.web = {};
|
||||
rawConfig.web.authToken = process.env.WEB_DASHBOARD_TOKEN;
|
||||
@@ -195,5 +213,6 @@ export function loadConfig(): Config {
|
||||
throw new Error('WEB_DASHBOARD_TOKEN environment variable or web.authToken configuration is required when the dashboard is enabled');
|
||||
}
|
||||
|
||||
store?.save(result.data);
|
||||
return result.data;
|
||||
}
|
||||
+73
-64
@@ -1,74 +1,83 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import YAML from 'yaml';
|
||||
import type { SubredditConfig } from './index.js';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { ConfigSchema, type Config } from './index.js';
|
||||
|
||||
type StoredConfig = Omit<Config, 'database' | 'openrouter' | 'web'> & {
|
||||
openrouter: Omit<Config['openrouter'], 'apiKey'>;
|
||||
web: Omit<Config['web'], 'authToken'>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Writes an updated subreddit list back to `config.yaml`, preserving the
|
||||
* surrounding YAML structure, comment blocks, and per-row inline comments
|
||||
* for entries that survive the update.
|
||||
*
|
||||
* Strategy:
|
||||
* - Parse with `YAML.parseDocument` so the CST (including comments) is
|
||||
* intact.
|
||||
* - For each updated entry that matches an existing row by name, mutate
|
||||
* the existing node's fields in place — its `.comment` (the trailing
|
||||
* `# ...` annotation on the line) is preserved.
|
||||
* - New entries become fresh flow-style maps appended in the caller's
|
||||
* order; entries no longer in the list are dropped (and their comments
|
||||
* go with them, since the comment is attached to the row).
|
||||
* - Atomic write via tmp file + rename so a crash mid-write can't leave
|
||||
* a half-written config on disk.
|
||||
* Persists application-owned configuration in SQLite. Secrets and the
|
||||
* database location remain environment-owned bootstrap settings and are
|
||||
* deliberately never written to the settings record.
|
||||
*/
|
||||
export function persistSubreddits(
|
||||
subreddits: SubredditConfig[],
|
||||
configPath: string = path.resolve(process.cwd(), 'config.yaml'),
|
||||
): void {
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
const doc = YAML.parseDocument(raw);
|
||||
export class ConfigStore {
|
||||
constructor(private readonly db: Database.Database) {}
|
||||
|
||||
const seq = doc.getIn(['reddit', 'subreddits']) as YAML.YAMLSeq | undefined;
|
||||
if (!seq || !YAML.isSeq(seq)) {
|
||||
throw new Error('reddit.subreddits is not a sequence in config.yaml');
|
||||
}
|
||||
load(environment: NodeJS.ProcessEnv = process.env): Config | null {
|
||||
const row = this.db.prepare('SELECT config_json FROM app_config WHERE id = 1').get() as
|
||||
| { config_json: string }
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
|
||||
// Map existing items by their `name` so we can reuse YAMLMap nodes
|
||||
// (and the trailing comments attached to them).
|
||||
const existingByName = new Map<string, YAML.YAMLMap>();
|
||||
for (const item of seq.items) {
|
||||
if (!YAML.isMap(item)) continue;
|
||||
const name = item.get('name');
|
||||
if (typeof name === 'string') existingByName.set(name, item);
|
||||
}
|
||||
|
||||
const nextItems: YAML.YAMLMap[] = [];
|
||||
for (const sub of subreddits) {
|
||||
const existing = existingByName.get(sub.name);
|
||||
const node = existing ?? new YAML.YAMLMap();
|
||||
if (!existing) {
|
||||
// Match the on-disk style: flow-mapped, single-quoted strings.
|
||||
node.flow = true;
|
||||
node.set('name', sub.name);
|
||||
let stored: unknown;
|
||||
try {
|
||||
stored = JSON.parse(row.config_json);
|
||||
} catch (error) {
|
||||
throw new Error(`Stored application configuration is not valid JSON: ${String(error)}`);
|
||||
}
|
||||
setOrDelete(node, 'type', sub.type === 'image' ? undefined : sub.type);
|
||||
setOrDelete(node, 'weight', sub.weight);
|
||||
setOrDelete(node, 'tone', sub.tone === 'default' ? undefined : sub.tone);
|
||||
setOrDelete(node, 'scripted', sub.scripted);
|
||||
nextItems.push(node);
|
||||
|
||||
return validateConfig(withBootstrapSettings(stored, environment));
|
||||
}
|
||||
|
||||
seq.items = nextItems;
|
||||
|
||||
const out = doc.toString();
|
||||
const tmp = configPath + '.tmp';
|
||||
fs.writeFileSync(tmp, out, 'utf-8');
|
||||
fs.renameSync(tmp, configPath);
|
||||
}
|
||||
|
||||
function setOrDelete(node: YAML.YAMLMap, key: string, value: unknown): void {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
if (node.has(key)) node.delete(key);
|
||||
return;
|
||||
save(config: Config): void {
|
||||
const stored = toStoredConfig(config);
|
||||
this.db.prepare(`
|
||||
INSERT INTO app_config (id, config_json, updated_at)
|
||||
VALUES (1, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
config_json = excluded.config_json,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(JSON.stringify(stored), Date.now());
|
||||
}
|
||||
node.set(key, value);
|
||||
}
|
||||
|
||||
function toStoredConfig(config: Config): StoredConfig {
|
||||
const { database: _database, ...applicationConfig } = config;
|
||||
const { apiKey: _apiKey, ...openrouter } = applicationConfig.openrouter;
|
||||
const { authToken: _authToken, ...web } = applicationConfig.web;
|
||||
return { ...applicationConfig, openrouter, web };
|
||||
}
|
||||
|
||||
function withBootstrapSettings(stored: unknown, environment: NodeJS.ProcessEnv): unknown {
|
||||
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return stored;
|
||||
const record = stored as Record<string, unknown>;
|
||||
const openrouter = objectValue(record.openrouter);
|
||||
const web = objectValue(record.web);
|
||||
return {
|
||||
...record,
|
||||
database: { path: environment.DATABASE_PATH ?? './data/reddit-bot.db' },
|
||||
openrouter: { ...openrouter, apiKey: environment.OPENROUTER_API_KEY },
|
||||
web: { ...web, authToken: environment.WEB_DASHBOARD_TOKEN },
|
||||
};
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function validateConfig(raw: unknown): Config {
|
||||
const result = ConfigSchema.safeParse(raw);
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues
|
||||
.map((issue) => ` - ${String(issue.path.join('.'))}: ${issue.message}`)
|
||||
.join('\n');
|
||||
throw new Error(`Invalid stored configuration:\n${errors}`);
|
||||
}
|
||||
if (result.data.web.enabled && !result.data.web.authToken) {
|
||||
throw new Error('WEB_DASHBOARD_TOKEN environment variable is required when the dashboard is enabled');
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
+51
-1
@@ -3,7 +3,7 @@ import type { Config } from '../config/index.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
export function createDatabase(config: Config): Database.Database {
|
||||
export function createDatabase(config: Pick<Config, 'database'>): Database.Database {
|
||||
const dbPath = config.database.path === ':memory:'
|
||||
? ':memory:'
|
||||
: path.resolve(process.cwd(), config.database.path);
|
||||
@@ -142,6 +142,56 @@ export function runMigrations(db: Database.Database): void {
|
||||
FOREIGN KEY (sent_message_id) REFERENCES sent_messages(id)
|
||||
);
|
||||
`),
|
||||
() => {
|
||||
addColumnIfNotExists(db, 'processed_posts', 'post_url', 'TEXT');
|
||||
addColumnIfNotExists(db, 'activity_log', 'post_title', 'TEXT');
|
||||
addColumnIfNotExists(db, 'activity_log', 'post_url', 'TEXT');
|
||||
db.exec(`
|
||||
UPDATE processed_posts
|
||||
SET post_url = 'https://www.reddit.com/r/' || subreddit || '/comments/' || id || '/'
|
||||
WHERE post_url IS NULL;
|
||||
|
||||
UPDATE activity_log
|
||||
SET
|
||||
post_title = (
|
||||
SELECT pp.title
|
||||
FROM sent_messages sm
|
||||
JOIN processed_posts pp ON pp.id = sm.post_id
|
||||
WHERE sm.recipient = activity_log.recipient COLLATE NOCASE
|
||||
AND sm.sent_at <= activity_log.timestamp
|
||||
ORDER BY sm.sent_at DESC
|
||||
LIMIT 1
|
||||
),
|
||||
post_url = (
|
||||
SELECT pp.post_url
|
||||
FROM sent_messages sm
|
||||
JOIN processed_posts pp ON pp.id = sm.post_id
|
||||
WHERE sm.recipient = activity_log.recipient COLLATE NOCASE
|
||||
AND sm.sent_at <= activity_log.timestamp
|
||||
ORDER BY sm.sent_at DESC
|
||||
LIMIT 1
|
||||
),
|
||||
image_url = COALESCE(image_url, (
|
||||
SELECT pp.image_url
|
||||
FROM sent_messages sm
|
||||
JOIN processed_posts pp ON pp.id = sm.post_id
|
||||
WHERE sm.recipient = activity_log.recipient COLLATE NOCASE
|
||||
AND sm.sent_at <= activity_log.timestamp
|
||||
ORDER BY sm.sent_at DESC
|
||||
LIMIT 1
|
||||
))
|
||||
WHERE type = 'sent'
|
||||
AND recipient IS NOT NULL
|
||||
AND post_url IS NULL;
|
||||
`);
|
||||
},
|
||||
() => db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS app_config (
|
||||
id INTEGER PRIMARY KEY CHECK(id = 1),
|
||||
config_json TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`),
|
||||
];
|
||||
|
||||
let currentVersion = db.pragma('user_version', { simple: true }) as number;
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface ProcessedPostRecord {
|
||||
subreddit: string;
|
||||
author: string;
|
||||
title: string;
|
||||
postUrl: string | null;
|
||||
imageUrl: string | null;
|
||||
imageAnalysis: string | null;
|
||||
generatedMessage: string | null;
|
||||
@@ -32,6 +33,7 @@ export interface SentMessageRecord {
|
||||
chatRoomId: string | null;
|
||||
subreddit?: string;
|
||||
postTitle?: string;
|
||||
postUrl?: string;
|
||||
postImageUrl?: string;
|
||||
postImageAnalysis?: string;
|
||||
}
|
||||
@@ -74,6 +76,8 @@ export interface ActivityLogRecord {
|
||||
message: string;
|
||||
recipient: string | null;
|
||||
imageUrl: string | null;
|
||||
postTitle: string | null;
|
||||
postUrl: string | null;
|
||||
}
|
||||
|
||||
export type DeliveryStatus = 'discovered' | 'prepared' | 'sending' | 'sent' | 'failed';
|
||||
@@ -233,12 +237,12 @@ export class ActivityLogRepository {
|
||||
|
||||
constructor(private db: Database.Database) {
|
||||
this.insertStmt = db.prepare(`
|
||||
INSERT INTO activity_log (timestamp, type, message, recipient, image_url)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO activity_log (timestamp, type, message, recipient, image_url, post_title, post_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
this.getRecentStmt = db.prepare(`
|
||||
SELECT timestamp, type, message, recipient, image_url
|
||||
SELECT timestamp, type, message, recipient, image_url, post_title, post_url
|
||||
FROM activity_log
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
@@ -260,6 +264,8 @@ export class ActivityLogRepository {
|
||||
message: string;
|
||||
recipient?: string;
|
||||
imageUrl?: string;
|
||||
postTitle?: string;
|
||||
postUrl?: string;
|
||||
}): void {
|
||||
this.insertStmt.run(
|
||||
item.timestamp,
|
||||
@@ -267,6 +273,8 @@ export class ActivityLogRepository {
|
||||
item.message,
|
||||
item.recipient ?? null,
|
||||
item.imageUrl ?? null,
|
||||
item.postTitle ?? null,
|
||||
item.postUrl ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -278,6 +286,8 @@ export class ActivityLogRepository {
|
||||
message: row.message as string,
|
||||
recipient: (row.recipient as string) ?? null,
|
||||
imageUrl: (row.image_url as string) ?? null,
|
||||
postTitle: (row.post_title as string) ?? null,
|
||||
postUrl: (row.post_url as string) ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -302,8 +312,8 @@ export class PostsRepository {
|
||||
|
||||
constructor(private db: Database.Database) {
|
||||
this.insertStmt = db.prepare(`
|
||||
INSERT INTO processed_posts (id, subreddit, author, title, image_url, processed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO processed_posts (id, subreddit, author, title, post_url, image_url, processed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
this.existsStmt = db.prepare(`SELECT 1 FROM processed_posts WHERE id = ?`);
|
||||
@@ -334,6 +344,7 @@ export class PostsRepository {
|
||||
subreddit: string;
|
||||
author: string;
|
||||
title: string;
|
||||
postUrl?: string | null;
|
||||
imageUrl: string | null;
|
||||
processedAt: number;
|
||||
}): void {
|
||||
@@ -342,6 +353,7 @@ export class PostsRepository {
|
||||
post.subreddit,
|
||||
post.author,
|
||||
post.title,
|
||||
post.postUrl ?? null,
|
||||
post.imageUrl,
|
||||
post.processedAt
|
||||
);
|
||||
@@ -368,6 +380,7 @@ export class PostsRepository {
|
||||
subreddit: row.subreddit as string,
|
||||
author: row.author as string,
|
||||
title: row.title as string,
|
||||
postUrl: (row.post_url as string | null) ?? null,
|
||||
imageUrl: row.image_url as string | null,
|
||||
imageAnalysis: row.image_analysis as string | null,
|
||||
generatedMessage: row.generated_message as string | null,
|
||||
@@ -384,6 +397,7 @@ export class PostsRepository {
|
||||
subreddit: row.subreddit as string,
|
||||
author: row.author as string,
|
||||
title: row.title as string,
|
||||
postUrl: (row.post_url as string | null) ?? null,
|
||||
imageUrl: row.image_url as string | null,
|
||||
imageAnalysis: row.image_analysis as string | null,
|
||||
generatedMessage: row.generated_message as string | null,
|
||||
@@ -408,8 +422,8 @@ export class MessagesRepository {
|
||||
|
||||
constructor(private db: Database.Database) {
|
||||
this.insertStmt = db.prepare(`
|
||||
INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at, strategy_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at, strategy_id, chat_room_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
this.insertChatStmt = db.prepare(`
|
||||
@@ -418,18 +432,19 @@ export class MessagesRepository {
|
||||
`);
|
||||
|
||||
this.getByChatRoomIdStmt = db.prepare(`
|
||||
SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.image_url AS post_image_url,
|
||||
SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.post_url, pp.image_url AS post_image_url,
|
||||
pp.image_analysis AS post_image_analysis
|
||||
FROM sent_messages sm
|
||||
LEFT JOIN processed_posts pp ON sm.post_id = pp.id
|
||||
WHERE sm.chat_room_id = ?
|
||||
ORDER BY sm.post_id IS NULL ASC, sm.sent_at DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
this.getByPostIdStmt = db.prepare(`SELECT * FROM sent_messages WHERE post_id = ?`);
|
||||
|
||||
this.getLatestByRecipientStmt = db.prepare(`
|
||||
SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.image_url AS post_image_url,
|
||||
SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.post_url, pp.image_url AS post_image_url,
|
||||
pp.image_analysis AS post_image_analysis
|
||||
FROM sent_messages sm
|
||||
LEFT JOIN processed_posts pp ON sm.post_id = pp.id
|
||||
@@ -439,7 +454,7 @@ export class MessagesRepository {
|
||||
`);
|
||||
|
||||
this.getRecentStmt = db.prepare(`
|
||||
SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.image_url AS post_image_url,
|
||||
SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.post_url, pp.image_url AS post_image_url,
|
||||
pp.image_analysis AS post_image_analysis
|
||||
FROM sent_messages sm
|
||||
LEFT JOIN processed_posts pp ON sm.post_id = pp.id
|
||||
@@ -476,6 +491,7 @@ export class MessagesRepository {
|
||||
message: string;
|
||||
sentAt: number;
|
||||
strategyId?: string | null;
|
||||
chatRoomId?: string | null;
|
||||
}): number {
|
||||
const result = this.insertStmt.run(
|
||||
message.postId,
|
||||
@@ -483,7 +499,8 @@ export class MessagesRepository {
|
||||
message.subject,
|
||||
message.message,
|
||||
message.sentAt,
|
||||
message.strategyId || null
|
||||
message.strategyId || null,
|
||||
message.chatRoomId ?? null,
|
||||
);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
@@ -608,6 +625,7 @@ export class MessagesRepository {
|
||||
SUM(CASE WHEN got_response = 1 THEN 1 ELSE 0 END) as total_responses
|
||||
FROM sent_messages
|
||||
WHERE sent_at > ?
|
||||
AND (post_id IS NOT NULL OR strategy_id IS NOT NULL)
|
||||
GROUP BY day
|
||||
ORDER BY day ASC
|
||||
`).all(cutoff) as Record<string, unknown>[];
|
||||
@@ -636,6 +654,7 @@ export class MessagesRepository {
|
||||
chatRoomId: (row.chat_room_id as string) ?? null,
|
||||
subreddit: (row.subreddit as string) || undefined,
|
||||
postTitle: (row.post_title as string) || undefined,
|
||||
postUrl: (row.post_url as string) || undefined,
|
||||
postImageUrl: (row.post_image_url as string) || undefined,
|
||||
postImageAnalysis: (row.post_image_analysis as string) || undefined,
|
||||
};
|
||||
|
||||
+8
-2
@@ -2,6 +2,8 @@ import { loadConfig } from './config/index.js';
|
||||
import { createLogger } from './utils/logger.js';
|
||||
import { bootstrap } from './bootstrap.js';
|
||||
import { runMainLoop } from './main-loop.js';
|
||||
import { createDatabase, runMigrations } from './database/client.js';
|
||||
import { ConfigStore } from './config/persistence.js';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const logger = createLogger();
|
||||
@@ -11,15 +13,19 @@ async function main(): Promise<void> {
|
||||
logger.info('='.repeat(60));
|
||||
|
||||
let config;
|
||||
const db = createDatabase({
|
||||
database: { path: process.env.DATABASE_PATH ?? './data/reddit-bot.db' },
|
||||
});
|
||||
runMigrations(db);
|
||||
try {
|
||||
config = loadConfig();
|
||||
config = loadConfig(new ConfigStore(db));
|
||||
logger.info(`Loaded configuration for ${config.reddit.subreddits.length} subreddits`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to load configuration:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = await bootstrap(config, logger);
|
||||
const app = await bootstrap(config, logger, db);
|
||||
await runMainLoop(app);
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -242,11 +242,13 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
||||
);
|
||||
const isChatSub = subredditEntry?.type === 'chat';
|
||||
const postImageUrl = isChatSub ? null : redditApi.getImageUrl(post);
|
||||
const postUrl = redditApi.getPostUrl(post);
|
||||
postsRepo.insert({
|
||||
id: post.id,
|
||||
subreddit: post.subreddit,
|
||||
author: post.author,
|
||||
title: post.title,
|
||||
postUrl,
|
||||
imageUrl: postImageUrl,
|
||||
processedAt: Date.now(),
|
||||
});
|
||||
@@ -309,7 +311,11 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
||||
logger.info(`Sending message to u/${post.author}...`);
|
||||
webServer?.pushLog('info', `Sending message to u/${post.author}...`);
|
||||
|
||||
const chat = await chatService.startConversation(post.author, processed.generatedMessage);
|
||||
const chat = await chatService.startConversation(
|
||||
post.author,
|
||||
processed.generatedMessage,
|
||||
post.author_fullname,
|
||||
);
|
||||
const success = chat !== null;
|
||||
|
||||
if (success) {
|
||||
@@ -327,6 +333,7 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
||||
message: processed.generatedMessage,
|
||||
sentAt: Date.now(),
|
||||
strategyId: processed.strategyId,
|
||||
chatRoomId: chat.roomId,
|
||||
});
|
||||
|
||||
const currentCount = currentHourlySendCount(rateLimiter);
|
||||
@@ -338,6 +345,7 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
||||
`Sent message to u/${post.author} (r/${post.subreddit})`,
|
||||
post.author,
|
||||
postImageUrl ?? undefined,
|
||||
{ postTitle: post.title, postUrl },
|
||||
);
|
||||
webServer?.pushLog('info', `✅ Message sent to u/${post.author} (${currentCount}/${config.rateLimit.maxMessagesPerHour} this hour)`);
|
||||
webServer?.updateStats(currentCount);
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface RedditPost {
|
||||
name: string; // fullname like "t3_xxxxx"
|
||||
subreddit: string;
|
||||
author: string;
|
||||
author_fullname?: string; // account fullname like "t2_xxxxx"
|
||||
title: string;
|
||||
url: string;
|
||||
permalink: string;
|
||||
@@ -83,6 +84,26 @@ export class RedditApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a Reddit username through the authenticated browser session. */
|
||||
async resolveUsernameToT2(username: string): Promise<string | null> {
|
||||
const cookieHeader = await this.getCookieHeader();
|
||||
const headers: Record<string, string> = {};
|
||||
if (cookieHeader) headers.Cookie = cookieHeader;
|
||||
|
||||
try {
|
||||
const response = await this.client.get(
|
||||
`/user/${encodeURIComponent(username)}/about.json`,
|
||||
{ params: { raw_json: 1 }, headers },
|
||||
);
|
||||
const id = response.data?.data?.id;
|
||||
if (typeof id !== 'string' || id.length === 0) return null;
|
||||
return id.startsWith('t2_') ? id : `t2_${id}`;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getNewPosts(subreddit: string, limit = 25, type: 'image' | 'chat' = 'image'): Promise<RedditPost[]> {
|
||||
try {
|
||||
const cookieHeader = await this.getCookieHeader();
|
||||
|
||||
@@ -5,6 +5,7 @@ export type {
|
||||
SyncStatus,
|
||||
SyncProgress,
|
||||
SyncPhase,
|
||||
SyncCompletion,
|
||||
SyncProgressListener,
|
||||
} from './service.js';
|
||||
export type {
|
||||
@@ -23,3 +24,4 @@ export {
|
||||
MatrixTokenRefresher,
|
||||
} from './matrix/token-refresher.js';
|
||||
export { MatrixMediaDownloader } from './matrix/http-media-downloader.js';
|
||||
export { MatrixUsernameResolver } from './matrix/username-resolver.js';
|
||||
@@ -190,7 +190,13 @@ export class MatrixClient {
|
||||
}
|
||||
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
throw new MatrixAuthError(`Matrix ${method} ${path} → ${res.status} (token likely expired)`);
|
||||
const failure = await readMatrixFailure(res);
|
||||
if (isMatrixAuthenticationFailure(res.status, failure.errcode)) {
|
||||
throw new MatrixAuthError(
|
||||
`Matrix ${method} ${path} → ${res.status} ${failure.errcode ?? ''} (token rejected)`,
|
||||
);
|
||||
}
|
||||
throw new Error(`Matrix ${method} ${path} → ${res.status}: ${failure.text.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
// Matrix-standard rate-limit response: { retry_after_ms } per spec.
|
||||
@@ -251,6 +257,13 @@ export class MatrixClient {
|
||||
return res.user_id;
|
||||
}
|
||||
|
||||
async getUserProfile(mxid: string): Promise<{ displayname?: string; avatar_url?: string }> {
|
||||
return this.request<{ displayname?: string; avatar_url?: string }>(
|
||||
'GET',
|
||||
`/_matrix/client/v3/profile/${encodeURIComponent(mxid)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* All rooms the user is currently joined to. Use this for backfill —
|
||||
* `/sync` only returns rooms with recent activity.
|
||||
@@ -502,7 +515,15 @@ export class MatrixClient {
|
||||
throw new Error(`Matrix media download ${url} timed out (exceeded ${maxAttempts} attempts)`);
|
||||
}
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
throw new MatrixAuthError(`Matrix media download → ${res.status} (token likely expired)`);
|
||||
const failure = await readMatrixFailure(res);
|
||||
if (isMatrixAuthenticationFailure(res.status, failure.errcode)) {
|
||||
throw new MatrixAuthError(
|
||||
`Matrix media download → ${res.status} ${failure.errcode ?? ''} (token rejected)`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Matrix media download ${url} → ${res.status}: ${failure.text.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
if (res.status === 429) {
|
||||
const body = (await res.json().catch(() => ({}))) as { retry_after_ms?: number };
|
||||
@@ -529,6 +550,29 @@ export class MatrixClient {
|
||||
}
|
||||
}
|
||||
|
||||
interface MatrixFailure {
|
||||
errcode?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
async function readMatrixFailure(response: Response): Promise<MatrixFailure> {
|
||||
const text = await response.text().catch(() => '');
|
||||
if (!text) return { text };
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { errcode?: unknown };
|
||||
return {
|
||||
text,
|
||||
...(typeof parsed.errcode === 'string' ? { errcode: parsed.errcode } : {}),
|
||||
};
|
||||
} catch {
|
||||
return { text };
|
||||
}
|
||||
}
|
||||
|
||||
function isMatrixAuthenticationFailure(status: number, errcode?: string): boolean {
|
||||
return status === 401 || errcode === 'M_UNKNOWN_TOKEN' || errcode === 'M_MISSING_TOKEN';
|
||||
}
|
||||
|
||||
function parseMxc(mxc: string): { serverName: string; mediaId: string } | null {
|
||||
const m = mxc.match(/^mxc:\/\/([^/]+)\/(.+)$/);
|
||||
if (!m) return null;
|
||||
|
||||
@@ -16,6 +16,10 @@ export class MatrixMediaDownloader implements MediaDownloader {
|
||||
private logger: Logger,
|
||||
) {}
|
||||
|
||||
setMatrixClient(client: MatrixClient): void {
|
||||
this.matrix = client;
|
||||
}
|
||||
|
||||
async fetch(url: string): Promise<MediaDownloadResult | null> {
|
||||
try {
|
||||
if (url.startsWith('mxc://')) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { UsernameResolver } from '../service.js';
|
||||
|
||||
interface MatrixProfileClient {
|
||||
getUserProfile(mxid: string): Promise<{ displayname?: string }>;
|
||||
}
|
||||
|
||||
export class MatrixUsernameResolver implements UsernameResolver {
|
||||
constructor(private profiles: MatrixProfileClient) {}
|
||||
|
||||
setMatrixClient(client: MatrixProfileClient): void {
|
||||
this.profiles = client;
|
||||
}
|
||||
|
||||
async resolveT2Ids(t2Ids: string[]): Promise<Record<string, string>> {
|
||||
const resolved: Record<string, string> = {};
|
||||
await Promise.all(t2Ids.map(async (value) => {
|
||||
const t2Id = value.startsWith('t2_') ? value : `t2_${value}`;
|
||||
try {
|
||||
const username = (await this.profiles.getUserProfile(`@${t2Id}:reddit.com`)).displayname?.trim();
|
||||
if (username && !username.startsWith('t2_') && !username.startsWith('@')) {
|
||||
resolved[t2Id] = username;
|
||||
}
|
||||
} catch {
|
||||
// A missing profile should not prevent the rest of the batch resolving.
|
||||
}
|
||||
}));
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
+160
-77
@@ -2,6 +2,7 @@ import type { Logger } from '../../utils/logger.js';
|
||||
import type {
|
||||
MessagesRepository,
|
||||
RedditUsersRepository,
|
||||
SentMessageRecord,
|
||||
SuccessfulMessagesRepository,
|
||||
} from '../../database/repositories.js';
|
||||
import type { MediaService } from '../media.js';
|
||||
@@ -40,10 +41,13 @@ class MatrixAuthenticationUnavailableError extends Error {
|
||||
export interface UsernameResolver {
|
||||
/** Map t2 IDs (with or without `t2_` prefix) to Reddit usernames. */
|
||||
resolveT2Ids(t2Ids: string[]): Promise<Record<string, string>>;
|
||||
/** Resolve a Reddit username to its t2 account ID when starting a DM. */
|
||||
resolveUsernameToT2?(username: string): Promise<string | null>;
|
||||
}
|
||||
|
||||
interface BuildChatOpts {
|
||||
knownUsernames: Map<string, string>;
|
||||
includeTimelineMessages?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,8 +102,15 @@ export interface SyncStatus {
|
||||
progress: SyncProgress | null;
|
||||
}
|
||||
|
||||
export type SyncCompletion =
|
||||
| { status: 'completed' }
|
||||
| { status: 'failed'; error: string };
|
||||
|
||||
/** `null` signals "sync ended" (clear progress UI). */
|
||||
export type SyncProgressListener = (progress: SyncProgress | null) => void;
|
||||
export type SyncProgressListener = (
|
||||
progress: SyncProgress | null,
|
||||
completion?: SyncCompletion,
|
||||
) => void;
|
||||
|
||||
/** Fired when a chat we sent into first receives a reply from the other party. */
|
||||
export type NewReplyListener = (info: {
|
||||
@@ -112,6 +123,7 @@ export type NewReplyListener = (info: {
|
||||
export class ChatService {
|
||||
private cache: ChatCache;
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
private deltaSyncInFlight: Promise<void> | null = null;
|
||||
private readonly t2ToUsername = new Map<string, string>();
|
||||
/** Rooms whose /joined_members lookup has already failed — don't retry. */
|
||||
private readonly unresolvableRooms = new Set<string>();
|
||||
@@ -263,6 +275,7 @@ export class ChatService {
|
||||
return this.enqueue(async () => {
|
||||
if (this.cache.isFresh()) return this.cache.get();
|
||||
this.beginSync('delta');
|
||||
let failure: unknown;
|
||||
try {
|
||||
this.updateProgress({ phase: 'starting', current: 0, total: 0 });
|
||||
const merged = await this.syncOnce();
|
||||
@@ -273,8 +286,11 @@ export class ChatService {
|
||||
this.logger.info(`[chat] sync → ${merged.length} known rooms, ${active.length} active`);
|
||||
this.updateProgress({ phase: 'done', current: merged.length, total: merged.length });
|
||||
return active;
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
throw error;
|
||||
} finally {
|
||||
this.endSync();
|
||||
this.endSync(failure);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -284,9 +300,12 @@ export class ChatService {
|
||||
* cache. The `limit` arg is preserved for API compatibility but Matrix
|
||||
* already returns only rooms with new events, so it's a no-op.
|
||||
*/
|
||||
async refreshTopChats(_limit = 5): Promise<void> {
|
||||
await this.enqueue(async () => {
|
||||
refreshTopChats(_limit = 5): Promise<void> {
|
||||
if (this.deltaSyncInFlight) return this.deltaSyncInFlight;
|
||||
|
||||
const operation = this.enqueue(async () => {
|
||||
this.beginSync('delta');
|
||||
let failure: unknown;
|
||||
try {
|
||||
this.updateProgress({ phase: 'starting', current: 0, total: 0 });
|
||||
await this.syncOnce();
|
||||
@@ -297,11 +316,19 @@ export class ChatService {
|
||||
total: this.cache.get().length,
|
||||
});
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
this.logger.error('[chat] refreshTopChats failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.endSync();
|
||||
this.endSync(failure);
|
||||
}
|
||||
});
|
||||
|
||||
const tracked = operation.finally(() => {
|
||||
this.deltaSyncInFlight = null;
|
||||
});
|
||||
this.deltaSyncInFlight = tracked;
|
||||
return tracked;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,6 +339,7 @@ export class ChatService {
|
||||
async fetchAllMessages(): Promise<ActiveChat[]> {
|
||||
return this.enqueue(async () => {
|
||||
this.beginSync('full');
|
||||
let failure: unknown;
|
||||
try {
|
||||
this.logger.info('[chat] full backfill starting (via /joined_rooms)...');
|
||||
const t0 = Date.now();
|
||||
@@ -326,24 +354,46 @@ export class ChatService {
|
||||
const joinedIds = await this.withAuth(() => this.matrix.getJoinedRooms());
|
||||
this.logger.info(`[chat] backfill: ${joinedIds.length} joined rooms`);
|
||||
|
||||
this.updateProgress({
|
||||
phase: 'resolving-usernames',
|
||||
current: 0,
|
||||
total: joinedIds.length,
|
||||
});
|
||||
const knownUsernames = await this.resolveUsernamesForRooms(joinedIds);
|
||||
const knownUsernames = new Map(this.t2ToUsername);
|
||||
const chats: ActiveChat[] = [];
|
||||
|
||||
for (const roomId of joinedIds) {
|
||||
try {
|
||||
const room = seedJoined[roomId];
|
||||
const chat = this.roomToActiveChat(roomId, room, { knownUsernames });
|
||||
const chat = this.roomToActiveChat(roomId, room, {
|
||||
knownUsernames,
|
||||
includeTimelineMessages: true,
|
||||
});
|
||||
chats.push(chat);
|
||||
} catch (err) {
|
||||
this.logger.warn(`[chat] backfill: failed to seed ${roomId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Make the inbox usable as soon as Matrix has listed the rooms. A
|
||||
// full history backfill can take many minutes for large accounts, so
|
||||
// keeping these summaries local until the final room has loaded makes
|
||||
// a healthy sync look like an empty inbox for the entire run.
|
||||
chats.sort((a, b) => b.lastMessageTime - a.lastMessageTime);
|
||||
this.cache.set(chats);
|
||||
await this.cache.save(chats);
|
||||
|
||||
this.updateProgress({
|
||||
phase: 'resolving-usernames',
|
||||
current: 0,
|
||||
total: joinedIds.length,
|
||||
});
|
||||
const resolvedUsernames = await this.resolveUsernamesForRooms(joinedIds, seedJoined);
|
||||
for (const chat of chats) {
|
||||
const t2 = this.extractT2(chat);
|
||||
if (!t2) continue;
|
||||
const resolved = resolvedUsernames.get(t2);
|
||||
if (!resolved) continue;
|
||||
chat.username = resolved;
|
||||
if (!chat.roomName || chat.roomName.startsWith('t2_')) chat.roomName = resolved;
|
||||
}
|
||||
await this.cache.save(chats);
|
||||
|
||||
let done = 0;
|
||||
for (const chat of chats) {
|
||||
this.updateProgress({
|
||||
@@ -377,10 +427,12 @@ export class ChatService {
|
||||
this.updateProgress({ phase: 'done', current: chats.length, total: chats.length });
|
||||
return chats;
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
this.logger.error('[chat] fetchAllMessages failed:', error);
|
||||
if (error instanceof MatrixAuthenticationUnavailableError) throw error;
|
||||
return this.cache.get();
|
||||
} finally {
|
||||
this.endSync();
|
||||
this.endSync(failure);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -446,12 +498,18 @@ export class ChatService {
|
||||
this.emitProgress();
|
||||
}
|
||||
|
||||
private endSync(): void {
|
||||
private endSync(error?: unknown): void {
|
||||
this.syncingKind = null;
|
||||
this.progress = null;
|
||||
if (this.progressListener) {
|
||||
try {
|
||||
this.progressListener(null);
|
||||
const completion: SyncCompletion = error === undefined
|
||||
? { status: 'completed' }
|
||||
: {
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
this.progressListener(null, completion);
|
||||
} catch (err) {
|
||||
this.logger.debug('progress listener error:', err);
|
||||
}
|
||||
@@ -522,7 +580,11 @@ export class ChatService {
|
||||
* Start a brand-new DM with the given Reddit user and send the opening
|
||||
* line. Returns the new chat (or null on failure).
|
||||
*/
|
||||
async startConversation(username: string, message: string): Promise<ActiveChat | null> {
|
||||
async startConversation(
|
||||
username: string,
|
||||
message: string,
|
||||
knownT2Id?: string,
|
||||
): Promise<ActiveChat | null> {
|
||||
if (!this.accountAuth?.isFullyAuthenticated()) return null;
|
||||
const active = this.getInvitationCooldown();
|
||||
if (active) {
|
||||
@@ -534,7 +596,7 @@ export class ChatService {
|
||||
}
|
||||
return this.enqueue(async () => {
|
||||
try {
|
||||
const t2Id = await this.resolveUsernameToT2(username);
|
||||
const t2Id = await this.resolveUsernameToT2(username, knownT2Id);
|
||||
if (!t2Id) {
|
||||
this.logger.error(`[chat] cannot start conversation: unknown user "${username}"`);
|
||||
return null;
|
||||
@@ -657,7 +719,7 @@ export class ChatService {
|
||||
const joined = sync.rooms?.join ?? {};
|
||||
|
||||
const existing = new Map(this.cache.get().map((c) => [c.roomId!, c] as const));
|
||||
const knownUsernames = await this.resolveUsernamesForRooms(Object.keys(joined));
|
||||
const knownUsernames = await this.resolveUsernamesForRooms(Object.keys(joined), joined);
|
||||
|
||||
for (const [roomId, room] of Object.entries(joined)) {
|
||||
const prev = existing.get(roomId);
|
||||
@@ -786,38 +848,36 @@ export class ChatService {
|
||||
if (firstSelf) chat.originMessage = firstSelf.text;
|
||||
|
||||
const hasReply = chat.messages.some((m) => !m.isSelf);
|
||||
let performanceMessage: SentMessageRecord | undefined;
|
||||
|
||||
if (firstSelf && chat.roomId && this.messagesRepo) {
|
||||
try {
|
||||
const existing = this.messagesRepo.getByChatRoomId(chat.roomId);
|
||||
const id = existing
|
||||
? existing.id
|
||||
: this.messagesRepo.insertChat({
|
||||
recipient: chat.username,
|
||||
subject: chat.username,
|
||||
message: firstSelf.text,
|
||||
sentAt: firstSelf.timestamp,
|
||||
chatRoomId: chat.roomId,
|
||||
});
|
||||
if (hasReply && !existing?.gotResponse) {
|
||||
this.messagesRepo.markGotResponse(id);
|
||||
const hasPerformanceContext = Boolean(
|
||||
existing?.postId || existing?.strategyId || existing?.subreddit,
|
||||
);
|
||||
if (existing && hasPerformanceContext) {
|
||||
performanceMessage = existing;
|
||||
if (hasReply && !existing.gotResponse) {
|
||||
this.messagesRepo.markGotResponse(existing.id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn('[chat] failed to record sent_message for stats:', err);
|
||||
this.logger.warn('[chat] failed to associate reply with performance context:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (firstSelf && hasReply && this.successfulMessagesRepo) {
|
||||
if (firstSelf && hasReply && performanceMessage && this.successfulMessagesRepo) {
|
||||
if (!this.successfulMessagesRepo.existsByMessage(firstSelf.text)) {
|
||||
const firstReply = chat.messages.find((m) => !m.isSelf);
|
||||
try {
|
||||
this.successfulMessagesRepo.insert({
|
||||
sentMessageId: null,
|
||||
sentMessageId: performanceMessage.id,
|
||||
originalMessage: firstSelf.text,
|
||||
imageAnalysis: null,
|
||||
postTitle: `Chat with ${chat.username}`,
|
||||
imageAnalysis: performanceMessage.postImageAnalysis ?? null,
|
||||
postTitle: performanceMessage.postTitle ?? null,
|
||||
responseSnippet: firstReply?.text.slice(0, 200) || null,
|
||||
subreddit: null,
|
||||
subreddit: performanceMessage.subreddit ?? null,
|
||||
});
|
||||
this.logger.info(`[chat] stored successful chat example from ${chat.username}`);
|
||||
try {
|
||||
@@ -916,6 +976,10 @@ export class ChatService {
|
||||
isFromUs = lastMessage.sender === this.matrix.userId;
|
||||
}
|
||||
|
||||
const timelineMessages = opts.includeTimelineMessages && !prev?.messages
|
||||
? this.scrapeMessageEvents(timeline).map(({ mediaUrls: _ignored, ...message }) => message)
|
||||
: prev?.messages;
|
||||
|
||||
const unread = (room?.unread_notifications?.notification_count ?? 0) > 0;
|
||||
|
||||
return {
|
||||
@@ -928,6 +992,7 @@ export class ChatService {
|
||||
unread,
|
||||
url: ROOM_URL_PREFIX + encodeURIComponent(roomId),
|
||||
roomId,
|
||||
...(timelineMessages ? { messages: timelineMessages } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -956,16 +1021,58 @@ export class ChatService {
|
||||
* as a fallback. Failures are logged and ignored, with the t2 ID
|
||||
* staying as the fallback display.
|
||||
*/
|
||||
private async resolveUsernamesForRooms(roomIds: string[]): Promise<Map<string, string>> {
|
||||
// Collect all known t2 IDs (from the in-memory cache) and query
|
||||
// Reddit's alfred API for reverse-resolution.
|
||||
private async resolveUsernamesForRooms(
|
||||
roomIds: string[],
|
||||
rooms: Record<string, MatrixJoinedRoom> = {},
|
||||
): Promise<Map<string, string>> {
|
||||
const cached = new Map(this.t2ToUsername);
|
||||
const candidateT2s = new Set(cached.keys());
|
||||
|
||||
const staleT2s = Array.from(cached.keys()).filter(id => {
|
||||
for (const roomId of roomIds) {
|
||||
const room = rooms[roomId];
|
||||
const mxids = [
|
||||
...(room?.summary?.['m.heroes'] ?? []),
|
||||
...(room?.state?.events ?? []).map((event) =>
|
||||
(event as MatrixTimelineEvent & { state_key?: string }).state_key,
|
||||
),
|
||||
...(room?.timeline?.events ?? []).map((event) => event.sender),
|
||||
];
|
||||
for (const mxid of mxids) {
|
||||
if (!mxid || mxid === this.matrix.userId) continue;
|
||||
const t2 = mxid.match(/^@t2_([^:]+):/)?.[1];
|
||||
if (t2) candidateT2s.add(t2);
|
||||
}
|
||||
}
|
||||
|
||||
let staleT2s = Array.from(candidateT2s).filter(id => {
|
||||
const name = cached.get(id);
|
||||
return !name || name.startsWith('t2_') || name.startsWith('@') || this.isStaleUsername(name);
|
||||
});
|
||||
|
||||
// Matrix already knows the display name attached to each room member, so
|
||||
// prefer that authenticated source before trying Reddit's legacy lookup.
|
||||
if (staleT2s.length > 0 && this.usernameResolver) {
|
||||
try {
|
||||
const resolved = await this.usernameResolver.resolveT2Ids(
|
||||
staleT2s.map((id) => `t2_${id}`),
|
||||
);
|
||||
for (const [t2, name] of Object.entries(resolved)) {
|
||||
const bare = t2.replace(/^t2_/, '');
|
||||
cached.set(bare, name);
|
||||
this.t2ToUsername.set(bare, name);
|
||||
this.persistUsername(t2, name);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.debug('[chat] MXID resolution failed:', err);
|
||||
}
|
||||
staleT2s = staleT2s.filter((id) => {
|
||||
const name = cached.get(id);
|
||||
return !name || this.isStaleUsername(name);
|
||||
});
|
||||
}
|
||||
|
||||
// Fall back to Reddit's alfred API for profiles without a Matrix display
|
||||
// name. This is best-effort and leaves the stable t2 ID visible on error.
|
||||
if (staleT2s.length > 0) {
|
||||
const numericIds = staleT2s.map(id => id.replace(/^t2_/, ''));
|
||||
try {
|
||||
@@ -1014,23 +1121,6 @@ export class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
// Also resolve MXID-based t2 IDs via the lifted UsernameResolver,
|
||||
// while avoiding re-queries for IDs we already resolved via alfred.
|
||||
const mxidT2s = Array.from(new Set(staleT2s.filter(id => !cached.get(id.replace(/^t2_/, '')))));
|
||||
if (mxidT2s.length > 0 && this.usernameResolver) {
|
||||
try {
|
||||
const resolved = await this.usernameResolver.resolveT2Ids(mxidT2s);
|
||||
for (const [t2, name] of Object.entries(resolved)) {
|
||||
const bare = t2.replace(/^t2_/, '');
|
||||
cached.set(bare, name);
|
||||
this.t2ToUsername.set(bare, name);
|
||||
this.persistUsername(t2, name);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.debug('[chat] MXID resolution failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
@@ -1170,28 +1260,21 @@ export class ChatService {
|
||||
if (dirty) await this.cache.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the t2 ID for a Reddit username via the standard about.json
|
||||
* endpoint. Returns `t2_xxx` or null.
|
||||
*/
|
||||
private async resolveUsernameToT2(username: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
`https://www.reddit.com/user/${encodeURIComponent(username)}/about.json`,
|
||||
{ headers: { 'User-Agent': 'reddit-bot-chat/1.0' }, timeoutMs: 15_000 },
|
||||
);
|
||||
if (!res.ok) {
|
||||
this.logger.warn(`[chat] /user/${username}/about.json → ${res.status}`);
|
||||
return null;
|
||||
}
|
||||
const json = (await res.json()) as { data?: { id?: string } };
|
||||
const id = json.data?.id;
|
||||
if (!id) return null;
|
||||
return id.startsWith('t2_') ? id : `t2_${id}`;
|
||||
} catch (err) {
|
||||
this.logger.error(`[chat] failed to resolve username "${username}":`, err);
|
||||
return null;
|
||||
/** Resolve an account ID from listing data, the durable cache, or Reddit auth. */
|
||||
private async resolveUsernameToT2(
|
||||
username: string,
|
||||
knownT2Id?: string,
|
||||
): Promise<string | null> {
|
||||
if (knownT2Id && /^t2_[a-z0-9]+$/i.test(knownT2Id)) return knownT2Id;
|
||||
|
||||
const cached = this.redditUsersRepo?.getByUsername(username);
|
||||
if (cached) return cached.t2Id;
|
||||
|
||||
if (this.usernameResolver?.resolveUsernameToT2) {
|
||||
return this.usernameResolver.resolveUsernameToT2(username);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse a chat URL like `…/room/!abc%3Areddit.com` → `!abc:reddit.com`. */
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface ActiveChat {
|
||||
roomId?: string;
|
||||
messages?: ChatMessage[];
|
||||
originMessage?: string;
|
||||
hasReceivedImages?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -57,7 +57,17 @@ export class ResponseCheckerService {
|
||||
results.push(result);
|
||||
|
||||
if (result.gotResponse) {
|
||||
// Mark as got response
|
||||
const hasPerformanceContext = Boolean(
|
||||
message.postId || message.strategyId || message.subreddit,
|
||||
);
|
||||
if (!hasPerformanceContext) {
|
||||
this.messagesRepo.markChecked(message.id);
|
||||
this.logger.info(
|
||||
`Response from u/${message.recipient} excluded from performance: no post, strategy, or subreddit context.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.messagesRepo.markGotResponse(message.id);
|
||||
|
||||
// Get the original post info
|
||||
|
||||
+10
-2
@@ -20,12 +20,20 @@ export class ActivityLog {
|
||||
message: r.message,
|
||||
recipient: r.recipient ?? undefined,
|
||||
imageUrl: r.imageUrl ?? undefined,
|
||||
postTitle: r.postTitle ?? undefined,
|
||||
postUrl: r.postUrl ?? undefined,
|
||||
}))
|
||||
.reverse();
|
||||
}
|
||||
|
||||
push(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void {
|
||||
const item: ActivityItem = { timestamp: Date.now(), type, message, recipient, imageUrl };
|
||||
push(
|
||||
type: ActivityItem['type'],
|
||||
message: string,
|
||||
recipient?: string,
|
||||
imageUrl?: string,
|
||||
origin?: { postTitle: string; postUrl: string },
|
||||
): void {
|
||||
const item: ActivityItem = { timestamp: Date.now(), type, message, recipient, imageUrl, ...origin };
|
||||
this.items.push(item);
|
||||
if (this.items.length > MAX_ITEMS) {
|
||||
this.items = this.items.slice(-MAX_ITEMS);
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { LogBroadcaster } from './log-broadcaster.js';
|
||||
import type { RuntimeConfig } from './types.js';
|
||||
import type { RedditSessionImporter } from '../services/reddit-session-importer.js';
|
||||
import type { AccountAuthContext } from '../services/account-auth-context.js';
|
||||
import type { ConfigStore } from '../config/persistence.js';
|
||||
|
||||
/** Bag of everything the route modules might need. */
|
||||
export interface WebContext {
|
||||
@@ -46,6 +47,7 @@ export interface WebContext {
|
||||
sessionImporter?: RedditSessionImporter;
|
||||
accountAuth?: AccountAuthContext;
|
||||
conversationState?: ConversationStateRepository;
|
||||
configStore?: ConfigStore;
|
||||
};
|
||||
|
||||
/** Bot pause/resume toggle read by the main loop. */
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ActivityItem } from './types.js';
|
||||
|
||||
const INTERNAL_REDDIT_ID = /@?t2_[a-z0-9]+(?::reddit\.com)?/gi;
|
||||
|
||||
export interface RedditIdentityResolver {
|
||||
resolveSenderDisplay(identity: string): string;
|
||||
}
|
||||
|
||||
function isInternalIdentity(value: string): boolean {
|
||||
return value === 'unknown' || value.startsWith('t2_') || /^@t2_[^:]+:/i.test(value);
|
||||
}
|
||||
|
||||
export function resolveRedditUsername(
|
||||
identity: string | null | undefined,
|
||||
resolver?: RedditIdentityResolver,
|
||||
): string | null {
|
||||
if (!identity) return null;
|
||||
const candidate = resolver?.resolveSenderDisplay(identity) ?? identity;
|
||||
if (isInternalIdentity(candidate)) return null;
|
||||
return candidate.replace(/^\/?u\//i, '');
|
||||
}
|
||||
|
||||
export function redactInternalRedditIds(message: string, username?: string | null): string {
|
||||
return message
|
||||
.replace(/u\/@?t2_[a-z0-9]+(?::reddit\.com)?/gi, username ? `u/${username}` : 'Reddit user')
|
||||
.replace(INTERNAL_REDDIT_ID, username ?? 'Reddit user');
|
||||
}
|
||||
|
||||
export function presentActivityItem(
|
||||
item: ActivityItem,
|
||||
resolver?: RedditIdentityResolver,
|
||||
): ActivityItem {
|
||||
const recipient = resolveRedditUsername(item.recipient, resolver);
|
||||
return {
|
||||
...item,
|
||||
message: redactInternalRedditIds(item.message, recipient),
|
||||
...(recipient ? { recipient } : { recipient: undefined }),
|
||||
};
|
||||
}
|
||||
+59
-10
@@ -1,11 +1,36 @@
|
||||
import { Router } from 'express';
|
||||
import type { WebContext } from '../context.js';
|
||||
import { SubredditConfigSchema, type SubredditConfig } from '../../config/index.js';
|
||||
import { ConfigSchema, SubredditConfigSchema, type SubredditConfig } from '../../config/index.js';
|
||||
import { TONE_KEYS } from '../../config/tone-profiles.js';
|
||||
import { persistSubreddits } from '../../config/persistence.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
const SubredditsUpdateSchema = z.array(SubredditConfigSchema).min(1);
|
||||
const AdvancedConfigSchema = z.strictObject({
|
||||
reddit: ConfigSchema.shape.reddit.omit({
|
||||
subreddits: true,
|
||||
prioritizeRecentPosts: true,
|
||||
recentWindowMinutes: true,
|
||||
}),
|
||||
openrouter: ConfigSchema.shape.openrouter.omit({ apiKey: true }),
|
||||
browser: z.strictObject({ sessionDir: z.string() }),
|
||||
media: ConfigSchema.shape.media,
|
||||
strategies: ConfigSchema.shape.strategies,
|
||||
chatStrategies: ConfigSchema.shape.chatStrategies,
|
||||
});
|
||||
|
||||
function advancedConfig(ctx: WebContext): z.infer<typeof AdvancedConfigSchema> {
|
||||
const { subreddits: _subreddits, prioritizeRecentPosts: _prioritize, recentWindowMinutes: _window, ...reddit } = ctx.config.reddit;
|
||||
const { apiKey: _apiKey, ...openrouter } = ctx.config.openrouter;
|
||||
const { dryRun: _dryRun, ...browser } = ctx.config.browser;
|
||||
return {
|
||||
reddit,
|
||||
openrouter,
|
||||
browser,
|
||||
media: ctx.config.media,
|
||||
strategies: ctx.config.strategies,
|
||||
chatStrategies: ctx.config.chatStrategies,
|
||||
};
|
||||
}
|
||||
|
||||
export function createConfigRouter(ctx: WebContext): Router {
|
||||
const router = Router();
|
||||
@@ -17,12 +42,30 @@ export function createConfigRouter(ctx: WebContext): Router {
|
||||
reddit: ctx.runtimeConfig.reddit,
|
||||
subreddits: ctx.config.reddit.subreddits,
|
||||
tones: TONE_KEYS,
|
||||
advanced: advancedConfig(ctx),
|
||||
});
|
||||
});
|
||||
|
||||
router.put('/config', (req, res) => {
|
||||
try {
|
||||
const updates = req.body;
|
||||
const configStore = ctx.services.configStore;
|
||||
if (!configStore) throw new Error('Configuration persistence is unavailable');
|
||||
|
||||
const parsedSubreddits = updates.subreddits === undefined
|
||||
? null
|
||||
: SubredditsUpdateSchema.safeParse(updates.subreddits);
|
||||
if (parsedSubreddits && !parsedSubreddits.success) {
|
||||
res.status(400).json({ error: 'Invalid subreddits payload', detail: parsedSubreddits.error.format() });
|
||||
return;
|
||||
}
|
||||
const parsedAdvanced = updates.advanced === undefined
|
||||
? null
|
||||
: AdvancedConfigSchema.safeParse(updates.advanced);
|
||||
if (parsedAdvanced && !parsedAdvanced.success) {
|
||||
res.status(400).json({ error: 'Invalid advanced configuration', detail: parsedAdvanced.error.format() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (updates.rateLimit) {
|
||||
if (typeof updates.rateLimit.minDelayBetweenMessagesMs === 'number') {
|
||||
@@ -65,13 +108,8 @@ export function createConfigRouter(ctx: WebContext): Router {
|
||||
ctx.config.reddit.prioritizeRecentPosts = ctx.runtimeConfig.reddit.prioritizeRecentPosts;
|
||||
ctx.config.reddit.recentWindowMinutes = ctx.runtimeConfig.reddit.recentWindowMinutes;
|
||||
|
||||
if (updates.subreddits !== undefined) {
|
||||
const parsed = SubredditsUpdateSchema.safeParse(updates.subreddits);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: 'Invalid subreddits payload', detail: parsed.error.format() });
|
||||
return;
|
||||
}
|
||||
const next: SubredditConfig[] = parsed.data;
|
||||
if (parsedSubreddits?.success) {
|
||||
const next: SubredditConfig[] = parsedSubreddits.data;
|
||||
const seen = new Set<string>();
|
||||
for (const s of next) {
|
||||
const key = s.name.toLowerCase();
|
||||
@@ -81,10 +119,20 @@ export function createConfigRouter(ctx: WebContext): Router {
|
||||
}
|
||||
seen.add(key);
|
||||
}
|
||||
persistSubreddits(next);
|
||||
ctx.config.reddit.subreddits = next;
|
||||
}
|
||||
|
||||
if (parsedAdvanced?.success) {
|
||||
Object.assign(ctx.config.reddit, parsedAdvanced.data.reddit);
|
||||
Object.assign(ctx.config.openrouter, parsedAdvanced.data.openrouter);
|
||||
Object.assign(ctx.config.browser, parsedAdvanced.data.browser);
|
||||
ctx.config.media = parsedAdvanced.data.media;
|
||||
ctx.config.strategies = parsedAdvanced.data.strategies;
|
||||
ctx.config.chatStrategies = parsedAdvanced.data.chatStrategies;
|
||||
}
|
||||
|
||||
configStore.save(ctx.config);
|
||||
|
||||
ctx.getOnConfigChange()?.(ctx.runtimeConfig);
|
||||
|
||||
ctx.activity.push('info', 'Config updated via dashboard');
|
||||
@@ -94,6 +142,7 @@ export function createConfigRouter(ctx: WebContext): Router {
|
||||
success: true,
|
||||
config: ctx.runtimeConfig,
|
||||
subreddits: ctx.config.reddit.subreddits,
|
||||
advanced: advancedConfig(ctx),
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.logger.error('Failed to update config:', error);
|
||||
|
||||
@@ -30,16 +30,45 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
||||
return { messages, state, intent: chooseConversationIntent(messages, state, requestedIntent) };
|
||||
}
|
||||
|
||||
router.get('/conversations', async (_req, res) => {
|
||||
router.get('/conversations', async (req, res) => {
|
||||
if (!ctx.services.chat) {
|
||||
res.json([]);
|
||||
res.status(503).json({ error: 'Chat service not initialized' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
res.json(await ctx.services.chat.getCachedConversations());
|
||||
const conversations = await ctx.services.chat.getCachedConversations();
|
||||
if (req.query.includeImageMetadata !== 'true') {
|
||||
res.json(conversations);
|
||||
return;
|
||||
}
|
||||
const receivedMediaRooms = new Set<string>();
|
||||
const receivedMediaUsers = new Set<string>();
|
||||
|
||||
if (ctx.services.media) {
|
||||
try {
|
||||
const mediaItems = await ctx.services.media.getMediaItems();
|
||||
for (const item of mediaItems) {
|
||||
if (item.sender.toLowerCase() === 'me') continue;
|
||||
if (item.chatRoomId) receivedMediaRooms.add(item.chatRoomId);
|
||||
if (item.chatUsername) receivedMediaUsers.add(item.chatUsername.toLowerCase());
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.logger.warn('Failed to read media index for conversation markers:', error);
|
||||
}
|
||||
}
|
||||
|
||||
res.json(conversations.map((conversation) => ({
|
||||
...conversation,
|
||||
hasReceivedImages:
|
||||
receivedMediaRooms.has(conversation.roomId ?? '')
|
||||
|| receivedMediaUsers.has(conversation.username.toLowerCase())
|
||||
|| (conversation.messages ?? []).some(
|
||||
(message) => !message.isSelf && (message.media?.length ?? 0) > 0,
|
||||
),
|
||||
})));
|
||||
} catch (error) {
|
||||
ctx.logger.error('Failed to fetch conversations:', error);
|
||||
res.json([]);
|
||||
res.status(500).json({ error: 'Failed to fetch conversations' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -49,21 +78,10 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// If a sync is already in flight (e.g. a Full sync the user kicked off
|
||||
// from the dashboard), don't queue *another* delta + lazy-load behind it
|
||||
// — that's what makes the modal sit on "Loading…" for minutes. Render
|
||||
// from cache and let the frontend show a "sync in progress" banner; the
|
||||
// user can reopen once it's done.
|
||||
// Conversation reads never initiate a sync. Sync lifecycle events can
|
||||
// invalidate this query, so starting a sync here would create a loop.
|
||||
const syncStatus = ctx.services.chat.getSyncStatus();
|
||||
const syncInFlight = syncStatus.syncing;
|
||||
|
||||
if (!syncInFlight) {
|
||||
try {
|
||||
await ctx.services.chat.refreshTopChats();
|
||||
} catch (err) {
|
||||
ctx.logger.warn('Pre-open delta sync failed:', err);
|
||||
}
|
||||
}
|
||||
const conversation = await findConversation(decodeURIComponent(req.params.id));
|
||||
if (!conversation) {
|
||||
res.status(404).json({ error: 'Conversation not found' });
|
||||
@@ -94,19 +112,24 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
||||
subreddit?: string;
|
||||
title?: string;
|
||||
url: string;
|
||||
imageUrl: string | null;
|
||||
sentAt: number;
|
||||
} | null = null;
|
||||
try {
|
||||
const sent = ctx.repos.messages.getLatestByRecipient(conversation.username);
|
||||
const sent = conversation.roomId
|
||||
? ctx.repos.messages.getByChatRoomId(conversation.roomId)
|
||||
?? ctx.repos.messages.getLatestByRecipient(conversation.username)
|
||||
: ctx.repos.messages.getLatestByRecipient(conversation.username);
|
||||
if (sent && sent.postId) {
|
||||
const url = sent.subreddit
|
||||
const url = sent.postUrl ?? (sent.subreddit
|
||||
? `https://www.reddit.com/r/${sent.subreddit}/comments/${sent.postId}/`
|
||||
: `https://redd.it/${sent.postId}`;
|
||||
: `https://redd.it/${sent.postId}`);
|
||||
triggeringPost = {
|
||||
id: sent.postId,
|
||||
subreddit: sent.subreddit,
|
||||
title: sent.postTitle,
|
||||
url,
|
||||
imageUrl: sent.postImageUrl ?? null,
|
||||
sentAt: sent.sentAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import type { WebContext } from '../context.js';
|
||||
import { resolveRedditUsername } from '../reddit-identities.js';
|
||||
|
||||
export function createMediaRouter(ctx: WebContext): Router {
|
||||
const router = Router();
|
||||
@@ -23,11 +24,17 @@ export function createMediaRouter(ctx: WebContext): Router {
|
||||
|
||||
res.json(items.map((item) => {
|
||||
const live = item.chatRoomId ? byRoom.get(item.chatRoomId) : undefined;
|
||||
const liveName = live?.username && live.username !== 'unknown' ? live.username : null;
|
||||
const username = liveName ?? item.chatUsername ?? null;
|
||||
const username = resolveRedditUsername(
|
||||
live?.username ?? item.chatUsername,
|
||||
ctx.services.chat,
|
||||
);
|
||||
const sender = /^(me|you)$/i.test(item.sender)
|
||||
? 'You'
|
||||
: resolveRedditUsername(item.sender, ctx.services.chat) ?? username ?? 'Unknown Reddit user';
|
||||
return {
|
||||
...item,
|
||||
chatUsername: username,
|
||||
sender,
|
||||
urlPath: `/media/${encodeURIComponent(item.relativePath)}`,
|
||||
};
|
||||
}));
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { Router } from 'express';
|
||||
import type { WebContext } from '../context.js';
|
||||
import type { BotStats } from '../types.js';
|
||||
import { presentActivityItem } from '../reddit-identities.js';
|
||||
|
||||
export function createStatsRouter(ctx: WebContext): Router {
|
||||
const router = Router();
|
||||
|
||||
const recentActivity = (limit: number) => ctx.activity
|
||||
.recent(limit)
|
||||
.map((item) => presentActivityItem(item, ctx.services.chat));
|
||||
|
||||
router.get('/stats', (_req, res) => {
|
||||
const messages = ctx.repos.messages.getRecent(1000);
|
||||
const totalSent = messages.length;
|
||||
@@ -23,7 +28,7 @@ export function createStatsRouter(ctx: WebContext): Router {
|
||||
maxMessagesPerHour: ctx.config.rateLimit.maxMessagesPerHour,
|
||||
subreddits: ctx.config.reddit.subreddits.map((s) => ({ name: s.name, type: s.type })),
|
||||
targetGenders: ctx.config.reddit.targetGenders,
|
||||
recentActivity: ctx.activity.recent(10),
|
||||
recentActivity: recentActivity(10),
|
||||
invitationCooldown: cooldown
|
||||
? { until: cooldown.until, reason: cooldown.reason, redditCode: cooldown.redditCode }
|
||||
: null,
|
||||
@@ -33,7 +38,7 @@ export function createStatsRouter(ctx: WebContext): Router {
|
||||
});
|
||||
|
||||
router.get('/activity', (_req, res) => {
|
||||
res.json(ctx.activity.recent(50));
|
||||
res.json(recentActivity(50));
|
||||
});
|
||||
|
||||
router.get('/posts/by-author/:username', (req, res) => {
|
||||
|
||||
+13
-2
@@ -21,6 +21,7 @@ import type { StrategySelector } from '../services/strategy-selector.js';
|
||||
import type { BrowserClient } from '../browser/client.js';
|
||||
import type { RedditSessionImporter } from '../services/reddit-session-importer.js';
|
||||
import type { AccountAuthContext } from '../services/account-auth-context.js';
|
||||
import type { ConfigStore } from '../config/persistence.js';
|
||||
import { ActivityLog } from './activity-log.js';
|
||||
import { LogBroadcaster } from './log-broadcaster.js';
|
||||
import type { WebContext } from './context.js';
|
||||
@@ -214,8 +215,18 @@ export class WebServer {
|
||||
this.services.conversationState = conversationState;
|
||||
}
|
||||
|
||||
logActivity(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void {
|
||||
this.activity.push(type, message, recipient, imageUrl);
|
||||
setConfigStore(configStore: ConfigStore): void {
|
||||
this.services.configStore = configStore;
|
||||
}
|
||||
|
||||
logActivity(
|
||||
type: ActivityItem['type'],
|
||||
message: string,
|
||||
recipient?: string,
|
||||
imageUrl?: string,
|
||||
origin?: { postTitle: string; postUrl: string },
|
||||
): void {
|
||||
this.activity.push(type, message, recipient, imageUrl, origin);
|
||||
}
|
||||
|
||||
pushLog(level: LogEntry['level'], message: string, data?: unknown): void {
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface ActivityItem {
|
||||
message: string;
|
||||
recipient?: string;
|
||||
imageUrl?: string;
|
||||
postTitle?: string;
|
||||
postUrl?: string;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
|
||||
Reference in new issue
Block a user