Improve Reddit chat and media reliability

This commit is contained in:
jjbrunton committed 2026-08-08 20:37:25 +01:00
1 parent a44768c089
commit 345ec99348
14 files changed
+323 -676

No files matched your search

@@ -27,7 +27,7 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp
return ( return (
<ol aria-label="Message history" className="conversation-messages"> <ol aria-label="Message history" className="conversation-messages">
{conversation.messages.map((message) => ( {conversation.messages.map((message) => (
<li className={message.isSelf ? 'is-self' : 'is-recipient'} key={`${message.timestamp}-${message.sender}-${message.text}`}> <li className={message.isSelf ? 'is-self' : 'is-recipient'} key={message.eventId ?? `${message.timestamp}-${message.sender}-${message.text}`}>
<div> <div>
<strong>{message.isSelf <strong>{message.isSelf
? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}` ? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}`
+1 -1
View File
@@ -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. 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.
@@ -3,7 +3,7 @@
"name": "RedditBot Session Exporter", "name": "RedditBot Session Exporter",
"version": "1.1.1", "version": "1.1.1",
"description": "Exports Reddit cookies and Reddit Chat credentials for a self-hosted RedditBot dashboard.", "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": [ "host_permissions": [
"https://reddit.com/*", "https://reddit.com/*",
"https://*.reddit.com/*", "https://*.reddit.com/*",
@@ -26,27 +26,40 @@ chrome.webRequest.onBeforeSendHeaders.addListener(
}); });
await chrome.action.setBadgeText({ text: 'OK' }); await chrome.action.setBadgeText({ text: 'OK' });
await chrome.action.setBadgeBackgroundColor({ color: '#198754' }); await chrome.action.setBadgeBackgroundColor({ color: '#198754' });
await chrome.action.setTitle({ title: 'Reddit Chat token captured. Click to export.' });
}, },
{ urls: MATRIX_URLS }, { urls: MATRIX_URLS },
['requestHeaders', 'extraHeaders'], ['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() { async function getRedditIdentity() {
const response = await fetch('https://www.reddit.com/api/me.json', { const tabs = await chrome.tabs.query({
credentials: 'include', url: ['https://reddit.com/*', 'https://www.reddit.com/*'],
cache: 'no-store',
}); });
if (!response.ok) throw new Error('Reddit cookies did not authenticate. Sign in and try again.'); const tab = tabs.find((candidate) => candidate.active) ?? tabs[0];
const payload = await response.json(); 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; const account = payload?.data ?? payload;
if (!account?.name || !account?.id) { if (!account?.name || !account?.id) {
throw new Error('Reddit did not return an authenticated account identity.'); 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 }), ...createSessionExportDownload(cookies, { ...matrixCapture, userId: identity.user_id }),
saveAs: true, saveAs: true,
}); });
await chrome.action.setBadgeText({ text: 'OK' });
await chrome.action.setTitle({ title: 'RedditBot session export created.' });
} catch (error) { } catch (error) {
await showError(error instanceof Error ? error.message : 'Could not create the session dump.'); await showError(error instanceof Error ? error.message : 'Could not create the session dump.');
} }
+11 -645
View File
@@ -11,7 +11,7 @@
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.90.20", "@tanstack/react-query": "^5.90.20",
"axios": "^1.13.2", "axios": "^1.13.2",
"better-sqlite3": "^12.5.0", "better-sqlite3": "^13.0.3",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.2.1", "express": "^5.2.1",
"openai": "^6.15.0", "openai": "^6.15.0",
@@ -2258,26 +2258,6 @@
"node": ">= 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": { "node_modules/baseline-browser-mapping": {
"version": "2.11.12", "version": "2.11.12",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz",
@@ -2292,17 +2272,15 @@
} }
}, },
"node_modules/better-sqlite3": { "node_modules/better-sqlite3": {
"version": "12.5.0", "version": "13.0.3",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.5.0.tgz", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
"integrity": "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==", "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
"hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bindings": "^1.5.0", "node-addon-api": "^8.0.0"
"prebuild-install": "^7.1.1"
}, },
"engines": { "engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x" "node": ">=22"
} }
}, },
"node_modules/bidi-js": { "node_modules/bidi-js": {
@@ -2315,26 +2293,6 @@
"require-from-string": "^2.0.2" "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": { "node_modules/body-parser": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", "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": "^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": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -2499,12 +2433,6 @@
"node": ">=18" "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": { "node_modules/cli-width": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
@@ -2717,30 +2645,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -2769,15 +2673,6 @@
"node": ">=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": { "node_modules/dom-accessibility-api": {
"version": "0.5.16", "version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
@@ -2847,15 +2742,6 @@
"node": ">= 0.8" "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": { "node_modules/entities": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
@@ -3004,15 +2890,6 @@
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT" "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": { "node_modules/expect-type": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -3142,12 +3019,6 @@
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
"license": "MIT" "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": { "node_modules/finalhandler": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -3229,12 +3100,6 @@
"node": ">= 0.8" "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": { "node_modules/fsevents": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@@ -3328,12 +3193,6 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" "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": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -3483,26 +3342,6 @@
"url": "https://opencollective.com/express" "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": { "node_modules/indent-string": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
@@ -3519,12 +3358,6 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "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": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -3668,280 +3501,6 @@
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
"license": "MIT" "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": { "node_modules/logform": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
@@ -4048,18 +3607,6 @@
"node": ">= 0.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": { "node_modules/min-indent": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -4070,21 +3617,6 @@
"node": ">=4" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "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": "^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": { "node_modules/negotiator": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
@@ -4201,16 +3727,13 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-abi": { "node_modules/node-addon-api": {
"version": "3.85.0", "version": "8.9.1",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.1.tgz",
"integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", "integrity": "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==",
"license": "MIT", "license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": { "engines": {
"node": ">=10" "node": "^18 || ^20 || >= 21"
} }
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
@@ -4465,32 +3988,6 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/pretty-format": {
"version": "27.5.1", "version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@@ -4529,16 +4026,6 @@
"node": ">=10" "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": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -4589,21 +4076,6 @@
"node": ">= 0.10" "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": { "node_modules/react": {
"version": "19.2.8", "version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "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==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT" "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": { "node_modules/send": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
@@ -5068,51 +4528,6 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -5212,15 +4627,6 @@
"node": ">=8" "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": { "node_modules/symbol-tree": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -5241,34 +4647,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/text-hex": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", "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": "^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": { "node_modules/type-fest": {
"version": "5.8.0", "version": "5.8.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz",
+1 -1
View File
@@ -26,7 +26,7 @@
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.90.20", "@tanstack/react-query": "^5.90.20",
"axios": "^1.13.2", "axios": "^1.13.2",
"better-sqlite3": "^12.5.0", "better-sqlite3": "^13.0.3",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.2.1", "express": "^5.2.1",
"openai": "^6.15.0", "openai": "^6.15.0",
+1
View File
@@ -8,6 +8,7 @@ export const ChatMediaSchema = z.strictObject({
fileName: z.string(), fileName: z.string(),
}); });
export const ChatMessageSchema = z.strictObject({ export const ChatMessageSchema = z.strictObject({
eventId: z.string().optional(),
sender: z.string(), sender: z.string(),
text: z.string(), text: z.string(),
timestamp: z.number(), timestamp: z.number(),
+99 -8
View File
@@ -17,6 +17,7 @@ import type { AccountAuthGate } from '../account-auth-context.js';
const ACTIVE_WINDOW_MS = 96 * 60 * 60 * 1000; const ACTIVE_WINDOW_MS = 96 * 60 * 60 * 1000;
const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/'; const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/';
const OPTIMISTIC_RECONCILIATION_WINDOW_MS = 2_000;
export interface TokenRefresher { export interface TokenRefresher {
/** /**
@@ -221,6 +222,19 @@ export class ChatService {
async loadCache(): Promise<void> { async loadCache(): Promise<void> {
await this.cache.load(); 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()) { for (const chat of this.cache.get()) {
const t2 = this.extractT2(chat); const t2 = this.extractT2(chat);
if (t2 && chat.username && !chat.username.startsWith('t2_')) { if (t2 && chat.username && !chat.username.startsWith('t2_')) {
@@ -632,7 +646,7 @@ export class ChatService {
url: ROOM_URL_PREFIX + encodeURIComponent(roomId), url: ROOM_URL_PREFIX + encodeURIComponent(roomId),
roomId, roomId,
messages: [ messages: [
{ sender: 'Me', text: message, timestamp: Date.now(), isSelf: true }, { eventId, sender: 'Me', text: message, timestamp: Date.now(), isSelf: true },
], ],
originMessage: message, originMessage: message,
}; };
@@ -822,6 +836,7 @@ export class ChatService {
const body = (e.content.body as string | undefined) ?? ''; const body = (e.content.body as string | undefined) ?? '';
const mxc = typeof e.content.url === 'string' ? (e.content.url as string) : null; const mxc = typeof e.content.url === 'string' ? (e.content.url as string) : null;
return { return {
eventId: e.event_id,
sender: e.sender === this.matrix.userId ? 'Me' : this.shortenSender(e.sender), sender: e.sender === this.matrix.userId ? 'Me' : this.shortenSender(e.sender),
text: body || (mxc ? '[Image]' : ''), text: body || (mxc ? '[Image]' : ''),
timestamp: e.origin_server_ts, timestamp: e.origin_server_ts,
@@ -833,13 +848,87 @@ export class ChatService {
private mergeNewMessages(chat: ActiveChat, newMessages: ChatMessage[]): void { private mergeNewMessages(chat: ActiveChat, newMessages: ChatMessage[]): void {
if (!chat.messages || chat.messages.length === 0) return; if (!chat.messages || chat.messages.length === 0) return;
const seen = new Set(chat.messages.map((m) => `${m.timestamp}|${m.sender}`)); chat.messages = this.normalizeMessages([...chat.messages, ...newMessages]);
for (const msg of newMessages) { }
const key = `${msg.timestamp}|${msg.sender}`;
if (seen.has(key)) continue; /**
chat.messages.push(msg); * Matrix event IDs are authoritative. The content/time fallback only
seen.add(key); * exists to repair cache entries written before ChatMessage stored them.
*/
private normalizeMessages(
messages: ChatMessage[],
repairLegacyOpeningDuplicate = false,
): ChatMessage[] {
const normalized: ChatMessage[] = [];
const eventIndexes = new Map<string, number>();
const fallbackKeys = new Set<string>();
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<void> { private async loadDetails(chat: ActiveChat): Promise<void> {
@@ -866,6 +955,7 @@ export class ChatService {
const body = (e.content.body as string | undefined) ?? ''; const body = (e.content.body as string | undefined) ?? '';
const mxc = typeof e.content.url === 'string' ? (e.content.url as string) : null; const mxc = typeof e.content.url === 'string' ? (e.content.url as string) : null;
return { return {
eventId: e.event_id,
sender: e.sender === this.matrix.userId ? 'Me' : this.shortenSender(e.sender), sender: e.sender === this.matrix.userId ? 'Me' : this.shortenSender(e.sender),
text: body || (mxc ? '[Image]' : ''), text: body || (mxc ? '[Image]' : ''),
timestamp: e.origin_server_ts, 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) ? await this.mediaService.attachMedia(chat, scraped)
: scraped.map(({ mediaUrls: _ignored, ...m }) => m as ChatMessage); : scraped.map(({ mediaUrls: _ignored, ...m }) => m as ChatMessage);
chat.messages = this.normalizeMessages(attachedMessages);
if (chat.messages.length === 0) { if (chat.messages.length === 0) {
this.logger.debug(`[chat] no messages in ${chat.roomName}`); this.logger.debug(`[chat] no messages in ${chat.roomName}`);
+2
View File
@@ -1,4 +1,6 @@
export interface ChatMessage { export interface ChatMessage {
/** Stable Matrix identity used to reconcile optimistic sends with /sync. */
eventId?: string;
sender: string; sender: string;
text: string; text: string;
timestamp: number; timestamp: number;
+1
View File
@@ -76,6 +76,7 @@ export class MediaService {
for (const message of messages) { for (const message of messages) {
const media = await this.downloadMessageMedia(chat, message); const media = await this.downloadMessageMedia(chat, message);
updated.push({ updated.push({
eventId: message.eventId,
sender: message.sender, sender: message.sender,
text: message.text, text: message.text,
timestamp: message.timestamp, timestamp: message.timestamp,
+5 -4
View File
@@ -31,7 +31,8 @@ export function createMediaRouter(ctx: WebContext): Router {
const byRoom = new Map(liveChats.map((c) => [`${c.accountId}:${c.roomId ?? ''}`, c] as const)); const byRoom = new Map(liveChats.map((c) => [`${c.accountId}:${c.roomId ?? ''}`, c] as const));
res.json(items.map((item) => { 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( const username = resolveRedditUsername(
live?.username ?? item.chatUsername, live?.username ?? item.chatUsername,
ctx.services.chat, ctx.services.chat,
@@ -39,11 +40,11 @@ export function createMediaRouter(ctx: WebContext): Router {
const sender = /^(me|you)$/i.test(item.sender) const sender = /^(me|you)$/i.test(item.sender)
? 'You' ? 'You'
: resolveRedditUsername(item.sender, ctx.services.chat) ?? username ?? 'Unknown Reddit user'; : resolveRedditUsername(item.sender, ctx.services.chat) ?? username ?? 'Unknown Reddit user';
const account = item.accountId const account = accountId
? ctx.services.redditAccounts?.get(item.accountId) ? ctx.services.redditAccounts?.get(accountId)
: null; : null;
return { return {
...item, ...publicItem,
chatUsername: username, chatUsername: username,
sender, sender,
urlPath: `/media/${item.relativePath.split(/[\\/]/).map(encodeURIComponent).join('/')}`, urlPath: `/media/${item.relativePath.split(/[\\/]/).map(encodeURIComponent).join('/')}`,
+136 -1
View File
@@ -85,7 +85,7 @@ describe('ChatService text delta sync', () => {
const [conversation] = await service.getCachedConversations(); const [conversation] = await service.getCachedConversations();
expect(conversation.messages).toEqual([ expect(conversation.messages).toEqual([
{ sender: 'alice', text: 'hello', timestamp: now - 1_000, isSelf: false }, { 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 { 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); 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);
});
}); });
@@ -94,6 +94,9 @@ describe('ChatService username persistence', () => {
const chat = await svc.startConversation('alice', 'hello', 't2_abc123'); const chat = await svc.startConversation('alice', 'hello', 't2_abc123');
expect(chat?.username).toBe('alice'); expect(chat?.username).toBe('alice');
expect(chat?.messages).toEqual([
expect.objectContaining({ eventId: '$event', text: 'hello', isSelf: true }),
]);
expect(usernameResolver.resolveUsernameToT2).not.toHaveBeenCalled(); expect(usernameResolver.resolveUsernameToT2).not.toHaveBeenCalled();
expect(matrix.sendInitialDirectMessage).toHaveBeenCalledWith( expect(matrix.sendInitialDirectMessage).toHaveBeenCalledWith(
'@t2_abc123:reddit.com', '@t2_abc123:reddit.com',
+32
View File
@@ -1,6 +1,7 @@
import express from 'express'; import express from 'express';
import type { AddressInfo } from 'node:net'; import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest'; 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 type { WebContext } from '../../src/web/context.js';
import { createMediaRouter } from '../../src/web/routes/media.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(JSON.stringify(body)).not.toContain('t2_missing');
expect(body[0]).toMatchObject({ chatUsername: null, sender: 'Unknown Reddit user' }); 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() })]);
});
}); });