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:
1 parent
633bb0ec04
commit
2260388dc8
15 files changed
+675
-41
No files matched your search
@@ -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 });
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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
@@ -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 [];
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user