Files
RedditBot/tests/chat-service-media-notification.test.ts
Jamie BruntonandClaude Opus 5 38d175e4a1 feat: push selected events to a phone over ntfy
Adds a Notifications tab to Settings so an operator can pick which events
reach their phone, published to a single self-hosted ntfy topic.

The notifier subscribes to the realtime event hub the dashboard already
consumes, so every notifiable event keeps exactly one publisher. Inbound
media had no event, so `media.received` is added and published from the
existing durable `gotImage` gate in the chat service — one notification
per conversation, never repeated when the room re-syncs. It feeds the
in-app notification bell as well.

- Best-effort delivery: a rejecting or unreachable ntfy server is logged
  and never propagated into the path that produced the event.
- Events buffered before the notifier attaches are skipped, so a restart
  cannot replay a backlog to somebody's phone.
- Settings are applied field by field onto the live config object, so a
  save takes effect without a restart.
- The access token is environment-owned (NTFY_AUTH_TOKEN) like the other
  secrets: stripped before the settings record is written, never returned
  by the API, which reports only `hasAuthToken`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:55:00 +01:00

141 lines
4.9 KiB
TypeScript

import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ConversationOutcomesRepository, ConversationStateRepository } from '../src/database/repositories.js';
import { ChatCache } from '../src/services/chat/cache.js';
import { MatrixClient } from '../src/services/chat/matrix/client.js';
import type { MatrixSyncResponse } from '../src/services/chat/matrix/types.js';
import { ChatService } from '../src/services/chat/service.js';
import { createMockLogger } from './mockLogger.js';
import { authenticatedAccountAuth, createTestDb, createTestRepositories } from './testUtils.js';
const credentials = {
homeserver: 'https://matrix.example.test',
accessToken: 'token',
userId: '@me:example.test',
capturedAt: Date.now(),
};
const temporaryDirectories: string[] = [];
const openDatabases: Array<ReturnType<typeof createTestDb>> = [];
afterEach(async () => {
for (const database of openDatabases.splice(0)) database.close();
await Promise.all(temporaryDirectories.splice(0).map((directory) =>
fs.rm(directory, { recursive: true, force: true })));
});
describe('ChatService inbound media notifications', () => {
it('reports an inbound image exactly once, however often the chat re-syncs', async () => {
const fixture = await createFixture({ inboundMediaCount: 2 });
const received: unknown[] = [];
fixture.service.setMediaReceivedListener((info) => received.push(info));
await fixture.service.runStartupSync();
expect(received).toEqual([{ conversationId: fixture.roomId, username: 'alice', count: 2 }]);
expect(fixture.repos.messagesRepo.getByChatRoomId(fixture.roomId)).toMatchObject({ gotImage: true });
await fixture.service.runStartupSync();
expect(received).toHaveLength(1);
});
it('stays quiet for a text-only conversation', async () => {
const fixture = await createFixture({ inboundMediaCount: 0 });
const received: unknown[] = [];
fixture.service.setMediaReceivedListener((info) => received.push(info));
await fixture.service.runStartupSync();
expect(received).toEqual([]);
});
it('records the image even when the listener throws', async () => {
const fixture = await createFixture({ inboundMediaCount: 1 });
fixture.service.setMediaReceivedListener(() => { throw new Error('notifier exploded'); });
await fixture.service.runStartupSync();
expect(fixture.repos.messagesRepo.getByChatRoomId(fixture.roomId)).toMatchObject({ gotImage: true });
});
});
async function createFixture(options: { inboundMediaCount: number }) {
const logger = createMockLogger();
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-media-notify-'));
temporaryDirectories.push(directory);
const cacheFile = path.join(directory, 'chat-cache.json');
const db = createTestDb();
openDatabases.push(db);
const repos = createTestRepositories(db);
const outcomes = new ConversationOutcomesRepository(db);
const conversationStates = new ConversationStateRepository(db);
const now = Date.now();
const roomId = '!alice:example.test';
repos.postsRepo.insert({
id: 'source-post', subreddit: 'example', author: 'alice', title: 'Source',
imageUrl: null, processedAt: now - 10_000,
});
repos.messagesRepo.insert({
postId: 'source-post', recipient: 'alice', subject: 'alice', message: 'Hello',
sentAt: now - 9_000, strategyId: 'direct', chatRoomId: roomId,
});
const media = Array.from({ length: options.inboundMediaCount }, (_unused, index) => ({
originalUrl: `mxc://example.test/image-${index}`,
localPath: `/data/media/image-${index}.jpg`,
fileName: `image-${index}.jpg`,
}));
await fs.writeFile(cacheFile, JSON.stringify({
version: 1,
savedAt: now - 60_000,
chats: [{
username: 'alice',
roomName: 'alice',
lastMessageTime: now - 2_000,
lastMessageSnippet: '[Image]',
isFromUs: false,
unread: false,
roomId,
messages: [
{ sender: 'Me', text: 'Hello', timestamp: now - 5_000, isSelf: true },
{
sender: 'alice',
text: media.length > 0 ? '[Image]' : 'Thanks!',
timestamp: now - 2_000,
isSelf: false,
...(media.length > 0 ? { media } : {}),
},
],
}],
nextBatch: 's1',
lastFullSyncAt: now - 120_000,
lastDeltaSyncAt: now - 60_000,
cooldown: null,
}), 'utf8');
const matrix = new MatrixClient(credentials, logger, { minRequestIntervalMs: 0 });
matrix.sync = vi.fn(async (): Promise<MatrixSyncResponse> => ({ next_batch: 's2' })) as typeof matrix.sync;
const cache = new ChatCache(logger, cacheFile);
const service = new ChatService(
matrix,
logger,
repos.successfulMessagesRepo,
undefined,
undefined,
undefined,
cache,
repos.messagesRepo,
undefined,
authenticatedAccountAuth,
outcomes,
conversationStates,
);
await service.loadCache();
return { repos, roomId, service };
}