Merge remote-tracking branch 'origin/main'

# Conflicts:
#	src/browser/client.ts
#	src/services/reddit-session-importer.ts
#	src/web/routes/conversations.ts
This commit is contained in:
jjbrunton committed 2026-08-08 20:17:19 +01:00
commit 48492d5238
30 files changed
+1041 -181

No files matched your search

+39 -10
View File
@@ -6,11 +6,18 @@ export type AccountAuthStatus =
| 'authenticated';
export interface AuthenticatedAccount {
/** Reddit account fullname, e.g. `t2_abc123`. */
accountId: string;
redditUsername: string;
matrixUserId: string;
}
export interface RedditAccountIdentity {
username: string;
/** Reddit's account ID, with or without the `t2_` prefix. */
accountId: string;
}
export interface AccountAuthSnapshot {
status: AccountAuthStatus;
accountId: string | null;
@@ -34,21 +41,43 @@ export interface AccountAuthContextOptions {
}
export interface CurrentAccountAuthentication {
getRedditUsername(): Promise<string | null>;
getRedditIdentity(): Promise<RedditAccountIdentity | null>;
refreshMatrixCredentials(): Promise<{ userId: string } | null>;
}
export function normalizeRedditAccountId(accountId: string): string {
const trimmed = accountId.trim();
const normalized = trimmed.startsWith('t2_') ? trimmed : `t2_${trimmed}`;
if (!/^t2_[a-z0-9]+$/i.test(normalized)) {
throw new Error('Reddit returned an invalid account ID');
}
return normalized;
}
export function expectedMatrixUserId(accountId: string): string {
return `@${normalizeRedditAccountId(accountId)}:reddit.com`;
}
export function assertMatchingAccountIdentity(account: AuthenticatedAccount): void {
const expected = expectedMatrixUserId(account.accountId);
if (account.matrixUserId !== expected) {
throw new Error(
`Reddit account u/${account.redditUsername} (${normalizeRedditAccountId(account.accountId)}) does not match Matrix account ${account.matrixUserId}`,
);
}
}
export async function authenticateCurrentAccount(
authentication: CurrentAccountAuthentication,
): Promise<AuthenticatedAccount | null> {
const redditUsername = await authentication.getRedditUsername();
if (!redditUsername) return null;
const redditIdentity = await authentication.getRedditIdentity();
if (!redditIdentity) return null;
const credentials = await authentication.refreshMatrixCredentials();
if (!credentials?.userId) return null;
return {
accountId: credentials.userId,
redditUsername,
accountId: normalizeRedditAccountId(redditIdentity.accountId),
redditUsername: redditIdentity.username,
matrixUserId: credentials.userId,
};
}
@@ -104,15 +133,15 @@ export class AccountAuthContext implements AccountAuthGate {
if (!account.accountId || !account.redditUsername || !account.matrixUserId) {
throw new Error('A fully authenticated account requires Reddit and Matrix identities');
}
if (account.accountId !== account.matrixUserId) {
throw new Error('Authenticated account ID must match the Matrix user ID');
}
assertMatchingAccountIdentity(account);
const changedAccount = this.snapshot.accountId !== account.accountId;
const accountId = normalizeRedditAccountId(account.accountId);
const changedAccount = this.snapshot.accountId !== accountId;
const wasAuthenticated = this.isFullyAuthenticated();
this.snapshot = {
status: 'authenticated',
accountId: account.accountId,
accountId,
redditUsername: account.redditUsername,
matrixUserId: account.matrixUserId,
reason: null,
+55 -2
View File
@@ -54,6 +54,11 @@ export interface RoomSummary {
recentEvents: MatrixTimelineEvent[];
}
export interface DirectRoomMapping {
recipientMxid: string;
roomId: string;
}
/**
* Cap on how long we'll sit and sleep waiting out a 429. Anything longer
* is surfaced as a `MatrixRateLimitError` so the main loop can back off
@@ -405,7 +410,7 @@ export class MatrixClient {
* "Start chat" in the UI:
*
* POST /_matrix/client/v3/createRoom
* { "preset": "reddit_dm", "invite": [mxid] }
* { "preset": "reddit_dm", "invite": [mxid], "is_direct": true }
*
* `reddit_dm` is a Reddit-specific preset that the homeserver expands
* server-side into the right power levels, history visibility, and
@@ -413,12 +418,53 @@ export class MatrixClient {
*/
async createDirectRoom(recipientMxid: string): Promise<string> {
const res = await this.request<{ room_id: string }>('POST', '/_matrix/client/v3/createRoom', {
body: { preset: 'reddit_dm', invite: [recipientMxid] },
body: { preset: 'reddit_dm', invite: [recipientMxid], is_direct: true },
});
this.logger.debug(`[matrix] created DM room ${res.room_id} with ${recipientMxid}`);
return res.room_id;
}
/**
* Add missing rooms to the account-level `m.direct` map used by Matrix
* clients to classify joined rooms as direct chats. Existing mappings are
* preserved verbatim; the write is skipped when there is nothing to repair.
*/
async repairDirectRoomMappings(mappings: DirectRoomMapping[]): Promise<number> {
if (mappings.length === 0) return 0;
let directRooms: Record<string, string[]>;
try {
directRooms = await this.request<Record<string, string[]>>(
'GET',
`/_matrix/client/v3/user/${encodeURIComponent(this.creds.userId)}/account_data/m.direct`,
);
} catch (error) {
if (error instanceof Error && error.message.includes('→ 404')) {
directRooms = {};
} else {
throw error;
}
}
let repaired = 0;
for (const { recipientMxid, roomId } of mappings) {
const existing = Array.isArray(directRooms[recipientMxid])
? directRooms[recipientMxid]
: [];
if (existing.includes(roomId)) continue;
directRooms[recipientMxid] = [...existing, roomId];
repaired++;
}
if (repaired === 0) return 0;
await this.request<Record<string, never>>(
'PUT',
`/_matrix/client/v3/user/${encodeURIComponent(this.creds.userId)}/account_data/m.direct`,
{ body: directRooms },
);
return repaired;
}
/**
* One-shot: create a DM room with the recipient and send the opening
* message. Returns the new room_id and the event_id of the message.
@@ -428,6 +474,13 @@ export class MatrixClient {
body: string,
): Promise<{ roomId: string; eventId: string }> {
const roomId = await this.createDirectRoom(recipientMxid);
try {
await this.repairDirectRoomMappings([{ recipientMxid, roomId }]);
} catch (error) {
// Room creation has already succeeded. Do not turn a metadata repair
// failure into an orphan-and-retry cycle that creates duplicate rooms.
this.logger.warn(`[matrix] failed to register ${roomId} as a direct chat:`, error);
}
const eventId = await this.sendTextMessage(roomId, body);
return { roomId, eventId };
}
+56 -10
View File
@@ -402,6 +402,7 @@ export class ChatService {
if (!chat.roomName || chat.roomName.startsWith('t2_')) chat.roomName = resolved;
}
await this.cache.save(chats);
await this.repairDirectRoomMappings(chats);
let done = 0;
for (const chat of chats) {
@@ -736,17 +737,21 @@ export class ChatService {
existing.set(roomId, next);
}
if (this.mediaService) {
for (const [roomId, room] of Object.entries(joined)) {
const scraped = this.scrapeMessageEvents(room?.timeline?.events ?? []);
const mediaMessages = scraped.filter((m) => m.mediaUrls.length > 0);
if (mediaMessages.length === 0) continue;
const chat = existing.get(roomId);
if (!chat) continue;
for (const [roomId, room] of Object.entries(joined)) {
const scraped = this.scrapeMessageEvents(room?.timeline?.events ?? []);
if (scraped.length === 0) continue;
const chat = existing.get(roomId);
if (!chat) continue;
let newMessages: ChatMessage[] = scraped.map(
({ mediaUrls: _ignored, ...message }) => message,
);
if (this.mediaService && scraped.some((message) => message.mediaUrls.length > 0)) {
try {
const withMedia = await this.mediaService.attachMedia(chat, mediaMessages);
this.mergeNewMessages(chat, withMedia);
if (withMedia.some((message) => !message.isSelf && (message.media?.length ?? 0) > 0)) {
newMessages = await this.mediaService.attachMedia(chat, scraped);
if (newMessages.some(
(message) => !message.isSelf && (message.media?.length ?? 0) > 0,
)) {
this.recordReceivedImage(chat);
}
} catch (err) {
@@ -755,6 +760,7 @@ export class ChatService {
);
}
}
this.mergeNewMessages(chat, newMessages);
}
const merged = Array.from(existing.values()).sort(
@@ -766,9 +772,49 @@ export class ChatService {
// Resolve any t2_-prefixed usernames the delta just introduced, so the
// dashboard doesn't show raw Matrix-style IDs for new rooms.
await this.enrichUsernames();
await this.repairDirectRoomMappings(merged);
return merged;
}
/**
* Reconcile cached one-to-one chats with Matrix account data. The durable
* username repository lets delta sync repair older rooms even when Reddit
* omits their member state from the latest /sync response.
*/
private async repairDirectRoomMappings(chats: ActiveChat[]): Promise<void> {
const mappings: Array<{ recipientMxid: string; roomId: string }> = [];
const seen = new Set<string>();
for (const chat of chats) {
if (!chat.roomId) continue;
const rawT2 = this.extractT2(chat);
const persistedT2 = rawT2
? `t2_${rawT2}`
: this.redditUsersRepo?.getByUsername(chat.username)?.t2Id;
if (!persistedT2) continue;
const recipientMxid = MatrixClient.t2ToMxid(persistedT2);
const key = `${recipientMxid}|${chat.roomId}`;
if (seen.has(key)) continue;
seen.add(key);
mappings.push({ recipientMxid, roomId: chat.roomId });
}
if (mappings.length === 0) return;
try {
const repaired = await this.withAuth(() =>
this.matrix.repairDirectRoomMappings(mappings),
);
if (repaired > 0) {
this.logger.info(`[chat] repaired ${repaired} missing direct-room mapping(s)`);
}
} catch (error) {
// Sync remains useful even if Reddit temporarily rejects account-data
// maintenance. A later delta/full sync will try the additive repair again.
this.logger.warn('[chat] direct-room mapping repair failed:', error);
}
}
private scrapeMessageEvents(events: MatrixTimelineEvent[]): ScrapedChatMessage[] {
return events
.filter((e) => e.type === 'm.room.message')
+57 -27
View File
@@ -36,9 +36,12 @@ export class MonitorService {
this.logger.debug(`Checking r/${subreddit.name} (${subreddit.type}) for new posts...`);
const posts = await this.redditApi.getNewPosts(subreddit.name, 25, subreddit.type);
// Filter for valid, unprocessed posts
const validPosts: RedditPost[] = [];
for (const post of posts) {
// Reddit normally returns /new in descending order, but sort explicitly
// so we can stop as soon as the first fully eligible candidate is found.
// Previously we screened the entire batch before choosing one, causing up
// to two profile requests for every otherwise-valid post in the listing.
const newestFirst = [...posts].sort((a, b) => b.created_utc - a.created_utc);
for (const post of newestFirst) {
if (this.postsRepo.isProcessed(post.id)) {
continue;
}
@@ -48,6 +51,23 @@ export class MonitorService {
continue;
}
if (
this.config.reddit.filterSellers &&
(this.config.reddit.sellerKeywords.length > 0 || this.config.reddit.sellerDomains.length > 0)
) {
const isSeller = await this.redditApi.isSellerProfile(
post.author,
this.config.reddit.sellerKeywords,
this.config.reddit.sellerDomains,
post,
);
if (isSeller) {
this.logger.info(`Skipping post ${post.id} from u/${post.author} - seller signals detected`);
this.recordRejection(post, subreddit.type, 'Seller signals detected');
continue;
}
}
// Chat subs almost always have a self-declared gender tag in the
// title/body (e.g. [25F], M4F). Drop posts that don't match the
// configured targetGenders here so they never enter the processor.
@@ -55,41 +75,33 @@ export class MonitorService {
const tag = parseGenderTag(`${post.title}\n${post.selftext ?? ''}`);
if (!tag) {
this.logger.debug(`Skipping chat post ${post.id} - no gender tag in title/body`);
this.recordRejection(post, subreddit.type, 'No gender tag in title/body');
continue;
}
if (!targetGenders.includes(tag)) {
this.logger.debug(
`Skipping chat post ${post.id} - gender tag "${tag}" not in target list [${targetGenders.join(', ')}]`,
);
this.recordRejection(post, subreddit.type, `Gender tag "${tag}" not targeted`);
continue;
}
}
validPosts.push(post);
}
if (validPosts.length > 0) {
// Sort by creation time (newest first)
validPosts.sort((a, b) => b.created_utc - a.created_utc);
if (prioritizeRecent) {
const recent = validPosts.find((p) => p.created_utc >= recentCutoffSec);
if (recent) {
this.logger.info(`Found ${validPosts.length} new posts in r/${subreddit.name} (recent match)`);
return [recent];
}
// Remember the freshest non-recent candidate as a fallback and
// keep scanning other subs for an actually-recent post.
if (!fallback || validPosts[0].created_utc > fallback.created_utc) {
fallback = validPosts[0];
}
} else {
this.logger.info(`Found ${validPosts.length} new posts in r/${subreddit.name}`);
// Return just the first (newest) valid post
// This ensures we switch to a different random subreddit on the next poll cycle
// instead of processing a batch from the same subreddit
return [validPosts[0]];
// At this point the candidate has passed every check. Select it now
// rather than spending more Reddit requests screening users that
// cannot be acted on during this poll cycle.
if (!prioritizeRecent || post.created_utc >= recentCutoffSec) {
this.logger.info(
`Found eligible post in r/${subreddit.name}${prioritizeRecent ? ' (recent match)' : ''}`,
);
return [post];
}
// This is the newest eligible post in this subreddit. Keep it as a
// possible fallback, then move to the next subreddit to look for a
// recent candidate. Older posts in this batch cannot improve it.
if (!fallback || post.created_utc > fallback.created_utc) fallback = post;
break;
}
} catch (error) {
if (error instanceof RedditRateLimitError) throw error;
@@ -160,4 +172,22 @@ export class MonitorService {
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Persist a completed screening decision so later polls do not repeat it. */
private recordRejection(
post: RedditPost,
subredditType: 'image' | 'chat',
reason: string,
): void {
this.postsRepo.insert({
id: post.id,
subreddit: post.subreddit,
author: post.author,
title: post.title,
postUrl: this.redditApi.getPostUrl(post),
imageUrl: subredditType === 'image' ? this.redditApi.getImageUrl(post) : null,
processedAt: Date.now(),
});
this.postsRepo.setError(post.id, reason);
}
}
+27 -22
View File
@@ -2,12 +2,16 @@ import type { Cookie } from 'playwright';
import type { MatrixClient } from './chat/matrix/client.js';
import type { MatrixCredentials } from './chat/matrix/types.js';
import type { AccountAuthContext } from './account-auth-context.js';
import {
assertMatchingAccountIdentity,
normalizeRedditAccountId,
type RedditAccountIdentity,
} from './account-auth-context.js';
interface BrowserSessionTarget {
importRedditCookies(cookies: Cookie[]): Promise<void>;
isSessionValid(): Promise<boolean>;
getAuthenticatedUsername(): Promise<string | null>;
getAuthenticatedIdentity?(): Promise<{ username: string; accountId: string } | null>;
getAuthenticatedIdentity(): Promise<RedditAccountIdentity | null>;
}
interface ChatSessionTarget {
@@ -28,11 +32,6 @@ export interface VerifiedRedditSessionStore {
}): void;
}
function matrixRedditAccountId(matrixUserId: string): string | null {
const match = matrixUserId.match(/^@t2_([^:]+):reddit\.com$/i);
return match?.[1]?.toLowerCase() ?? null;
}
export class RedditSessionImporter {
constructor(
private readonly browser: BrowserSessionTarget,
@@ -63,31 +62,37 @@ export class RedditSessionImporter {
}
await this.browser.importRedditCookies(cookies);
if (!(await this.browser.isSessionValid())) {
throw new Error('Imported cookies did not authenticate with Reddit');
}
const identity = this.browser.getAuthenticatedIdentity
? await this.browser.getAuthenticatedIdentity()
: null;
const redditUsername = identity?.username ?? await this.browser.getAuthenticatedUsername();
if (!redditUsername) throw new Error('Reddit did not return an authenticated account identity');
if (identity) {
const matrixAccountId = matrixRedditAccountId(userId);
if (!matrixAccountId || matrixAccountId !== identity.accountId.toLowerCase().replace(/^t2_/, '')) {
throw new Error('Reddit cookies and Matrix credentials belong to different accounts');
let redditIdentity: RedditAccountIdentity;
try {
if (!(await this.browser.isSessionValid())) {
throw new Error('Imported cookies did not authenticate with Reddit');
}
const identity = await this.browser.getAuthenticatedIdentity();
if (!identity) throw new Error('Reddit did not return an authenticated account identity');
redditIdentity = identity;
assertMatchingAccountIdentity({
accountId: identity.accountId,
redditUsername: identity.username,
matrixUserId: userId,
});
} catch (error) {
this.accountAuth.invalidate(error instanceof Error ? error.message : String(error));
throw error;
}
this.saveCredentials(credentials);
this.chat.setMatrixClient(matrixClient);
const account = {
accountId: userId,
redditUsername,
redditUsername: redditIdentity.username,
matrixUserId: userId,
};
this.accountAuth.markAuthenticated({
accountId: normalizeRedditAccountId(redditIdentity.accountId),
redditUsername: redditIdentity.username,
matrixUserId: userId,
});
this.sessionStore?.save({ ...account, cookies, credentials });
await this.chat.switchAccount?.(account.accountId);
this.accountAuth.markAuthenticated(account);
return account;
}
}