Blur gallery images by default, sync progress UI, and add chat/monitor tests

- Blur NSFW media gallery images until hover/reveal
- Surface chat sync progress in the dashboard header
- Extend chat service, cache, and monitor with delta-media handling
- Add tests for chat cooldown, delta media, and monitor flows

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Opus 4.7 committed 2026-05-22 10:01:09 +01:00
1 parent 633bb0ec04
commit 2260388dc8
15 files changed
+675 -41

No files matched your search

+47 -1
View File
@@ -1,7 +1,7 @@
import fs from 'fs/promises';
import path from 'path';
import type { Logger } from '../../utils/logger.js';
import type { ActiveChat, ChatCacheFile } from './types.js';
import type { ActiveChat, ChatCacheFile, InvitationCooldown } from './types.js';
export interface SyncMarkers {
nextBatch: string | null;
@@ -22,6 +22,7 @@ export class ChatCache {
private nextBatch: string | null = null;
private lastFullSyncAt: number | null = null;
private lastDeltaSyncAt: number | null = null;
private cooldown: InvitationCooldown | null = null;
constructor(private logger: Logger, filePath?: string) {
this.filePath = filePath ?? path.join(process.cwd(), 'data', 'chat-cache.json');
@@ -61,6 +62,14 @@ export class ChatCache {
this.lastDeltaSyncAt = at;
}
getCooldown(): InvitationCooldown | null {
return this.cooldown;
}
setCooldown(cooldown: InvitationCooldown | null): void {
this.cooldown = cooldown;
}
async load(): Promise<void> {
try {
const raw = await fs.readFile(this.filePath, 'utf-8');
@@ -74,6 +83,7 @@ export class ChatCache {
this.nextBatch = parsed.nextBatch ?? null;
this.lastFullSyncAt = parsed.lastFullSyncAt ?? null;
this.lastDeltaSyncAt = parsed.lastDeltaSyncAt ?? null;
this.cooldown = parsed.cooldown ?? null;
this.logger.info(
`Loaded ${parsed.chats.length} chats from cache` +
(this.lastFullSyncAt
@@ -89,6 +99,41 @@ export class ChatCache {
}
}
/**
* Persist only the cooldown field, preserving whatever chats / sync markers
* are already on disk. Used when a rate-limit fires before the in-memory
* cache has been populated — a plain `save()` would clobber a prior full
* sync with `chats: []` and null markers.
*/
async persistCooldown(): Promise<void> {
let existing: ChatCacheFile | null = null;
try {
const raw = await fs.readFile(this.filePath, 'utf-8');
existing = JSON.parse(raw) as ChatCacheFile;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
this.logger.warn('Failed to read chat cache for cooldown update:', error);
}
}
const payload: ChatCacheFile = existing
? { ...existing, cooldown: this.cooldown, savedAt: Date.now() }
: {
version: 1,
savedAt: Date.now(),
chats: [],
nextBatch: null,
lastFullSyncAt: null,
lastDeltaSyncAt: null,
cooldown: this.cooldown,
};
try {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
await fs.writeFile(this.filePath, JSON.stringify(payload, null, 2), 'utf-8');
} catch (error) {
this.logger.warn('Failed to save chat cache cooldown:', error);
}
}
async save(chats: ActiveChat[] = this.chats): Promise<void> {
const payload: ChatCacheFile = {
version: 1,
@@ -97,6 +142,7 @@ export class ChatCache {
nextBatch: this.nextBatch,
lastFullSyncAt: this.lastFullSyncAt,
lastDeltaSyncAt: this.lastDeltaSyncAt,
cooldown: this.cooldown,
};
try {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
+64 -18
View File
@@ -4,7 +4,9 @@ import type { MediaService } from '../media.js';
import { ChatCache } from './cache.js';
import { MatrixClient, MatrixAuthError, MatrixRateLimitError } from './matrix/client.js';
import type { MatrixJoinedRoom, MatrixTimelineEvent } from './matrix/types.js';
import type { ActiveChat, ChatMessage, ScrapedChatMessage } from './types.js';
import type { ActiveChat, ChatMessage, ScrapedChatMessage, InvitationCooldown } from './types.js';
export type { InvitationCooldown } from './types.js';
import { fetchWithTimeout } from '../../utils/fetch-with-timeout.js';
const ACTIVE_WINDOW_MS = 96 * 60 * 60 * 1000;
@@ -94,22 +96,6 @@ export type NewReplyListener = (info: {
replySnippet: string;
}) => void;
/**
* Set when Reddit's homeserver tells us to stop creating new DMs for a while
* (e.g. the "Wow, you've created a lot of chats. Let's take a break." soft
* throttle, errcode `rate.invitation_limit_mature_account`).
*/
export interface InvitationCooldown {
/** Wall-clock ms when sends may resume. */
until: number;
/** Reddit-side error code, e.g. `rate.invitation_limit_mature_account`. */
redditCode: string | null;
/** Human-readable explanation suitable for the dashboard. */
reason: string;
/** Wall-clock ms when the cooldown was set. */
setAt: number;
}
export class ChatService {
private cache: ChatCache;
private queue: Promise<unknown> = Promise.resolve();
@@ -127,8 +113,9 @@ export class ChatService {
private mediaService?: MediaService,
private tokenRefresher?: TokenRefresher,
private usernameResolver?: UsernameResolver,
cache?: ChatCache,
) {
this.cache = new ChatCache(logger);
this.cache = cache ?? new ChatCache(logger);
}
// ------------ scheduling + auth helpers ------------
@@ -168,6 +155,16 @@ export class ChatService {
this.t2ToUsername.set(t2, chat.username);
}
}
const persisted = this.cache.getCooldown();
if (persisted && persisted.until > Date.now()) {
this.invitationCooldown = persisted;
const minutesLeft = Math.ceil((persisted.until - Date.now()) / 60_000);
this.logger.warn(
`[chat] restored send cooldown from cache: ${persisted.reason} (~${minutesLeft}m left, code=${persisted.redditCode ?? 'n/a'})`,
);
} else if (persisted) {
this.cache.setCooldown(null);
}
}
async saveCacheExternal(): Promise<void> {
@@ -537,6 +534,8 @@ export class ChatService {
if (!this.invitationCooldown) return null;
if (Date.now() >= this.invitationCooldown.until) {
this.invitationCooldown = null;
this.cache.setCooldown(null);
void this.cache.persistCooldown();
return null;
}
return this.invitationCooldown;
@@ -561,6 +560,8 @@ export class ChatService {
this.logger.warn(
`[chat] invitation cooldown set: ${this.invitationCooldown.reason} (~${minutes}m, code=${error.redditCode ?? error.errcode ?? '429'})`,
);
this.cache.setCooldown(this.invitationCooldown);
void this.cache.persistCooldown();
}
// ------------ internals ------------
@@ -582,6 +583,24 @@ 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;
try {
const withMedia = await this.mediaService.attachMedia(chat, mediaMessages);
this.mergeNewMessages(chat, withMedia);
} catch (err) {
this.logger.warn(
`[chat] delta media attach failed for ${roomId}: ${(err as Error).message}`,
);
}
}
}
const merged = Array.from(existing.values()).sort(
(a, b) => b.lastMessageTime - a.lastMessageTime,
);
@@ -591,6 +610,33 @@ export class ChatService {
return merged;
}
private scrapeMessageEvents(events: MatrixTimelineEvent[]): ScrapedChatMessage[] {
return events
.filter((e) => e.type === 'm.room.message')
.map((e) => {
const body = (e.content.body as string | undefined) ?? '';
const mxc = typeof e.content.url === 'string' ? (e.content.url as string) : null;
return {
sender: e.sender === this.matrix.userId ? 'Me' : this.shortenSender(e.sender),
text: body || (mxc ? '[Image]' : ''),
timestamp: e.origin_server_ts,
isSelf: e.sender === this.matrix.userId,
mediaUrls: mxc ? [mxc] : [],
};
});
}
private mergeNewMessages(chat: ActiveChat, newMessages: ChatMessage[]): void {
if (!chat.messages || chat.messages.length === 0) return;
const seen = new Set(chat.messages.map((m) => `${m.timestamp}|${m.sender}`));
for (const msg of newMessages) {
const key = `${msg.timestamp}|${msg.sender}`;
if (seen.has(key)) continue;
chat.messages.push(msg);
seen.add(key);
}
}
private async loadDetails(chat: ActiveChat): Promise<void> {
if (!chat.roomId) {
this.logger.warn(`[chat] no roomId for ${chat.roomName}, skipping details`);
+18
View File
@@ -25,6 +25,22 @@ export interface ActiveChat {
originMessage?: string;
}
/**
* Set when Reddit's homeserver tells us to stop sending for a while
* (e.g. `M_LIMIT_EXCEEDED`, or `rate.invitation_limit_mature_account` on
* new DMs). Persisted so it survives restarts.
*/
export interface InvitationCooldown {
/** Wall-clock ms when sends may resume. */
until: number;
/** Reddit-side error code, e.g. `rate.invitation_limit_mature_account`. */
redditCode: string | null;
/** Human-readable explanation suitable for the dashboard. */
reason: string;
/** Wall-clock ms when the cooldown was set. */
setAt: number;
}
export interface ChatCacheFile {
version: number;
savedAt: number;
@@ -37,6 +53,8 @@ export interface ChatCacheFile {
lastFullSyncAt?: number | null;
/** Timestamp of the most recent delta sync. */
lastDeltaSyncAt?: number | null;
/** Active send cooldown — null/missing when sends are allowed. */
cooldown?: InvitationCooldown | null;
}
export type ScrapedChatMessage = Omit<ChatMessage, 'media'> & { mediaUrls: string[] };
+32 -6
View File
@@ -23,6 +23,13 @@ export class MonitorService {
const subreddits = this.buildCheckOrder();
const targetGenders = this.config.reddit.targetGenders;
const prioritizeRecent = this.config.reddit.prioritizeRecentPosts;
const recentCutoffSec = prioritizeRecent
? Math.floor(Date.now() / 1000) - this.config.reddit.recentWindowMinutes * 60
: 0;
// Older posts found while prioritizing recent ones — used as a fallback
// so the bot still has something to do if no recent posts exist anywhere.
let fallback: RedditPost | null = null;
for (const subreddit of subreddits) {
try {
@@ -62,15 +69,27 @@ export class MonitorService {
}
if (validPosts.length > 0) {
this.logger.info(`Found ${validPosts.length} new posts in r/${subreddit.name}`);
// Sort by creation time (newest first)
validPosts.sort((a, b) => b.created_utc - a.created_utc);
// 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]];
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]];
}
}
} catch (error) {
this.logger.error(`Failed to fetch posts from r/${subreddit.name}:`, error);
@@ -82,6 +101,13 @@ export class MonitorService {
}
}
if (fallback) {
this.logger.info(
`No posts within last ${this.config.reddit.recentWindowMinutes}m — falling back to newest available (r/${fallback.subreddit})`,
);
return [fallback];
}
return [];
}