diff --git a/agents.md b/agents.md index 4182df1..ac5ddf3 100644 --- a/agents.md +++ b/agents.md @@ -50,3 +50,5 @@ All new functionality and bug fixes MUST follow the red-green-refactor cycle. Th 4. **Implement (Green)**: Write the smallest change that makes the test pass. Create new small files when SRP calls for it. 5. **Refactor**: Tidy the implementation and the test with the suite green. Re-run tests after each refactor step. 6. **Verify**: Ensure the full suite passes (`npm test`) and types check (`npm run build` or `tsc --noEmit`). + +You must use the dokploy credentials in .env for access to dokploy \ No newline at end of file diff --git a/public/index.html b/public/index.html index 5fd0de5..158fa71 100644 --- a/public/index.html +++ b/public/index.html @@ -1515,6 +1515,39 @@ filter: none; } + .media-grid.revealed-all .media-card img { + filter: none; + } + + .unblur-all-btn { + padding: 7px 13px; + border-radius: var(--r-pill); + background: var(--card); + color: var(--ink-soft); + border-color: var(--rule); + font-size: 12px; + font-family: var(--f-body); + font-weight: 500; + cursor: pointer; + box-shadow: var(--shadow-sm); + transition: transform .15s ease, background .2s ease, box-shadow .2s ease, color .2s ease; + display: inline-flex; + align-items: center; + gap: 5px; + white-space: nowrap; + } + + .unblur-all-btn:hover { + background: var(--card-2); + color: var(--ink); + } + + .unblur-all-btn.active { + background: var(--terracotta-soft); + color: var(--terracotta-deep); + border-color: rgba(201, 123, 94, .35); + } + .media-meta { padding: 11px 13px; display: flex; @@ -2340,6 +2373,10 @@ title="Walks every joined chat from scratch and re-downloads all history + media. Slow (~1–2 min). Use after long absences, missing rooms, or to rebuild the cache." >Full sync +
No media cached yet
@@ -3091,6 +3128,21 @@ } } + let isUnblurAll = false; + + function toggleUnblurAll() { + isUnblurAll = !isUnblurAll; + const grid = document.getElementById('media-grid'); + const btn = document.getElementById('unblur-all-btn'); + if (isUnblurAll) { + grid.classList.add('revealed-all'); + btn.classList.add('active'); + } else { + grid.classList.remove('revealed-all'); + btn.classList.remove('active'); + } + } + // Timer Logic let timerState = { nextMessageAvailableAt: null, diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 19a6a7d..17afb16 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -206,6 +206,8 @@ export async function bootstrap(config: Config, logger: Logger): Promise c.name === 'post_id'); + if (postIdCol && postIdCol.notnull === 1) { + db.exec(` + CREATE TABLE sent_messages_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id TEXT, + recipient TEXT NOT NULL, + subject TEXT NOT NULL, + message TEXT NOT NULL, + sent_at INTEGER NOT NULL, + got_response INTEGER DEFAULT 0, + response_checked_at INTEGER, + strategy_id TEXT, + chat_room_id TEXT, + FOREIGN KEY (post_id) REFERENCES processed_posts(id) + ); + + INSERT INTO sent_messages_new (id, post_id, recipient, subject, message, sent_at, got_response, response_checked_at, strategy_id, chat_room_id) + SELECT id, post_id, recipient, subject, message, sent_at, got_response, response_checked_at, strategy_id, chat_room_id + FROM sent_messages; + + DROP TABLE sent_messages; + ALTER TABLE sent_messages_new RENAME TO sent_messages; + + CREATE INDEX IF NOT EXISTS idx_sent_messages_response ON sent_messages(got_response); + CREATE INDEX IF NOT EXISTS idx_sent_messages_recipient ON sent_messages(recipient); + CREATE INDEX IF NOT EXISTS idx_sent_messages_chat_room ON sent_messages(chat_room_id); + `); + } + // Migration: Make sent_message_id nullable in successful_messages // SQLite doesn't support ALTER COLUMN, so we need to recreate the table const successfulMsgColumns = db.prepare(`PRAGMA table_info(successful_messages)`).all() as { name: string; notnull: number }[]; diff --git a/src/database/repositories.ts b/src/database/repositories.ts index 9f7c39f..b950870 100644 --- a/src/database/repositories.ts +++ b/src/database/repositories.ts @@ -15,7 +15,7 @@ export interface ProcessedPostRecord { export interface SentMessageRecord { id: number; - postId: string; + postId: string | null; recipient: string; subject: string; message: string; @@ -23,6 +23,7 @@ export interface SentMessageRecord { gotResponse: boolean; responseCheckedAt: number | null; strategyId: string | null; + chatRoomId: string | null; subreddit?: string; postTitle?: string; postImageUrl?: string; @@ -240,7 +241,9 @@ export class PostsRepository { export class MessagesRepository { private insertStmt: Database.Statement; + private insertChatStmt: Database.Statement; private getByPostIdStmt: Database.Statement; + private getByChatRoomIdStmt: Database.Statement; private getLatestByRecipientStmt: Database.Statement; private getRecentStmt: Database.Statement; private getUncheckedStmt: Database.Statement; @@ -254,6 +257,20 @@ export class MessagesRepository { VALUES (?, ?, ?, ?, ?, ?) `); + this.insertChatStmt = db.prepare(` + INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at, chat_room_id) + VALUES (NULL, ?, ?, ?, ?, ?) + `); + + this.getByChatRoomIdStmt = db.prepare(` + SELECT sm.*, pp.subreddit, pp.title AS post_title, 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 = ? + LIMIT 1 + `); + this.getByPostIdStmt = db.prepare(`SELECT * FROM sent_messages WHERE post_id = ?`); this.getLatestByRecipientStmt = db.prepare(` @@ -316,12 +333,35 @@ export class MessagesRepository { return result.lastInsertRowid as number; } + insertChat(message: { + recipient: string; + subject: string; + message: string; + sentAt: number; + chatRoomId: string; + }): number { + const result = this.insertChatStmt.run( + message.recipient, + message.subject, + message.message, + message.sentAt, + message.chatRoomId, + ); + return result.lastInsertRowid as number; + } + getByPostId(postId: string): SentMessageRecord | undefined { const row = this.getByPostIdStmt.get(postId) as Record | undefined; if (!row) return undefined; return this.mapRow(row); } + getByChatRoomId(chatRoomId: string): SentMessageRecord | undefined { + const row = this.getByChatRoomIdStmt.get(chatRoomId) as Record | undefined; + if (!row) return undefined; + return this.mapRow(row); + } + getLatestByRecipient(recipient: string): SentMessageRecord | undefined { const row = this.getLatestByRecipientStmt.get(recipient) as Record | undefined; if (!row) return undefined; @@ -422,7 +462,7 @@ export class MessagesRepository { private mapRow(row: Record): SentMessageRecord { return { id: row.id as number, - postId: row.post_id as string, + postId: (row.post_id as string) ?? null, recipient: row.recipient as string, subject: row.subject as string, message: row.message as string, @@ -430,6 +470,7 @@ export class MessagesRepository { gotResponse: Boolean(row.got_response), responseCheckedAt: row.response_checked_at as number | null, strategyId: (row.strategy_id as string) || null, + chatRoomId: (row.chat_room_id as string) ?? null, subreddit: (row.subreddit as string) || undefined, postTitle: (row.post_title as string) || undefined, postImageUrl: (row.post_image_url as string) || undefined, diff --git a/src/services/chat/service.ts b/src/services/chat/service.ts index ac1f476..7affe26 100644 --- a/src/services/chat/service.ts +++ b/src/services/chat/service.ts @@ -1,5 +1,5 @@ import type { Logger } from '../../utils/logger.js'; -import type { SuccessfulMessagesRepository } from '../../database/repositories.js'; +import type { MessagesRepository, SuccessfulMessagesRepository } from '../../database/repositories.js'; import type { MediaService } from '../media.js'; import { ChatCache } from './cache.js'; import { MatrixClient, MatrixAuthError, MatrixRateLimitError } from './matrix/client.js'; @@ -114,6 +114,7 @@ export class ChatService { private tokenRefresher?: TokenRefresher, private usernameResolver?: UsernameResolver, cache?: ChatCache, + private messagesRepo?: MessagesRepository, ) { this.cache = cache ?? new ChatCache(logger); } @@ -701,6 +702,27 @@ export class ChatService { if (firstSelf) chat.originMessage = firstSelf.text; const hasReply = chat.messages.some((m) => !m.isSelf); + + 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); + } + } catch (err) { + this.logger.warn('[chat] failed to record sent_message for stats:', err); + } + } + if (firstSelf && hasReply && this.successfulMessagesRepo) { if (!this.successfulMessagesRepo.existsByMessage(firstSelf.text)) { const firstReply = chat.messages.find((m) => !m.isSelf); diff --git a/src/services/response-checker.ts b/src/services/response-checker.ts index a265abf..c26f969 100644 --- a/src/services/response-checker.ts +++ b/src/services/response-checker.ts @@ -56,7 +56,7 @@ export class ResponseCheckerService { this.messagesRepo.markGotResponse(message.id); // Get the original post info - const post = this.postsRepo.getById(message.postId); + const post = message.postId ? this.postsRepo.getById(message.postId) : undefined; // Store as successful message for learning this.successfulMessagesRepo.insert({ diff --git a/src/web/routes/conversations.ts b/src/web/routes/conversations.ts index ac9e122..bf59b50 100644 --- a/src/web/routes/conversations.ts +++ b/src/web/routes/conversations.ts @@ -81,7 +81,7 @@ export function createConversationsRouter(ctx: WebContext): Router { } | null = null; try { const sent = ctx.repos.messages.getLatestByRecipient(conversation.username); - if (sent) { + if (sent && sent.postId) { const url = sent.subreddit ? `https://www.reddit.com/r/${sent.subreddit}/comments/${sent.postId}/` : `https://redd.it/${sent.postId}`; diff --git a/tests/database.test.ts b/tests/database.test.ts index 8e71b52..2ba735e 100644 --- a/tests/database.test.ts +++ b/tests/database.test.ts @@ -130,7 +130,8 @@ describe('Database Client', () => { expect(columnMap.get('subject')?.notnull).toBe(1); expect(columnMap.get('message')?.notnull).toBe(1); expect(columnMap.get('sent_at')?.notnull).toBe(1); - expect(columnMap.get('post_id')?.notnull).toBe(1); + expect(columnMap.get('post_id')?.notnull).toBe(0); // nullable — chat rows have no post + expect(columnMap.get('chat_room_id')).toBeDefined(); }); it('successful_messages has correct schema', () => { diff --git a/tests/repositories.test.ts b/tests/repositories.test.ts index d8cd775..605820b 100644 --- a/tests/repositories.test.ts +++ b/tests/repositories.test.ts @@ -171,6 +171,40 @@ describe('MessagesRepository', () => { db = repos.db; }); + it('insertChat stores a chat-originated send and getByChatRoomId returns it', () => { + const id = messagesRepo.insertChat({ + recipient: 'alice', + subject: 'alice', + message: 'hey there', + sentAt: 1700000000000, + chatRoomId: '!room123:reddit.com', + }); + expect(id).toBeGreaterThan(0); + + const row = messagesRepo.getByChatRoomId('!room123:reddit.com'); + expect(row).toBeDefined(); + expect(row!.postId).toBeNull(); + expect(row!.chatRoomId).toBe('!room123:reddit.com'); + expect(row!.recipient).toBe('alice'); + expect(row!.gotResponse).toBe(false); + + messagesRepo.markGotResponse(id); + const updated = messagesRepo.getByChatRoomId('!room123:reddit.com'); + expect(updated!.gotResponse).toBe(true); + }); + + it('chat-originated rows appear in getRecent and contribute to stats', () => { + messagesRepo.insertChat({ + recipient: 'bob', + subject: 'bob', + message: 'hi', + sentAt: Date.now(), + chatRoomId: '!r1:reddit.com', + }); + const recent = messagesRepo.getRecent(10); + expect(recent.some((m) => m.chatRoomId === '!r1:reddit.com')).toBe(true); + }); + it('inserts and retrieves a message', () => { const messageId = messagesRepo.insert({ postId: 'post1',