diff --git a/apps/dashboard/src/features/conversations/conversation-detail.tsx b/apps/dashboard/src/features/conversations/conversation-detail.tsx index de87d18..6e3808f 100644 --- a/apps/dashboard/src/features/conversations/conversation-detail.tsx +++ b/apps/dashboard/src/features/conversations/conversation-detail.tsx @@ -27,7 +27,7 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp return (
    {conversation.messages.map((message) => ( -
  1. +
  2. {message.isSelf ? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}` diff --git a/extensions/reddit-session-exporter/README.md b/extensions/reddit-session-exporter/README.md index 1a135a5..d3fb132 100644 --- a/extensions/reddit-session-exporter/README.md +++ b/extensions/reddit-session-exporter/README.md @@ -14,4 +14,4 @@ This local Chrome extension exports `reddit.com` cookies and the current Reddit The extension observes only requests to Reddit's Matrix homeservers, verifies the captured token with Matrix `whoami`, reads Reddit cookies, and saves the combined export locally. It does not send the dump to any server; you upload it directly to your RedditBot dashboard. -Before downloading, the extension independently resolves the current Reddit cookie identity and refuses the export unless its `t2` account ID matches Matrix `whoami`. Changing Reddit accounts clears the stored Matrix capture, so Reddit Chat must be reopened after every account switch. +Before downloading, the extension independently resolves the current identity through the open `www.reddit.com` tab and refuses the export unless its `t2` account ID matches Matrix `whoami`. If you switch accounts, reload Reddit Chat so the extension captures that account's current Matrix token. diff --git a/extensions/reddit-session-exporter/manifest.json b/extensions/reddit-session-exporter/manifest.json index b8caf40..6b45c8a 100644 --- a/extensions/reddit-session-exporter/manifest.json +++ b/extensions/reddit-session-exporter/manifest.json @@ -3,7 +3,7 @@ "name": "RedditBot Session Exporter", "version": "1.1.1", "description": "Exports Reddit cookies and Reddit Chat credentials for a self-hosted RedditBot dashboard.", - "permissions": ["cookies", "downloads", "storage", "webRequest"], + "permissions": ["cookies", "downloads", "scripting", "storage", "webRequest"], "host_permissions": [ "https://reddit.com/*", "https://*.reddit.com/*", diff --git a/extensions/reddit-session-exporter/service-worker.js b/extensions/reddit-session-exporter/service-worker.js index 7f845ba..cadcfc0 100644 --- a/extensions/reddit-session-exporter/service-worker.js +++ b/extensions/reddit-session-exporter/service-worker.js @@ -26,27 +26,40 @@ chrome.webRequest.onBeforeSendHeaders.addListener( }); await chrome.action.setBadgeText({ text: 'OK' }); await chrome.action.setBadgeBackgroundColor({ color: '#198754' }); + await chrome.action.setTitle({ title: 'Reddit Chat token captured. Click to export.' }); }, { urls: MATRIX_URLS }, ['requestHeaders', 'extraHeaders'], ); -chrome.cookies.onChanged.addListener(async ({ cookie }) => { - if (!['reddit_session', 'token_v2'].includes(cookie.name)) return; - await chrome.storage.local.remove('matrixCapture'); - await chrome.action.setBadgeText({ text: '' }); - await chrome.action.setTitle({ - title: 'Reddit account changed. Open Reddit Chat before exporting.', - }); -}); - async function getRedditIdentity() { - const response = await fetch('https://www.reddit.com/api/me.json', { - credentials: 'include', - cache: 'no-store', + const tabs = await chrome.tabs.query({ + url: ['https://reddit.com/*', 'https://www.reddit.com/*'], }); - if (!response.ok) throw new Error('Reddit cookies did not authenticate. Sign in and try again.'); - const payload = await response.json(); + const tab = tabs.find((candidate) => candidate.active) ?? tabs[0]; + if (tab?.id === undefined) { + throw new Error('Keep a signed-in www.reddit.com tab open while exporting.'); + } + + const [{ result } = {}] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + world: 'MAIN', + func: async () => { + try { + const response = await fetch('https://www.reddit.com/api/me.json', { + credentials: 'include', + cache: 'no-store', + }); + if (!response.ok) return { error: 'Reddit cookies did not authenticate. Sign in and try again.' }; + return { payload: await response.json() }; + } catch { + return { error: 'Could not read the current Reddit account. Reload Reddit and try again.' }; + } + }, + }); + + if (result?.error) throw new Error(result.error); + const payload = result?.payload; const account = payload?.data ?? payload; if (!account?.name || !account?.id) { throw new Error('Reddit did not return an authenticated account identity.'); @@ -93,6 +106,8 @@ chrome.action.onClicked.addListener(async () => { ...createSessionExportDownload(cookies, { ...matrixCapture, userId: identity.user_id }), saveAs: true, }); + await chrome.action.setBadgeText({ text: 'OK' }); + await chrome.action.setTitle({ title: 'RedditBot session export created.' }); } catch (error) { await showError(error instanceof Error ? error.message : 'Could not create the session dump.'); } diff --git a/package-lock.json b/package-lock.json index 57cb67f..d7dd06e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@tanstack/react-query": "^5.90.20", "axios": "^1.13.2", - "better-sqlite3": "^12.5.0", + "better-sqlite3": "^13.0.3", "dotenv": "^17.2.3", "express": "^5.2.1", "openai": "^6.15.0", @@ -2258,26 +2258,6 @@ "node": ">= 6" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/baseline-browser-mapping": { "version": "2.11.12", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", @@ -2292,17 +2272,15 @@ } }, "node_modules/better-sqlite3": { - "version": "12.5.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.5.0.tgz", - "integrity": "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==", - "hasInstallScript": true, + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", "license": "MIT", "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" + "node-addon-api": "^8.0.0" }, "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" + "node": ">=22" } }, "node_modules/bidi-js": { @@ -2315,26 +2293,6 @@ "require-from-string": "^2.0.2" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -2406,30 +2364,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2499,12 +2433,6 @@ "node": ">=18" } }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -2717,30 +2645,6 @@ "dev": true, "license": "MIT" }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2769,15 +2673,6 @@ "node": ">=6" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -2847,15 +2742,6 @@ "node": ">= 0.8" } }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -3004,15 +2890,6 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -3142,12 +3019,6 @@ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "license": "MIT" }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -3229,12 +3100,6 @@ "node": ">= 0.8" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -3328,12 +3193,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3483,26 +3342,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -3519,12 +3358,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3668,280 +3501,6 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/logform": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", @@ -4048,18 +3607,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -4070,21 +3617,6 @@ "node": ">=4" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4186,12 +3718,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -4201,16 +3727,13 @@ "node": ">= 0.6" } }, - "node_modules/node-abi": { - "version": "3.85.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", - "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "node_modules/node-addon-api": { + "version": "8.9.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.1.tgz", + "integrity": "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==", "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, "engines": { - "node": ">=10" + "node": "^18 || ^20 || >= 21" } }, "node_modules/node-releases": { @@ -4465,32 +3988,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -4529,16 +4026,6 @@ "node": ">=10" } }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4589,21 +4076,6 @@ "node": ">= 0.10" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -4881,18 +4353,6 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, - "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -5068,51 +4528,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5212,15 +4627,6 @@ "node": ">=8" } }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -5241,34 +4647,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -5418,18 +4796,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/type-fest": { "version": "5.8.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", diff --git a/package.json b/package.json index e8c95ae..fc01ef4 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "dependencies": { "@tanstack/react-query": "^5.90.20", "axios": "^1.13.2", - "better-sqlite3": "^12.5.0", + "better-sqlite3": "^13.0.3", "dotenv": "^17.2.3", "express": "^5.2.1", "openai": "^6.15.0", diff --git a/packages/api-contracts/src/chats.ts b/packages/api-contracts/src/chats.ts index e4037cd..ca217f3 100644 --- a/packages/api-contracts/src/chats.ts +++ b/packages/api-contracts/src/chats.ts @@ -8,6 +8,7 @@ export const ChatMediaSchema = z.strictObject({ fileName: z.string(), }); export const ChatMessageSchema = z.strictObject({ + eventId: z.string().optional(), sender: z.string(), text: z.string(), timestamp: z.number(), diff --git a/src/services/chat/service.ts b/src/services/chat/service.ts index 4a16b74..b1b1d31 100644 --- a/src/services/chat/service.ts +++ b/src/services/chat/service.ts @@ -17,6 +17,7 @@ import type { AccountAuthGate } from '../account-auth-context.js'; const ACTIVE_WINDOW_MS = 96 * 60 * 60 * 1000; const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/'; +const OPTIMISTIC_RECONCILIATION_WINDOW_MS = 2_000; export interface TokenRefresher { /** @@ -221,6 +222,19 @@ export class ChatService { async loadCache(): Promise { await this.cache.load(); + let repairedDuplicateMessages = 0; + for (const chat of this.cache.get()) { + if (!chat.messages || chat.messages.length < 2) continue; + const normalized = this.normalizeMessages(chat.messages, true); + repairedDuplicateMessages += chat.messages.length - normalized.length; + chat.messages = normalized; + } + if (repairedDuplicateMessages > 0) { + this.logger.info( + `[chat] removed ${repairedDuplicateMessages} legacy optimistic message duplicate(s) from cache`, + ); + await this.cache.save(); + } for (const chat of this.cache.get()) { const t2 = this.extractT2(chat); if (t2 && chat.username && !chat.username.startsWith('t2_')) { @@ -632,7 +646,7 @@ export class ChatService { url: ROOM_URL_PREFIX + encodeURIComponent(roomId), roomId, messages: [ - { sender: 'Me', text: message, timestamp: Date.now(), isSelf: true }, + { eventId, sender: 'Me', text: message, timestamp: Date.now(), isSelf: true }, ], originMessage: message, }; @@ -822,6 +836,7 @@ export class ChatService { const body = (e.content.body as string | undefined) ?? ''; const mxc = typeof e.content.url === 'string' ? (e.content.url as string) : null; return { + eventId: e.event_id, sender: e.sender === this.matrix.userId ? 'Me' : this.shortenSender(e.sender), text: body || (mxc ? '[Image]' : ''), timestamp: e.origin_server_ts, @@ -833,13 +848,87 @@ export class ChatService { 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); + chat.messages = this.normalizeMessages([...chat.messages, ...newMessages]); + } + + /** + * Matrix event IDs are authoritative. The content/time fallback only + * exists to repair cache entries written before ChatMessage stored them. + */ + private normalizeMessages( + messages: ChatMessage[], + repairLegacyOpeningDuplicate = false, + ): ChatMessage[] { + const normalized: ChatMessage[] = []; + const eventIndexes = new Map(); + const fallbackKeys = new Set(); + + for (const message of messages) { + if (message.eventId) { + const existingIndex = eventIndexes.get(message.eventId); + if (existingIndex !== undefined) { + const existing = normalized[existingIndex]; + normalized[existingIndex] = { + ...existing, + ...message, + media: message.media ?? existing.media, + }; + continue; + } + + const optimisticIndex = normalized.findIndex((candidate) => + !candidate.eventId + && candidate.isSelf + && message.isSelf + && candidate.sender === message.sender + && candidate.text === message.text + && Math.abs(candidate.timestamp - message.timestamp) + <= OPTIMISTIC_RECONCILIATION_WINDOW_MS, + ); + if (optimisticIndex !== -1) { + normalized[optimisticIndex] = { + ...normalized[optimisticIndex], + ...message, + media: message.media ?? normalized[optimisticIndex].media, + }; + eventIndexes.set(message.eventId, optimisticIndex); + continue; + } + + eventIndexes.set(message.eventId, normalized.length); + normalized.push(message); + continue; + } + + const fallbackKey = `${message.timestamp}|${message.sender}|${message.text}`; + if (fallbackKeys.has(fallbackKey)) continue; + fallbackKeys.add(fallbackKey); + normalized.push(message); } + + normalized.sort((a, b) => a.timestamp - b.timestamp); + + // Before event IDs were persisted, startConversation cached a Date.now() + // copy and /sync appended the same opening send with the server timestamp. + // Restrict legacy repair to the opening pair and self-authored text so two + // genuine repeated messages elsewhere in a conversation remain intact. + if (repairLegacyOpeningDuplicate && normalized.length >= 2) { + const first = normalized[0]; + const second = normalized[1]; + if ( + !first.eventId + && !second.eventId + && first.isSelf + && second.isSelf + && first.sender === second.sender + && first.text === second.text + && second.timestamp - first.timestamp <= OPTIMISTIC_RECONCILIATION_WINDOW_MS + ) { + normalized.splice(1, 1); + } + } + + return normalized; } private async loadDetails(chat: ActiveChat): Promise { @@ -866,6 +955,7 @@ export class ChatService { const body = (e.content.body as string | undefined) ?? ''; const mxc = typeof e.content.url === 'string' ? (e.content.url as string) : null; return { + eventId: e.event_id, sender: e.sender === this.matrix.userId ? 'Me' : this.shortenSender(e.sender), text: body || (mxc ? '[Image]' : ''), timestamp: e.origin_server_ts, @@ -890,9 +980,10 @@ export class ChatService { } } - chat.messages = this.mediaService + const attachedMessages = this.mediaService ? await this.mediaService.attachMedia(chat, scraped) : scraped.map(({ mediaUrls: _ignored, ...m }) => m as ChatMessage); + chat.messages = this.normalizeMessages(attachedMessages); if (chat.messages.length === 0) { this.logger.debug(`[chat] no messages in ${chat.roomName}`); diff --git a/src/services/chat/types.ts b/src/services/chat/types.ts index 54e6960..be3ca9b 100644 --- a/src/services/chat/types.ts +++ b/src/services/chat/types.ts @@ -1,4 +1,6 @@ export interface ChatMessage { + /** Stable Matrix identity used to reconcile optimistic sends with /sync. */ + eventId?: string; sender: string; text: string; timestamp: number; diff --git a/src/services/media.ts b/src/services/media.ts index bdc16cd..750e80d 100644 --- a/src/services/media.ts +++ b/src/services/media.ts @@ -76,6 +76,7 @@ export class MediaService { for (const message of messages) { const media = await this.downloadMessageMedia(chat, message); updated.push({ + eventId: message.eventId, sender: message.sender, text: message.text, timestamp: message.timestamp, diff --git a/src/web/routes/media.ts b/src/web/routes/media.ts index b3a7710..756521a 100644 --- a/src/web/routes/media.ts +++ b/src/web/routes/media.ts @@ -31,7 +31,8 @@ export function createMediaRouter(ctx: WebContext): Router { const byRoom = new Map(liveChats.map((c) => [`${c.accountId}:${c.roomId ?? ''}`, c] as const)); res.json(items.map((item) => { - const live = item.chatRoomId ? byRoom.get(`${item.accountId ?? ''}:${item.chatRoomId}`) : undefined; + const { accountId, ...publicItem } = item; + const live = item.chatRoomId ? byRoom.get(`${accountId ?? ''}:${item.chatRoomId}`) : undefined; const username = resolveRedditUsername( live?.username ?? item.chatUsername, ctx.services.chat, @@ -39,11 +40,11 @@ export function createMediaRouter(ctx: WebContext): Router { const sender = /^(me|you)$/i.test(item.sender) ? 'You' : resolveRedditUsername(item.sender, ctx.services.chat) ?? username ?? 'Unknown Reddit user'; - const account = item.accountId - ? ctx.services.redditAccounts?.get(item.accountId) + const account = accountId + ? ctx.services.redditAccounts?.get(accountId) : null; return { - ...item, + ...publicItem, chatUsername: username, sender, urlPath: `/media/${item.relativePath.split(/[\\/]/).map(encodeURIComponent).join('/')}`, diff --git a/tests/chat-service-text-sync.test.ts b/tests/chat-service-text-sync.test.ts index ae4bd56..96f212c 100644 --- a/tests/chat-service-text-sync.test.ts +++ b/tests/chat-service-text-sync.test.ts @@ -85,7 +85,7 @@ describe('ChatService text delta sync', () => { const [conversation] = await service.getCachedConversations(); expect(conversation.messages).toEqual([ { sender: 'alice', text: 'hello', timestamp: now - 1_000, isSelf: false }, - { sender: 'Me', text: 'AI-assisted reply', timestamp: now, isSelf: true }, + { eventId: '$sent', sender: 'Me', text: 'AI-assisted reply', timestamp: now, isSelf: true }, ]); const persisted = JSON.parse(await fs.readFile(cacheFile, 'utf-8')) as { @@ -93,4 +93,139 @@ describe('ChatService text delta sync', () => { }; expect(persisted.chats[0].messages).toHaveLength(2); }); + + it('reconciles an optimistic opening message with its Matrix event', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-text-reconcile-')); + temporaryDirectories.push(directory); + const cacheFile = path.join(directory, 'chat-cache.json'); + const serverTimestamp = Date.now(); + await fs.writeFile(cacheFile, JSON.stringify({ + version: 1, + savedAt: serverTimestamp, + chats: [{ + username: 'alice', + roomName: 'alice', + roomId: '!alice:example.test', + lastMessageTime: serverTimestamp + 80, + lastMessageSnippet: 'hello', + isFromUs: true, + unread: false, + messages: [{ + eventId: '$opening', + sender: 'Me', + text: 'hello', + timestamp: serverTimestamp + 80, + isSelf: true, + }], + }], + nextBatch: 's1', + lastDeltaSyncAt: serverTimestamp, + }), 'utf-8'); + + const logger = createMockLogger(); + const matrix = new MatrixClient({ + homeserver: 'https://matrix.example.test', + accessToken: 'token', + userId: '@me:example.test', + capturedAt: serverTimestamp, + }, logger, { minRequestIntervalMs: 0 }); + matrix.sync = vi.fn(async () => ({ + next_batch: 's2', + rooms: { + join: { + '!alice:example.test': { + timeline: { + events: [{ + event_id: '$opening', + type: 'm.room.message', + sender: '@me:example.test', + origin_server_ts: serverTimestamp, + content: { msgtype: 'm.text', body: 'hello' }, + }], + }, + }, + }, + }, + })) as typeof matrix.sync; + + const service = new ChatService( + matrix, + logger, + undefined, + undefined, + undefined, + undefined, + new ChatCache(logger, cacheFile), + undefined, + undefined, + authenticatedAccountAuth, + ); + await service.loadCache(); + await service.refreshTopChats(); + + const [conversation] = await service.getCachedConversations(); + expect(conversation.messages).toEqual([{ + eventId: '$opening', + sender: 'Me', + text: 'hello', + timestamp: serverTimestamp, + isSelf: true, + }]); + }); + + it('repairs a legacy cached opening-message duplicate', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-text-legacy-repair-')); + temporaryDirectories.push(directory); + const cacheFile = path.join(directory, 'chat-cache.json'); + const serverTimestamp = Date.now(); + const message = { sender: 'Me', text: 'hello', isSelf: true }; + await fs.writeFile(cacheFile, JSON.stringify({ + version: 1, + savedAt: serverTimestamp, + chats: [{ + username: 'alice', + roomName: 'alice', + roomId: '!alice:example.test', + lastMessageTime: serverTimestamp, + lastMessageSnippet: 'hello', + isFromUs: true, + unread: false, + messages: [ + { ...message, timestamp: serverTimestamp + 80 }, + { ...message, timestamp: serverTimestamp }, + ], + }], + nextBatch: 's1', + lastDeltaSyncAt: serverTimestamp, + }), 'utf-8'); + + const logger = createMockLogger(); + const matrix = new MatrixClient({ + homeserver: 'https://matrix.example.test', + accessToken: 'token', + userId: '@me:example.test', + capturedAt: serverTimestamp, + }, logger, { minRequestIntervalMs: 0 }); + const service = new ChatService( + matrix, + logger, + undefined, + undefined, + undefined, + undefined, + new ChatCache(logger, cacheFile), + undefined, + undefined, + authenticatedAccountAuth, + ); + + await service.loadCache(); + + const [conversation] = await service.getCachedConversations(); + expect(conversation.messages).toEqual([{ ...message, timestamp: serverTimestamp }]); + const persisted = JSON.parse(await fs.readFile(cacheFile, 'utf-8')) as { + chats: Array<{ messages: unknown[] }>; + }; + expect(persisted.chats[0].messages).toHaveLength(1); + }); }); diff --git a/tests/chat-service-username-persist.test.ts b/tests/chat-service-username-persist.test.ts index 999daa6..06f09db 100644 --- a/tests/chat-service-username-persist.test.ts +++ b/tests/chat-service-username-persist.test.ts @@ -94,6 +94,9 @@ describe('ChatService username persistence', () => { const chat = await svc.startConversation('alice', 'hello', 't2_abc123'); expect(chat?.username).toBe('alice'); + expect(chat?.messages).toEqual([ + expect.objectContaining({ eventId: '$event', text: 'hello', isSelf: true }), + ]); expect(usernameResolver.resolveUsernameToT2).not.toHaveBeenCalled(); expect(matrix.sendInitialDirectMessage).toHaveBeenCalledWith( '@t2_abc123:reddit.com', diff --git a/tests/web/media-route.test.ts b/tests/web/media-route.test.ts index ed05781..ce3e8f9 100644 --- a/tests/web/media-route.test.ts +++ b/tests/web/media-route.test.ts @@ -1,6 +1,7 @@ import express from 'express'; import type { AddressInfo } from 'node:net'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { MediaResponseSchema } from '../../packages/api-contracts/src/media.js'; import type { WebContext } from '../../src/web/context.js'; import { createMediaRouter } from '../../src/web/routes/media.js'; @@ -63,4 +64,35 @@ describe('media route username presentation', () => { expect(JSON.stringify(body)).not.toContain('t2_missing'); expect(body[0]).toMatchObject({ chatUsername: null, sender: 'Unknown Reddit user' }); }); + + it('keeps the internal account ID out of the public media item', async () => { + const context = { + logger: { error: vi.fn() }, + services: { + media: { getMediaItems: vi.fn().mockResolvedValue([{ + accountId: 't2_owner', id: 'one', chatUsername: 'alice', chatRoomId: 'room-one', sender: 'alice', + timestamp: 1, originalUrl: 'mxc://one', fileName: 'one.jpg', relativePath: 'one.jpg', createdAt: 2, + }]) }, + chat: { + getCachedConversations: vi.fn().mockResolvedValue([]), + resolveSenderDisplay: vi.fn((identity: string) => identity), + }, + redditAccounts: { + get: vi.fn().mockReturnValue({ accountId: 't2_owner', redditUsername: 'owner' }), + }, + }, + } as unknown as WebContext; + const app = express().use('/api', createMediaRouter(context)); + + const response = await request(app, '/api/media'); + const body: unknown = await response.json(); + + expect(MediaResponseSchema.parse(body)).toEqual([ + expect.objectContaining({ + id: 'one', + owner: { accountId: 't2_owner', redditUsername: 'owner' }, + }), + ]); + expect(body).not.toEqual([expect.objectContaining({ accountId: expect.anything() })]); + }); });