This commit is contained in:
@@ -66,7 +66,18 @@
|
||||
"Bash(awk 'NR>=535 && NR<=560 {print NR\": \"$0}' packages/shared/src/components/settings/UserSettingsModal.tsx)",
|
||||
"Bash(awk 'NR>=460 && NR<=480 {print NR\": \"$0}' packages/shared/src/components/settings/UserSettingsModal.tsx)",
|
||||
"Bash(python3 -)",
|
||||
"Bash(awk 'NR>=525 && NR<=540 {print NR\": \"$0}' packages/shared/src/components/settings/UserSettingsModal.tsx)"
|
||||
"Bash(awk 'NR>=525 && NR<=540 {print NR\": \"$0}' packages/shared/src/components/settings/UserSettingsModal.tsx)",
|
||||
"Bash(sqlite3 data/backup.db \".schema\")",
|
||||
"Bash(sqlite3 data/backup.db \"SELECT COUNT\\(*\\) as message_count FROM messages;\")",
|
||||
"Bash(sqlite3 data/backup.db \"SELECT * FROM channels LIMIT 5;\")",
|
||||
"Bash(sqlite3 data/backup.db \"SELECT id, channel_id, author_id, content, created_at, edited_at, deleted_at, replied_to_id FROM messages LIMIT 3;\")",
|
||||
"Bash(sqlite3 data/backup.db \"SELECT COUNT\\(*\\) as attachment_count, SUM\\(CASE WHEN downloaded = 1 THEN 1 ELSE 0 END\\) as downloaded FROM attachments;\")",
|
||||
"Bash(sqlite3 data/backup.db \"SELECT id, message_id, filename, size, content_type, local_path FROM attachments LIMIT 3;\")",
|
||||
"Bash(sqlite3 data/backup.db \"SELECT COUNT\\(*\\) as reaction_count FROM reactions; SELECT COUNT\\(*\\) as embed_count FROM embeds; SELECT COUNT\\(*\\) as author_count FROM authors;\")",
|
||||
"Bash(sqlite3 data/backup.db \"SELECT * FROM reactions LIMIT 3;\")",
|
||||
"Bash(sqlite3 data/backup.db \"SELECT * FROM embeds LIMIT 2;\")",
|
||||
"Bash(sqlite3 data/backup.db \"SELECT id, username, display_name, avatar_url FROM authors LIMIT 5;\")",
|
||||
"Bash(sqlite3 data/backup.db \"SELECT message_id, content, edited_at FROM message_edits LIMIT 3;\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ All platform-specific APIs are accessed via the `usePlatform()` hook:
|
||||
- Push-to-talk: `voiceSettings.inputMode` is `'voice-activity'` (default) or `'push-to-talk'`. Paired with the `voice.pushToTalk` keybind (marked `pressAndHold: true`). `KeybindContext` dispatches `brycord:keybind:voice.pushToTalk:down` / `:up` events — pressAndHold actions never `preventDefault`, so binding PTT to a letter still lets you type. `VoiceContext` reads the settings via the `brycord:voice-settings-changed` window event, listens for the PTT events, and routes them through a configurable release-delay timer before reconciling the LiveKit mic track. All mic-on/mic-off sources (user mute, deafen, server mute, PTT gate) converge on a single `setMicrophoneEnabled` effect
|
||||
- Plaintext cache: `SearchDatabase` has a `plaintext_cache` table (per-channel cap of 500) that persists decrypted message bodies encrypted at rest by the existing search DB key. `SearchContext` exposes `cachePlaintexts` / `getChannelPlaintexts`. `Messages.tsx` seeds the in-memory `decryptionCache` from it on cold channel open (source-scoped on the full ciphertext so edits naturally invalidate), and writes back after each successful decrypt batch. Cuts the "empty bubbles then fill in" wave on app restart
|
||||
- Webcam video: camera publish already lived in `VoiceContext.setCamera` via LiveKit `setCameraEnabled`. Now also propagates `isCameraOn` through `voiceStates.updateState`. `CameraTile` attaches a participant's camera track to a `<video>` element (mirrored for the local preview, `muted` locally). `CameraGrid` (mounted in `VoiceCallView` above the audio-only tile grid) renders one tile per participant with `isCameraOn: true`, force-including the local user immediately when `voice.isCameraOn` flips so the preview doesn't lag behind the Convex round-trip
|
||||
- Electron lifecycle (tray + launch options): `main.cjs` maintains `isQuitting` + a `Tray` with Show / Toggle Mute / Toggle Deafen / Quit. Close handler is split into two `on('close')` listeners — the first intercepts close when `minimizeToTrayOnClose` is on and hides instead; the second does the normal state-save. `platform.lifecycle.{get,set,show,onTrayAction}` expose this to the renderer (Electron only — web = `null`). Launch section in the Appearance tab toggles `launchAtStartup` (via `app.setLoginItemSettings`), `startMinimized`, and `minimizeToTrayOnClose`. Tray menu Mute/Deafen routes through `brycord:keybind:voice.toggleMute|toggleDeafen` so it converges on the same voice action path the hotkeys already use
|
||||
|
||||
## Environment Variables
|
||||
|
||||
|
||||
4
Discord Backup Bot/.gitignore
vendored
Normal file
4
Discord Backup Bot/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
data/
|
||||
config.json
|
||||
*.log
|
||||
115
Discord Backup Bot/README.md
Normal file
115
Discord Backup Bot/README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Discord Backup Bot
|
||||
|
||||
Archives a single Discord server's text channels in full fidelity: messages, attachments, embeds, reactions, reply/thread structure, edits, and deletions. Stores everything in a local SQLite database with attachment files on disk. Resumes cleanly after crashes or restarts.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Create a Discord bot
|
||||
|
||||
1. Open <https://discord.com/developers/applications> and create a new application.
|
||||
2. Under **Bot**, click **Reset Token** and copy the token somewhere safe.
|
||||
3. Under **Privileged Gateway Intents**, enable:
|
||||
- **Message Content Intent** (required)
|
||||
4. Under **OAuth2 → URL Generator**:
|
||||
- Scopes: `bot`
|
||||
- Bot Permissions: `View Channels`, `Read Message History`
|
||||
5. Open the generated URL and add the bot to your server.
|
||||
|
||||
### 2. Get the IDs
|
||||
|
||||
Enable Developer Mode in Discord: **User Settings → Advanced → Developer Mode**.
|
||||
|
||||
Right-click your server icon → **Copy Server ID** (this is `guildId`).
|
||||
Right-click each channel you want backed up → **Copy Channel ID**.
|
||||
|
||||
### 3. Install and configure
|
||||
|
||||
Requires Node.js 22.5 or newer (uses the built-in `node:sqlite` module — no native compilation).
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Copy `config.example.json` to `config.json` and fill in:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "your bot token",
|
||||
"guildId": "your server id",
|
||||
"channelIds": ["channel id 1", "channel id 2"],
|
||||
"dataDir": "./data",
|
||||
"attachmentConcurrency": 3
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Run
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
The bot will log in, attach its live listener, start downloading attachments in the background, and backfill each configured channel (walking backward through old messages and forward to catch anything missed during downtime). Leave it running 24/7. Stop with Ctrl+C — next start resumes from the last checkpoint.
|
||||
|
||||
## What gets captured
|
||||
|
||||
- Messages (content, author, timestamps, reply target)
|
||||
- Edits — full edit history from when the bot is present
|
||||
- Deletions — soft-delete flag with timestamp
|
||||
- Attachments — downloaded to `data/attachments/<channel_id>/<message_id>/<filename>`
|
||||
- Embeds — link previews, bot embeds (stored as JSON)
|
||||
- Reactions — per user, with add/remove timestamps
|
||||
- Threads — active and public archived; new threads via live events
|
||||
- Author info — username, display name, avatar URL snapshot
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Pre-bot edits and deletions are unrecoverable.** Discord's API does not expose edit/deletion history — the bot only sees them live.
|
||||
- **Reactions removed before the bot joined are lost.** Reactions still present at backfill time are captured.
|
||||
- **Private archived threads** are not backfilled (requires extra permissions). Private threads created while the bot is live are captured.
|
||||
- **Signed attachment URLs expire after ~24 hours.** If the bot is offline that long after a message is posted, its attachment URL may 403/410 before the downloader can fetch it — metadata is preserved but the file is unrecoverable.
|
||||
- **Gateway reconnects that don't trigger a fresh `ready`** don't re-run backfill. Restart the bot to catch up after long disconnects.
|
||||
|
||||
## Storage
|
||||
|
||||
```
|
||||
data/
|
||||
├── backup.db # SQLite (WAL mode; -shm / -wal files appear while running)
|
||||
└── attachments/
|
||||
└── <channel_id>/<message_id>/<attachment_id>/<filename>
|
||||
```
|
||||
|
||||
## Inspecting the archive
|
||||
|
||||
```bash
|
||||
sqlite3 data/backup.db
|
||||
```
|
||||
|
||||
Useful queries:
|
||||
|
||||
```sql
|
||||
-- Messages per channel (including threads)
|
||||
SELECT c.name, COUNT(*) AS count
|
||||
FROM messages m JOIN channels c ON c.id = m.channel_id
|
||||
GROUP BY c.id ORDER BY count DESC;
|
||||
|
||||
-- Backfill progress
|
||||
SELECT name, is_thread, backfill_complete,
|
||||
oldest_fetched_id, newest_fetched_id
|
||||
FROM channels;
|
||||
|
||||
-- Pending / failed attachments
|
||||
SELECT
|
||||
SUM(CASE WHEN downloaded = 1 THEN 1 ELSE 0 END) AS done,
|
||||
SUM(CASE WHEN downloaded = 0 AND download_failed = 0 THEN 1 ELSE 0 END) AS pending,
|
||||
SUM(download_failed) AS failed
|
||||
FROM attachments;
|
||||
|
||||
-- Messages with edit history
|
||||
SELECT m.id, m.content, COUNT(e.message_id) AS edits
|
||||
FROM messages m LEFT JOIN message_edits e ON e.message_id = m.id
|
||||
GROUP BY m.id HAVING edits > 0;
|
||||
|
||||
-- Deleted messages
|
||||
SELECT id, author_id, content, deleted_at FROM messages
|
||||
WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT 20;
|
||||
```
|
||||
7
Discord Backup Bot/config.example.json
Normal file
7
Discord Backup Bot/config.example.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"guildId": "YOUR_GUILD_ID",
|
||||
"channelIds": ["CHANNEL_ID_1", "CHANNEL_ID_2"],
|
||||
"dataDir": "./data",
|
||||
"attachmentConcurrency": 3
|
||||
}
|
||||
327
Discord Backup Bot/package-lock.json
generated
Normal file
327
Discord Backup Bot/package-lock.json
generated
Normal file
@@ -0,0 +1,327 @@
|
||||
{
|
||||
"name": "discord-backup-bot",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "discord-backup-bot",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"discord.js": "^14.16.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/builders": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz",
|
||||
"integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@discordjs/formatters": "^0.6.2",
|
||||
"@discordjs/util": "^1.2.0",
|
||||
"@sapphire/shapeshift": "^4.0.0",
|
||||
"discord-api-types": "^0.38.40",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"ts-mixer": "^6.0.4",
|
||||
"tslib": "^2.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.11.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/collection": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz",
|
||||
"integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=16.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/formatters": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz",
|
||||
"integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"discord-api-types": "^0.38.33"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.11.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/rest": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz",
|
||||
"integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@discordjs/collection": "^2.1.1",
|
||||
"@discordjs/util": "^1.2.0",
|
||||
"@sapphire/async-queue": "^1.5.3",
|
||||
"@sapphire/snowflake": "^3.5.5",
|
||||
"@vladfrangu/async_event_emitter": "^2.4.6",
|
||||
"discord-api-types": "^0.38.40",
|
||||
"magic-bytes.js": "^1.13.0",
|
||||
"tslib": "^2.6.3",
|
||||
"undici": "6.24.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/rest/node_modules/@discordjs/collection": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
|
||||
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": {
|
||||
"version": "3.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz",
|
||||
"integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=v14.0.0",
|
||||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/util": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz",
|
||||
"integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"discord-api-types": "^0.38.33"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/ws": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz",
|
||||
"integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@discordjs/collection": "^2.1.0",
|
||||
"@discordjs/rest": "^2.5.1",
|
||||
"@discordjs/util": "^1.1.0",
|
||||
"@sapphire/async-queue": "^1.5.2",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@vladfrangu/async_event_emitter": "^2.2.4",
|
||||
"discord-api-types": "^0.38.1",
|
||||
"tslib": "^2.6.2",
|
||||
"ws": "^8.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.11.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/ws/node_modules/@discordjs/collection": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
|
||||
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@sapphire/async-queue": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
|
||||
"integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=v14.0.0",
|
||||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sapphire/shapeshift": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz",
|
||||
"integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v16"
|
||||
}
|
||||
},
|
||||
"node_modules/@sapphire/snowflake": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz",
|
||||
"integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=v14.0.0",
|
||||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vladfrangu/async_event_emitter": {
|
||||
"version": "2.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz",
|
||||
"integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=v14.0.0",
|
||||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/discord-api-types": {
|
||||
"version": "0.38.47",
|
||||
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.47.tgz",
|
||||
"integrity": "sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"scripts/actions/documentation"
|
||||
]
|
||||
},
|
||||
"node_modules/discord.js": {
|
||||
"version": "14.26.3",
|
||||
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.3.tgz",
|
||||
"integrity": "sha512-XEKtYn28YFsiJ5l4fLRyikdbo6RD5oFyqfVHQlvXz2104JhH/E8slN28dbky05w3DCrJcNVWvhVvcJCTSl/KIg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@discordjs/builders": "^1.14.1",
|
||||
"@discordjs/collection": "1.5.3",
|
||||
"@discordjs/formatters": "^0.6.2",
|
||||
"@discordjs/rest": "^2.6.1",
|
||||
"@discordjs/util": "^1.2.0",
|
||||
"@discordjs/ws": "^1.2.3",
|
||||
"@sapphire/snowflake": "3.5.3",
|
||||
"discord-api-types": "^0.38.40",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"lodash.snakecase": "4.1.1",
|
||||
"magic-bytes.js": "^1.13.0",
|
||||
"tslib": "^2.6.3",
|
||||
"undici": "6.24.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.snakecase": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
|
||||
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/magic-bytes.js": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz",
|
||||
"integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-mixer": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
|
||||
"integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.24.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
|
||||
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
Discord Backup Bot/package.json
Normal file
15
Discord Backup Bot/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "discord-backup-bot",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node --no-warnings=ExperimentalWarning src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"discord.js": "^14.16.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.5"
|
||||
}
|
||||
}
|
||||
132
Discord Backup Bot/src/backfill.js
Normal file
132
Discord Backup Bot/src/backfill.js
Normal file
@@ -0,0 +1,132 @@
|
||||
const { writeMessageCore, emojiKey, updateNewestIfGreater } = require('./writers');
|
||||
|
||||
async function backfillTarget(db, stmts, target) {
|
||||
stmts.insertChannel.run(
|
||||
target.id,
|
||||
target.name ?? null,
|
||||
target.parentId ?? null,
|
||||
target.isThread?.() ? 1 : 0,
|
||||
);
|
||||
|
||||
let row = stmts.getChannel.get(target.id);
|
||||
if (row.newest_fetched_id) {
|
||||
await walkForward(db, stmts, target, row.newest_fetched_id);
|
||||
}
|
||||
|
||||
row = stmts.getChannel.get(target.id);
|
||||
if (!row.backfill_complete) {
|
||||
await walkBackward(db, stmts, target, row.oldest_fetched_id);
|
||||
stmts.markBackfillComplete.run(target.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function backfillChannelAndThreads(db, stmts, channel) {
|
||||
console.log(`[backfill] channel ${channel.name} (${channel.id})`);
|
||||
await backfillTarget(db, stmts, channel);
|
||||
|
||||
if (typeof channel.threads?.fetchActive === 'function') {
|
||||
try {
|
||||
const active = await channel.threads.fetchActive();
|
||||
for (const thread of active.threads.values()) {
|
||||
console.log(`[backfill] active thread ${thread.name} (${thread.id})`);
|
||||
await backfillTarget(db, stmts, thread);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[backfill] active threads for ${channel.id}:`, err.message);
|
||||
}
|
||||
|
||||
try {
|
||||
let before;
|
||||
while (true) {
|
||||
const batch = await channel.threads.fetchArchived({ type: 'public', before, limit: 100 });
|
||||
for (const thread of batch.threads.values()) {
|
||||
console.log(`[backfill] archived thread ${thread.name} (${thread.id})`);
|
||||
stmts.updateArchived.run(1, thread.id);
|
||||
await backfillTarget(db, stmts, thread);
|
||||
}
|
||||
if (!batch.hasMore) break;
|
||||
const threadArr = [...batch.threads.values()];
|
||||
const last = threadArr[threadArr.length - 1];
|
||||
before = last.archivedAt ?? last.archiveTimestamp;
|
||||
if (!before) break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[backfill] archived threads for ${channel.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function walkForward(db, stmts, target, afterId) {
|
||||
let cursor = afterId;
|
||||
while (true) {
|
||||
const batch = await target.messages.fetch({ limit: 100, after: cursor, cache: false });
|
||||
if (batch.size === 0) return;
|
||||
const messages = [...batch.values()].sort((a, b) =>
|
||||
BigInt(a.id) < BigInt(b.id) ? -1 : 1
|
||||
);
|
||||
await writeBatch(db, stmts, target, messages);
|
||||
cursor = messages[messages.length - 1].id;
|
||||
updateNewestIfGreater(stmts, target.id, cursor);
|
||||
}
|
||||
}
|
||||
|
||||
async function walkBackward(db, stmts, target, beforeId) {
|
||||
let cursor = beforeId;
|
||||
let emptyRuns = 0;
|
||||
while (true) {
|
||||
const opts = { limit: 100, cache: false };
|
||||
if (cursor) opts.before = cursor;
|
||||
const batch = await target.messages.fetch(opts);
|
||||
if (batch.size === 0) {
|
||||
emptyRuns++;
|
||||
if (emptyRuns >= 2) return;
|
||||
console.log(`[backfill] empty batch for ${target.id}, retrying once after 3s`);
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
continue;
|
||||
}
|
||||
emptyRuns = 0;
|
||||
const messages = [...batch.values()].sort((a, b) =>
|
||||
BigInt(a.id) < BigInt(b.id) ? 1 : -1
|
||||
);
|
||||
await writeBatch(db, stmts, target, messages);
|
||||
|
||||
const newestInBatch = messages[0].id;
|
||||
const oldestInBatch = messages[messages.length - 1].id;
|
||||
updateNewestIfGreater(stmts, target.id, newestInBatch);
|
||||
stmts.updateOldest.run(oldestInBatch, target.id);
|
||||
cursor = oldestInBatch;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeBatch(db, stmts, target, messages) {
|
||||
const reactionData = [];
|
||||
for (const msg of messages) {
|
||||
if (!msg.reactions?.cache) continue;
|
||||
for (const reaction of msg.reactions.cache.values()) {
|
||||
try {
|
||||
const users = await reaction.users.fetch();
|
||||
reactionData.push({
|
||||
messageId: msg.id,
|
||||
emoji: emojiKey(reaction.emoji),
|
||||
userIds: [...users.keys()],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[backfill] fetch reactions for ${msg.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
for (const msg of messages) {
|
||||
writeMessageCore(stmts, msg);
|
||||
}
|
||||
for (const r of reactionData) {
|
||||
for (const userId of r.userIds) {
|
||||
stmts.upsertReaction.run(r.messageId, r.emoji, userId, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
module.exports = { backfillChannelAndThreads };
|
||||
30
Discord Backup Bot/src/db.js
Normal file
30
Discord Backup Bot/src/db.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
|
||||
function openDb(dataDir) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
const db = new DatabaseSync(path.join(dataDir, 'backup.db'));
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA synchronous = NORMAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
|
||||
db.exec(schema);
|
||||
|
||||
db.transaction = (fn) => (...args) => {
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
const result = fn(...args);
|
||||
db.exec('COMMIT');
|
||||
return result;
|
||||
} catch (err) {
|
||||
try { db.exec('ROLLBACK'); } catch (_) { /* nested */ }
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
module.exports = { openDb };
|
||||
15
Discord Backup Bot/src/discord.js
Normal file
15
Discord Backup Bot/src/discord.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const { Client, GatewayIntentBits, Partials } = require('discord.js');
|
||||
|
||||
function createClient() {
|
||||
return new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
GatewayIntentBits.GuildMessageReactions,
|
||||
],
|
||||
partials: [Partials.Message, Partials.Channel, Partials.Reaction],
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { createClient };
|
||||
69
Discord Backup Bot/src/downloader.js
Normal file
69
Discord Backup Bot/src/downloader.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const { Readable } = require('node:stream');
|
||||
const { pipeline } = require('node:stream/promises');
|
||||
|
||||
async function runDownloader(stmts, dataDir, concurrency, getRunning) {
|
||||
const attachmentsDir = path.join(dataDir, 'attachments');
|
||||
fs.mkdirSync(attachmentsDir, { recursive: true });
|
||||
|
||||
while (getRunning()) {
|
||||
const pending = stmts.pendingAttachments.all(concurrency * 4);
|
||||
if (pending.length === 0) {
|
||||
await sleep(5000);
|
||||
continue;
|
||||
}
|
||||
|
||||
const queue = [...pending];
|
||||
const workers = Array.from({ length: concurrency }, () =>
|
||||
worker(queue, stmts, attachmentsDir, dataDir, getRunning)
|
||||
);
|
||||
await Promise.all(workers);
|
||||
}
|
||||
}
|
||||
|
||||
async function worker(queue, stmts, attachmentsDir, dataDir, getRunning) {
|
||||
while (queue.length > 0 && getRunning()) {
|
||||
const item = queue.shift();
|
||||
if (!item) return;
|
||||
await downloadOne(item, stmts, attachmentsDir, dataDir);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadOne(item, stmts, attachmentsDir, dataDir) {
|
||||
const destDir = path.join(attachmentsDir, item.channel_id, item.message_id, item.id);
|
||||
const destPath = path.join(destDir, safeFilename(item.filename));
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const res = await fetch(item.url);
|
||||
if (res.status === 404 || res.status === 403 || res.status === 410) {
|
||||
console.error(`[download] ${item.filename} → HTTP ${res.status} (permanent)`);
|
||||
stmts.markAttachmentFailed.run(item.id);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const tmpPath = destPath + '.part';
|
||||
await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(tmpPath));
|
||||
fs.renameSync(tmpPath, destPath);
|
||||
const relPath = path.relative(dataDir, destPath);
|
||||
stmts.markAttachmentDownloaded.run(relPath, item.id);
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error(`[download] ${item.filename} attempt ${attempt + 1}: ${err.message}`);
|
||||
if (attempt < 2) await sleep(1000 * (attempt + 1));
|
||||
}
|
||||
}
|
||||
stmts.markAttachmentFailed.run(item.id);
|
||||
}
|
||||
|
||||
function safeFilename(name) {
|
||||
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').slice(0, 200);
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
module.exports = { runDownloader };
|
||||
80
Discord Backup Bot/src/index.js
Normal file
80
Discord Backup Bot/src/index.js
Normal file
@@ -0,0 +1,80 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { openDb } = require('./db');
|
||||
const { prepareStatements } = require('./writers');
|
||||
const { createClient } = require('./discord');
|
||||
const { backfillChannelAndThreads } = require('./backfill');
|
||||
const { attachHandlers } = require('./live');
|
||||
const { runDownloader } = require('./downloader');
|
||||
|
||||
function loadConfig() {
|
||||
const configPath = path.resolve(process.cwd(), 'config.json');
|
||||
if (!fs.existsSync(configPath)) {
|
||||
throw new Error(`config.json not found at ${configPath}. Copy config.example.json and fill it in.`);
|
||||
}
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
if (!config.token) throw new Error('config.json missing "token"');
|
||||
if (!config.guildId) throw new Error('config.json missing "guildId"');
|
||||
if (!Array.isArray(config.channelIds) || config.channelIds.length === 0) {
|
||||
throw new Error('config.json must have a non-empty "channelIds" array');
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const config = loadConfig();
|
||||
const dataDir = path.resolve(process.cwd(), config.dataDir ?? './data');
|
||||
const db = openDb(dataDir);
|
||||
const stmts = prepareStatements(db);
|
||||
const client = createClient();
|
||||
|
||||
attachHandlers(client, db, stmts, config.channelIds);
|
||||
|
||||
let running = true;
|
||||
const shutdown = () => {
|
||||
if (!running) return;
|
||||
console.log('[bot] shutting down');
|
||||
running = false;
|
||||
client.destroy();
|
||||
try {
|
||||
db.close();
|
||||
} catch (err) {
|
||||
console.error('[bot] db close:', err.message);
|
||||
}
|
||||
setTimeout(() => process.exit(0), 500).unref();
|
||||
};
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
client.once('ready', async (c) => {
|
||||
console.log(`[bot] logged in as ${c.user.tag}`);
|
||||
|
||||
runDownloader(stmts, dataDir, config.attachmentConcurrency ?? 3, () => running).catch((err) => {
|
||||
console.error('[downloader] fatal:', err);
|
||||
});
|
||||
|
||||
const guild = await c.guilds.fetch(config.guildId);
|
||||
for (const channelId of config.channelIds) {
|
||||
if (!running) return;
|
||||
try {
|
||||
const channel = await guild.channels.fetch(channelId);
|
||||
if (!channel) {
|
||||
console.error(`[backfill] channel ${channelId} not found or not accessible`);
|
||||
continue;
|
||||
}
|
||||
await backfillChannelAndThreads(db, stmts, channel);
|
||||
console.log(`[backfill] complete for ${channel.name}`);
|
||||
} catch (err) {
|
||||
console.error(`[backfill] ${channelId}:`, err.message);
|
||||
}
|
||||
}
|
||||
console.log('[backfill] all channels complete; live listener active');
|
||||
});
|
||||
|
||||
await client.login(config.token);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
153
Discord Backup Bot/src/live.js
Normal file
153
Discord Backup Bot/src/live.js
Normal file
@@ -0,0 +1,153 @@
|
||||
const { Events } = require('discord.js');
|
||||
const { writeMessageCore, emojiKey, updateNewestIfGreater } = require('./writers');
|
||||
|
||||
function attachHandlers(client, db, stmts, channelIds) {
|
||||
const channelSet = new Set(channelIds);
|
||||
|
||||
const parentOf = (channel) => (channel?.isThread?.() ? channel.parentId : null);
|
||||
const inScope = (channelId, parentId) =>
|
||||
channelSet.has(channelId) || (parentId && channelSet.has(parentId));
|
||||
|
||||
client.on(Events.MessageCreate, (msg) => {
|
||||
const parentId = parentOf(msg.channel);
|
||||
if (!inScope(msg.channelId, parentId)) return;
|
||||
const tx = db.transaction(() => {
|
||||
if (msg.channel?.isThread?.()) {
|
||||
stmts.insertChannel.run(msg.channel.id, msg.channel.name, msg.channel.parentId, 1);
|
||||
}
|
||||
writeMessageCore(stmts, msg);
|
||||
updateNewestIfGreater(stmts, msg.channelId, msg.id);
|
||||
});
|
||||
try {
|
||||
tx();
|
||||
} catch (err) {
|
||||
console.error('[live] messageCreate:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
client.on(Events.MessageUpdate, (_oldMsg, newMsg) => {
|
||||
const parentId = parentOf(newMsg.channel);
|
||||
if (!inScope(newMsg.channelId, parentId)) return;
|
||||
const editedAt = newMsg.editedTimestamp ?? Date.now();
|
||||
const tx = db.transaction(() => {
|
||||
const existing = stmts.existingContent.get(newMsg.id);
|
||||
if (existing) {
|
||||
stmts.insertEdit.run(newMsg.id, existing.content, editedAt);
|
||||
stmts.updateContent.run(newMsg.content ?? '', editedAt, newMsg.id);
|
||||
} else {
|
||||
writeMessageCore(stmts, newMsg);
|
||||
}
|
||||
});
|
||||
try {
|
||||
tx();
|
||||
} catch (err) {
|
||||
console.error('[live] messageUpdate:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
client.on(Events.MessageDelete, (msg) => {
|
||||
const parentId = parentOf(msg.channel);
|
||||
if (!inScope(msg.channelId, parentId)) return;
|
||||
const now = Date.now();
|
||||
const tx = db.transaction(() => {
|
||||
stmts.insertDeletedPlaceholder.run(
|
||||
msg.id,
|
||||
msg.channelId,
|
||||
msg.author?.id ?? null,
|
||||
msg.content ?? '',
|
||||
msg.createdTimestamp ?? now,
|
||||
now,
|
||||
);
|
||||
stmts.markDeleted.run(now, msg.id);
|
||||
});
|
||||
try {
|
||||
tx();
|
||||
} catch (err) {
|
||||
console.error('[live] messageDelete:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
client.on(Events.MessageBulkDelete, (messages) => {
|
||||
const now = Date.now();
|
||||
const tx = db.transaction(() => {
|
||||
for (const msg of messages.values()) {
|
||||
const parentId = parentOf(msg.channel);
|
||||
if (!inScope(msg.channelId, parentId)) continue;
|
||||
stmts.insertDeletedPlaceholder.run(
|
||||
msg.id,
|
||||
msg.channelId,
|
||||
msg.author?.id ?? null,
|
||||
msg.content ?? '',
|
||||
msg.createdTimestamp ?? now,
|
||||
now,
|
||||
);
|
||||
stmts.markDeleted.run(now, msg.id);
|
||||
}
|
||||
});
|
||||
try {
|
||||
tx();
|
||||
} catch (err) {
|
||||
console.error('[live] messageDeleteBulk:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
client.on(Events.MessageReactionAdd, (reaction, user) => {
|
||||
const parentId = parentOf(reaction.message.channel);
|
||||
if (!inScope(reaction.message.channelId, parentId)) return;
|
||||
try {
|
||||
stmts.upsertReaction.run(reaction.message.id, emojiKey(reaction.emoji), user.id, Date.now());
|
||||
} catch (err) {
|
||||
console.error('[live] reactionAdd:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
client.on(Events.MessageReactionRemove, (reaction, user) => {
|
||||
const parentId = parentOf(reaction.message.channel);
|
||||
if (!inScope(reaction.message.channelId, parentId)) return;
|
||||
try {
|
||||
stmts.removeReaction.run(Date.now(), reaction.message.id, emojiKey(reaction.emoji), user.id);
|
||||
} catch (err) {
|
||||
console.error('[live] reactionRemove:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
client.on(Events.MessageReactionRemoveAll, (msg) => {
|
||||
const parentId = parentOf(msg.channel);
|
||||
if (!inScope(msg.channelId, parentId)) return;
|
||||
try {
|
||||
stmts.removeAllReactions.run(Date.now(), msg.id);
|
||||
} catch (err) {
|
||||
console.error('[live] reactionRemoveAll:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
client.on(Events.ThreadCreate, (thread) => {
|
||||
if (!channelSet.has(thread.parentId)) return;
|
||||
try {
|
||||
stmts.insertChannel.run(thread.id, thread.name, thread.parentId, 1);
|
||||
} catch (err) {
|
||||
console.error('[live] threadCreate:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
client.on(Events.ThreadUpdate, (_oldT, newT) => {
|
||||
if (!channelSet.has(newT.parentId)) return;
|
||||
try {
|
||||
stmts.insertChannel.run(newT.id, newT.name, newT.parentId, 1);
|
||||
stmts.updateArchived.run(newT.archived ? 1 : 0, newT.id);
|
||||
} catch (err) {
|
||||
console.error('[live] threadUpdate:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
client.on(Events.ThreadDelete, (thread) => {
|
||||
if (!channelSet.has(thread.parentId)) return;
|
||||
try {
|
||||
stmts.updateArchived.run(1, thread.id);
|
||||
} catch (err) {
|
||||
console.error('[live] threadDelete:', err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { attachHandlers };
|
||||
68
Discord Backup Bot/src/schema.sql
Normal file
68
Discord Backup Bot/src/schema.sql
Normal file
@@ -0,0 +1,68 @@
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
parent_channel_id TEXT,
|
||||
is_thread INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
oldest_fetched_id TEXT,
|
||||
newest_fetched_id TEXT,
|
||||
backfill_complete INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL,
|
||||
author_id TEXT,
|
||||
content TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
edited_at INTEGER,
|
||||
deleted_at INTEGER,
|
||||
replied_to_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_edits (
|
||||
message_id TEXT NOT NULL,
|
||||
content TEXT,
|
||||
edited_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (message_id, edited_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
local_path TEXT,
|
||||
size INTEGER,
|
||||
content_type TEXT,
|
||||
downloaded INTEGER NOT NULL DEFAULT 0,
|
||||
download_failed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_pending ON attachments(downloaded)
|
||||
WHERE downloaded = 0 AND download_failed = 0;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reactions (
|
||||
message_id TEXT NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
added_at INTEGER,
|
||||
removed_at INTEGER,
|
||||
PRIMARY KEY (message_id, emoji, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS embeds (
|
||||
message_id TEXT NOT NULL,
|
||||
idx INTEGER NOT NULL,
|
||||
data_json TEXT NOT NULL,
|
||||
PRIMARY KEY (message_id, idx)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS authors (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT,
|
||||
display_name TEXT,
|
||||
avatar_url TEXT,
|
||||
last_seen INTEGER
|
||||
);
|
||||
142
Discord Backup Bot/src/writers.js
Normal file
142
Discord Backup Bot/src/writers.js
Normal file
@@ -0,0 +1,142 @@
|
||||
function prepareStatements(db) {
|
||||
return {
|
||||
insertChannel: db.prepare(`
|
||||
INSERT INTO channels (id, name, parent_channel_id, is_thread)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
parent_channel_id = excluded.parent_channel_id,
|
||||
is_thread = excluded.is_thread
|
||||
`),
|
||||
updateArchived: db.prepare('UPDATE channels SET archived = ? WHERE id = ?'),
|
||||
updateOldest: db.prepare('UPDATE channels SET oldest_fetched_id = ? WHERE id = ?'),
|
||||
updateNewest: db.prepare(`
|
||||
UPDATE channels
|
||||
SET newest_fetched_id = CASE
|
||||
WHEN newest_fetched_id IS NULL THEN ?
|
||||
WHEN CAST(? AS INTEGER) > CAST(newest_fetched_id AS INTEGER) THEN ?
|
||||
ELSE newest_fetched_id
|
||||
END
|
||||
WHERE id = ?
|
||||
`),
|
||||
markBackfillComplete: db.prepare('UPDATE channels SET backfill_complete = 1 WHERE id = ?'),
|
||||
getChannel: db.prepare('SELECT * FROM channels WHERE id = ?'),
|
||||
|
||||
insertMessage: db.prepare(`
|
||||
INSERT OR IGNORE INTO messages
|
||||
(id, channel_id, author_id, content, created_at, edited_at, replied_to_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`),
|
||||
insertDeletedPlaceholder: db.prepare(`
|
||||
INSERT OR IGNORE INTO messages
|
||||
(id, channel_id, author_id, content, created_at, deleted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`),
|
||||
markDeleted: db.prepare('UPDATE messages SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL'),
|
||||
updateContent: db.prepare('UPDATE messages SET content = ?, edited_at = ? WHERE id = ?'),
|
||||
existingContent: db.prepare('SELECT content FROM messages WHERE id = ?'),
|
||||
insertEdit: db.prepare(`
|
||||
INSERT OR IGNORE INTO message_edits (message_id, content, edited_at) VALUES (?, ?, ?)
|
||||
`),
|
||||
|
||||
insertAttachment: db.prepare(`
|
||||
INSERT OR IGNORE INTO attachments (id, message_id, filename, url, size, content_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`),
|
||||
markAttachmentDownloaded: db.prepare(
|
||||
'UPDATE attachments SET downloaded = 1, local_path = ? WHERE id = ?'
|
||||
),
|
||||
markAttachmentFailed: db.prepare('UPDATE attachments SET download_failed = 1 WHERE id = ?'),
|
||||
pendingAttachments: db.prepare(`
|
||||
SELECT a.id, a.message_id, a.filename, a.url, m.channel_id
|
||||
FROM attachments a JOIN messages m ON m.id = a.message_id
|
||||
WHERE a.downloaded = 0 AND a.download_failed = 0
|
||||
LIMIT ?
|
||||
`),
|
||||
|
||||
upsertReaction: db.prepare(`
|
||||
INSERT INTO reactions (message_id, emoji, user_id, added_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(message_id, emoji, user_id) DO UPDATE SET
|
||||
added_at = COALESCE(reactions.added_at, excluded.added_at),
|
||||
removed_at = NULL
|
||||
`),
|
||||
removeReaction: db.prepare(`
|
||||
UPDATE reactions SET removed_at = ?
|
||||
WHERE message_id = ? AND emoji = ? AND user_id = ? AND removed_at IS NULL
|
||||
`),
|
||||
removeAllReactions: db.prepare(`
|
||||
UPDATE reactions SET removed_at = ?
|
||||
WHERE message_id = ? AND removed_at IS NULL
|
||||
`),
|
||||
|
||||
insertEmbed: db.prepare(`
|
||||
INSERT OR REPLACE INTO embeds (message_id, idx, data_json) VALUES (?, ?, ?)
|
||||
`),
|
||||
|
||||
upsertAuthor: db.prepare(`
|
||||
INSERT INTO authors (id, username, display_name, avatar_url, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
username = excluded.username,
|
||||
display_name = excluded.display_name,
|
||||
avatar_url = excluded.avatar_url,
|
||||
last_seen = MAX(COALESCE(authors.last_seen, 0), COALESCE(excluded.last_seen, 0))
|
||||
`),
|
||||
};
|
||||
}
|
||||
|
||||
function emojiKey(emoji) {
|
||||
if (emoji.id) {
|
||||
return `<${emoji.animated ? 'a' : ''}:${emoji.name}:${emoji.id}>`;
|
||||
}
|
||||
return emoji.name;
|
||||
}
|
||||
|
||||
function writeMessageCore(stmts, msg) {
|
||||
const createdAt = msg.createdTimestamp;
|
||||
const editedAt = msg.editedTimestamp ?? null;
|
||||
|
||||
if (msg.author) {
|
||||
stmts.upsertAuthor.run(
|
||||
msg.author.id,
|
||||
msg.author.username ?? null,
|
||||
msg.member?.displayName ?? msg.author.globalName ?? null,
|
||||
msg.author.displayAvatarURL?.() ?? null,
|
||||
createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
stmts.insertMessage.run(
|
||||
msg.id,
|
||||
msg.channelId,
|
||||
msg.author?.id ?? null,
|
||||
msg.content ?? '',
|
||||
createdAt,
|
||||
editedAt,
|
||||
msg.reference?.messageId ?? null,
|
||||
);
|
||||
|
||||
for (const att of msg.attachments.values()) {
|
||||
stmts.insertAttachment.run(
|
||||
att.id,
|
||||
msg.id,
|
||||
att.name,
|
||||
att.url,
|
||||
att.size ?? null,
|
||||
att.contentType ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
let idx = 0;
|
||||
for (const embed of msg.embeds) {
|
||||
const data = typeof embed.toJSON === 'function' ? embed.toJSON() : embed;
|
||||
stmts.insertEmbed.run(msg.id, idx++, JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
function updateNewestIfGreater(stmts, channelId, messageId) {
|
||||
stmts.updateNewest.run(messageId, messageId, messageId, channelId);
|
||||
}
|
||||
|
||||
module.exports = { prepareStatements, emojiKey, writeMessageCore, updateNewestIfGreater };
|
||||
@@ -1,4 +1,4 @@
|
||||
const { app, BrowserWindow, dialog, ipcMain, shell, screen, safeStorage, powerMonitor, Notification, nativeImage } = require('electron');
|
||||
const { app, BrowserWindow, dialog, ipcMain, shell, screen, safeStorage, powerMonitor, Notification, nativeImage, Tray, Menu } = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -32,9 +32,21 @@ const DEFAULT_SETTINGS = {
|
||||
windowHeight: 800,
|
||||
isMaximized: false,
|
||||
theme: 'theme-dark',
|
||||
// Power features. All default-off so users aren't surprised by
|
||||
// invisible state on first upgrade; the Launch tab exposes toggles
|
||||
// for each.
|
||||
launchAtStartup: false,
|
||||
startMinimized: false,
|
||||
minimizeToTrayOnClose: false,
|
||||
};
|
||||
|
||||
let mainWindow = null;
|
||||
let tray = null;
|
||||
// Flipped to true by the tray's Quit action (and any other explicit
|
||||
// quit path) so the `close` handler knows to actually exit instead of
|
||||
// hiding the window. Without this, tray users who pick Quit would
|
||||
// just hide the window again.
|
||||
let isQuitting = false;
|
||||
|
||||
// Screen-share source picked by the renderer right before LiveKit's
|
||||
// setScreenShareEnabled(true) call triggers getDisplayMedia. The
|
||||
@@ -235,8 +247,22 @@ function createWindow() {
|
||||
try { app.setBadgeCount(0); } catch {}
|
||||
});
|
||||
|
||||
// Intercept close when the user has opted into minimize-to-tray —
|
||||
// the tray still shows the app and a Show / Quit menu keeps the
|
||||
// window accessible. Triggered before the normal close-cleanup
|
||||
// below so the window stays alive.
|
||||
mainWindow.on('close', (event) => {
|
||||
const current = loadSettings();
|
||||
if (current.minimizeToTrayOnClose && !isQuitting && tray) {
|
||||
event.preventDefault();
|
||||
mainWindow.hide();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Save window state on close
|
||||
mainWindow.on('close', () => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
// Flush localStorage/sessionStorage to disk before renderer is destroyed
|
||||
mainWindow.webContents.session.flushStorageData();
|
||||
|
||||
@@ -270,6 +296,81 @@ function createWindow() {
|
||||
}
|
||||
}
|
||||
|
||||
function showMainWindow() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
function toggleMainWindow() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
if (mainWindow.isVisible() && mainWindow.isFocused()) {
|
||||
mainWindow.hide();
|
||||
} else {
|
||||
showMainWindow();
|
||||
}
|
||||
}
|
||||
|
||||
// Tray icon + menu. The menu mirrors what users expect from a chat
|
||||
// app that runs in the background: quick window toggle, a mute /
|
||||
// deafen pair that just routes through to the renderer via IPC (so
|
||||
// the existing keybind handlers do the work), and an explicit Quit
|
||||
// that sets `isQuitting` so the `close` interceptor doesn't fight us.
|
||||
function createTray() {
|
||||
if (tray) return;
|
||||
try {
|
||||
const iconPath = path.join(__dirname, 'icon.png');
|
||||
const img = nativeImage.createFromPath(iconPath);
|
||||
tray = new Tray(img.isEmpty() ? nativeImage.createEmpty() : img);
|
||||
tray.setToolTip('Brycord');
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Show Brycord',
|
||||
click: () => showMainWindow(),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Toggle Mute',
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('tray:action', 'toggle-mute');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Toggle Deafen',
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('tray:action', 'toggle-deafen');
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Quit',
|
||||
click: () => {
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
tray.setContextMenu(menu);
|
||||
tray.on('click', () => toggleMainWindow());
|
||||
tray.on('double-click', () => showMainWindow());
|
||||
} catch (err) {
|
||||
console.error('Failed to create tray:', err);
|
||||
tray = null;
|
||||
}
|
||||
}
|
||||
|
||||
function destroyTray() {
|
||||
if (tray) {
|
||||
try { tray.destroy(); } catch {}
|
||||
tray = null;
|
||||
}
|
||||
}
|
||||
|
||||
function createSplashWindow() {
|
||||
const splash = new BrowserWindow({
|
||||
width: 300,
|
||||
@@ -312,6 +413,82 @@ app.whenReady().then(async () => {
|
||||
ipcMain.handle('update:get-status', () => getUpdateStatus());
|
||||
ipcMain.handle('update:download-and-install', () => downloadAndInstallUpdate());
|
||||
|
||||
// ── Lifecycle / power features ──────────────────────────────
|
||||
// Auto-start is implemented via Electron's cross-platform
|
||||
// `setLoginItemSettings`. Works on Windows + macOS natively; on
|
||||
// Linux it expects a .desktop file, which electron-builder ships
|
||||
// with the packaged app. Unpackaged dev builds just flip the flag
|
||||
// in-memory and don't actually register with the OS.
|
||||
const applyLaunchAtStartup = (enabled, startMinimized) => {
|
||||
try {
|
||||
if (app.isPackaged) {
|
||||
app.setLoginItemSettings({
|
||||
openAtLogin: !!enabled,
|
||||
openAsHidden: !!startMinimized,
|
||||
args: startMinimized ? ['--start-minimized'] : [],
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('setLoginItemSettings failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Apply whatever's in settings.json on every launch so a change
|
||||
// persists across upgrades even if the OS forgot the entry.
|
||||
const bootSettings = loadSettings();
|
||||
applyLaunchAtStartup(bootSettings.launchAtStartup, bootSettings.startMinimized);
|
||||
if (bootSettings.launchAtStartup && bootSettings.startMinimized &&
|
||||
(process.argv.includes('--start-minimized') || process.argv.includes('--hidden'))) {
|
||||
// Honour the hidden-launch arg: if the tray option is on,
|
||||
// keep the window hidden until the user clicks the tray. If
|
||||
// the tray option is off, still hide briefly so the first
|
||||
// paint doesn't flash.
|
||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.hide();
|
||||
}
|
||||
|
||||
ipcMain.handle('lifecycle:get', () => {
|
||||
const current = loadSettings();
|
||||
return {
|
||||
launchAtStartup: !!current.launchAtStartup,
|
||||
startMinimized: !!current.startMinimized,
|
||||
minimizeToTrayOnClose: !!current.minimizeToTrayOnClose,
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle('lifecycle:set', (_event, patch) => {
|
||||
const current = loadSettings();
|
||||
const next = { ...current };
|
||||
if (typeof patch?.launchAtStartup === 'boolean') next.launchAtStartup = patch.launchAtStartup;
|
||||
if (typeof patch?.startMinimized === 'boolean') next.startMinimized = patch.startMinimized;
|
||||
if (typeof patch?.minimizeToTrayOnClose === 'boolean') next.minimizeToTrayOnClose = patch.minimizeToTrayOnClose;
|
||||
saveSettings(next);
|
||||
applyLaunchAtStartup(next.launchAtStartup, next.startMinimized);
|
||||
// Tray is only required when minimize-to-tray is on — destroy
|
||||
// it when turned off to free the icon slot, recreate on next
|
||||
// enable. It's cheap either way.
|
||||
if (next.minimizeToTrayOnClose) createTray();
|
||||
else destroyTray();
|
||||
return {
|
||||
launchAtStartup: !!next.launchAtStartup,
|
||||
startMinimized: !!next.startMinimized,
|
||||
minimizeToTrayOnClose: !!next.minimizeToTrayOnClose,
|
||||
};
|
||||
});
|
||||
|
||||
// Expose a way for the renderer to show the window programmatically
|
||||
// (e.g. after a desktop notification click) — complements the tray.
|
||||
ipcMain.on('window:show', () => showMainWindow());
|
||||
|
||||
// Create the tray up front if the user has minimize-to-tray enabled
|
||||
// so their first close works as expected after a cold launch.
|
||||
if (bootSettings.minimizeToTrayOnClose) {
|
||||
createTray();
|
||||
}
|
||||
|
||||
app.on('before-quit', () => {
|
||||
isQuitting = true;
|
||||
});
|
||||
|
||||
ipcMain.on('window-minimize', () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
if (win) win.minimize();
|
||||
@@ -1122,6 +1299,84 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Discord backup importer ---
|
||||
// The renderer picks the backup SQLite file, then we hand back
|
||||
// its bytes + the parent directory that contains the
|
||||
// `attachments/<channel>/<message>/<att>/` tree. sql.js parses
|
||||
// the db in-renderer so we don't need a native sqlite binding
|
||||
// (Electron 33 ships Node 20, which predates node:sqlite).
|
||||
ipcMain.handle('importer:pick-database', async () => {
|
||||
try {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
const result = await dialog.showOpenDialog(win ?? undefined, {
|
||||
title: 'Choose Discord backup database',
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{ name: 'SQLite database', extensions: ['db', 'sqlite', 'sqlite3'] },
|
||||
{ name: 'All files', extensions: ['*'] },
|
||||
],
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { ok: false, path: null };
|
||||
}
|
||||
return { ok: true, path: result.filePaths[0] };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message ?? 'pick failed' };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('importer:read-database', async (_event, dbPath) => {
|
||||
if (typeof dbPath !== 'string' || !dbPath) {
|
||||
return { ok: false, error: 'missing path' };
|
||||
}
|
||||
try {
|
||||
const buf = await fs.promises.readFile(dbPath);
|
||||
// The backup bot writes attachments next to the db in a
|
||||
// sibling `attachments/` folder — surface the db's dir so
|
||||
// the renderer can resolve relative `local_path` values
|
||||
// from the `attachments` table.
|
||||
const dataDir = path.dirname(dbPath);
|
||||
return {
|
||||
ok: true,
|
||||
bytes: buf.buffer.slice(
|
||||
buf.byteOffset,
|
||||
buf.byteOffset + buf.byteLength,
|
||||
),
|
||||
dataDir,
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message ?? 'read failed' };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('importer:read-attachment', async (_event, payload) => {
|
||||
const { dataDir, localPath } = payload || {};
|
||||
if (typeof dataDir !== 'string' || typeof localPath !== 'string') {
|
||||
return { ok: false, error: 'missing dataDir/localPath' };
|
||||
}
|
||||
try {
|
||||
// Defence-in-depth: resolve the absolute path and refuse
|
||||
// anything that escapes `dataDir`. The renderer is trusted
|
||||
// but a corrupt backup.db with `..`-laden `local_path`
|
||||
// values could otherwise cough up arbitrary host files.
|
||||
const abs = path.resolve(dataDir, localPath);
|
||||
const rootWithSep = path.resolve(dataDir) + path.sep;
|
||||
if (!abs.startsWith(rootWithSep) && abs !== path.resolve(dataDir)) {
|
||||
return { ok: false, error: 'path escapes dataDir' };
|
||||
}
|
||||
const buf = await fs.promises.readFile(abs);
|
||||
return {
|
||||
ok: true,
|
||||
bytes: buf.buffer.slice(
|
||||
buf.byteOffset,
|
||||
buf.byteOffset + buf.byteLength,
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message ?? 'read failed' };
|
||||
}
|
||||
});
|
||||
|
||||
// --- Auto-idle detection ---
|
||||
const IDLE_THRESHOLD_SECONDS = 300; // 5 minutes
|
||||
let wasIdle = false;
|
||||
|
||||
@@ -51,6 +51,17 @@ contextBridge.exposeInMainWorld('updateAPI', {
|
||||
},
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('lifecycleAPI', {
|
||||
get: () => ipcRenderer.invoke('lifecycle:get'),
|
||||
set: (patch) => ipcRenderer.invoke('lifecycle:set', patch),
|
||||
show: () => ipcRenderer.send('window:show'),
|
||||
onTrayAction: (callback) => {
|
||||
const handler = (_event, action) => callback(action);
|
||||
ipcRenderer.on('tray:action', handler);
|
||||
return () => ipcRenderer.removeListener('tray:action', handler);
|
||||
},
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('sessionPersistence', {
|
||||
save: (data) => ipcRenderer.invoke('save-session', data),
|
||||
load: () => ipcRenderer.invoke('load-session'),
|
||||
@@ -72,6 +83,16 @@ contextBridge.exposeInMainWorld('idleAPI', {
|
||||
// Voice recording — per-participant audio capture that writes
|
||||
// append-only WebM files to the user's chosen folder. See
|
||||
// apps/electron/main.cjs for the main-process implementation.
|
||||
// Discord backup importer — exposes a native file picker for the
|
||||
// backup SQLite file plus file-read helpers the renderer uses to
|
||||
// load the db bytes (parsed in-renderer with sql.js) and to read
|
||||
// individual attachment files off disk during import.
|
||||
contextBridge.exposeInMainWorld('importerAPI', {
|
||||
pickDatabase: () => ipcRenderer.invoke('importer:pick-database'),
|
||||
readDatabase: (dbPath) => ipcRenderer.invoke('importer:read-database', dbPath),
|
||||
readAttachment: (payload) => ipcRenderer.invoke('importer:read-attachment', payload),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('recordingAPI', {
|
||||
getDefaultFolder: () => ipcRenderer.invoke('recording-get-default-folder'),
|
||||
pickFolder: () => ipcRenderer.invoke('recording-pick-folder'),
|
||||
|
||||
@@ -81,8 +81,27 @@ const electronPlatform = {
|
||||
downloadAndInstall: () => window.updateAPI.downloadAndInstall(),
|
||||
onStatusChanged: (cb) => window.updateAPI.onStatusChanged(cb),
|
||||
},
|
||||
lifecycle: {
|
||||
get: () => window.lifecycleAPI.get(),
|
||||
set: (patch) => window.lifecycleAPI.set(patch),
|
||||
show: () => window.lifecycleAPI.show(),
|
||||
onTrayAction: (cb) => window.lifecycleAPI.onTrayAction(cb),
|
||||
},
|
||||
systemBars: null,
|
||||
searchDB,
|
||||
// The importer bridge is gated on `window.importerAPI` being
|
||||
// present — it arrives via preload.cjs, which only reloads on a
|
||||
// full Electron restart (not Ctrl+R / Vite HMR). If the user is
|
||||
// running an older preload the whole surface falls back to `null`
|
||||
// so the Import tab renders its "restart the desktop app"
|
||||
// placeholder instead of throwing.
|
||||
importer: window.importerAPI
|
||||
? {
|
||||
pickDatabase: () => window.importerAPI.pickDatabase(),
|
||||
readDatabase: (dbPath) => window.importerAPI.readDatabase(dbPath),
|
||||
readAttachment: (payload) => window.importerAPI.readAttachment(payload),
|
||||
}
|
||||
: null,
|
||||
features: {
|
||||
hasWindowControls: true,
|
||||
hasScreenCapture: true,
|
||||
@@ -91,6 +110,8 @@ const electronPlatform = {
|
||||
hasSystemBars: false,
|
||||
hasRecording: true,
|
||||
hasNotifications: true,
|
||||
hasLifecycle: true,
|
||||
hasBackupImporter: !!window.importerAPI,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
@@ -20,6 +20,8 @@ import type * as customEmojis from "../customEmojis.js";
|
||||
import type * as dms from "../dms.js";
|
||||
import type * as files from "../files.js";
|
||||
import type * as gifs from "../gifs.js";
|
||||
import type * as importer from "../importer.js";
|
||||
import type * as importerActions from "../importerActions.js";
|
||||
import type * as invites from "../invites.js";
|
||||
import type * as links from "../links.js";
|
||||
import type * as members from "../members.js";
|
||||
@@ -57,6 +59,8 @@ declare const fullApi: ApiFromModules<{
|
||||
dms: typeof dms;
|
||||
files: typeof files;
|
||||
gifs: typeof gifs;
|
||||
importer: typeof importer;
|
||||
importerActions: typeof importerActions;
|
||||
invites: typeof invites;
|
||||
links: typeof links;
|
||||
members: typeof members;
|
||||
|
||||
@@ -26,6 +26,10 @@ export const AUDIT_ACTIONS = {
|
||||
SERVER_SETTINGS_UPDATE: "server.settings_update",
|
||||
BAN_ADD: "ban.add",
|
||||
BAN_REMOVE: "ban.remove",
|
||||
MESSAGES_PURGE_ALL: "messages.purge_all",
|
||||
MESSAGES_IMPORT_BULK: "messages.import_bulk",
|
||||
GHOST_MERGE: "user.ghost_merge",
|
||||
KEYS_GRANT: "keys.grant",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -217,6 +217,12 @@ export const getPublicKeys = query({
|
||||
const users = await ctx.db.query("userProfiles").collect();
|
||||
const results = [];
|
||||
for (const u of users) {
|
||||
// Ghost profiles are import-only — they have no real public
|
||||
// identity key, can't log in, and shouldn't surface in
|
||||
// mention autocomplete / DM pickers. Rendering of imported
|
||||
// messages goes through `messages.enrichMessage` which hits
|
||||
// `userProfiles` directly, so filtering here is safe.
|
||||
if (u.isGhost) continue;
|
||||
let avatarUrl: string | null = null;
|
||||
if (u.avatarStorageId) {
|
||||
avatarUrl = await getPublicStorageUrl(ctx, u.avatarStorageId);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { query, mutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { hasPermission } from "./roles";
|
||||
import { AUDIT_ACTIONS, logAudit } from "./audit";
|
||||
import { getPublicStorageUrl } from "./storageUrl";
|
||||
|
||||
/**
|
||||
* Rotate the symmetric key for a DM channel. Inserts a brand-new
|
||||
@@ -142,3 +145,138 @@ export const getKeysForUser = query({
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Admin-only query: list users who don't have a `channelKeys` row for
|
||||
* the given (non-DM) channel. Powers the Channel Settings → Access
|
||||
* panel where an admin grants missing keys to users who joined via a
|
||||
* broken invite (only received one channel's key instead of all).
|
||||
*
|
||||
* The actor themselves is filtered out — you can't be missing your own
|
||||
* key from your own POV, and the UI never needs to grant to self.
|
||||
* Ghosts and users without a public key are filtered (can't log in
|
||||
* anyway / nothing to encrypt against).
|
||||
*/
|
||||
export const getUsersMissingChannelKey = query({
|
||||
args: {
|
||||
actorId: v.id("userProfiles"),
|
||||
channelId: v.id("channels"),
|
||||
},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
userId: v.id("userProfiles"),
|
||||
username: v.string(),
|
||||
displayName: v.union(v.string(), v.null()),
|
||||
avatarUrl: v.union(v.string(), v.null()),
|
||||
userPublicKey: v.string(),
|
||||
}),
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
if (!(await hasPermission(ctx, args.actorId, "manage_channels"))) {
|
||||
throw new Error("Forbidden");
|
||||
}
|
||||
const channel = await ctx.db.get(args.channelId);
|
||||
if (!channel) throw new Error("Channel not found");
|
||||
if (channel.type === "dm") {
|
||||
throw new Error("grantChannelAccess is not supported for DM channels");
|
||||
}
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("channelKeys")
|
||||
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
|
||||
.collect();
|
||||
const have = new Set(existing.map((k) => k.userId as unknown as string));
|
||||
|
||||
const users = await ctx.db.query("userProfiles").collect();
|
||||
const missing = users.filter(
|
||||
(u) =>
|
||||
!!u.publicIdentityKey &&
|
||||
!u.isGhost &&
|
||||
(u._id as unknown as string) !== (args.actorId as unknown as string) &&
|
||||
!have.has(u._id as unknown as string),
|
||||
);
|
||||
|
||||
const results = [];
|
||||
for (const u of missing) {
|
||||
let avatarUrl: string | null = null;
|
||||
if (u.avatarStorageId) {
|
||||
avatarUrl = await getPublicStorageUrl(ctx, u.avatarStorageId);
|
||||
}
|
||||
results.push({
|
||||
userId: u._id,
|
||||
username: u.username,
|
||||
displayName: u.displayName ?? null,
|
||||
avatarUrl,
|
||||
userPublicKey: u.publicIdentityKey,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Admin-gated single-user grant. The admin's client has already
|
||||
* decrypted its own channel-key bundle and re-encrypted the key against
|
||||
* the target user's RSA public key — this mutation just upserts the
|
||||
* row, re-checks auth, and writes an audit entry. Kept separate from
|
||||
* `uploadKeys` on purpose: that path is used unauthenticated during
|
||||
* register/invite-accept for the caller's own keys, and loosening it
|
||||
* to accept cross-user writes would let any client grant themselves
|
||||
* access to any channel.
|
||||
*/
|
||||
export const grantChannelAccess = mutation({
|
||||
args: {
|
||||
actorId: v.id("userProfiles"),
|
||||
channelId: v.id("channels"),
|
||||
userId: v.id("userProfiles"),
|
||||
encryptedKeyBundle: v.string(),
|
||||
keyVersion: v.number(),
|
||||
},
|
||||
returns: v.object({ success: v.boolean() }),
|
||||
handler: async (ctx, args) => {
|
||||
if (!(await hasPermission(ctx, args.actorId, "manage_channels"))) {
|
||||
throw new Error("Forbidden");
|
||||
}
|
||||
const channel = await ctx.db.get(args.channelId);
|
||||
if (!channel) throw new Error("Channel not found");
|
||||
if (channel.type === "dm") {
|
||||
throw new Error("grantChannelAccess is not supported for DM channels");
|
||||
}
|
||||
const target = await ctx.db.get(args.userId);
|
||||
if (!target) throw new Error("Target user not found");
|
||||
if (target.isGhost || !target.publicIdentityKey) {
|
||||
throw new Error("Target user can't receive keys");
|
||||
}
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("channelKeys")
|
||||
.withIndex("by_channel_and_user", (q) =>
|
||||
q.eq("channelId", args.channelId).eq("userId", args.userId),
|
||||
)
|
||||
.unique();
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
encryptedKeyBundle: args.encryptedKeyBundle,
|
||||
keyVersion: args.keyVersion,
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert("channelKeys", {
|
||||
channelId: args.channelId,
|
||||
userId: args.userId,
|
||||
encryptedKeyBundle: args.encryptedKeyBundle,
|
||||
keyVersion: args.keyVersion,
|
||||
});
|
||||
}
|
||||
|
||||
await logAudit(ctx, {
|
||||
actorId: args.actorId,
|
||||
action: AUDIT_ACTIONS.KEYS_GRANT,
|
||||
targetType: "channel",
|
||||
targetId: args.channelId as unknown as string,
|
||||
targetName: channel.name,
|
||||
metadata: { userId: args.userId, username: target.username },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
677
convex/importer.ts
Normal file
677
convex/importer.ts
Normal file
@@ -0,0 +1,677 @@
|
||||
import { query, internalMutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { hasPermission } from "./roles";
|
||||
import { AUDIT_ACTIONS, logAudit } from "./audit";
|
||||
import { getPublicStorageUrl } from "./storageUrl";
|
||||
|
||||
/**
|
||||
* Discord backup importer — server-side non-node half.
|
||||
*
|
||||
* Ghost profiles
|
||||
* --------------
|
||||
* A ghost is a `userProfiles` row with `isGhost: true` and random
|
||||
* junk in every auth-sensitive field. It can't log in (its DAK hash
|
||||
* can't be reproduced), doesn't get channelKeys / roles / presence,
|
||||
* and is filtered out of member / mention / DM-target lookups. The
|
||||
* admin creates ghosts so imported messages have a real `senderId`
|
||||
* to point at; later, "merge" replaces every imported message's
|
||||
* senderId with a real user's ID and deletes the ghost.
|
||||
*
|
||||
* All public entry points live in `importerActions.ts` because they
|
||||
* run node crypto to verify the admin's Ed25519 signature.
|
||||
*/
|
||||
|
||||
const MAX_CIPHERTEXT_CHARS = 64 * 1024;
|
||||
const MAX_IMPORT_BATCH = 100;
|
||||
const MERGE_PAGE = 200;
|
||||
|
||||
/**
|
||||
* Upsert a batch of ghost profiles keyed by Discord snowflake.
|
||||
*
|
||||
* Idempotent: re-running for the same `discordId` returns the
|
||||
* existing row. If the incoming `displayName` / `avatarUrl` changes,
|
||||
* the existing ghost is patched so the UI reflects the most recent
|
||||
* Discord snapshot — but a real user who already had `discordId`
|
||||
* attached (via a prior merge) is never touched.
|
||||
*
|
||||
* Internal-only. Called from `importerActions.prepareGhostsAction`
|
||||
* which does the signature + admin-permission check.
|
||||
*/
|
||||
export const ensureGhostsInternal = internalMutation({
|
||||
args: {
|
||||
authors: v.array(
|
||||
v.object({
|
||||
discordId: v.string(),
|
||||
username: v.string(),
|
||||
displayName: v.optional(v.string()),
|
||||
avatarUrl: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
discordId: v.string(),
|
||||
userId: v.id("userProfiles"),
|
||||
created: v.boolean(),
|
||||
isGhost: v.boolean(),
|
||||
}),
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const out: Array<{
|
||||
discordId: string;
|
||||
userId: Id<"userProfiles">;
|
||||
created: boolean;
|
||||
isGhost: boolean;
|
||||
}> = [];
|
||||
for (const author of args.authors) {
|
||||
const existing = await ctx.db
|
||||
.query("userProfiles")
|
||||
.withIndex("by_discord_id", (q) => q.eq("discordId", author.discordId))
|
||||
.first();
|
||||
if (existing) {
|
||||
if (existing.isGhost) {
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (author.displayName && author.displayName !== existing.displayName) {
|
||||
patch.displayName = author.displayName;
|
||||
}
|
||||
if (author.avatarUrl && author.avatarUrl !== existing.ghostAvatarUrl) {
|
||||
patch.ghostAvatarUrl = author.avatarUrl;
|
||||
}
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await ctx.db.patch(existing._id, patch);
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
discordId: author.discordId,
|
||||
userId: existing._id,
|
||||
created: false,
|
||||
isGhost: !!existing.isGhost,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ghost usernames are deterministically scoped by Discord
|
||||
// snowflake so they never collide with real usernames, even
|
||||
// if two Discord users happen to share a handle locally.
|
||||
const ghostUsername = `discord_${author.discordId}`;
|
||||
const junk = `ghost-${author.discordId}-${Date.now()}`;
|
||||
const userId = await ctx.db.insert("userProfiles", {
|
||||
username: ghostUsername,
|
||||
clientSalt: junk,
|
||||
encryptedMasterKey: "",
|
||||
hashedAuthKey: junk,
|
||||
publicIdentityKey: "",
|
||||
publicSigningKey: "",
|
||||
encryptedPrivateKeys: "",
|
||||
isAdmin: false,
|
||||
isGhost: true,
|
||||
discordId: author.discordId,
|
||||
displayName: author.displayName ?? author.username,
|
||||
ghostAvatarUrl: author.avatarUrl,
|
||||
});
|
||||
out.push({
|
||||
discordId: author.discordId,
|
||||
userId,
|
||||
created: true,
|
||||
isGhost: true,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Insert a batch of pre-encrypted imported messages. Skips rows
|
||||
* whose `discordMessageId` already exists in the target channel so
|
||||
* re-runs after a partial failure don't duplicate history.
|
||||
*
|
||||
* `isBanned` is intentionally NOT checked: ghosts can't be banned
|
||||
* (no login path) and historical messages from a later-banned real
|
||||
* user shouldn't be blocked from import. The admin-only entry
|
||||
* point is the permission gate.
|
||||
*
|
||||
* Returns the per-row Convex IDs keyed by the original Discord
|
||||
* message ID so the client can resolve cross-batch replies via
|
||||
* `resolveReplyTargets` without a second query.
|
||||
*/
|
||||
export const importBatchInternal = internalMutation({
|
||||
args: {
|
||||
actorId: v.id("userProfiles"),
|
||||
channelId: v.id("channels"),
|
||||
messages: v.array(
|
||||
v.object({
|
||||
senderId: v.id("userProfiles"),
|
||||
ciphertext: v.string(),
|
||||
nonce: v.string(),
|
||||
signature: v.string(),
|
||||
keyVersion: v.number(),
|
||||
replyTo: v.optional(v.id("messages")),
|
||||
importedCreatedAt: v.number(),
|
||||
discordMessageId: v.string(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
returns: v.object({
|
||||
inserted: v.number(),
|
||||
skipped: v.number(),
|
||||
resolved: v.array(
|
||||
v.object({
|
||||
discordMessageId: v.string(),
|
||||
messageId: v.id("messages"),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
if (!(await hasPermission(ctx, args.actorId, "manage_channels"))) {
|
||||
throw new Error("You don't have permission to import messages.");
|
||||
}
|
||||
if (args.messages.length === 0) {
|
||||
return { inserted: 0, skipped: 0, resolved: [] };
|
||||
}
|
||||
if (args.messages.length > MAX_IMPORT_BATCH) {
|
||||
throw new Error(`Batch too large (max ${MAX_IMPORT_BATCH}).`);
|
||||
}
|
||||
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
const resolved: Array<{ discordMessageId: string; messageId: Id<"messages"> }> = [];
|
||||
|
||||
for (const msg of args.messages) {
|
||||
if (msg.ciphertext.length > MAX_CIPHERTEXT_CHARS) {
|
||||
throw new Error("Imported message too large");
|
||||
}
|
||||
const existing = await ctx.db
|
||||
.query("messages")
|
||||
.withIndex("by_channel_discord_message_id", (q) =>
|
||||
q
|
||||
.eq("channelId", args.channelId)
|
||||
.eq("discordMessageId", msg.discordMessageId),
|
||||
)
|
||||
.first();
|
||||
if (existing) {
|
||||
skipped++;
|
||||
resolved.push({
|
||||
discordMessageId: msg.discordMessageId,
|
||||
messageId: existing._id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const id = await ctx.db.insert("messages", {
|
||||
channelId: args.channelId,
|
||||
senderId: msg.senderId,
|
||||
ciphertext: msg.ciphertext,
|
||||
nonce: msg.nonce,
|
||||
signature: msg.signature,
|
||||
keyVersion: msg.keyVersion,
|
||||
replyTo: msg.replyTo,
|
||||
importedCreatedAt: msg.importedCreatedAt,
|
||||
discordMessageId: msg.discordMessageId,
|
||||
isImported: true,
|
||||
});
|
||||
inserted++;
|
||||
resolved.push({ discordMessageId: msg.discordMessageId, messageId: id });
|
||||
}
|
||||
|
||||
if (inserted > 0) {
|
||||
await logAudit(ctx, {
|
||||
actorId: args.actorId,
|
||||
action: AUDIT_ACTIONS.MESSAGES_IMPORT_BULK,
|
||||
targetType: "channel",
|
||||
targetId: args.channelId,
|
||||
metadata: { inserted, skipped, total: args.messages.length },
|
||||
});
|
||||
}
|
||||
return { inserted, skipped, resolved };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Merge step 1: rewrite one page of messages from `ghostUserId` to
|
||||
* `targetUserId`. Called repeatedly from the action until no more
|
||||
* pages. Kept at a bounded page size (`MERGE_PAGE`) so a single
|
||||
* mutation never trips Convex's execution limits on users with
|
||||
* tens of thousands of imported messages.
|
||||
*
|
||||
* The action also tracks the count across pages for the audit log.
|
||||
*/
|
||||
export const mergeGhostPageInternal = internalMutation({
|
||||
args: {
|
||||
actorId: v.id("userProfiles"),
|
||||
ghostUserId: v.id("userProfiles"),
|
||||
targetUserId: v.id("userProfiles"),
|
||||
},
|
||||
returns: v.object({ rewritten: v.number(), done: v.boolean() }),
|
||||
handler: async (ctx, args) => {
|
||||
if (!(await hasPermission(ctx, args.actorId, "manage_channels"))) {
|
||||
throw new Error("You don't have permission to merge ghosts.");
|
||||
}
|
||||
const ghost = await ctx.db.get(args.ghostUserId);
|
||||
if (!ghost) throw new Error("Ghost not found");
|
||||
if (!ghost.isGhost) throw new Error("Refusing to merge a non-ghost user");
|
||||
const target = await ctx.db.get(args.targetUserId);
|
||||
if (!target) throw new Error("Target user not found");
|
||||
if (target.isGhost) throw new Error("Merge target must be a real user");
|
||||
|
||||
const page = await ctx.db
|
||||
.query("messages")
|
||||
.withIndex("by_sender", (q) => q.eq("senderId", args.ghostUserId))
|
||||
.take(MERGE_PAGE);
|
||||
for (const m of page) {
|
||||
await ctx.db.patch(m._id, { senderId: args.targetUserId });
|
||||
}
|
||||
return { rewritten: page.length, done: page.length < MERGE_PAGE };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Final step of the merge flow: once all messages have been
|
||||
* rewritten, inherit the ghost's `discordId` onto the target (so
|
||||
* future imports of the same Discord author auto-link), delete the
|
||||
* ghost, and write a single audit entry.
|
||||
*/
|
||||
export const finalizeMergeInternal = internalMutation({
|
||||
args: {
|
||||
actorId: v.id("userProfiles"),
|
||||
ghostUserId: v.id("userProfiles"),
|
||||
targetUserId: v.id("userProfiles"),
|
||||
totalRewritten: v.number(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
if (!(await hasPermission(ctx, args.actorId, "manage_channels"))) {
|
||||
throw new Error("You don't have permission to merge ghosts.");
|
||||
}
|
||||
const ghost = await ctx.db.get(args.ghostUserId);
|
||||
if (!ghost) return null; // already finalized
|
||||
if (!ghost.isGhost) throw new Error("Refusing to finalize a non-ghost user");
|
||||
const target = await ctx.db.get(args.targetUserId);
|
||||
if (!target) throw new Error("Target user not found");
|
||||
|
||||
// Guard against a stray message slipping in between the last
|
||||
// page and this finalize call. If anything's left, refuse —
|
||||
// the caller will run another page.
|
||||
const stray = await ctx.db
|
||||
.query("messages")
|
||||
.withIndex("by_sender", (q) => q.eq("senderId", args.ghostUserId))
|
||||
.first();
|
||||
if (stray) {
|
||||
throw new Error("Messages still attributed to ghost; run another page.");
|
||||
}
|
||||
|
||||
// Inherit discordId if the target doesn't already have one. If
|
||||
// the target already claimed a different Discord identity we
|
||||
// don't overwrite — that's an admin error, not ours to resolve.
|
||||
if (ghost.discordId && !target.discordId) {
|
||||
await ctx.db.patch(args.targetUserId, { discordId: ghost.discordId });
|
||||
}
|
||||
|
||||
await ctx.db.delete(args.ghostUserId);
|
||||
|
||||
await logAudit(ctx, {
|
||||
actorId: args.actorId,
|
||||
action: AUDIT_ACTIONS.GHOST_MERGE,
|
||||
targetType: "user",
|
||||
targetId: args.targetUserId,
|
||||
targetName: target.displayName ?? target.username,
|
||||
metadata: {
|
||||
ghostDisplayName: ghost.displayName,
|
||||
ghostDiscordId: ghost.discordId,
|
||||
rewritten: args.totalRewritten,
|
||||
},
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Bulk-delete all imported messages in a channel. Paged so even a
|
||||
* channel with tens of thousands of imports completes without
|
||||
* tripping Convex's per-mutation execution limit. The action layer
|
||||
* loops until `done: true`.
|
||||
*
|
||||
* Used by the Import tab's "Clear imports" button to wipe blank
|
||||
* rows left behind by a failed earlier import (eg. partial
|
||||
* attachment uploads under the old silent-skip behaviour) so the
|
||||
* admin can re-run from scratch.
|
||||
*/
|
||||
const CLEAR_IMPORTS_PAGE = 200;
|
||||
|
||||
export const clearChannelImportsPageInternal = internalMutation({
|
||||
args: {
|
||||
actorId: v.id("userProfiles"),
|
||||
channelId: v.id("channels"),
|
||||
},
|
||||
returns: v.object({ deleted: v.number(), done: v.boolean() }),
|
||||
handler: async (ctx, args) => {
|
||||
if (!(await hasPermission(ctx, args.actorId, "manage_channels"))) {
|
||||
throw new Error("You don't have permission to clear imports.");
|
||||
}
|
||||
// `by_channel_imported_at` is keyed [channelId, importedCreatedAt].
|
||||
// Live messages don't set `importedCreatedAt`, so this index is
|
||||
// effectively scoped to imported rows for this channel — no
|
||||
// accidental deletion of live content.
|
||||
const page = await ctx.db
|
||||
.query("messages")
|
||||
.withIndex("by_channel_imported_at", (q) =>
|
||||
q.eq("channelId", args.channelId),
|
||||
)
|
||||
.take(CLEAR_IMPORTS_PAGE);
|
||||
let deleted = 0;
|
||||
for (const m of page) {
|
||||
if (!m.isImported) continue; // defence-in-depth
|
||||
// Cascade: drop any reactions tied to this message so we
|
||||
// don't orphan rows in `messageReactions`.
|
||||
const reactions = await ctx.db
|
||||
.query("messageReactions")
|
||||
.withIndex("by_message", (q) => q.eq("messageId", m._id))
|
||||
.collect();
|
||||
for (const r of reactions) await ctx.db.delete(r._id);
|
||||
await ctx.db.delete(m._id);
|
||||
deleted++;
|
||||
}
|
||||
return { deleted, done: page.length < CLEAR_IMPORTS_PAGE };
|
||||
},
|
||||
});
|
||||
|
||||
export const finalizeClearImportsInternal = internalMutation({
|
||||
args: {
|
||||
actorId: v.id("userProfiles"),
|
||||
channelId: v.id("channels"),
|
||||
totalDeleted: v.number(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
if (!(await hasPermission(ctx, args.actorId, "manage_channels"))) {
|
||||
throw new Error("You don't have permission to clear imports.");
|
||||
}
|
||||
const channel = await ctx.db.get(args.channelId);
|
||||
await logAudit(ctx, {
|
||||
actorId: args.actorId,
|
||||
action: AUDIT_ACTIONS.MESSAGES_IMPORT_BULK,
|
||||
targetType: "channel",
|
||||
targetId: args.channelId,
|
||||
targetName: channel?.name,
|
||||
metadata: { cleared: args.totalDeleted },
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Surgical-repair companion to `resolveReplyTargets`. Returns the
|
||||
* full decryptable body (ciphertext + nonce + keyVersion) for each
|
||||
* existing imported message in `discordMessageIds`. The runner
|
||||
* uses this in repair mode to decrypt each row locally and decide
|
||||
* whether its attachment list is missing entries — cheaper than
|
||||
* dropping and re-inserting every row.
|
||||
*/
|
||||
export const getImportedState = query({
|
||||
args: {
|
||||
channelId: v.id("channels"),
|
||||
discordMessageIds: v.array(v.string()),
|
||||
},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
discordMessageId: v.string(),
|
||||
messageId: v.id("messages"),
|
||||
ciphertext: v.string(),
|
||||
nonce: v.string(),
|
||||
keyVersion: v.number(),
|
||||
}),
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const out: Array<{
|
||||
discordMessageId: string;
|
||||
messageId: Id<"messages">;
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
keyVersion: number;
|
||||
}> = [];
|
||||
for (const dId of args.discordMessageIds) {
|
||||
const row = await ctx.db
|
||||
.query("messages")
|
||||
.withIndex("by_channel_discord_message_id", (q) =>
|
||||
q.eq("channelId", args.channelId).eq("discordMessageId", dId),
|
||||
)
|
||||
.first();
|
||||
if (!row) continue;
|
||||
out.push({
|
||||
discordMessageId: dId,
|
||||
messageId: row._id,
|
||||
ciphertext: row.ciphertext,
|
||||
nonce: row.nonce,
|
||||
keyVersion: row.keyVersion,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete a specific list of imported messages by their Discord
|
||||
* snowflakes. Paged so any-size list is safe. Used by the runner's
|
||||
* repair mode to surgically drop rows that decrypted to an empty
|
||||
* or under-attached plaintext, before re-inserting them fresh.
|
||||
*/
|
||||
const DELETE_BY_DISCORD_PAGE = 100;
|
||||
|
||||
export const deleteImportedByDiscordIdsInternal = internalMutation({
|
||||
args: {
|
||||
actorId: v.id("userProfiles"),
|
||||
channelId: v.id("channels"),
|
||||
discordMessageIds: v.array(v.string()),
|
||||
},
|
||||
returns: v.object({ deleted: v.number() }),
|
||||
handler: async (ctx, args) => {
|
||||
if (!(await hasPermission(ctx, args.actorId, "manage_channels"))) {
|
||||
throw new Error("You don't have permission to repair imports.");
|
||||
}
|
||||
if (args.discordMessageIds.length > DELETE_BY_DISCORD_PAGE) {
|
||||
throw new Error(`Delete batch too large (max ${DELETE_BY_DISCORD_PAGE}).`);
|
||||
}
|
||||
let deleted = 0;
|
||||
for (const dId of args.discordMessageIds) {
|
||||
const row = await ctx.db
|
||||
.query("messages")
|
||||
.withIndex("by_channel_discord_message_id", (q) =>
|
||||
q.eq("channelId", args.channelId).eq("discordMessageId", dId),
|
||||
)
|
||||
.first();
|
||||
if (!row) continue;
|
||||
if (!row.isImported) continue; // defence-in-depth
|
||||
const reactions = await ctx.db
|
||||
.query("messageReactions")
|
||||
.withIndex("by_message", (q) => q.eq("messageId", row._id))
|
||||
.collect();
|
||||
for (const r of reactions) await ctx.db.delete(r._id);
|
||||
await ctx.db.delete(row._id);
|
||||
deleted++;
|
||||
}
|
||||
return { deleted };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Reply-remapping helper. Given a list of Discord message IDs the
|
||||
* import runner has yet to place, return the Convex IDs for the
|
||||
* ones that ARE already imported in this channel. Unimported
|
||||
* entries are omitted (client treats missing keys as "no reply
|
||||
* parent yet").
|
||||
*/
|
||||
export const resolveReplyTargets = query({
|
||||
args: {
|
||||
channelId: v.id("channels"),
|
||||
discordMessageIds: v.array(v.string()),
|
||||
},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
discordMessageId: v.string(),
|
||||
messageId: v.id("messages"),
|
||||
}),
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const out: Array<{ discordMessageId: string; messageId: Id<"messages"> }> = [];
|
||||
for (const dId of args.discordMessageIds) {
|
||||
const row = await ctx.db
|
||||
.query("messages")
|
||||
.withIndex("by_channel_discord_message_id", (q) =>
|
||||
q.eq("channelId", args.channelId).eq("discordMessageId", dId),
|
||||
)
|
||||
.first();
|
||||
if (row) out.push({ discordMessageId: dId, messageId: row._id });
|
||||
}
|
||||
return out;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the highest Discord message ID already imported into
|
||||
* `channelId`, or null if nothing's been imported yet. The runner
|
||||
* uses this to resume after a restart without double-uploading
|
||||
* attachments for rows that are already in Convex.
|
||||
*
|
||||
* Note: Discord snowflakes are monotonic by creation time, so
|
||||
* "highest string when compared lexicographically after left-padding
|
||||
* to 20 chars" ≈ "most recent". We sort by `importedCreatedAt`
|
||||
* (already indexed) instead, which is more robust and avoids
|
||||
* scanning every row.
|
||||
*/
|
||||
export const getImportProgress = query({
|
||||
args: { channelId: v.id("channels"), actorId: v.id("userProfiles") },
|
||||
returns: v.object({
|
||||
importedCount: v.number(),
|
||||
latestImportedAt: v.union(v.number(), v.null()),
|
||||
earliestImportedAt: v.union(v.number(), v.null()),
|
||||
latestDiscordMessageId: v.union(v.string(), v.null()),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
if (!(await hasPermission(ctx, args.actorId, "manage_channels"))) {
|
||||
throw new Error("Not authorized");
|
||||
}
|
||||
// Newest imported row first.
|
||||
const latest = await ctx.db
|
||||
.query("messages")
|
||||
.withIndex("by_channel_imported_at", (q) =>
|
||||
q.eq("channelId", args.channelId),
|
||||
)
|
||||
.order("desc")
|
||||
.first();
|
||||
// Oldest imported row first.
|
||||
const earliest = await ctx.db
|
||||
.query("messages")
|
||||
.withIndex("by_channel_imported_at", (q) =>
|
||||
q.eq("channelId", args.channelId),
|
||||
)
|
||||
.order("asc")
|
||||
.first();
|
||||
|
||||
// Count via a bounded take — no dedicated counter. 10k is a
|
||||
// generous ceiling for the progress badge; the real ground
|
||||
// truth is the client's own run state. We return `importedCount`
|
||||
// as an approximate "at least" measure.
|
||||
const sample = await ctx.db
|
||||
.query("messages")
|
||||
.withIndex("by_channel_imported_at", (q) =>
|
||||
q.eq("channelId", args.channelId),
|
||||
)
|
||||
.take(10_000);
|
||||
const importedCount = sample.filter(
|
||||
(m) => m.importedCreatedAt !== undefined,
|
||||
).length;
|
||||
|
||||
return {
|
||||
importedCount,
|
||||
latestImportedAt: latest?.importedCreatedAt ?? null,
|
||||
earliestImportedAt: earliest?.importedCreatedAt ?? null,
|
||||
latestDiscordMessageId: latest?.discordMessageId ?? null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* List ghost users for the admin merge UI. Each row includes a
|
||||
* message count (bounded-take approximation) so the operator can
|
||||
* prioritise merging noisy ghosts first.
|
||||
*/
|
||||
export const listGhosts = query({
|
||||
args: { actorId: v.id("userProfiles") },
|
||||
returns: v.array(
|
||||
v.object({
|
||||
_id: v.id("userProfiles"),
|
||||
username: v.string(),
|
||||
displayName: v.optional(v.string()),
|
||||
discordId: v.optional(v.string()),
|
||||
ghostAvatarUrl: v.optional(v.string()),
|
||||
messageCount: v.number(),
|
||||
}),
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
if (!(await hasPermission(ctx, args.actorId, "manage_channels"))) {
|
||||
throw new Error("Not authorized");
|
||||
}
|
||||
const users = await ctx.db.query("userProfiles").collect();
|
||||
const ghosts = users.filter((u) => u.isGhost);
|
||||
const out = [];
|
||||
for (const g of ghosts) {
|
||||
const sample = await ctx.db
|
||||
.query("messages")
|
||||
.withIndex("by_sender", (q) => q.eq("senderId", g._id))
|
||||
.take(1000);
|
||||
out.push({
|
||||
_id: g._id,
|
||||
username: g.username,
|
||||
displayName: g.displayName,
|
||||
discordId: g.discordId,
|
||||
ghostAvatarUrl: g.ghostAvatarUrl,
|
||||
messageCount: sample.length,
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => b.messageCount - a.messageCount);
|
||||
return out;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Admin-only fetch of the current channel roster's public keys
|
||||
* (used by the import UI to discover which local users could be
|
||||
* mapped to Discord authors). Returns ghosts too so the UI can
|
||||
* show "already-imported-as-ghost" inline.
|
||||
*/
|
||||
export const listMappingCandidates = query({
|
||||
args: { actorId: v.id("userProfiles") },
|
||||
returns: v.array(
|
||||
v.object({
|
||||
_id: v.id("userProfiles"),
|
||||
username: v.string(),
|
||||
displayName: v.optional(v.string()),
|
||||
avatarUrl: v.union(v.string(), v.null()),
|
||||
isGhost: v.boolean(),
|
||||
discordId: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
if (!(await hasPermission(ctx, args.actorId, "manage_channels"))) {
|
||||
throw new Error("Not authorized");
|
||||
}
|
||||
const users = await ctx.db.query("userProfiles").collect();
|
||||
const out = [];
|
||||
for (const u of users) {
|
||||
let avatarUrl: string | null = null;
|
||||
if (u.avatarStorageId) {
|
||||
avatarUrl = await getPublicStorageUrl(ctx, u.avatarStorageId);
|
||||
} else if (u.isGhost && u.ghostAvatarUrl) {
|
||||
avatarUrl = u.ghostAvatarUrl;
|
||||
}
|
||||
out.push({
|
||||
_id: u._id,
|
||||
username: u.username,
|
||||
displayName: u.displayName,
|
||||
avatarUrl,
|
||||
isGhost: !!u.isGhost,
|
||||
discordId: u.discordId,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
},
|
||||
});
|
||||
241
convex/importerActions.ts
Normal file
241
convex/importerActions.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
"use node";
|
||||
|
||||
import { action } from "./_generated/server";
|
||||
import { internal } from "./_generated/api";
|
||||
import { v } from "convex/values";
|
||||
import { requireAuth } from "./authGuard";
|
||||
|
||||
/**
|
||||
* Signed-import actions.
|
||||
*
|
||||
* The same `requireAuth` pattern used for `messageActions.send`:
|
||||
* the admin signs a canonical string with their Ed25519 key so
|
||||
* the server can prove the caller controls `actorId` before any
|
||||
* ghost is created, any message is attributed to someone, or any
|
||||
* ghost is merged into a real user. Without these signatures an
|
||||
* attacker who knows the admin's userId could spoof `actorId` and
|
||||
* piggy-back on the admin's `manage_channels` permission.
|
||||
*/
|
||||
|
||||
export const prepareGhostsAction = action({
|
||||
args: {
|
||||
actorId: v.id("userProfiles"),
|
||||
authors: v.array(
|
||||
v.object({
|
||||
discordId: v.string(),
|
||||
username: v.string(),
|
||||
displayName: v.optional(v.string()),
|
||||
avatarUrl: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
authTimestamp: v.number(),
|
||||
authSignature: v.string(),
|
||||
},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
discordId: v.string(),
|
||||
userId: v.id("userProfiles"),
|
||||
created: v.boolean(),
|
||||
isGhost: v.boolean(),
|
||||
}),
|
||||
),
|
||||
handler: async (ctx, args): Promise<any> => {
|
||||
// Signature covers the actor + count so a replayed sig can't be
|
||||
// redirected at a larger author list.
|
||||
const canonical = `prepareGhosts:${args.actorId}:${args.authors.length}:${args.authTimestamp}`;
|
||||
await requireAuth(
|
||||
ctx,
|
||||
args.actorId,
|
||||
args.authTimestamp,
|
||||
args.authSignature,
|
||||
canonical,
|
||||
);
|
||||
return await ctx.runMutation(internal.importer.ensureGhostsInternal, {
|
||||
authors: args.authors,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const importBatchAction = action({
|
||||
args: {
|
||||
actorId: v.id("userProfiles"),
|
||||
channelId: v.id("channels"),
|
||||
messages: v.array(
|
||||
v.object({
|
||||
senderId: v.id("userProfiles"),
|
||||
ciphertext: v.string(),
|
||||
nonce: v.string(),
|
||||
signature: v.string(),
|
||||
keyVersion: v.number(),
|
||||
replyTo: v.optional(v.id("messages")),
|
||||
importedCreatedAt: v.number(),
|
||||
discordMessageId: v.string(),
|
||||
}),
|
||||
),
|
||||
authTimestamp: v.number(),
|
||||
authSignature: v.string(),
|
||||
},
|
||||
returns: v.object({
|
||||
inserted: v.number(),
|
||||
skipped: v.number(),
|
||||
resolved: v.array(
|
||||
v.object({
|
||||
discordMessageId: v.string(),
|
||||
messageId: v.id("messages"),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
handler: async (ctx, args): Promise<any> => {
|
||||
const canonical = `importBatch:${args.actorId}:${args.channelId}:${args.messages.length}:${args.authTimestamp}`;
|
||||
await requireAuth(
|
||||
ctx,
|
||||
args.actorId,
|
||||
args.authTimestamp,
|
||||
args.authSignature,
|
||||
canonical,
|
||||
);
|
||||
return await ctx.runMutation(internal.importer.importBatchInternal, {
|
||||
actorId: args.actorId,
|
||||
channelId: args.channelId,
|
||||
messages: args.messages,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Merge a ghost into a real user. Paged so any ghost size is
|
||||
* supported — the client polls this in a loop until `done: true`,
|
||||
* accumulating the rewritten count, then calls `finalizeMerge`.
|
||||
*/
|
||||
export const mergeGhostPageAction = action({
|
||||
args: {
|
||||
actorId: v.id("userProfiles"),
|
||||
ghostUserId: v.id("userProfiles"),
|
||||
targetUserId: v.id("userProfiles"),
|
||||
authTimestamp: v.number(),
|
||||
authSignature: v.string(),
|
||||
},
|
||||
returns: v.object({ rewritten: v.number(), done: v.boolean() }),
|
||||
handler: async (ctx, args): Promise<any> => {
|
||||
const canonical = `mergeGhostPage:${args.actorId}:${args.ghostUserId}:${args.targetUserId}:${args.authTimestamp}`;
|
||||
await requireAuth(
|
||||
ctx,
|
||||
args.actorId,
|
||||
args.authTimestamp,
|
||||
args.authSignature,
|
||||
canonical,
|
||||
);
|
||||
return await ctx.runMutation(internal.importer.mergeGhostPageInternal, {
|
||||
actorId: args.actorId,
|
||||
ghostUserId: args.ghostUserId,
|
||||
targetUserId: args.targetUserId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Repair-mode helper: delete a specific set of imported messages
|
||||
* by Discord snowflake. The runner calls this right before it
|
||||
* re-inserts the same rows (with their attachments) under normal
|
||||
* import flow. Capped at 100 ids per call to match the internal
|
||||
* mutation's `DELETE_BY_DISCORD_PAGE`.
|
||||
*/
|
||||
export const deleteImportedByDiscordIdsAction = action({
|
||||
args: {
|
||||
actorId: v.id("userProfiles"),
|
||||
channelId: v.id("channels"),
|
||||
discordMessageIds: v.array(v.string()),
|
||||
authTimestamp: v.number(),
|
||||
authSignature: v.string(),
|
||||
},
|
||||
returns: v.object({ deleted: v.number() }),
|
||||
handler: async (ctx, args): Promise<{ deleted: number }> => {
|
||||
const canonical = `deleteImportedByDiscordIds:${args.actorId}:${args.channelId}:${args.discordMessageIds.length}:${args.authTimestamp}`;
|
||||
await requireAuth(
|
||||
ctx,
|
||||
args.actorId,
|
||||
args.authTimestamp,
|
||||
args.authSignature,
|
||||
canonical,
|
||||
);
|
||||
return await ctx.runMutation(
|
||||
internal.importer.deleteImportedByDiscordIdsInternal,
|
||||
{
|
||||
actorId: args.actorId,
|
||||
channelId: args.channelId,
|
||||
discordMessageIds: args.discordMessageIds,
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Wipe every imported message in a channel. Paged so any volume
|
||||
* succeeds — the action loops over `clearChannelImportsPageInternal`
|
||||
* until it reports `done`, then writes a single audit entry. Used
|
||||
* by the Import tab's per-channel "Clear imports" button so a
|
||||
* partial earlier run can be wiped + re-imported cleanly.
|
||||
*/
|
||||
export const clearChannelImportsAction = action({
|
||||
args: {
|
||||
actorId: v.id("userProfiles"),
|
||||
channelId: v.id("channels"),
|
||||
authTimestamp: v.number(),
|
||||
authSignature: v.string(),
|
||||
},
|
||||
returns: v.object({ deleted: v.number() }),
|
||||
handler: async (ctx, args): Promise<{ deleted: number }> => {
|
||||
const canonical = `clearChannelImports:${args.actorId}:${args.channelId}:${args.authTimestamp}`;
|
||||
await requireAuth(
|
||||
ctx,
|
||||
args.actorId,
|
||||
args.authTimestamp,
|
||||
args.authSignature,
|
||||
canonical,
|
||||
);
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const page: { deleted: number; done: boolean } = await ctx.runMutation(
|
||||
internal.importer.clearChannelImportsPageInternal,
|
||||
{ actorId: args.actorId, channelId: args.channelId },
|
||||
);
|
||||
total += page.deleted;
|
||||
if (page.done) break;
|
||||
}
|
||||
await ctx.runMutation(internal.importer.finalizeClearImportsInternal, {
|
||||
actorId: args.actorId,
|
||||
channelId: args.channelId,
|
||||
totalDeleted: total,
|
||||
});
|
||||
return { deleted: total };
|
||||
},
|
||||
});
|
||||
|
||||
export const finalizeMergeAction = action({
|
||||
args: {
|
||||
actorId: v.id("userProfiles"),
|
||||
ghostUserId: v.id("userProfiles"),
|
||||
targetUserId: v.id("userProfiles"),
|
||||
totalRewritten: v.number(),
|
||||
authTimestamp: v.number(),
|
||||
authSignature: v.string(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args): Promise<null> => {
|
||||
const canonical = `finalizeMerge:${args.actorId}:${args.ghostUserId}:${args.targetUserId}:${args.authTimestamp}`;
|
||||
await requireAuth(
|
||||
ctx,
|
||||
args.actorId,
|
||||
args.authTimestamp,
|
||||
args.authSignature,
|
||||
canonical,
|
||||
);
|
||||
await ctx.runMutation(internal.importer.finalizeMergeInternal, {
|
||||
actorId: args.actorId,
|
||||
ghostUserId: args.ghostUserId,
|
||||
targetUserId: args.targetUserId,
|
||||
totalRewritten: args.totalRewritten,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
@@ -71,6 +71,10 @@ export const listAll = query({
|
||||
const users = await ctx.db.query("userProfiles").collect();
|
||||
const results = [];
|
||||
for (const user of users) {
|
||||
// Ghosts are placeholder profiles for imported messages —
|
||||
// keep them out of the server-wide member list so they
|
||||
// don't pollute presence / DM-target / mention pickers.
|
||||
if (user.isGhost) continue;
|
||||
let avatarUrl: string | null = null;
|
||||
if (user.avatarStorageId) {
|
||||
avatarUrl = await getPublicStorageUrl(ctx, user.avatarStorageId);
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { query, internalMutation } from "./_generated/server";
|
||||
import { query, mutation, internalMutation } from "./_generated/server";
|
||||
import { paginationOptsValidator } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
import { getPublicStorageUrl } from "./storageUrl";
|
||||
import { getRolesForUser } from "./roles";
|
||||
import { isBanned } from "./bans";
|
||||
import { AUDIT_ACTIONS, logAudit } from "./audit";
|
||||
|
||||
const DEFAULT_ROLE_COLOR = "#99aab5";
|
||||
|
||||
async function enrichMessage(ctx: any, msg: any, userId?: any) {
|
||||
const sender = await ctx.db.get(msg.senderId);
|
||||
|
||||
// Real users use `avatarStorageId` (Convex storage). Ghost users
|
||||
// (placeholder authors for imported Discord messages) don't have
|
||||
// a storage blob — they carry the original Discord CDN URL in
|
||||
// `ghostAvatarUrl` and the client renders it directly. If the
|
||||
// CDN link expires the UI will fall back to initials, which is
|
||||
// acceptable for historical content.
|
||||
let avatarUrl: string | null = null;
|
||||
if (sender?.avatarStorageId) {
|
||||
avatarUrl = await getPublicStorageUrl(ctx, sender.avatarStorageId);
|
||||
} else if (sender?.isGhost && sender?.ghostAvatarUrl) {
|
||||
avatarUrl = sender.ghostAvatarUrl as string;
|
||||
}
|
||||
|
||||
// Highest-position role with a non-default colour — mirrors how
|
||||
@@ -137,10 +146,18 @@ async function enrichMessage(ctx: any, msg: any, userId?: any) {
|
||||
replyToNonce = repliedMsg.nonce;
|
||||
if (repliedSender?.avatarStorageId) {
|
||||
replyToAvatarUrl = await getPublicStorageUrl(ctx, repliedSender.avatarStorageId);
|
||||
} else if (repliedSender?.isGhost && repliedSender?.ghostAvatarUrl) {
|
||||
replyToAvatarUrl = repliedSender.ghostAvatarUrl as string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Imported messages preserve the original Discord timestamp in
|
||||
// `importedCreatedAt` so they slot into the channel history at
|
||||
// the right spot. `created_at` still serialises as ISO for client
|
||||
// compatibility — the client sorts by it directly.
|
||||
const effectiveCreatedAt = msg.importedCreatedAt ?? msg._creationTime;
|
||||
|
||||
return {
|
||||
id: msg._id,
|
||||
channel_id: msg.channelId,
|
||||
@@ -149,7 +166,7 @@ async function enrichMessage(ctx: any, msg: any, userId?: any) {
|
||||
nonce: msg.nonce,
|
||||
signature: msg.signature,
|
||||
key_version: msg.keyVersion,
|
||||
created_at: new Date(msg._creationTime).toISOString(),
|
||||
created_at: new Date(effectiveCreatedAt).toISOString(),
|
||||
username: sender?.username || "Unknown",
|
||||
displayName: sender?.displayName || null,
|
||||
public_signing_key: sender?.publicSigningKey || "",
|
||||
@@ -164,6 +181,8 @@ async function enrichMessage(ctx: any, msg: any, userId?: any) {
|
||||
replyToAvatarUrl,
|
||||
editedAt: msg.editedAt || null,
|
||||
pinned: msg.pinned || false,
|
||||
isImported: msg.isImported ?? false,
|
||||
importedCreatedAt: msg.importedCreatedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -537,3 +556,116 @@ export const removeInternal = internalMutation({
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Owner-only: wipe every message (and its reactions) across every
|
||||
* channel. Designed for a fresh-start reset — the Danger Zone UI
|
||||
* double-confirms before calling this.
|
||||
*
|
||||
* Returns the remaining message count so the client can loop if a
|
||||
* single batch isn't enough. `batchSize` caps per-call work to stay
|
||||
* inside Convex's default read/write limits; default is 1000 which
|
||||
* comfortably covers a small server in one shot.
|
||||
*/
|
||||
export const purgeAllMessages = mutation({
|
||||
args: {
|
||||
actorId: v.id("userProfiles"),
|
||||
batchSize: v.optional(v.number()),
|
||||
},
|
||||
returns: v.object({
|
||||
deletedMessages: v.number(),
|
||||
deletedReactions: v.number(),
|
||||
deletedPolls: v.number(),
|
||||
deletedPollVotes: v.number(),
|
||||
deletedPollReactions: v.number(),
|
||||
remaining: v.number(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.db.get(args.actorId);
|
||||
if (!user) throw new Error("User not found.");
|
||||
const roles = await getRolesForUser(ctx, args.actorId);
|
||||
const isOwner = user.isAdmin || roles.some((r) => r.name === "Owner");
|
||||
if (!isOwner) {
|
||||
throw new Error("Only the Owner can clear all messages.");
|
||||
}
|
||||
|
||||
const batch = Math.max(1, Math.min(args.batchSize ?? 1000, 2000));
|
||||
|
||||
// 1) Messages + their reactions.
|
||||
const messages = await ctx.db.query("messages").take(batch);
|
||||
let deletedMessages = 0;
|
||||
let deletedReactions = 0;
|
||||
for (const m of messages) {
|
||||
const reactions = await ctx.db
|
||||
.query("messageReactions")
|
||||
.withIndex("by_message", (q) => q.eq("messageId", m._id))
|
||||
.collect();
|
||||
for (const r of reactions) {
|
||||
await ctx.db.delete(r._id);
|
||||
deletedReactions += 1;
|
||||
}
|
||||
await ctx.db.delete(m._id);
|
||||
deletedMessages += 1;
|
||||
}
|
||||
|
||||
// 2) Polls + their votes + poll-level emoji reactions. Runs in the
|
||||
// same batch so a single "Clear all messages" pass wipes everything
|
||||
// a user can see in chat, not just plain text messages.
|
||||
const polls = await ctx.db.query("polls").take(batch);
|
||||
let deletedPolls = 0;
|
||||
let deletedPollVotes = 0;
|
||||
let deletedPollReactions = 0;
|
||||
for (const p of polls) {
|
||||
const votes = await ctx.db
|
||||
.query("pollVotes")
|
||||
.withIndex("by_poll", (q) => q.eq("pollId", p._id))
|
||||
.collect();
|
||||
for (const v of votes) {
|
||||
await ctx.db.delete(v._id);
|
||||
deletedPollVotes += 1;
|
||||
}
|
||||
const preactions = await ctx.db
|
||||
.query("pollReactions")
|
||||
.withIndex("by_poll", (q) => q.eq("pollId", p._id))
|
||||
.collect();
|
||||
for (const r of preactions) {
|
||||
await ctx.db.delete(r._id);
|
||||
deletedPollReactions += 1;
|
||||
}
|
||||
await ctx.db.delete(p._id);
|
||||
deletedPolls += 1;
|
||||
}
|
||||
|
||||
// Remaining count — either table still has rows means the client
|
||||
// should call again. We peek one past the batch size so `remaining`
|
||||
// is positive whenever there's *anything* left.
|
||||
const leftoverMessages = await ctx.db.query("messages").take(1);
|
||||
const leftoverPolls = await ctx.db.query("polls").take(1);
|
||||
const remaining = leftoverMessages.length + leftoverPolls.length;
|
||||
|
||||
if (deletedMessages > 0 || deletedPolls > 0) {
|
||||
await logAudit(ctx, {
|
||||
actorId: args.actorId,
|
||||
action: AUDIT_ACTIONS.MESSAGES_PURGE_ALL,
|
||||
targetType: "server",
|
||||
metadata: {
|
||||
deletedMessages,
|
||||
deletedReactions,
|
||||
deletedPolls,
|
||||
deletedPollVotes,
|
||||
deletedPollReactions,
|
||||
remainingAfterBatch: remaining,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
deletedMessages,
|
||||
deletedReactions,
|
||||
deletedPolls,
|
||||
deletedPollVotes,
|
||||
deletedPollReactions,
|
||||
remaining,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { getRolesForUser } from "./roles";
|
||||
|
||||
const pollOptionValidator = v.object({
|
||||
id: v.string(),
|
||||
@@ -195,8 +196,17 @@ export const remove = mutation({
|
||||
handler: async (ctx, args) => {
|
||||
const poll = await ctx.db.get(args.pollId);
|
||||
if (!poll) return null;
|
||||
if (poll.createdBy !== args.userId) {
|
||||
throw new Error("Only the poll creator can delete it");
|
||||
const isCreator = poll.createdBy === args.userId;
|
||||
if (!isCreator) {
|
||||
// Mirror `messages.removeInternal` — users with `manage_messages`
|
||||
// can delete any poll, not just their own.
|
||||
const roles = await getRolesForUser(ctx, args.userId);
|
||||
const canManage = roles.some(
|
||||
(role) => (role.permissions as Record<string, boolean>)?.manage_messages,
|
||||
);
|
||||
if (!canManage) {
|
||||
throw new Error("Not authorized to delete this poll");
|
||||
}
|
||||
}
|
||||
const votes = await ctx.db
|
||||
.query("pollVotes")
|
||||
|
||||
@@ -262,6 +262,24 @@ export const unassign = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Owner check — true for the bootstrap admin flag (isAdmin) AND for
|
||||
* anyone bearing the reserved "Owner" role. Exposed as a query so
|
||||
* the UI can gate destructive "whole-server" actions (Danger Zone)
|
||||
* behind owner-only visibility without duplicating the rule.
|
||||
*/
|
||||
export const isOwner = query({
|
||||
args: { userId: v.id("userProfiles") },
|
||||
returns: v.boolean(),
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.db.get(args.userId);
|
||||
if (!user) return false;
|
||||
if (user.isAdmin) return true;
|
||||
const roles = await getRolesForUser(ctx, args.userId);
|
||||
return roles.some((r) => r.name === "Owner");
|
||||
},
|
||||
});
|
||||
|
||||
// Get current user's aggregated permissions
|
||||
export const getMyPermissions = query({
|
||||
args: { userId: v.id("userProfiles") },
|
||||
|
||||
@@ -19,7 +19,24 @@ export default defineSchema({
|
||||
joinSoundStorageId: v.optional(v.id("_storage")),
|
||||
accentColor: v.optional(v.string()),
|
||||
bannerStorageId: v.optional(v.id("_storage")),
|
||||
}).index("by_username", ["username"]),
|
||||
// Discord backup import support. `discordId` lets us dedupe
|
||||
// ghost creation across re-runs and later "claim" an identity
|
||||
// by attaching the snowflake to a real user. `isGhost` marks a
|
||||
// placeholder profile created to attribute imported messages
|
||||
// when the original Discord user hasn't been mapped to a real
|
||||
// local user yet — ghosts can't log in (their auth material is
|
||||
// random junk) and are filtered out of member / presence /
|
||||
// mention lookups.
|
||||
discordId: v.optional(v.string()),
|
||||
isGhost: v.optional(v.boolean()),
|
||||
// URL-based avatar fallback for ghosts — we copy Discord's CDN
|
||||
// URL rather than re-hosting, since the ghost might never be
|
||||
// merged and uploading 35+ avatars-that-might-expire isn't
|
||||
// worth the storage churn. `null`/missing on real users, who
|
||||
// use `avatarStorageId` instead.
|
||||
ghostAvatarUrl: v.optional(v.string()),
|
||||
}).index("by_username", ["username"])
|
||||
.index("by_discord_id", ["discordId"]),
|
||||
|
||||
categories: defineTable({
|
||||
name: v.string(),
|
||||
@@ -45,9 +62,22 @@ export default defineSchema({
|
||||
replyTo: v.optional(v.id("messages")),
|
||||
editedAt: v.optional(v.number()),
|
||||
pinned: v.optional(v.boolean()),
|
||||
// Discord backup import fields. `importedCreatedAt` preserves
|
||||
// the original Discord timestamp so imports land in the right
|
||||
// place in channel history — the renderer prefers it over
|
||||
// `_creationTime` when present. `discordMessageId` is the
|
||||
// snowflake used for dedupe on re-runs + remapping Discord
|
||||
// reply-to IDs to Convex message IDs. `isImported` is a cheap
|
||||
// flag for UI badges and future filters.
|
||||
importedCreatedAt: v.optional(v.number()),
|
||||
discordMessageId: v.optional(v.string()),
|
||||
isImported: v.optional(v.boolean()),
|
||||
}).index("by_channel", ["channelId"])
|
||||
.index("by_channel_pinned", ["channelId", "pinned"])
|
||||
.index("by_sender", ["senderId"]),
|
||||
.index("by_sender", ["senderId"])
|
||||
.index("by_channel_imported_at", ["channelId", "importedCreatedAt"])
|
||||
.index("by_discord_message_id", ["discordMessageId"])
|
||||
.index("by_channel_discord_message_id", ["channelId", "discordMessageId"]),
|
||||
|
||||
messageReactions: defineTable({
|
||||
messageId: v.id("messages"),
|
||||
|
||||
@@ -79,11 +79,17 @@ const webPlatform = {
|
||||
},
|
||||
windowControls: null,
|
||||
notifications: makeWebNotifications(),
|
||||
lifecycle: null,
|
||||
recording: null,
|
||||
updates: null,
|
||||
voiceService: null,
|
||||
systemBars: null,
|
||||
searchDB,
|
||||
// Backup importer needs native filesystem access for the SQLite
|
||||
// file + attachment tree, which the browser sandbox doesn't
|
||||
// provide. Null on web/Capacitor; the Import tab renders a
|
||||
// "requires desktop" placeholder in that case.
|
||||
importer: null,
|
||||
features: {
|
||||
hasWindowControls: false,
|
||||
hasScreenCapture: true,
|
||||
@@ -93,6 +99,8 @@ const webPlatform = {
|
||||
hasSystemBars: false,
|
||||
hasRecording: false,
|
||||
hasNotifications: typeof window !== 'undefined' && typeof window.Notification !== 'undefined',
|
||||
hasLifecycle: false,
|
||||
hasBackupImporter: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background-color: var(--background-tertiary, rgba(255, 255, 255, 0.03));
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.introIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background-color: color-mix(in srgb, var(--brand-primary, #5865f2) 18%, transparent);
|
||||
color: var(--brand-primary, #5865f2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.introTitle {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.introText {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
background-color: color-mix(in srgb, var(--status-warning, #faa61a) 12%, transparent);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.errorBanner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background-color: color-mix(in srgb, var(--status-warning, #faa61a) 14%, transparent);
|
||||
color: var(--status-warning, #faa61a);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 0.8125rem;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.statusSuccess {
|
||||
color: var(--status-positive, #23a55a);
|
||||
}
|
||||
|
||||
.statusError {
|
||||
color: var(--status-danger, #da373c);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 22px 14px;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-tertiary);
|
||||
background-color: var(--background-tertiary, rgba(255, 255, 255, 0.03));
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--background-tertiary, rgba(255, 255, 255, 0.03));
|
||||
}
|
||||
|
||||
.rowText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rowName {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowSub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
253
packages/shared/src/components/channel/ChannelAccessPanel.tsx
Normal file
253
packages/shared/src/components/channel/ChannelAccessPanel.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* ChannelAccessPanel — admin tool that lists users who don't have a
|
||||
* `channelKeys` row for a given (non-DM) channel and grants them the
|
||||
* key one-by-one.
|
||||
*
|
||||
* Background: an earlier broken invite flow caused new joiners to only
|
||||
* receive the key for a single channel instead of every server channel.
|
||||
* Those users have no row for the missing channels, can't decrypt
|
||||
* messages there, and don't show up in the member list. Since E2E means
|
||||
* the server never holds plaintext channel keys, the admin's client
|
||||
* does the re-encryption: decrypt our own bundle → re-encrypt against
|
||||
* the target user's RSA public key → hand the ciphertext to the
|
||||
* `grantChannelAccess` mutation.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useConvex, useMutation, useQuery } from 'convex/react';
|
||||
import { Key, UserPlus, WarningCircle } from '@phosphor-icons/react';
|
||||
import { Avatar, Button } from '@discord-clone/ui';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import type { Id } from '../../../../../convex/_generated/dataModel';
|
||||
import { usePlatform } from '../../platform';
|
||||
import styles from './ChannelAccessPanel.module.css';
|
||||
|
||||
interface ChannelAccessPanelProps {
|
||||
channelId: Id<'channels'>;
|
||||
}
|
||||
|
||||
export function ChannelAccessPanel({ channelId }: ChannelAccessPanelProps) {
|
||||
const { crypto } = usePlatform();
|
||||
const convex = useConvex();
|
||||
const grantAccess = useMutation(api.channelKeys.grantChannelAccess);
|
||||
|
||||
const myUserId =
|
||||
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
|
||||
const myPerms = useQuery(
|
||||
api.roles.getMyPermissions,
|
||||
myUserId ? { userId: myUserId as Id<'userProfiles'> } : 'skip',
|
||||
);
|
||||
const canManage = !!myPerms?.manage_channels;
|
||||
|
||||
const missing = useQuery(
|
||||
api.channelKeys.getUsersMissingChannelKey,
|
||||
canManage && myUserId
|
||||
? {
|
||||
actorId: myUserId as Id<'userProfiles'>,
|
||||
channelId,
|
||||
}
|
||||
: 'skip',
|
||||
);
|
||||
|
||||
// Admin's decrypted key for this channel. `null` = not loaded yet,
|
||||
// `{ hex: null }` = admin themselves is missing the key and thus
|
||||
// can't grant it.
|
||||
const [myKey, setMyKey] = useState<
|
||||
| { hex: string; version: number }
|
||||
| { hex: null; version: null }
|
||||
| null
|
||||
>(null);
|
||||
const [loadErr, setLoadErr] = useState<string | null>(null);
|
||||
const [granting, setGranting] = useState<Id<'userProfiles'> | null>(null);
|
||||
const [status, setStatus] = useState<
|
||||
{ type: 'success' | 'error'; message: string } | null
|
||||
>(null);
|
||||
|
||||
// Load + decrypt the admin's own channel-key bundle for this
|
||||
// channel. One-shot on channel change — not `useQuery`, because
|
||||
// reactivity would reshuffle the map mid-grant. Pattern mirrors
|
||||
// InviteModal.tsx:91-123.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
if (!canManage || !myUserId) return;
|
||||
setLoadErr(null);
|
||||
setMyKey(null);
|
||||
const privateKey =
|
||||
typeof sessionStorage !== 'undefined'
|
||||
? sessionStorage.getItem('privateKey')
|
||||
: null;
|
||||
if (!privateKey) {
|
||||
setLoadErr(
|
||||
'No decryption key available. Log out and back in to restore your session.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const bundles = await convex.query(api.channelKeys.getKeysForUser, {
|
||||
userId: myUserId as Id<'userProfiles'>,
|
||||
});
|
||||
let found: { hex: string; version: number } | null = null;
|
||||
for (const b of bundles) {
|
||||
try {
|
||||
const plaintext = await crypto.privateDecrypt(
|
||||
privateKey,
|
||||
b.encrypted_key_bundle,
|
||||
);
|
||||
const parsed = JSON.parse(plaintext) as Record<string, string>;
|
||||
const hex = parsed[channelId as unknown as string];
|
||||
if (hex) {
|
||||
found = { hex, version: b.key_version };
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
/* unreadable bundle — keep scanning others */
|
||||
}
|
||||
}
|
||||
if (cancelled) return;
|
||||
setMyKey(found ?? { hex: null, version: null });
|
||||
} catch (err: any) {
|
||||
if (!cancelled) setLoadErr(err?.message ?? 'Failed to load your keys.');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canManage, myUserId, channelId, convex, crypto]);
|
||||
|
||||
const handleGrant = useCallback(
|
||||
async (user: {
|
||||
userId: Id<'userProfiles'>;
|
||||
userPublicKey: string;
|
||||
username: string;
|
||||
}) => {
|
||||
if (!myUserId || !myKey || myKey.hex === null) return;
|
||||
setGranting(user.userId);
|
||||
setStatus(null);
|
||||
try {
|
||||
const payload = JSON.stringify({
|
||||
[channelId as unknown as string]: myKey.hex,
|
||||
});
|
||||
const encryptedKeyBundle = await crypto.publicEncrypt(
|
||||
user.userPublicKey,
|
||||
payload,
|
||||
);
|
||||
await grantAccess({
|
||||
actorId: myUserId as Id<'userProfiles'>,
|
||||
channelId,
|
||||
userId: user.userId,
|
||||
encryptedKeyBundle,
|
||||
keyVersion: myKey.version,
|
||||
});
|
||||
setStatus({
|
||||
type: 'success',
|
||||
message: `Granted access to @${user.username}.`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setStatus({
|
||||
type: 'error',
|
||||
message: err?.message ?? 'Failed to grant access.',
|
||||
});
|
||||
} finally {
|
||||
setGranting(null);
|
||||
}
|
||||
},
|
||||
[channelId, crypto, grantAccess, myKey, myUserId],
|
||||
);
|
||||
|
||||
if (!canManage) {
|
||||
return (
|
||||
<div className={styles.notice}>
|
||||
You need the <strong>Manage Channels</strong> permission to manage
|
||||
channel access.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadErr) {
|
||||
return (
|
||||
<div className={styles.errorBanner}>
|
||||
<WarningCircle size={18} weight="fill" />
|
||||
<span>{loadErr}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const adminHasKey = myKey !== null && myKey.hex !== null;
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.intro}>
|
||||
<div className={styles.introIcon}>
|
||||
<Key size={18} weight="fill" />
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles.introTitle}>Channel Access</div>
|
||||
<p className={styles.introText}>
|
||||
Users listed here don't have the key for this channel — most
|
||||
likely because they joined via an invite that predated it. Click
|
||||
<strong> Grant Access </strong>to hand them the key so they can
|
||||
decrypt messages.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{myKey !== null && !adminHasKey && (
|
||||
<div className={styles.errorBanner}>
|
||||
<WarningCircle size={18} weight="fill" />
|
||||
<span>
|
||||
Your account is also missing the key for this channel. Ask
|
||||
another admin to grant access to you first.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<p
|
||||
className={`${styles.status} ${
|
||||
status.type === 'success' ? styles.statusSuccess : styles.statusError
|
||||
}`}
|
||||
>
|
||||
{status.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{missing === undefined ? (
|
||||
<div className={styles.empty}>Loading users…</div>
|
||||
) : missing.length === 0 ? (
|
||||
<div className={styles.empty}>Everyone has access to this channel.</div>
|
||||
) : (
|
||||
<ul className={styles.list}>
|
||||
{missing.map((u) => {
|
||||
const name = u.displayName?.trim() || u.username;
|
||||
const isGranting = granting === u.userId;
|
||||
return (
|
||||
<li key={u.userId as unknown as string} className={styles.row}>
|
||||
<Avatar src={u.avatarUrl} fallback={name} size={36} />
|
||||
<div className={styles.rowText}>
|
||||
<div className={styles.rowName}>{name}</div>
|
||||
<div className={styles.rowSub}>@{u.username}</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<UserPlus size={14} weight="bold" />}
|
||||
onClick={() =>
|
||||
handleGrant({
|
||||
userId: u.userId,
|
||||
userPublicKey: u.userPublicKey,
|
||||
username: u.username,
|
||||
})
|
||||
}
|
||||
loading={isGranting}
|
||||
disabled={!adminHasKey || granting !== null}
|
||||
>
|
||||
Grant Access
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,39 @@
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Tab strip ───────────────────────────────────────────────────────── */
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--background-modifier-accent, rgba(255, 255, 255, 0.08));
|
||||
margin: 0 -2px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: -1px;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--text-primary);
|
||||
border-bottom-color: var(--brand-primary, #5865f2);
|
||||
}
|
||||
|
||||
/* ── Form fields ─────────────────────────────────────────────────────── */
|
||||
|
||||
.field {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Hash, SpeakerHigh, Trash } from '@phosphor-icons/react';
|
||||
import { Button, Modal } from '@discord-clone/ui';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import type { Id } from '../../../../../convex/_generated/dataModel';
|
||||
import { ChannelAccessPanel } from './ChannelAccessPanel';
|
||||
import styles from './ChannelSettingsModal.module.css';
|
||||
|
||||
interface ChannelSettingsModalProps {
|
||||
@@ -51,6 +52,7 @@ export function ChannelSettingsModal({
|
||||
{ type: 'success' | 'error'; message: string } | null
|
||||
>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'settings' | 'access'>('settings');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && channel) {
|
||||
@@ -58,6 +60,7 @@ export function ChannelSettingsModal({
|
||||
setTopic(channel.topic || '');
|
||||
setStatus(null);
|
||||
setConfirmDelete(false);
|
||||
setActiveTab('settings');
|
||||
}
|
||||
}, [isOpen, channel?._id, channel?.name, channel?.topic]);
|
||||
|
||||
@@ -115,6 +118,7 @@ export function ChannelSettingsModal({
|
||||
};
|
||||
|
||||
const isVoice = channel.type === 'voice';
|
||||
const showAccessTab = canModify;
|
||||
|
||||
return (
|
||||
<Modal.Root isOpen={isOpen} onClose={onClose} size="medium">
|
||||
@@ -146,6 +150,37 @@ export function ChannelSettingsModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAccessTab && (
|
||||
<div className={styles.tabs} role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'settings'}
|
||||
className={`${styles.tab} ${
|
||||
activeTab === 'settings' ? styles.tabActive : ''
|
||||
}`}
|
||||
onClick={() => setActiveTab('settings')}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'access'}
|
||||
className={`${styles.tab} ${
|
||||
activeTab === 'access' ? styles.tabActive : ''
|
||||
}`}
|
||||
onClick={() => setActiveTab('access')}
|
||||
>
|
||||
Access
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'access' && showAccessTab ? (
|
||||
<ChannelAccessPanel channelId={channel._id as Id<'channels'>} />
|
||||
) : (
|
||||
<>
|
||||
<fieldset className={styles.field} disabled={!canModify}>
|
||||
<label className={styles.label} htmlFor="channel-settings-name">
|
||||
Channel Name
|
||||
@@ -254,12 +289,15 @@ export function ChannelSettingsModal({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
{activeTab === 'access' ? 'Close' : 'Cancel'}
|
||||
</Button>
|
||||
{activeTab !== 'access' && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@@ -271,6 +309,7 @@ export function ChannelSettingsModal({
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
</Modal.Footer>
|
||||
</Modal.Root>
|
||||
);
|
||||
|
||||
@@ -187,6 +187,7 @@ export function MessageActionBar({
|
||||
>
|
||||
<Smiley size={20} />
|
||||
</button>
|
||||
{onReply && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.button}
|
||||
@@ -196,6 +197,7 @@ export function MessageActionBar({
|
||||
>
|
||||
<ArrowBendUpLeft size={20} />
|
||||
</button>
|
||||
)}
|
||||
{isOwnMessage && onEdit && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1111,8 +1111,16 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
||||
}, [status, loadMore]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
// Imported messages carry their original Discord timestamp on
|
||||
// `timestamp` (via the server's `importedCreatedAt` override),
|
||||
// so sort the window by effective timestamp before grouping —
|
||||
// otherwise a freshly-imported old message would land next to
|
||||
// a live message that happens to share an insertion neighbour,
|
||||
// and the author-merge grouping would conflate the two despite
|
||||
// a huge display-time gap.
|
||||
const ordered = decrypted.slice().sort((a, b) => a.timestamp - b.timestamp);
|
||||
const result: DecryptedMessage[][] = [];
|
||||
for (const msg of decrypted) {
|
||||
for (const msg of ordered) {
|
||||
const last = result[result.length - 1];
|
||||
if (last && last[last.length - 1].senderId === msg.senderId) {
|
||||
const gap = msg.timestamp - last[last.length - 1].timestamp;
|
||||
|
||||
@@ -63,6 +63,38 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
border-bottom: 1px solid var(--background-header-secondary, rgba(255, 255, 255, 0.08));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: -1px;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #b5bac1);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.tab:active {
|
||||
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--text-primary, #fff);
|
||||
border-bottom-color: var(--brand-primary, #5865f2);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@@ -35,6 +35,7 @@ import { Button, BottomSheet } from '@discord-clone/ui';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import type { Id } from '../../../../../convex/_generated/dataModel';
|
||||
import { useBackHandler } from '../../hooks/useBackHandler';
|
||||
import { ChannelAccessPanel } from './ChannelAccessPanel';
|
||||
import styles from './MobileChannelSettingsPage.module.css';
|
||||
|
||||
const NAME_MAX = 100;
|
||||
@@ -81,6 +82,7 @@ export function MobileChannelSettingsPage({
|
||||
>(null);
|
||||
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'settings' | 'access'>('settings');
|
||||
|
||||
// Seed local form state whenever the page (re)opens against a
|
||||
// different channel, so stale edits never leak across switches.
|
||||
@@ -92,6 +94,7 @@ export function MobileChannelSettingsPage({
|
||||
setStatus(null);
|
||||
setConfirmDelete(false);
|
||||
setShowCategoryPicker(false);
|
||||
setActiveTab('settings');
|
||||
}
|
||||
}, [isOpen, channel?._id, channel?.name, channel?.topic, channel?.categoryId]);
|
||||
|
||||
@@ -192,6 +195,7 @@ export function MobileChannelSettingsPage({
|
||||
<ArrowLeft size={22} weight="bold" />
|
||||
</button>
|
||||
<h1 className={styles.headerTitle}>Channel Settings</h1>
|
||||
{activeTab === 'settings' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.headerAction} ${
|
||||
@@ -206,8 +210,38 @@ export function MobileChannelSettingsPage({
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
) : (
|
||||
<span className={styles.headerAction} />
|
||||
)}
|
||||
</header>
|
||||
|
||||
{canModify && (
|
||||
<div className={styles.tabs} role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'settings'}
|
||||
className={`${styles.tab} ${
|
||||
activeTab === 'settings' ? styles.tabActive : ''
|
||||
}`}
|
||||
onClick={() => setActiveTab('settings')}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'access'}
|
||||
className={`${styles.tab} ${
|
||||
activeTab === 'access' ? styles.tabActive : ''
|
||||
}`}
|
||||
onClick={() => setActiveTab('access')}
|
||||
>
|
||||
Access
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className={styles.body}>
|
||||
{!canModify && (
|
||||
<div className={styles.warning}>
|
||||
@@ -215,6 +249,10 @@ export function MobileChannelSettingsPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'access' && canModify ? (
|
||||
<ChannelAccessPanel channelId={channel._id as Id<'channels'>} />
|
||||
) : (
|
||||
<>
|
||||
<label className={styles.fieldLabel}>Channel Name</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -276,6 +314,8 @@ export function MobileChannelSettingsPage({
|
||||
<span>Delete Channel</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Category picker sheet — tap a row to select, sheet auto-closes. */}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Modal, Button } from '@discord-clone/ui';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import type { Id } from '../../../../../convex/_generated/dataModel';
|
||||
import { EmojiPicker, type EmojiPickerValue } from './EmojiPicker';
|
||||
import { MessageActionBar } from './MessageActionBar';
|
||||
import { TwemojiImg } from './TwemojiImg';
|
||||
import { resolveReactionKeyToUnicode } from '../../utils/emojiLookup';
|
||||
import styles from './PollCard.module.css';
|
||||
@@ -37,8 +38,21 @@ export function PollCard({ pollId }: PollCardProps) {
|
||||
const voteMutation = useMutation(api.polls.vote);
|
||||
const clearVoteMutation = useMutation(api.polls.clearVote);
|
||||
const closeMutation = useMutation(api.polls.close);
|
||||
const removePollMutation = useMutation(api.polls.remove);
|
||||
const addReactionMutation = useMutation(api.polls.addReaction);
|
||||
const removeReactionMutation = useMutation(api.polls.removeReaction);
|
||||
// Permission check for "delete any poll" — mirrors how the message
|
||||
// action bar gates the delete button on own-message or manage_messages.
|
||||
const myPerms = useQuery(
|
||||
api.roles.getMyPermissions,
|
||||
myUserId ? { userId: myUserId as Id<'userProfiles'> } : 'skip',
|
||||
);
|
||||
// Right-click / long-press context menu anchor for the action bar
|
||||
// dropdown. `null` = closed; a point opens the More menu at that spot.
|
||||
const [contextMenuAt, setContextMenuAt] = useState<
|
||||
{ x: number; y: number } | null
|
||||
>(null);
|
||||
const [forceBarVisible, setForceBarVisible] = useState(false);
|
||||
|
||||
// Reaction picker — anchored off the Add Reaction button. `null`
|
||||
// means the picker is closed.
|
||||
@@ -79,8 +93,8 @@ export function PollCard({ pollId }: PollCardProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const openReactPicker = () => {
|
||||
const btn = addReactionButtonRef.current;
|
||||
const openReactPicker = (anchor?: HTMLElement | null) => {
|
||||
const btn = anchor ?? addReactionButtonRef.current;
|
||||
if (!btn) return;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
setReactPickerPos({
|
||||
@@ -156,8 +170,54 @@ export function PollCard({ pollId }: PollCardProps) {
|
||||
if (c > maxCount) maxCount = c;
|
||||
}
|
||||
|
||||
const isCreator = !!myUserId && poll.createdBy === myUserId;
|
||||
const canDeletePoll = isCreator || !!myPerms?.manage_messages;
|
||||
|
||||
const handleQuickReact = (emoji: string) => {
|
||||
if (!myUserId) return;
|
||||
void addReactionMutation({
|
||||
pollId: poll._id,
|
||||
userId: myUserId as Id<'userProfiles'>,
|
||||
emoji,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeletePoll = () => {
|
||||
if (!myUserId || !canDeletePoll) return;
|
||||
void removePollMutation({
|
||||
pollId: poll._id,
|
||||
userId: myUserId as Id<'userProfiles'>,
|
||||
}).catch((err) => {
|
||||
console.error('Failed to delete poll:', err);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyPollText = () => {
|
||||
const text = [poll.question || 'Poll', ...poll.options.map((o) => `- ${o.text}`)].join(
|
||||
'\n',
|
||||
);
|
||||
void navigator.clipboard?.writeText?.(text).catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div
|
||||
className={`${styles.card} messageHoverable ${forceBarVisible ? 'actionBarForceVisible' : ''}`}
|
||||
style={{ position: 'relative' }}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenuAt({ x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
>
|
||||
<MessageActionBar
|
||||
isOwnMessage={canDeletePoll}
|
||||
onQuickReact={handleQuickReact}
|
||||
onReact={(e) => openReactPicker(e?.currentTarget ?? null)}
|
||||
onDelete={canDeletePoll ? handleDeletePoll : undefined}
|
||||
onCopyText={handleCopyPollText}
|
||||
externalMenuAt={contextMenuAt}
|
||||
onExternalMenuClose={() => setContextMenuAt(null)}
|
||||
onMenuOpenChange={(open) => setForceBarVisible(open)}
|
||||
/>
|
||||
<div className={styles.question}>{poll.question || 'Poll'}</div>
|
||||
|
||||
{!countsVisible && !isEnded && (
|
||||
|
||||
@@ -186,6 +186,17 @@ export function AppLayout() {
|
||||
window.addEventListener('brycord:keybind:navigation.goToDMs', goHome);
|
||||
window.addEventListener('brycord:keybind:navigation.focusSearch', focusSearch);
|
||||
window.addEventListener('brycord:keybind:popouts.openUserSettings', openSettings);
|
||||
// Tray menu → keybind events. We reuse the voice keybind
|
||||
// dispatch channel (already handled by UserArea) so the tray,
|
||||
// hotkeys, and UI buttons all converge on the same toggle path.
|
||||
const unsubTray =
|
||||
platform?.lifecycle?.onTrayAction?.((action: string) => {
|
||||
if (action === 'toggle-mute') {
|
||||
window.dispatchEvent(new CustomEvent('brycord:keybind:voice.toggleMute'));
|
||||
} else if (action === 'toggle-deafen') {
|
||||
window.dispatchEvent(new CustomEvent('brycord:keybind:voice.toggleDeafen'));
|
||||
}
|
||||
}) ?? null;
|
||||
return () => {
|
||||
window.removeEventListener('brycord:keybind:navigation.goToDMs', goHome);
|
||||
window.removeEventListener(
|
||||
@@ -196,8 +207,9 @@ export function AppLayout() {
|
||||
'brycord:keybind:popouts.openUserSettings',
|
||||
openSettings,
|
||||
);
|
||||
if (typeof unsubTray === 'function') unsubTray();
|
||||
};
|
||||
}, [navigate]);
|
||||
}, [navigate, platform]);
|
||||
|
||||
const hasSession =
|
||||
typeof sessionStorage !== 'undefined' &&
|
||||
|
||||
309
packages/shared/src/components/settings/GhostsTab.tsx
Normal file
309
packages/shared/src/components/settings/GhostsTab.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* GhostsTab — lists every placeholder profile created by the
|
||||
* backup importer and lets an admin merge one into a real user.
|
||||
*
|
||||
* Merge flow is paged (`mergeGhostPageAction` returns `done: false`
|
||||
* until all messages have been rewritten) so even ghosts with
|
||||
* 50k+ messages complete without tripping Convex's mutation time
|
||||
* limit. The component drives the loop, tallying the total count
|
||||
* for the audit metadata.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useAction, useQuery } from 'convex/react';
|
||||
import { Ghost, X } from '@phosphor-icons/react';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import type { Id } from '../../../../../convex/_generated/dataModel';
|
||||
import { usePlatform } from '../../platform';
|
||||
|
||||
export function GhostsTab() {
|
||||
const platform = usePlatform();
|
||||
const myUserId =
|
||||
typeof localStorage !== 'undefined'
|
||||
? (localStorage.getItem('userId') as Id<'userProfiles'> | null)
|
||||
: null;
|
||||
const ghosts =
|
||||
useQuery(
|
||||
api.importer.listGhosts,
|
||||
myUserId ? { actorId: myUserId } : 'skip',
|
||||
) ?? [];
|
||||
const candidates =
|
||||
useQuery(
|
||||
api.importer.listMappingCandidates,
|
||||
myUserId ? { actorId: myUserId } : 'skip',
|
||||
) ?? [];
|
||||
const mergePage = useAction(api.importerActions.mergeGhostPageAction);
|
||||
const finalize = useAction(api.importerActions.finalizeMergeAction);
|
||||
|
||||
const [activeGhost, setActiveGhost] = useState<string | null>(null);
|
||||
const [target, setTarget] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const realUsers = (candidates as any[]).filter((c) => !c.isGhost);
|
||||
|
||||
const handleMerge = async () => {
|
||||
if (!myUserId || !activeGhost || !target) return;
|
||||
const signingKey =
|
||||
typeof sessionStorage !== 'undefined'
|
||||
? sessionStorage.getItem('signingKey')
|
||||
: null;
|
||||
if (!signingKey) {
|
||||
setError('Session signing key missing — please log out and back in.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setStatus('Rewriting messages…');
|
||||
try {
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const authTimestamp = Date.now();
|
||||
const canonical = `mergeGhostPage:${myUserId}:${activeGhost}:${target}:${authTimestamp}`;
|
||||
const authSignature = await platform.crypto.signMessage(
|
||||
signingKey,
|
||||
canonical,
|
||||
);
|
||||
const page = await mergePage({
|
||||
actorId: myUserId,
|
||||
ghostUserId: activeGhost as Id<'userProfiles'>,
|
||||
targetUserId: target as Id<'userProfiles'>,
|
||||
authTimestamp,
|
||||
authSignature,
|
||||
});
|
||||
total += page.rewritten;
|
||||
setStatus(`Rewrote ${total.toLocaleString()} messages…`);
|
||||
if (page.done) break;
|
||||
}
|
||||
const finalTs = Date.now();
|
||||
const canonical = `finalizeMerge:${myUserId}:${activeGhost}:${target}:${finalTs}`;
|
||||
const sig = await platform.crypto.signMessage(signingKey, canonical);
|
||||
await finalize({
|
||||
actorId: myUserId,
|
||||
ghostUserId: activeGhost as Id<'userProfiles'>,
|
||||
targetUserId: target as Id<'userProfiles'>,
|
||||
totalRewritten: total,
|
||||
authTimestamp: finalTs,
|
||||
authSignature: sig,
|
||||
});
|
||||
setStatus(`Merged ${total.toLocaleString()} messages.`);
|
||||
setActiveGhost(null);
|
||||
setTarget('');
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? 'Merge failed.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<h2 style={headingStyle}>Ghost profiles</h2>
|
||||
<p style={descStyle}>
|
||||
Placeholder authors created by the backup importer. Merge a
|
||||
ghost into a real user to rewrite every imported message's
|
||||
author and delete the placeholder.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={errorStyle}>
|
||||
<X size={16} weight="bold" /> {error}
|
||||
</div>
|
||||
)}
|
||||
{status && !error && (
|
||||
<div style={infoStyle}>{status}</div>
|
||||
)}
|
||||
|
||||
{ghosts.length === 0 ? (
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: 13 }}>
|
||||
No ghost profiles. Imported messages will land here if their
|
||||
Discord author wasn't mapped to a local user.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{ghosts.map((g: any) => (
|
||||
<div key={g._id} style={rowStyle}>
|
||||
{g.ghostAvatarUrl ? (
|
||||
<img
|
||||
src={g.ghostAvatarUrl}
|
||||
alt=""
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={avatarPlaceholder}>
|
||||
<Ghost size={18} weight="bold" />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--text-primary)',
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{g.displayName || g.username}
|
||||
</div>
|
||||
<div style={descSmallStyle}>
|
||||
{g.messageCount.toLocaleString()} messages
|
||||
{g.discordId ? ` · Discord ${g.discordId}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveGhost(g._id);
|
||||
setTarget('');
|
||||
setError(null);
|
||||
setStatus(null);
|
||||
}}
|
||||
disabled={busy}
|
||||
style={primaryBtnStyle}
|
||||
>
|
||||
Merge
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeGhost && (
|
||||
<div style={{ ...cardStyle, marginTop: 16 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>
|
||||
Merge into real user
|
||||
</div>
|
||||
<select
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
disabled={busy}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">Select a user…</option>
|
||||
{realUsers.map((u: any) => (
|
||||
<option key={u._id} value={u._id}>
|
||||
{u.displayName || u.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMerge}
|
||||
disabled={!target || busy}
|
||||
style={primaryBtnStyle}
|
||||
>
|
||||
{busy ? 'Merging…' : 'Confirm merge'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveGhost(null);
|
||||
setTarget('');
|
||||
}}
|
||||
disabled={busy}
|
||||
style={secondaryBtnStyle}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const headingStyle: React.CSSProperties = {
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-primary)',
|
||||
margin: 0,
|
||||
marginBottom: 6,
|
||||
};
|
||||
const descStyle: React.CSSProperties = {
|
||||
fontSize: 14,
|
||||
color: 'var(--text-secondary)',
|
||||
margin: 0,
|
||||
};
|
||||
const descSmallStyle: React.CSSProperties = {
|
||||
fontSize: 12,
|
||||
color: 'var(--text-secondary)',
|
||||
marginTop: 2,
|
||||
};
|
||||
const cardStyle: React.CSSProperties = {
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
background: 'var(--background-secondary)',
|
||||
border: '1px solid var(--background-tertiary)',
|
||||
};
|
||||
const rowStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
background: 'var(--background-secondary)',
|
||||
border: '1px solid var(--background-tertiary)',
|
||||
};
|
||||
const avatarPlaceholder: React.CSSProperties = {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--background-tertiary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--text-primary)',
|
||||
};
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--background-tertiary)',
|
||||
border: '1px solid var(--background-modifier-accent)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 14,
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
const primaryBtnStyle: React.CSSProperties = {
|
||||
background: 'var(--brand-primary)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
padding: '10px 18px',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
};
|
||||
const secondaryBtnStyle: React.CSSProperties = {
|
||||
...primaryBtnStyle,
|
||||
background: 'var(--background-tertiary)',
|
||||
color: 'var(--text-primary)',
|
||||
};
|
||||
const errorStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
padding: 10,
|
||||
marginBottom: 12,
|
||||
borderRadius: 6,
|
||||
background: 'rgba(248, 113, 113, 0.12)',
|
||||
color: '#f87171',
|
||||
fontSize: 13,
|
||||
};
|
||||
const infoStyle: React.CSSProperties = {
|
||||
padding: 10,
|
||||
marginBottom: 12,
|
||||
borderRadius: 6,
|
||||
background: 'var(--background-secondary)',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 13,
|
||||
};
|
||||
814
packages/shared/src/components/settings/ImportTab.tsx
Normal file
814
packages/shared/src/components/settings/ImportTab.tsx
Normal file
@@ -0,0 +1,814 @@
|
||||
/**
|
||||
* ImportTab — admin-only Discord backup importer UI.
|
||||
*
|
||||
* The actual run state lives in the `importSession` module so that
|
||||
* closing/reopening the settings modal (which unmounts this
|
||||
* component) doesn't cancel the run or lose progress. This
|
||||
* component is a thin view over that session — it picks the backup,
|
||||
* drives the mapping UI, and renders whatever state the session is
|
||||
* in.
|
||||
*/
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
|
||||
import { useAction, useConvex, useMutation, useQuery } from 'convex/react';
|
||||
import {
|
||||
ArrowCounterClockwise,
|
||||
CheckCircle,
|
||||
FileArrowUp,
|
||||
Folder,
|
||||
Stop,
|
||||
Trash,
|
||||
Warning,
|
||||
} from '@phosphor-icons/react';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import type { Id } from '../../../../../convex/_generated/dataModel';
|
||||
import { usePlatform } from '../../platform';
|
||||
import {
|
||||
openBackup,
|
||||
type BackupSummary,
|
||||
} from '../../utils/importRunner';
|
||||
import { importSession } from '../../utils/importSession';
|
||||
|
||||
interface ChannelKeyBundle {
|
||||
channelId: string;
|
||||
keyHex: string;
|
||||
keyVersion: number;
|
||||
/** Every key version we hold for this channel. Repair mode needs
|
||||
* older versions to decrypt rows imported under a prior
|
||||
* rotation. */
|
||||
allVersions: Map<number, string>;
|
||||
}
|
||||
|
||||
function useChannelKeyBundles(): Map<string, ChannelKeyBundle> {
|
||||
const platform = usePlatform();
|
||||
const userId =
|
||||
typeof localStorage !== 'undefined'
|
||||
? (localStorage.getItem('userId') as Id<'userProfiles'> | null)
|
||||
: null;
|
||||
const privateKeyPem =
|
||||
typeof sessionStorage !== 'undefined'
|
||||
? sessionStorage.getItem('privateKey')
|
||||
: null;
|
||||
const allKeys = useQuery(
|
||||
api.channelKeys.getKeysForUser,
|
||||
userId ? { userId: userId as any } : 'skip',
|
||||
);
|
||||
const [map, setMap] = useState<Map<string, ChannelKeyBundle>>(new Map());
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!allKeys || !privateKeyPem) {
|
||||
setMap(new Map());
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
// First pass: decrypt every bundle and collect all
|
||||
// versions per channel. A single bundle row carries one
|
||||
// version but may map to multiple channels in its JSON.
|
||||
const byChannel = new Map<
|
||||
string,
|
||||
{ latestVer: number; latestKey: string; versions: Map<number, string> }
|
||||
>();
|
||||
for (const item of allKeys as any[]) {
|
||||
try {
|
||||
const json = await platform.crypto.privateDecrypt(
|
||||
privateKeyPem,
|
||||
item.encrypted_key_bundle,
|
||||
);
|
||||
const parsed = JSON.parse(json) as Record<string, string>;
|
||||
const ver = Number(item.key_version ?? 1);
|
||||
for (const [chId, keyHex] of Object.entries(parsed)) {
|
||||
let entry = byChannel.get(chId);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
latestVer: ver,
|
||||
latestKey: keyHex,
|
||||
versions: new Map([[ver, keyHex]]),
|
||||
};
|
||||
byChannel.set(chId, entry);
|
||||
} else {
|
||||
entry.versions.set(ver, keyHex);
|
||||
if (ver > entry.latestVer) {
|
||||
entry.latestVer = ver;
|
||||
entry.latestKey = keyHex;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to decrypt key bundle', err);
|
||||
}
|
||||
}
|
||||
const next = new Map<string, ChannelKeyBundle>();
|
||||
for (const [chId, entry] of byChannel) {
|
||||
next.set(chId, {
|
||||
channelId: chId,
|
||||
keyHex: entry.latestKey,
|
||||
keyVersion: entry.latestVer,
|
||||
allVersions: entry.versions,
|
||||
});
|
||||
}
|
||||
if (!cancelled) setMap(next);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [allKeys, privateKeyPem, platform]);
|
||||
return map;
|
||||
}
|
||||
|
||||
function useImportSession() {
|
||||
return useSyncExternalStore(
|
||||
(cb) => importSession.subscribe(cb),
|
||||
() => importSession.getState(),
|
||||
() => importSession.getState(),
|
||||
);
|
||||
}
|
||||
|
||||
export function ImportTab() {
|
||||
const platform = usePlatform();
|
||||
const myUserId =
|
||||
typeof localStorage !== 'undefined'
|
||||
? (localStorage.getItem('userId') as Id<'userProfiles'> | null)
|
||||
: null;
|
||||
const channels = useQuery(api.channels.list, {}) ?? [];
|
||||
const candidates =
|
||||
useQuery(
|
||||
api.importer.listMappingCandidates,
|
||||
myUserId ? { actorId: myUserId } : 'skip',
|
||||
) ?? [];
|
||||
const prepareGhosts = useAction(api.importerActions.prepareGhostsAction);
|
||||
const importBatch = useAction(api.importerActions.importBatchAction);
|
||||
const clearChannelImports = useAction(
|
||||
api.importerActions.clearChannelImportsAction,
|
||||
);
|
||||
const deleteByDiscordIdsAction = useAction(
|
||||
api.importerActions.deleteImportedByDiscordIdsAction,
|
||||
);
|
||||
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
|
||||
const validateUpload = useMutation(api.files.validateUpload);
|
||||
// `resolveReplyTargets` is a query — we call it imperatively from
|
||||
// the runner to pre-skip already-imported rows and to remap
|
||||
// reply-parent IDs in one round-trip. `useQuery` is declarative
|
||||
// only, so we go through the raw Convex client.
|
||||
const convexClient = useConvex();
|
||||
const channelKeyBundles = useChannelKeyBundles();
|
||||
|
||||
const session = useImportSession();
|
||||
const supported = !!platform.features?.hasBackupImporter && !!platform.importer;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [clearingChannel, setClearingChannel] = useState<string | null>(null);
|
||||
|
||||
const isRunning =
|
||||
session.status === 'running' || session.status === 'cancelling';
|
||||
|
||||
// Once a backup is loaded and `candidates` arrives, auto-fill the
|
||||
// author dropdowns where we can match Discord users to real locals
|
||||
// by discordId or by case-insensitive username/displayName.
|
||||
useEffect(() => {
|
||||
const summary: BackupSummary | null = session.parsed?.summary ?? null;
|
||||
if (!summary || candidates.length === 0) return;
|
||||
const next = { ...session.authorMap };
|
||||
let changed = false;
|
||||
for (const a of summary.authors) {
|
||||
if (next[a.discordId] !== undefined && next[a.discordId] !== '') continue;
|
||||
const byDiscordId = candidates.find(
|
||||
(c: any) => c.discordId === a.discordId,
|
||||
);
|
||||
if (byDiscordId) {
|
||||
next[a.discordId] = byDiscordId._id;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
const byName = candidates.find(
|
||||
(c: any) =>
|
||||
!c.isGhost &&
|
||||
(c.username?.toLowerCase() === a.username.toLowerCase() ||
|
||||
c.displayName?.toLowerCase() ===
|
||||
(a.displayName ?? '').toLowerCase()),
|
||||
);
|
||||
if (byName) {
|
||||
next[a.discordId] = byName._id;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) importSession.setAuthorMap(next);
|
||||
}, [session.parsed, candidates, session.authorMap]);
|
||||
|
||||
const summary: BackupSummary | null = session.parsed?.summary ?? null;
|
||||
|
||||
const handlePick = async () => {
|
||||
if (!platform.importer) return;
|
||||
setLocalError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await platform.importer.pickDatabase();
|
||||
if (!result.ok || !result.path) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const opened = await openBackup(platform, result.path);
|
||||
importSession.setParsed(opened, result.path);
|
||||
const nextChannelMap: Record<string, string> = {};
|
||||
for (const ch of opened.summary.channels)
|
||||
nextChannelMap[ch.discordId] = '';
|
||||
importSession.setChannelMap(nextChannelMap);
|
||||
} catch (err: any) {
|
||||
setLocalError(err?.message ?? 'Failed to open backup');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedChannels = useMemo(() => {
|
||||
if (!summary) return [];
|
||||
return summary.channels.filter((c) => session.channelMap[c.discordId]);
|
||||
}, [summary, session.channelMap]);
|
||||
|
||||
const totalMessages = selectedChannels.reduce(
|
||||
(n, c) => n + c.messageCount,
|
||||
0,
|
||||
);
|
||||
const totalAttachments = selectedChannels.reduce(
|
||||
(n, c) => n + c.attachmentCount,
|
||||
0,
|
||||
);
|
||||
const ghostsToCreate = summary
|
||||
? summary.authors.filter((a) => !session.authorMap[a.discordId]).length
|
||||
: 0;
|
||||
|
||||
// Wipe every imported message in a local channel and reset the
|
||||
// Discord-channel → cursor so a subsequent run re-imports from
|
||||
// scratch. Used to repair channels polluted by an earlier run
|
||||
// that left blank bubbles behind. Keyed by the *Discord* channel
|
||||
// id so the cursor clearance matches the resume key format.
|
||||
const handleClearChannel = async (
|
||||
discordChannelId: string,
|
||||
convexChannelId: string,
|
||||
) => {
|
||||
if (!myUserId) return;
|
||||
const signingKey =
|
||||
typeof sessionStorage !== 'undefined'
|
||||
? sessionStorage.getItem('signingKey')
|
||||
: null;
|
||||
if (!signingKey) {
|
||||
setLocalError('Session signing key missing — please log out and back in.');
|
||||
return;
|
||||
}
|
||||
const channelLabel =
|
||||
summary?.channels.find((c) => c.discordId === discordChannelId)?.name ??
|
||||
'this channel';
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
!window.confirm(
|
||||
`Delete ALL imported messages from #${channelLabel}? Live (non-imported) messages are kept. This can't be undone.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setClearingChannel(discordChannelId);
|
||||
setLocalError(null);
|
||||
try {
|
||||
const authTimestamp = Date.now();
|
||||
const canonical = `clearChannelImports:${myUserId}:${convexChannelId}:${authTimestamp}`;
|
||||
const authSignature = await platform.crypto.signMessage(
|
||||
signingKey,
|
||||
canonical,
|
||||
);
|
||||
await clearChannelImports({
|
||||
actorId: myUserId,
|
||||
channelId: convexChannelId as any,
|
||||
authTimestamp,
|
||||
authSignature,
|
||||
});
|
||||
// Drop the client-side resume cursor so the next run walks
|
||||
// the full channel from the oldest row. Matches the
|
||||
// `RESUME_KEY_PREFIX` in importRunner.ts.
|
||||
try {
|
||||
localStorage.removeItem(
|
||||
'brycord:importer:cursor:' + discordChannelId,
|
||||
);
|
||||
} catch {}
|
||||
} catch (err: any) {
|
||||
setLocalError(err?.message ?? 'Failed to clear imports.');
|
||||
} finally {
|
||||
setClearingChannel(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRun = async (
|
||||
opts: { resume?: boolean; repair?: boolean } = {},
|
||||
) => {
|
||||
const resume = !!opts.resume;
|
||||
const repair = !!opts.repair;
|
||||
if (!session.parsed || !summary || !myUserId) return;
|
||||
const signingKey =
|
||||
typeof sessionStorage !== 'undefined'
|
||||
? sessionStorage.getItem('signingKey')
|
||||
: null;
|
||||
if (!signingKey) {
|
||||
setLocalError('Session signing key missing — please log out and back in.');
|
||||
return;
|
||||
}
|
||||
|
||||
const keyMap = new Map<
|
||||
string,
|
||||
{ keyHex: string; keyVersion: number; allVersions: Map<number, string> }
|
||||
>();
|
||||
for (const mapping of selectedChannels) {
|
||||
const convexId = session.channelMap[mapping.discordId];
|
||||
const bundle = channelKeyBundles.get(convexId);
|
||||
if (!bundle) {
|
||||
setLocalError(
|
||||
`No key available for channel "${mapping.name}" — you must be a member of the target channel.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
keyMap.set(convexId, {
|
||||
keyHex: bundle.keyHex,
|
||||
keyVersion: bundle.keyVersion,
|
||||
allVersions: bundle.allVersions,
|
||||
});
|
||||
}
|
||||
|
||||
setLocalError(null);
|
||||
// Fire-and-forget: the session manages the promise. This
|
||||
// function returns immediately so unmounting the component
|
||||
// doesn't reject the awaited call.
|
||||
void importSession.start(
|
||||
session.parsed,
|
||||
{
|
||||
crypto: platform.crypto as any,
|
||||
platform,
|
||||
actorId: myUserId,
|
||||
signingKey,
|
||||
channelKeys: keyMap,
|
||||
convex: {
|
||||
prepareGhosts: prepareGhosts as any,
|
||||
importBatch: importBatch as any,
|
||||
generateUploadUrl: generateUploadUrl as any,
|
||||
validateUpload: validateUpload as any,
|
||||
resolveReplyTargets: (args: {
|
||||
channelId: string;
|
||||
discordMessageIds: string[];
|
||||
}) =>
|
||||
convexClient.query(
|
||||
api.importer.resolveReplyTargets as any,
|
||||
args as any,
|
||||
) as Promise<
|
||||
Array<{ discordMessageId: string; messageId: string }>
|
||||
>,
|
||||
getImportedState: (args) =>
|
||||
convexClient.query(
|
||||
api.importer.getImportedState as any,
|
||||
args as any,
|
||||
) as Promise<
|
||||
Array<{
|
||||
discordMessageId: string;
|
||||
messageId: string;
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
keyVersion: number;
|
||||
}>
|
||||
>,
|
||||
deleteImportedByDiscordIds: (args) =>
|
||||
deleteByDiscordIdsAction(args as any),
|
||||
},
|
||||
},
|
||||
{
|
||||
channels: selectedChannels.map((c) => ({
|
||||
discordId: c.discordId,
|
||||
convexChannelId: session.channelMap[c.discordId] || null,
|
||||
})),
|
||||
authors: summary.authors.map((a) => ({
|
||||
discordId: a.discordId,
|
||||
convexUserId: session.authorMap[a.discordId] || null,
|
||||
})),
|
||||
resume,
|
||||
repair,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (!supported) {
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<h2 style={headingStyle}>Import Discord Backup</h2>
|
||||
<p style={descStyle}>
|
||||
Pull historical messages + attachments from a Discord Backup Bot database into this server.
|
||||
</p>
|
||||
</div>
|
||||
<div style={warnStyle}>
|
||||
<Warning size={18} weight="bold" />
|
||||
<div>
|
||||
Backup import runs on the <strong>desktop app</strong> only —
|
||||
it needs local filesystem access for the SQLite file and the
|
||||
attachment folder.
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const progress = session.progress;
|
||||
const effectiveError = localError ?? session.error;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<h2 style={headingStyle}>Import Discord Backup</h2>
|
||||
<p style={descStyle}>
|
||||
Pulls historical messages + attachments from a Discord Backup Bot
|
||||
database into channels on this server. Imported messages are
|
||||
end-to-end encrypted under the current channel key, the same way
|
||||
live messages are. The importer runs in the background — you can
|
||||
close this modal and come back to it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{effectiveError && (
|
||||
<div style={errorStyle}>
|
||||
<Warning size={16} weight="bold" /> {effectiveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!session.parsed ? (
|
||||
<div style={cardStyle}>
|
||||
<Folder size={28} weight="regular" style={{ marginBottom: 8 }} />
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
Choose a backup
|
||||
</div>
|
||||
<div style={descSmallStyle}>
|
||||
Point this at the <code>backup.db</code> written by your
|
||||
Discord Backup Bot. Attachments are read from the sibling{' '}
|
||||
<code>attachments/</code> folder.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePick}
|
||||
disabled={loading}
|
||||
style={{ ...primaryBtnStyle, marginTop: 14 }}
|
||||
>
|
||||
<FileArrowUp size={16} weight="bold" />
|
||||
{loading ? 'Opening…' : 'Select backup.db'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={cardStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<CheckCircle size={20} weight="bold" color="#3ba55d" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600 }}>{session.dbPath}</div>
|
||||
<div style={descSmallStyle}>
|
||||
{summary?.channels.length ?? 0} channels ·{' '}
|
||||
{summary?.authors.length ?? 0} authors ·{' '}
|
||||
{summary?.channels.reduce(
|
||||
(n, c) => n + c.messageCount,
|
||||
0,
|
||||
) ?? 0}{' '}
|
||||
messages
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => importSession.reset()}
|
||||
disabled={isRunning}
|
||||
style={{ ...secondaryBtnStyle }}
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style={sectionHeadingStyle}>Link channels</h3>
|
||||
<p style={descSmallStyle}>
|
||||
Every Discord channel in the backup can be pointed at a local
|
||||
channel here, or skipped.
|
||||
</p>
|
||||
<div style={{ display: 'grid', gap: 8, marginTop: 10 }}>
|
||||
{summary?.channels.map((c) => {
|
||||
const linkedConvexId = session.channelMap[c.discordId];
|
||||
const isClearing = clearingChannel === c.discordId;
|
||||
return (
|
||||
<div key={c.discordId} style={rowStyle}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
#{c.name}
|
||||
{c.isThread && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 6,
|
||||
fontSize: 11,
|
||||
background: 'var(--background-tertiary)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 4,
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
thread
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={descSmallStyle}>
|
||||
{c.messageCount} messages · {c.attachmentCount} files
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={linkedConvexId ?? ''}
|
||||
onChange={(e) =>
|
||||
importSession.setChannelMap({
|
||||
...session.channelMap,
|
||||
[c.discordId]: e.target.value,
|
||||
})
|
||||
}
|
||||
disabled={isRunning || isClearing}
|
||||
style={{ ...inputStyle, maxWidth: 240 }}
|
||||
>
|
||||
<option value="">Skip</option>
|
||||
{channels.map((ch: any) => (
|
||||
<option key={ch._id} value={ch._id}>
|
||||
#{ch.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{linkedConvexId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleClearChannel(c.discordId, linkedConvexId)
|
||||
}
|
||||
disabled={isRunning || !!clearingChannel}
|
||||
title="Delete every imported message in the linked channel and reset the cursor — useful to fix a partial earlier run."
|
||||
style={{
|
||||
...secondaryBtnStyle,
|
||||
padding: '8px 10px',
|
||||
background: 'transparent',
|
||||
color: 'var(--status-danger, #da373c)',
|
||||
border: '1px solid currentColor',
|
||||
}}
|
||||
>
|
||||
<Trash size={14} weight="bold" />
|
||||
{isClearing ? 'Clearing…' : 'Clear'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h3 style={sectionHeadingStyle}>Link authors</h3>
|
||||
<p style={descSmallStyle}>
|
||||
Discord users not linked here become ghost profiles you can
|
||||
merge into a real local user later from the <strong>Ghosts</strong>{' '}
|
||||
tab.
|
||||
</p>
|
||||
<div style={{ display: 'grid', gap: 6, marginTop: 10 }}>
|
||||
{summary?.authors.map((a) => (
|
||||
<div key={a.discordId} style={rowStyle}>
|
||||
{a.avatarUrl ? (
|
||||
<img
|
||||
src={a.avatarUrl}
|
||||
alt=""
|
||||
style={{ width: 32, height: 32, borderRadius: '50%' }}
|
||||
/>
|
||||
) : (
|
||||
<div style={avatarPlaceholder}>
|
||||
{(a.displayName || a.username || '?')
|
||||
.slice(0, 1)
|
||||
.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{a.displayName || a.username}
|
||||
</div>
|
||||
<div style={descSmallStyle}>
|
||||
{a.messageCount} messages
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={session.authorMap[a.discordId] ?? ''}
|
||||
onChange={(e) =>
|
||||
importSession.setAuthorMap({
|
||||
...session.authorMap,
|
||||
[a.discordId]: e.target.value,
|
||||
})
|
||||
}
|
||||
disabled={isRunning}
|
||||
style={{ ...inputStyle, maxWidth: 240 }}
|
||||
>
|
||||
<option value="">Create ghost</option>
|
||||
{(candidates as any[])
|
||||
.filter((c) => !c.isGhost)
|
||||
.map((c) => (
|
||||
<option key={c._id} value={c._id}>
|
||||
{c.displayName || c.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ ...cardStyle, marginTop: 20 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6 }}>Summary</div>
|
||||
<div style={descSmallStyle}>
|
||||
{selectedChannels.length} channels ·{' '}
|
||||
{totalMessages.toLocaleString()} messages ·{' '}
|
||||
{totalAttachments.toLocaleString()} attachments · {ghostsToCreate}{' '}
|
||||
ghosts to create
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
marginTop: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRun({ resume: false })}
|
||||
disabled={isRunning || selectedChannels.length === 0}
|
||||
style={primaryBtnStyle}
|
||||
>
|
||||
<FileArrowUp size={16} weight="bold" /> Start import
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRun({ resume: true })}
|
||||
disabled={isRunning || selectedChannels.length === 0}
|
||||
style={secondaryBtnStyle}
|
||||
>
|
||||
<ArrowCounterClockwise size={16} weight="bold" /> Resume
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRun({ repair: true })}
|
||||
disabled={isRunning || selectedChannels.length === 0}
|
||||
title="Surgical fix — walks the backup and only re-imports rows whose attachments are currently missing. Complete messages are left alone."
|
||||
style={secondaryBtnStyle}
|
||||
>
|
||||
<ArrowCounterClockwise size={16} weight="bold" /> Repair
|
||||
missing attachments
|
||||
</button>
|
||||
{isRunning && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => importSession.cancel()}
|
||||
style={dangerBtnStyle}
|
||||
>
|
||||
<Stop size={16} weight="bold" />{' '}
|
||||
{session.status === 'cancelling'
|
||||
? 'Cancelling…'
|
||||
: 'Cancel'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{progress && (
|
||||
<div style={{ ...cardStyle, marginTop: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{session.status === 'done'
|
||||
? 'Finished'
|
||||
: session.status === 'cancelling'
|
||||
? 'Cancelling…'
|
||||
: progress.stage === 'preparing'
|
||||
? 'Preparing…'
|
||||
: `Importing #${progress.channelName ?? ''}`}
|
||||
</div>
|
||||
<div style={descSmallStyle}>
|
||||
{progress.channelProgress
|
||||
? `${progress.channelProgress.inserted.toLocaleString()} / ${progress.channelProgress.total.toLocaleString()} messages`
|
||||
: progress.message ?? ''}
|
||||
</div>
|
||||
<div style={descSmallStyle}>
|
||||
Uploaded {progress.attachmentsUploaded.toLocaleString()}{' '}
|
||||
files (
|
||||
{Math.round(
|
||||
progress.attachmentBytesUploaded / 1024 / 1024,
|
||||
).toLocaleString()}{' '}
|
||||
MB)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Styles — re-declared locally so this file stays self-contained. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const headerStyle: React.CSSProperties = { marginBottom: 16 };
|
||||
const headingStyle: React.CSSProperties = {
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-primary)',
|
||||
margin: 0,
|
||||
marginBottom: 6,
|
||||
};
|
||||
const descStyle: React.CSSProperties = {
|
||||
fontSize: 14,
|
||||
color: 'var(--text-secondary)',
|
||||
margin: 0,
|
||||
};
|
||||
const descSmallStyle: React.CSSProperties = {
|
||||
fontSize: 12,
|
||||
color: 'var(--text-secondary)',
|
||||
};
|
||||
const sectionHeadingStyle: React.CSSProperties = {
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
color: 'var(--text-secondary)',
|
||||
marginTop: 24,
|
||||
marginBottom: 6,
|
||||
};
|
||||
const cardStyle: React.CSSProperties = {
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
background: 'var(--background-secondary)',
|
||||
border: '1px solid var(--background-tertiary)',
|
||||
marginBottom: 16,
|
||||
};
|
||||
const rowStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '8px 12px',
|
||||
borderRadius: 6,
|
||||
background: 'var(--background-secondary)',
|
||||
border: '1px solid var(--background-tertiary)',
|
||||
};
|
||||
const avatarPlaceholder: React.CSSProperties = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--background-tertiary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--text-primary)',
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
};
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: '8px 10px',
|
||||
background: 'var(--background-tertiary)',
|
||||
border: '1px solid var(--background-modifier-accent)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 13,
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
};
|
||||
const primaryBtnStyle: React.CSSProperties = {
|
||||
background: 'var(--brand-primary)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
padding: '10px 18px',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
};
|
||||
const secondaryBtnStyle: React.CSSProperties = {
|
||||
...primaryBtnStyle,
|
||||
background: 'var(--background-tertiary)',
|
||||
color: 'var(--text-primary)',
|
||||
};
|
||||
const dangerBtnStyle: React.CSSProperties = {
|
||||
...primaryBtnStyle,
|
||||
background: 'var(--status-danger, #da373c)',
|
||||
};
|
||||
const warnStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: 10,
|
||||
alignItems: 'flex-start',
|
||||
padding: 14,
|
||||
borderRadius: 8,
|
||||
background: 'rgba(250, 166, 26, 0.1)',
|
||||
color: 'var(--status-warning, #faa61a)',
|
||||
border: '1px solid rgba(250, 166, 26, 0.3)',
|
||||
fontSize: 13,
|
||||
};
|
||||
const errorStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
padding: 10,
|
||||
marginBottom: 12,
|
||||
borderRadius: 6,
|
||||
background: 'rgba(248, 113, 113, 0.12)',
|
||||
color: '#f87171',
|
||||
fontSize: 13,
|
||||
};
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
CaretLeft,
|
||||
CaretRight,
|
||||
ClockCounterClockwise,
|
||||
FileArrowUp,
|
||||
Gear,
|
||||
Ghost,
|
||||
Prohibit,
|
||||
ShieldStar,
|
||||
Smiley,
|
||||
@@ -31,6 +33,8 @@ import {
|
||||
OverviewTab,
|
||||
type ServerSettingsTab,
|
||||
} from './ServerSettingsModal';
|
||||
import { GhostsTab } from './GhostsTab';
|
||||
import { ImportTab } from './ImportTab';
|
||||
import { useRolesView } from './RolesView';
|
||||
import rolesStyles from './RolesView.module.css';
|
||||
import styles from './MobileServerSettings.module.css';
|
||||
@@ -54,6 +58,8 @@ const TABS: Array<{
|
||||
{ id: 'emojis', label: 'Custom Emoji', icon: Smiley },
|
||||
{ id: 'bans', label: 'Bans', icon: Prohibit },
|
||||
{ id: 'audit', label: 'Audit Log', icon: ClockCounterClockwise },
|
||||
{ id: 'import', label: 'Import', icon: FileArrowUp },
|
||||
{ id: 'ghosts', label: 'Ghosts', icon: Ghost },
|
||||
];
|
||||
|
||||
function getInitials(name: string): string {
|
||||
@@ -235,6 +241,8 @@ export function MobileServerSettings({
|
||||
{activeTab === 'emojis' && <EmojisTab />}
|
||||
{activeTab === 'bans' && <BansTab />}
|
||||
{activeTab === 'audit' && <AuditLogTab />}
|
||||
{activeTab === 'import' && <ImportTab />}
|
||||
{activeTab === 'ghosts' && <GhostsTab />}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import {
|
||||
ClockCounterClockwise,
|
||||
FileArrowUp,
|
||||
Gear,
|
||||
Ghost,
|
||||
Plus,
|
||||
Prohibit,
|
||||
ShieldStar,
|
||||
@@ -26,11 +28,20 @@ import { api } from '../../../../../convex/_generated/api';
|
||||
import type { Id } from '../../../../../convex/_generated/dataModel';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { CustomEmojisTab } from './CustomEmojisTab';
|
||||
import { GhostsTab } from './GhostsTab';
|
||||
import { ImportTab } from './ImportTab';
|
||||
import { MobileServerSettings } from './MobileServerSettings';
|
||||
import { useRolesView } from './RolesView';
|
||||
import userStyles from './UserSettingsModal.module.css';
|
||||
|
||||
export type ServerSettingsTab = 'overview' | 'roles' | 'emojis' | 'bans' | 'audit';
|
||||
export type ServerSettingsTab =
|
||||
| 'overview'
|
||||
| 'roles'
|
||||
| 'emojis'
|
||||
| 'bans'
|
||||
| 'audit'
|
||||
| 'import'
|
||||
| 'ghosts';
|
||||
|
||||
interface ServerSettingsModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -44,6 +55,8 @@ const TABS: Array<{ id: ServerSettingsTab; label: string; icon: typeof Gear }> =
|
||||
{ id: 'emojis', label: 'Custom Emoji', icon: Smiley },
|
||||
{ id: 'bans', label: 'Bans', icon: Prohibit },
|
||||
{ id: 'audit', label: 'Audit Log', icon: ClockCounterClockwise },
|
||||
{ id: 'import', label: 'Import', icon: FileArrowUp },
|
||||
{ id: 'ghosts', label: 'Ghosts', icon: Ghost },
|
||||
];
|
||||
|
||||
export function ServerSettingsModal({ isOpen, onClose, initialTab }: ServerSettingsModalProps) {
|
||||
@@ -164,6 +177,8 @@ export function ServerSettingsModal({ isOpen, onClose, initialTab }: ServerSetti
|
||||
{activeTab === 'emojis' && <CustomEmojisTab />}
|
||||
{activeTab === 'bans' && <BansTab />}
|
||||
{activeTab === 'audit' && <AuditLogTab />}
|
||||
{activeTab === 'import' && <ImportTab />}
|
||||
{activeTab === 'ghosts' && <GhostsTab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -338,10 +353,332 @@ export function OverviewTab() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DangerZone />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- */
|
||||
/* Danger Zone — Owner only */
|
||||
/* ------------------------------------------------------------------- */
|
||||
|
||||
function DangerZone() {
|
||||
const myUserId =
|
||||
typeof localStorage !== 'undefined'
|
||||
? (localStorage.getItem('userId') as Id<'userProfiles'> | null)
|
||||
: null;
|
||||
const isOwner = useQuery(
|
||||
api.roles.isOwner,
|
||||
myUserId ? { userId: myUserId } : 'skip',
|
||||
);
|
||||
const purgeAll = useMutation(api.messages.purgeAllMessages);
|
||||
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [progress, setProgress] = useState<{
|
||||
deletedMessages: number;
|
||||
deletedPolls: number;
|
||||
remaining: number | null;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!isOwner) return null;
|
||||
|
||||
const handlePurge = async () => {
|
||||
if (!myUserId || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setProgress({ deletedMessages: 0, deletedPolls: 0, remaining: null });
|
||||
try {
|
||||
// Loop the mutation until `remaining` is zero — the backend
|
||||
// caps per-call work so a single wipe on a busy server
|
||||
// doesn't blow the Convex write budget. For a small server
|
||||
// this usually completes in one call.
|
||||
let totalMessages = 0;
|
||||
let totalPolls = 0;
|
||||
let remaining = 1;
|
||||
let guard = 0;
|
||||
while (remaining > 0 && guard++ < 50) {
|
||||
const result = await purgeAll({ actorId: myUserId });
|
||||
totalMessages += result.deletedMessages;
|
||||
totalPolls += result.deletedPolls;
|
||||
remaining = result.remaining;
|
||||
setProgress({
|
||||
deletedMessages: totalMessages,
|
||||
deletedPolls: totalPolls,
|
||||
remaining,
|
||||
});
|
||||
if (
|
||||
result.deletedMessages === 0 &&
|
||||
result.deletedPolls === 0
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
setConfirmOpen(false);
|
||||
setConfirmText('');
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? 'Failed to clear messages.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 40,
|
||||
padding: 16,
|
||||
border: '1px solid var(--status-danger, #da373c)',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
color: 'var(--status-danger, #da373c)',
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
margin: '0 0 4px',
|
||||
}}
|
||||
>
|
||||
Danger Zone
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 13,
|
||||
margin: '0 0 16px',
|
||||
}}
|
||||
>
|
||||
These actions are permanent and affect every member of the server.
|
||||
Only the Owner can see and run them.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{ color: 'var(--text-primary)', fontWeight: 600, fontSize: 14 }}
|
||||
>
|
||||
Clear all messages
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
Deletes every message and reaction across every channel from
|
||||
every member. Channels, roles, and settings are left alone.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConfirmOpen(true);
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
setConfirmText('');
|
||||
}}
|
||||
style={dangerBtnStyle}
|
||||
>
|
||||
Clear messages…
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{confirmOpen && (
|
||||
<ConfirmPurgeModal
|
||||
busy={busy}
|
||||
confirmText={confirmText}
|
||||
onConfirmTextChange={setConfirmText}
|
||||
onCancel={() => {
|
||||
if (busy) return;
|
||||
setConfirmOpen(false);
|
||||
setConfirmText('');
|
||||
setError(null);
|
||||
}}
|
||||
onConfirm={handlePurge}
|
||||
error={error}
|
||||
progress={progress}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmPurgeModal({
|
||||
busy,
|
||||
confirmText,
|
||||
onConfirmTextChange,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
error,
|
||||
progress,
|
||||
}: {
|
||||
busy: boolean;
|
||||
confirmText: string;
|
||||
onConfirmTextChange: (v: string) => void;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
error: string | null;
|
||||
progress: {
|
||||
deletedMessages: number;
|
||||
deletedPolls: number;
|
||||
remaining: number | null;
|
||||
} | null;
|
||||
}) {
|
||||
const canConfirm = confirmText.trim().toUpperCase() === 'DELETE' && !busy;
|
||||
return createPortal(
|
||||
<div
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 30000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'var(--background-primary)',
|
||||
padding: 24,
|
||||
borderRadius: 10,
|
||||
width: 'calc(100% - 32px)',
|
||||
maxWidth: 440,
|
||||
border: '1px solid var(--status-danger, #da373c)',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
marginBottom: 8,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
Clear all messages?
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
marginBottom: 16,
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
This will permanently delete every message and reaction across the
|
||||
entire server. Channels and roles remain. This cannot be undone.
|
||||
</p>
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.4,
|
||||
color: 'var(--text-tertiary)',
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
Type <strong>DELETE</strong> to confirm
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmText}
|
||||
onChange={(e) => onConfirmTextChange(e.target.value)}
|
||||
autoFocus
|
||||
disabled={busy}
|
||||
style={inputStyle}
|
||||
placeholder="DELETE"
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
{progress && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
Deleted {progress.deletedMessages} messages
|
||||
{progress.deletedPolls > 0
|
||||
? `, ${progress.deletedPolls} polls`
|
||||
: ''}
|
||||
{progress.remaining !== null && progress.remaining > 0
|
||||
? ` · more remaining…`
|
||||
: ''}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
background: 'rgba(248, 113, 113, 0.12)',
|
||||
color: '#f87171',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 8,
|
||||
marginTop: 20,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={busy}
|
||||
style={{
|
||||
...primaryBtnStyle,
|
||||
background: 'var(--background-tertiary)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={!canConfirm}
|
||||
style={{
|
||||
...dangerBtnStyle,
|
||||
opacity: canConfirm ? 1 : 0.5,
|
||||
cursor: canConfirm ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
>
|
||||
{busy ? 'Clearing…' : 'Clear all messages'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- */
|
||||
/* Roles */
|
||||
/* ------------------------------------------------------------------- */
|
||||
@@ -1165,6 +1502,7 @@ const AUDIT_LABELS: Record<string, string> = {
|
||||
'server.settings_update': 'updated server settings',
|
||||
'ban.add': 'banned',
|
||||
'ban.remove': 'unbanned',
|
||||
'messages.purge_all': 'cleared all messages',
|
||||
};
|
||||
|
||||
export function AuditLogTab() {
|
||||
|
||||
@@ -1052,10 +1052,144 @@ export function AppearanceTab() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<LaunchSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch + tray settings — Electron-only. Hidden on web / Android
|
||||
* via `platform.features.hasLifecycle`. Three toggles:
|
||||
* - Launch at Startup — registers the app with the OS login items.
|
||||
* - Start Minimized — only meaningful when Launch at Startup is on;
|
||||
* asks the OS to open the app hidden so the user can bring it up
|
||||
* from the tray when they need it.
|
||||
* - Minimize to Tray on Close — flips the close button from "quit"
|
||||
* to "hide", with the tray's Quit menu as the explicit exit path.
|
||||
*/
|
||||
function LaunchSection() {
|
||||
const platform = usePlatform() as any;
|
||||
const lifecycle = platform?.lifecycle ?? null;
|
||||
const [state, setState] = useState<{
|
||||
launchAtStartup: boolean;
|
||||
startMinimized: boolean;
|
||||
minimizeToTrayOnClose: boolean;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!platform?.features?.hasLifecycle || !lifecycle?.get) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const current = await lifecycle.get();
|
||||
if (!cancelled && current) setState(current);
|
||||
} catch {}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [platform, lifecycle]);
|
||||
|
||||
if (!platform?.features?.hasLifecycle || !lifecycle?.set) return null;
|
||||
if (!state) return null;
|
||||
|
||||
const update = async (patch: Partial<typeof state>) => {
|
||||
try {
|
||||
const next = await lifecycle.set(patch);
|
||||
if (next) setState(next);
|
||||
} catch (err) {
|
||||
console.warn('lifecycle.set failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 32 }}>
|
||||
<h3 className={styles.profileSubheading} style={{ fontSize: 16 }}>
|
||||
Launch & Tray
|
||||
</h3>
|
||||
<p className={styles.profileDescription}>
|
||||
Desktop-only options that control how the app starts and what
|
||||
happens when you press the close button.
|
||||
</p>
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 0',
|
||||
borderBottom: '1px solid var(--background-modifier-accent)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ color: 'var(--text-primary)', fontWeight: 600, fontSize: 14 }}>
|
||||
Launch at Startup
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: 12, marginTop: 2 }}>
|
||||
Open Brycord automatically when you sign in to your computer.
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.launchAtStartup}
|
||||
onChange={(e) => update({ launchAtStartup: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 0',
|
||||
borderBottom: '1px solid var(--background-modifier-accent)',
|
||||
opacity: state.launchAtStartup ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ color: 'var(--text-primary)', fontWeight: 600, fontSize: 14 }}>
|
||||
Start Minimized
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: 12, marginTop: 2 }}>
|
||||
When launching at startup, hide the window until you open it
|
||||
from the tray.
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.startMinimized}
|
||||
onChange={(e) => update({ startMinimized: e.target.checked })}
|
||||
disabled={!state.launchAtStartup}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 0',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ color: 'var(--text-primary)', fontWeight: 600, fontSize: 14 }}>
|
||||
Minimize to Tray on Close
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: 12, marginTop: 2 }}>
|
||||
Hide the window instead of quitting when you close it. Use the
|
||||
tray icon's Quit menu to exit fully.
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.minimizeToTrayOnClose}
|
||||
onChange={(e) => update({ minimizeToTrayOnClose: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VoiceSettings {
|
||||
inputDeviceId: string;
|
||||
outputDeviceId: string;
|
||||
|
||||
@@ -61,6 +61,14 @@
|
||||
* @property {() => Promise<'granted'|'denied'|'default'|'unavailable'>} ensurePermission - Request permission if needed; resolves the current state
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PlatformLifecycle
|
||||
* @property {() => Promise<{launchAtStartup: boolean, startMinimized: boolean, minimizeToTrayOnClose: boolean}>} get
|
||||
* @property {(patch: {launchAtStartup?: boolean, startMinimized?: boolean, minimizeToTrayOnClose?: boolean}) => Promise<{launchAtStartup: boolean, startMinimized: boolean, minimizeToTrayOnClose: boolean}>} set
|
||||
* @property {() => void} show - Force-show the window (e.g. in response to a notification click)
|
||||
* @property {(cb: (action: 'toggle-mute'|'toggle-deafen'|string) => void) => (() => void)} onTrayAction - Subscribe to tray menu actions
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PlatformRecording
|
||||
* @property {() => Promise<string>} getDefaultFolder - Default recording root (e.g. %APPDATA%/Brycord/recordings)
|
||||
@@ -109,6 +117,13 @@
|
||||
* @property {(opts: {statusBarColor: string, navigationBarColor: string, isDarkContent: boolean}) => Promise<void>} setColors
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PlatformImporter
|
||||
* @property {() => Promise<{ok: boolean, path?: string|null, error?: string}>} pickDatabase - Opens a native file picker for the backup SQLite file. Returns the absolute path.
|
||||
* @property {(dbPath: string) => Promise<{ok: boolean, bytes?: ArrayBuffer, dataDir?: string, error?: string}>} readDatabase - Reads the SQLite file as raw bytes plus the parent dir that holds the `attachments/` tree. sql.js in the renderer parses `bytes`.
|
||||
* @property {(payload: {dataDir: string, localPath: string}) => Promise<{ok: boolean, bytes?: ArrayBuffer, error?: string}>} readAttachment - Read one attachment file. `localPath` is scoped inside `dataDir` — callers above are a no-op.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PlatformFeatures
|
||||
* @property {boolean} hasWindowControls
|
||||
@@ -119,6 +134,8 @@
|
||||
* @property {boolean} hasSystemBars
|
||||
* @property {boolean} [hasBackButton]
|
||||
* @property {boolean} [hasNotifications]
|
||||
* @property {boolean} [hasLifecycle]
|
||||
* @property {boolean} [hasBackupImporter]
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -131,11 +148,13 @@
|
||||
* @property {PlatformScreenCapture|null} screenCapture
|
||||
* @property {PlatformWindowControls|null} windowControls
|
||||
* @property {PlatformNotifications|null} notifications
|
||||
* @property {PlatformLifecycle|null} lifecycle
|
||||
* @property {PlatformRecording|null} recording
|
||||
* @property {PlatformUpdates|null} updates
|
||||
* @property {PlatformSearchDB|null} searchDB
|
||||
* @property {PlatformVoiceService|null} voiceService
|
||||
* @property {PlatformSystemBars|null} systemBars
|
||||
* @property {PlatformImporter|null} importer
|
||||
* @property {PlatformFeatures} features
|
||||
*/
|
||||
|
||||
|
||||
911
packages/shared/src/utils/importRunner.ts
Normal file
911
packages/shared/src/utils/importRunner.ts
Normal file
@@ -0,0 +1,911 @@
|
||||
/**
|
||||
* Discord backup importer — client pipeline.
|
||||
*
|
||||
* Reads a SQLite backup written by the Discord Backup Bot, encrypts
|
||||
* each message under the target channel's current key, re-uploads
|
||||
* every attachment through the existing Convex storage flow, and
|
||||
* submits signed batches via `importerActions.importBatchAction`.
|
||||
*
|
||||
* The full flow lives in the renderer so the admin's Ed25519 signing
|
||||
* key stays local. The Electron main process only exposes filesystem
|
||||
* helpers (`platform.importer.*`) — DB parsing uses `sql.js`, same
|
||||
* WASM bundle the search cache already depends on.
|
||||
*/
|
||||
// @ts-ignore — sql.js ships no type declarations
|
||||
import initSqlJsModule from 'sql.js';
|
||||
// @ts-ignore — ?url is a Vite suffix
|
||||
import wasmUrl from 'sql.js/dist/sql-wasm.wasm?url';
|
||||
import type { AttachmentMetadata } from '../components/channel/EncryptedAttachment';
|
||||
|
||||
// sql.js types aren't bundled; use `any` for the static factory +
|
||||
// Database handles. All call sites are local to this file so the
|
||||
// loose typing doesn't leak.
|
||||
type SqlJsStatic = any;
|
||||
type Database = any;
|
||||
|
||||
const initSqlJs = (initSqlJsModule as any).default ?? initSqlJsModule;
|
||||
|
||||
let sqlPromise: Promise<SqlJsStatic> | null = null;
|
||||
function getSql(): Promise<SqlJsStatic> {
|
||||
if (!sqlPromise) {
|
||||
sqlPromise = initSqlJs({ locateFile: () => wasmUrl });
|
||||
}
|
||||
return sqlPromise as Promise<SqlJsStatic>;
|
||||
}
|
||||
|
||||
export interface BackupChannel {
|
||||
discordId: string;
|
||||
name: string;
|
||||
isThread: boolean;
|
||||
parentChannelId: string | null;
|
||||
messageCount: number;
|
||||
authorIds: string[];
|
||||
attachmentCount: number;
|
||||
}
|
||||
|
||||
export interface BackupAuthor {
|
||||
discordId: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
export interface BackupSummary {
|
||||
channels: BackupChannel[];
|
||||
authors: BackupAuthor[];
|
||||
}
|
||||
|
||||
export interface ParsedBackup {
|
||||
db: Database;
|
||||
dataDir: string;
|
||||
summary: BackupSummary;
|
||||
}
|
||||
|
||||
export async function openBackup(
|
||||
platform: any,
|
||||
dbPath: string,
|
||||
): Promise<ParsedBackup> {
|
||||
const read = await platform.importer.readDatabase(dbPath);
|
||||
if (!read.ok || !read.bytes || !read.dataDir) {
|
||||
throw new Error(read.error ?? 'Failed to read backup database');
|
||||
}
|
||||
const SQL = await getSql();
|
||||
const db = new SQL.Database(new Uint8Array(read.bytes as ArrayBuffer));
|
||||
const summary = summarizeBackup(db);
|
||||
return { db, dataDir: read.dataDir, summary };
|
||||
}
|
||||
|
||||
function rowsToObjects<T = any>(db: Database, sql: string, params: any[] = []): T[] {
|
||||
const stmt = db.prepare(sql);
|
||||
try {
|
||||
stmt.bind(params);
|
||||
const rows: T[] = [];
|
||||
while (stmt.step()) rows.push(stmt.getAsObject() as any as T);
|
||||
return rows;
|
||||
} finally {
|
||||
stmt.free();
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeBackup(db: Database): BackupSummary {
|
||||
const channels = rowsToObjects<any>(
|
||||
db,
|
||||
`SELECT c.id, c.name, c.is_thread, c.parent_channel_id,
|
||||
(SELECT COUNT(*) FROM messages m WHERE m.channel_id = c.id) AS msg_count,
|
||||
(SELECT COUNT(*) FROM attachments a
|
||||
JOIN messages m ON a.message_id = m.id
|
||||
WHERE m.channel_id = c.id) AS att_count
|
||||
FROM channels c
|
||||
ORDER BY c.is_thread ASC, c.name ASC`,
|
||||
);
|
||||
|
||||
const authorsByChannel = new Map<string, Set<string>>();
|
||||
for (const row of rowsToObjects<any>(
|
||||
db,
|
||||
`SELECT DISTINCT channel_id, author_id FROM messages WHERE author_id IS NOT NULL`,
|
||||
)) {
|
||||
if (!authorsByChannel.has(row.channel_id)) {
|
||||
authorsByChannel.set(row.channel_id, new Set());
|
||||
}
|
||||
authorsByChannel.get(row.channel_id)!.add(row.author_id);
|
||||
}
|
||||
|
||||
const authors = rowsToObjects<any>(
|
||||
db,
|
||||
`SELECT a.id, a.username, a.display_name, a.avatar_url,
|
||||
(SELECT COUNT(*) FROM messages m WHERE m.author_id = a.id) AS msg_count
|
||||
FROM authors a
|
||||
ORDER BY msg_count DESC`,
|
||||
);
|
||||
|
||||
return {
|
||||
channels: channels.map((c) => ({
|
||||
discordId: String(c.id),
|
||||
name: String(c.name ?? 'unknown'),
|
||||
isThread: Number(c.is_thread) === 1,
|
||||
parentChannelId: c.parent_channel_id ? String(c.parent_channel_id) : null,
|
||||
messageCount: Number(c.msg_count ?? 0),
|
||||
attachmentCount: Number(c.att_count ?? 0),
|
||||
authorIds: Array.from(authorsByChannel.get(String(c.id)) ?? []),
|
||||
})),
|
||||
authors: authors.map((a) => ({
|
||||
discordId: String(a.id),
|
||||
username: String(a.username ?? ''),
|
||||
displayName: a.display_name ? String(a.display_name) : null,
|
||||
avatarUrl: a.avatar_url ? String(a.avatar_url) : null,
|
||||
messageCount: Number(a.msg_count ?? 0),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
interface BackupMessageRow {
|
||||
id: string;
|
||||
channel_id: string;
|
||||
author_id: string | null;
|
||||
content: string | null;
|
||||
created_at: number;
|
||||
replied_to_id: string | null;
|
||||
}
|
||||
|
||||
interface BackupAttachmentRow {
|
||||
id: string;
|
||||
message_id: string;
|
||||
filename: string;
|
||||
local_path: string | null;
|
||||
size: number | null;
|
||||
content_type: string | null;
|
||||
downloaded: number;
|
||||
}
|
||||
|
||||
export interface ChannelMapping {
|
||||
/** Discord source channel (from backup.db) */
|
||||
discordId: string;
|
||||
/** Target Convex channel._id, or null to skip */
|
||||
convexChannelId: string | null;
|
||||
}
|
||||
|
||||
export interface AuthorMapping {
|
||||
/** Discord source author */
|
||||
discordId: string;
|
||||
/** Existing Convex userProfiles._id, or null to auto-create a ghost */
|
||||
convexUserId: string | null;
|
||||
}
|
||||
|
||||
export interface ImportCrypto {
|
||||
encryptData: (
|
||||
data: string | Uint8Array,
|
||||
key: string | Uint8Array,
|
||||
) => Promise<{ content: string; iv: string; tag: string }>;
|
||||
decryptData: (
|
||||
encryptedData: string,
|
||||
key: string | Uint8Array,
|
||||
iv: string,
|
||||
tag: string,
|
||||
options?: any,
|
||||
) => Promise<string>;
|
||||
signMessage: (privateKey: string, message: string) => Promise<string>;
|
||||
randomBytes: (size: number) => Promise<string>;
|
||||
}
|
||||
|
||||
export interface ImportChannelKey {
|
||||
keyHex: string;
|
||||
keyVersion: number;
|
||||
/** Every key version the importer has access to for this channel.
|
||||
* Used in repair mode to decrypt rows that were encrypted under
|
||||
* an older key version than the current one. */
|
||||
allVersions: Map<number, string>;
|
||||
}
|
||||
|
||||
export interface ImportDeps {
|
||||
crypto: ImportCrypto;
|
||||
platform: any;
|
||||
actorId: string;
|
||||
signingKey: string;
|
||||
/** Per-target-channel: the channel key hex + current keyVersion */
|
||||
channelKeys: Map<string, ImportChannelKey>;
|
||||
convex: {
|
||||
prepareGhosts: (args: any) => Promise<
|
||||
Array<{ discordId: string; userId: string; created: boolean; isGhost: boolean }>
|
||||
>;
|
||||
importBatch: (args: any) => Promise<{
|
||||
inserted: number;
|
||||
skipped: number;
|
||||
resolved: Array<{ discordMessageId: string; messageId: string }>;
|
||||
}>;
|
||||
generateUploadUrl: () => Promise<string>;
|
||||
validateUpload: (args: { storageId: string }) => Promise<string>;
|
||||
/**
|
||||
* Given a channel and a set of Discord message IDs, return
|
||||
* the ones that already exist server-side (with their Convex
|
||||
* IDs). The runner uses this for two things in one round-trip:
|
||||
* 1. Skip already-imported rows on re-run — no attachment
|
||||
* re-upload, no redundant encryption, no wasted batch
|
||||
* call. Saves the vast majority of the re-import cost.
|
||||
* 2. Resolve reply-parent IDs for Discord messages the
|
||||
* current batch points at but didn't include itself
|
||||
* (e.g. reply-to an older message from a prior batch).
|
||||
*/
|
||||
resolveReplyTargets: (args: {
|
||||
channelId: string;
|
||||
discordMessageIds: string[];
|
||||
}) => Promise<Array<{ discordMessageId: string; messageId: string }>>;
|
||||
/**
|
||||
* Repair-mode companion to `resolveReplyTargets` — returns the
|
||||
* full decryptable body (ciphertext + nonce + keyVersion) so the
|
||||
* runner can decrypt locally and detect rows whose attachment
|
||||
* array is missing entries. Only called in repair mode; normal
|
||||
* imports never fetch this.
|
||||
*/
|
||||
getImportedState?: (args: {
|
||||
channelId: string;
|
||||
discordMessageIds: string[];
|
||||
}) => Promise<
|
||||
Array<{
|
||||
discordMessageId: string;
|
||||
messageId: string;
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
keyVersion: number;
|
||||
}>
|
||||
>;
|
||||
/**
|
||||
* Surgical delete by Discord snowflake list. Used by repair
|
||||
* mode to drop broken rows immediately before re-inserting
|
||||
* them through the normal batch path. Expected to be paged at
|
||||
* <=100 ids per call by the caller.
|
||||
*/
|
||||
deleteImportedByDiscordIds?: (args: {
|
||||
channelId: string;
|
||||
discordMessageIds: string[];
|
||||
}) => Promise<{ deleted: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImportProgress {
|
||||
stage: 'preparing' | 'importing' | 'done';
|
||||
channelDiscordId: string | null;
|
||||
channelName: string | null;
|
||||
channelProgress: { inserted: number; total: number } | null;
|
||||
attachmentsUploaded: number;
|
||||
attachmentBytesUploaded: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ImportOptions {
|
||||
channels: ChannelMapping[];
|
||||
authors: AuthorMapping[];
|
||||
onProgress?: (p: ImportProgress) => void;
|
||||
/** Set to true externally to request graceful stop after the current batch. */
|
||||
cancelSignal?: { cancelled: boolean };
|
||||
/** Starts each channel from its saved cursor if true. */
|
||||
resume?: boolean;
|
||||
/**
|
||||
* Repair-only pass: walk the backup from the start, but for each
|
||||
* row that (a) already exists server-side and (b) had attachments
|
||||
* in the backup, decrypt the server copy and compare counts. If
|
||||
* the server is missing attachments, delete + re-insert. Rows
|
||||
* that don't exist yet OR already have all their attachments are
|
||||
* left alone. No cursor is used — repair is always idempotent.
|
||||
*/
|
||||
repair?: boolean;
|
||||
}
|
||||
|
||||
// Batch / concurrency knobs. MESSAGE_BATCH matches the server-side
|
||||
// `MAX_IMPORT_BATCH` cap, ATTACHMENT_CONCURRENCY and PREP_CONCURRENCY
|
||||
// are empirically-tuned ceilings that balance throughput against
|
||||
// single-TCP-connection saturation + Convex's per-client rate limit.
|
||||
const MESSAGE_BATCH = 100;
|
||||
const ATTACHMENT_CONCURRENCY = 8;
|
||||
const PREP_CONCURRENCY = 8;
|
||||
const RESUME_KEY_PREFIX = 'brycord:importer:cursor:';
|
||||
|
||||
/**
|
||||
* Bounded-parallel `map`: runs up to `limit` promises in flight at
|
||||
* once, preserving input order in the result. Used for attachment
|
||||
* uploads and per-row message prep so we don't serialize an entire
|
||||
* batch through a single TCP connection when the server can handle
|
||||
* real concurrency.
|
||||
*/
|
||||
async function mapConcurrent<T, U>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T, index: number) => Promise<U>,
|
||||
): Promise<U[]> {
|
||||
const results: U[] = new Array(items.length);
|
||||
let cursor = 0;
|
||||
const workers: Promise<void>[] = [];
|
||||
const n = Math.min(limit, items.length);
|
||||
for (let w = 0; w < n; w++) {
|
||||
workers.push(
|
||||
(async () => {
|
||||
for (;;) {
|
||||
const idx = cursor++;
|
||||
if (idx >= items.length) return;
|
||||
results[idx] = await fn(items[idx], idx);
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
function fromHexString(hex: string): Uint8Array {
|
||||
const matches = hex.match(/.{1,2}/g) ?? [];
|
||||
return new Uint8Array(matches.map((b) => parseInt(b, 16)));
|
||||
}
|
||||
|
||||
function loadCursor(channelDiscordId: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(RESUME_KEY_PREFIX + channelDiscordId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveCursor(channelDiscordId: string, discordMessageId: string): void {
|
||||
try {
|
||||
localStorage.setItem(RESUME_KEY_PREFIX + channelDiscordId, discordMessageId);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function clearCursor(channelDiscordId: string): void {
|
||||
try {
|
||||
localStorage.removeItem(RESUME_KEY_PREFIX + channelDiscordId);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload one attachment through the standard encrypt-then-upload
|
||||
* pipeline and return the `AttachmentMetadata` JSON that would go
|
||||
* into a normal message's plaintext. Same shape the live send path
|
||||
* produces, so `EncryptedAttachment` renders imported files exactly
|
||||
* like live ones.
|
||||
*
|
||||
* Returns `null` ONLY for the known-unrecoverable case: the backup
|
||||
* bot never captured the file (no local_path / downloaded=0). Every
|
||||
* other failure (disk read, network, Convex upload, validate)
|
||||
* throws. The caller wraps this with retry + abort-the-batch
|
||||
* semantics, so a transient failure never leaves a blank row —
|
||||
* resume will re-enter this row from scratch.
|
||||
*/
|
||||
async function uploadOneAttachmentOnce(
|
||||
deps: ImportDeps,
|
||||
dataDir: string,
|
||||
att: BackupAttachmentRow,
|
||||
): Promise<AttachmentMetadata | null> {
|
||||
if (!att.local_path || !att.downloaded) return null;
|
||||
const read = await deps.platform.importer.readAttachment({
|
||||
dataDir,
|
||||
localPath: att.local_path,
|
||||
});
|
||||
if (!read.ok || !read.bytes) {
|
||||
throw new Error(
|
||||
`Can't read ${att.filename} at ${att.local_path}: ${read.error ?? 'no data'}`,
|
||||
);
|
||||
}
|
||||
|
||||
const fileKey = await deps.crypto.randomBytes(32);
|
||||
const buf = new Uint8Array(read.bytes as ArrayBuffer);
|
||||
const encrypted = await deps.crypto.encryptData(buf, fileKey);
|
||||
const encryptedHex = encrypted.content + encrypted.tag;
|
||||
const encryptedBytes = fromHexString(encryptedHex);
|
||||
|
||||
const blob = new Blob([encryptedBytes as BlobPart], { type: 'application/octet-stream' });
|
||||
const uploadUrl = await deps.convex.generateUploadUrl();
|
||||
const res = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': blob.type },
|
||||
body: blob,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Upload failed for ${att.filename}: ${res.status}`);
|
||||
const { storageId } = (await res.json()) as { storageId: string };
|
||||
const fileUrl = await deps.convex.validateUpload({ storageId });
|
||||
if (!fileUrl) throw new Error(`Failed to resolve file URL for ${att.filename}`);
|
||||
|
||||
return {
|
||||
type: 'attachment',
|
||||
url: fileUrl,
|
||||
filename: att.filename,
|
||||
mimeType: att.content_type || 'application/octet-stream',
|
||||
size: Number(att.size ?? buf.byteLength),
|
||||
key: fileKey,
|
||||
iv: encrypted.iv,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrying wrapper around `uploadOneAttachmentOnce`. Three attempts
|
||||
* with linear backoff; still returns `null` for the known-missing
|
||||
* case without consuming retries. Only throws if every attempt
|
||||
* fails — aborting the enclosing batch so resume redoes this row
|
||||
* instead of committing a message with missing files.
|
||||
*/
|
||||
async function uploadOneAttachment(
|
||||
deps: ImportDeps,
|
||||
dataDir: string,
|
||||
att: BackupAttachmentRow,
|
||||
maxAttempts = 3,
|
||||
): Promise<AttachmentMetadata | null> {
|
||||
let lastErr: any;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await uploadOneAttachmentOnce(deps, dataDir, att);
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (attempt < maxAttempts) {
|
||||
await new Promise((r) => setTimeout(r, 500 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to upload ${att.filename} after ${maxAttempts} attempts: ${lastErr?.message ?? lastErr}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ghost-prep pass. For every Discord author that hasn't been
|
||||
* manually mapped to a real user, call `prepareGhosts` once to
|
||||
* upsert a placeholder profile. Returns a full `discordId →
|
||||
* convexUserId` lookup table the batch phase uses.
|
||||
*/
|
||||
async function resolveAuthorMap(
|
||||
deps: ImportDeps,
|
||||
authors: AuthorMapping[],
|
||||
backupAuthors: BackupAuthor[],
|
||||
): Promise<Map<string, string>> {
|
||||
const map = new Map<string, string>();
|
||||
const needGhost: AuthorMapping[] = [];
|
||||
for (const a of authors) {
|
||||
if (a.convexUserId) {
|
||||
map.set(a.discordId, a.convexUserId);
|
||||
} else {
|
||||
needGhost.push(a);
|
||||
}
|
||||
}
|
||||
if (needGhost.length === 0) return map;
|
||||
|
||||
const authTimestamp = Date.now();
|
||||
const canonical = `prepareGhosts:${deps.actorId}:${needGhost.length}:${authTimestamp}`;
|
||||
const authSignature = await deps.crypto.signMessage(deps.signingKey, canonical);
|
||||
|
||||
const byDiscordId = new Map(backupAuthors.map((a) => [a.discordId, a]));
|
||||
const payload = needGhost.map((g) => {
|
||||
const src = byDiscordId.get(g.discordId);
|
||||
return {
|
||||
discordId: g.discordId,
|
||||
username: src?.username ?? g.discordId,
|
||||
displayName: src?.displayName ?? undefined,
|
||||
avatarUrl: src?.avatarUrl ?? undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const result = await deps.convex.prepareGhosts({
|
||||
actorId: deps.actorId,
|
||||
authors: payload,
|
||||
authTimestamp,
|
||||
authSignature,
|
||||
});
|
||||
for (const r of result) {
|
||||
map.set(r.discordId, r.userId);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function runImport(
|
||||
parsed: ParsedBackup,
|
||||
deps: ImportDeps,
|
||||
options: ImportOptions,
|
||||
): Promise<void> {
|
||||
const { db, dataDir, summary } = parsed;
|
||||
const onProgress = options.onProgress ?? (() => {});
|
||||
const cancelSignal = options.cancelSignal ?? { cancelled: false };
|
||||
|
||||
onProgress({
|
||||
stage: 'preparing',
|
||||
channelDiscordId: null,
|
||||
channelName: null,
|
||||
channelProgress: null,
|
||||
attachmentsUploaded: 0,
|
||||
attachmentBytesUploaded: 0,
|
||||
message: 'Preparing author mappings…',
|
||||
});
|
||||
const authorMap = await resolveAuthorMap(deps, options.authors, summary.authors);
|
||||
|
||||
let attachmentsUploaded = 0;
|
||||
let attachmentBytesUploaded = 0;
|
||||
|
||||
for (const mapping of options.channels) {
|
||||
if (cancelSignal.cancelled) break;
|
||||
if (!mapping.convexChannelId) continue;
|
||||
|
||||
const channel = summary.channels.find((c) => c.discordId === mapping.discordId);
|
||||
if (!channel) continue;
|
||||
|
||||
const channelKey = deps.channelKeys.get(mapping.convexChannelId);
|
||||
if (!channelKey) {
|
||||
throw new Error(
|
||||
`No channel key for ${channel.name} — make sure you're a member of the target channel.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Resume cursor: pick up from the last Discord message ID we
|
||||
// committed, if the user didn't explicitly request a fresh run.
|
||||
// Repair mode ignores the cursor entirely — it needs to walk
|
||||
// every row to discover which ones are broken.
|
||||
const resumeCursor =
|
||||
options.resume && !options.repair ? loadCursor(mapping.discordId) : null;
|
||||
const resumeCreatedAt = resumeCursor
|
||||
? (rowsToObjects<any>(db, `SELECT created_at FROM messages WHERE id = ?`, [
|
||||
resumeCursor,
|
||||
])[0]?.created_at ?? 0)
|
||||
: 0;
|
||||
|
||||
// Map Discord message IDs already inserted in this session so
|
||||
// cross-batch replyTo can resolve without a round-trip when
|
||||
// the target also exists in this backup. Pre-populated by the
|
||||
// `resolved` payload of every `importBatch` response.
|
||||
const discordIdToConvexId = new Map<string, string>();
|
||||
|
||||
// Ordered oldest-first so imports slot into the channel history
|
||||
// by `importedCreatedAt` naturally, and resume cursors work via
|
||||
// a simple monotonic ">" check.
|
||||
const allRows = rowsToObjects<BackupMessageRow>(
|
||||
db,
|
||||
`SELECT id, channel_id, author_id, content, created_at, replied_to_id
|
||||
FROM messages
|
||||
WHERE channel_id = ? AND created_at > ?
|
||||
ORDER BY created_at ASC`,
|
||||
[mapping.discordId, resumeCreatedAt],
|
||||
);
|
||||
|
||||
onProgress({
|
||||
stage: 'importing',
|
||||
channelDiscordId: channel.discordId,
|
||||
channelName: channel.name,
|
||||
channelProgress: { inserted: 0, total: allRows.length },
|
||||
attachmentsUploaded,
|
||||
attachmentBytesUploaded,
|
||||
});
|
||||
|
||||
// Walk rows in chunks of MESSAGE_BATCH. Per-chunk we:
|
||||
// 1. Ask the server which discordIds already exist (dedup +
|
||||
// reply-parent remap in one query).
|
||||
// 2. Upload attachments in parallel for the rows that DON'T
|
||||
// already exist.
|
||||
// 3. Encrypt + sign ciphertexts in parallel.
|
||||
// 4. Sign the batch canonical once, submit.
|
||||
// 5. Update the cursor at the end.
|
||||
let insertedInChannel = 0;
|
||||
let skippedInChannel = 0;
|
||||
for (let i = 0; i < allRows.length; i += MESSAGE_BATCH) {
|
||||
if (cancelSignal.cancelled) break;
|
||||
const slice = allRows.slice(i, i + MESSAGE_BATCH);
|
||||
|
||||
// ---- Step 0: bulk-fetch attachment rows for the slice -----
|
||||
// One SQLite query instead of one-per-message inside the
|
||||
// prep loop. Needed up-front in repair mode too (to know
|
||||
// which rows had attachments in the backup).
|
||||
const sliceIds = slice.map((r) => r.id);
|
||||
const attachmentsByMessageId = new Map<string, BackupAttachmentRow[]>();
|
||||
if (sliceIds.length > 0) {
|
||||
const placeholders = sliceIds.map(() => '?').join(',');
|
||||
const rows = rowsToObjects<BackupAttachmentRow>(
|
||||
db,
|
||||
`SELECT id, message_id, filename, local_path, size, content_type, downloaded
|
||||
FROM attachments WHERE message_id IN (${placeholders})`,
|
||||
sliceIds,
|
||||
);
|
||||
for (const att of rows) {
|
||||
const key = String(att.message_id);
|
||||
let list = attachmentsByMessageId.get(key);
|
||||
if (!list) {
|
||||
list = [];
|
||||
attachmentsByMessageId.set(key, list);
|
||||
}
|
||||
list.push(att);
|
||||
}
|
||||
}
|
||||
const countCaptured = (id: string): number => {
|
||||
const list = attachmentsByMessageId.get(id);
|
||||
if (!list) return 0;
|
||||
return list.filter((a) => a.downloaded && a.local_path).length;
|
||||
};
|
||||
|
||||
// ---- Step 1: dedup / repair / reply-parent prefetch -------
|
||||
// Build the union of every discordId referenced by this
|
||||
// batch — both the rows themselves and their reply targets.
|
||||
const idsToCheck = new Set<string>();
|
||||
for (const row of slice) {
|
||||
idsToCheck.add(row.id);
|
||||
if (row.replied_to_id && !discordIdToConvexId.has(row.replied_to_id)) {
|
||||
idsToCheck.add(row.replied_to_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Tracks rows that were imported before but whose attachment
|
||||
// count no longer matches the backup — scheduled for delete +
|
||||
// re-insert so the fresh upload brings them back in sync.
|
||||
const repairDeleteIds: string[] = [];
|
||||
|
||||
if (options.repair) {
|
||||
// Fetch full decryptable state for every candidate id. The
|
||||
// runner decrypts each locally and compares attachment
|
||||
// counts against the backup. Matches are left alone; only
|
||||
// mismatches get deleted + re-inserted.
|
||||
if (!deps.convex.getImportedState || !deps.convex.deleteImportedByDiscordIds) {
|
||||
throw new Error('Repair mode requires getImportedState + deleteImportedByDiscordIds deps');
|
||||
}
|
||||
const state = await deps.convex.getImportedState({
|
||||
channelId: mapping.convexChannelId,
|
||||
discordMessageIds: Array.from(idsToCheck),
|
||||
});
|
||||
const stateByDiscordId = new Map(
|
||||
state.map((s) => [s.discordMessageId, s]),
|
||||
);
|
||||
|
||||
// Record the ones that look complete so they can still
|
||||
// serve as reply-parent resolutions for later rows.
|
||||
for (const s of state) {
|
||||
discordIdToConvexId.set(s.discordMessageId, s.messageId);
|
||||
}
|
||||
|
||||
for (const row of slice) {
|
||||
const captured = countCaptured(row.id);
|
||||
if (captured === 0) continue; // no attachments to verify
|
||||
const existing = stateByDiscordId.get(row.id);
|
||||
if (!existing) continue; // missing entirely — normal flow will insert
|
||||
|
||||
const keyForVersion = channelKey.allVersions.get(existing.keyVersion);
|
||||
if (!keyForVersion) {
|
||||
// We don't hold the key version this row was encrypted
|
||||
// under (key rotation after import). Leave it alone.
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Ciphertext on disk is the hex-concatenation of
|
||||
// AES-GCM `content + tag`; split back into the form
|
||||
// decryptData expects (last 16 bytes / 32 hex chars =
|
||||
// the auth tag).
|
||||
const raw = existing.ciphertext;
|
||||
const content = raw.slice(0, -32);
|
||||
const tag = raw.slice(-32);
|
||||
const plaintext = await deps.crypto.decryptData(
|
||||
content,
|
||||
keyForVersion,
|
||||
existing.nonce,
|
||||
tag,
|
||||
);
|
||||
let actualAttachmentCount = 0;
|
||||
try {
|
||||
const parsedBody = JSON.parse(plaintext);
|
||||
if (Array.isArray(parsedBody?.attachments)) {
|
||||
actualAttachmentCount = parsedBody.attachments.length;
|
||||
} else if (
|
||||
parsedBody?.type === 'attachment' &&
|
||||
parsedBody.url
|
||||
) {
|
||||
actualAttachmentCount = 1; // legacy single-attachment shape
|
||||
}
|
||||
} catch {
|
||||
// Plaintext wasn't JSON — pure text message, zero
|
||||
// attachments. Stays zero.
|
||||
}
|
||||
if (actualAttachmentCount < captured) {
|
||||
repairDeleteIds.push(row.id);
|
||||
}
|
||||
} catch (err) {
|
||||
// Decrypt failure on one row shouldn't abort the whole
|
||||
// repair pass. Log + skip; the row stays untouched.
|
||||
console.warn(
|
||||
`Repair decrypt failed for ${row.id}; leaving as-is`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Purge the mismatches so the normal insert path below
|
||||
// re-adds them from scratch. Chunked to respect the
|
||||
// backend's per-call page cap.
|
||||
for (let k = 0; k < repairDeleteIds.length; k += 100) {
|
||||
const chunk = repairDeleteIds.slice(k, k + 100);
|
||||
const authTimestamp = Date.now();
|
||||
const canonical = `deleteImportedByDiscordIds:${deps.actorId}:${mapping.convexChannelId}:${chunk.length}:${authTimestamp}`;
|
||||
const authSignature = await deps.crypto.signMessage(
|
||||
deps.signingKey,
|
||||
canonical,
|
||||
);
|
||||
await (deps.convex.deleteImportedByDiscordIds as any)({
|
||||
actorId: deps.actorId,
|
||||
channelId: mapping.convexChannelId,
|
||||
discordMessageIds: chunk,
|
||||
authTimestamp,
|
||||
authSignature,
|
||||
});
|
||||
for (const id of chunk) discordIdToConvexId.delete(id);
|
||||
}
|
||||
} else {
|
||||
// Normal / resume flow: one round-trip for dedup + reply
|
||||
// parent resolution.
|
||||
const existing = await deps.convex.resolveReplyTargets({
|
||||
channelId: mapping.convexChannelId,
|
||||
discordMessageIds: Array.from(idsToCheck),
|
||||
});
|
||||
for (const e of existing) {
|
||||
discordIdToConvexId.set(e.discordMessageId, e.messageId);
|
||||
}
|
||||
}
|
||||
|
||||
const toProcess = slice.filter((row) => {
|
||||
const senderId = row.author_id ? authorMap.get(row.author_id) : undefined;
|
||||
if (!senderId) return false; // orphan row with no known author
|
||||
if (discordIdToConvexId.has(row.id)) return false; // already complete
|
||||
if (options.repair) {
|
||||
// In repair mode we only fix rows that (a) were freshly
|
||||
// deleted as mismatches above, OR (b) never existed
|
||||
// server-side AND have attachments worth uploading.
|
||||
// Plain text-only rows that never made it in stay out.
|
||||
const wasDeleted = repairDeleteIds.includes(row.id);
|
||||
const missingAndHasAttachments =
|
||||
countCaptured(row.id) > 0 && !discordIdToConvexId.has(row.id);
|
||||
if (!wasDeleted && !missingAndHasAttachments) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Count the skips toward total progress so the "N / total"
|
||||
// readout still advances through an idempotent re-run.
|
||||
skippedInChannel += slice.length - toProcess.length;
|
||||
|
||||
// ---- Step 2-3: parallel per-row prep (uploads + encrypt + sign)
|
||||
//
|
||||
// `mapConcurrent` rejects on the first error, which cascades
|
||||
// up out of the batch loop. That's the desired behaviour: a
|
||||
// transient upload failure should ABORT the whole batch so
|
||||
// none of its rows commit — resume then re-enters these rows
|
||||
// from scratch rather than committing half-empty messages.
|
||||
const preparedRaw = await mapConcurrent(
|
||||
toProcess,
|
||||
PREP_CONCURRENCY,
|
||||
async (row) => {
|
||||
const senderId = authorMap.get(row.author_id!)!;
|
||||
|
||||
const attachments = attachmentsByMessageId.get(row.id) ?? [];
|
||||
const attachmentMetas: AttachmentMetadata[] = [];
|
||||
let expectedAttachments = 0;
|
||||
let capturedAttachments = 0;
|
||||
if (attachments.length > 0) {
|
||||
expectedAttachments = attachments.length;
|
||||
capturedAttachments = attachments.filter(
|
||||
(a) => a.downloaded && a.local_path,
|
||||
).length;
|
||||
const uploaded = await mapConcurrent(
|
||||
attachments,
|
||||
ATTACHMENT_CONCURRENCY,
|
||||
(att) => uploadOneAttachment(deps, dataDir, att),
|
||||
);
|
||||
for (const meta of uploaded) {
|
||||
if (meta) {
|
||||
attachmentMetas.push(meta);
|
||||
attachmentsUploaded += 1;
|
||||
attachmentBytesUploaded += meta.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bodyText = row.content ?? '';
|
||||
|
||||
// Nothing to display: no text, and every attachment was
|
||||
// either missing on disk or never captured by the backup
|
||||
// bot. Better to drop the row than insert an invisible
|
||||
// bubble into the channel. Nothing is thrown — we mark
|
||||
// it `null` so the batch skips the insert but its cursor
|
||||
// still advances.
|
||||
if (!bodyText && attachmentMetas.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload =
|
||||
attachmentMetas.length > 0
|
||||
? JSON.stringify({ text: bodyText, attachments: attachmentMetas })
|
||||
: bodyText;
|
||||
|
||||
const { content, iv, tag } = await deps.crypto.encryptData(
|
||||
payload,
|
||||
channelKey.keyHex,
|
||||
);
|
||||
const ciphertext = content + tag;
|
||||
const signature = await deps.crypto.signMessage(
|
||||
deps.signingKey,
|
||||
ciphertext,
|
||||
);
|
||||
|
||||
let replyTo: string | undefined;
|
||||
if (row.replied_to_id) {
|
||||
const local = discordIdToConvexId.get(row.replied_to_id);
|
||||
if (local) replyTo = local;
|
||||
}
|
||||
|
||||
if (
|
||||
expectedAttachments > 0 &&
|
||||
attachmentMetas.length < capturedAttachments
|
||||
) {
|
||||
// Shouldn't happen — uploadOneAttachment throws on any
|
||||
// recoverable failure — but surface it loudly if some
|
||||
// future refactor re-introduces silent skips.
|
||||
throw new Error(
|
||||
`Internal: only ${attachmentMetas.length}/${capturedAttachments} attachments uploaded for message ${row.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
senderId,
|
||||
ciphertext,
|
||||
nonce: iv,
|
||||
signature,
|
||||
keyVersion: channelKey.keyVersion,
|
||||
replyTo,
|
||||
importedCreatedAt: Number(row.created_at),
|
||||
discordMessageId: row.id,
|
||||
};
|
||||
},
|
||||
);
|
||||
const prepared = preparedRaw.filter((p): p is NonNullable<typeof p> => p !== null);
|
||||
|
||||
// ---- Step 4: submit the batch -----------------------------
|
||||
if (prepared.length > 0) {
|
||||
const authTimestamp = Date.now();
|
||||
const canonical = `importBatch:${deps.actorId}:${mapping.convexChannelId}:${prepared.length}:${authTimestamp}`;
|
||||
const authSignature = await deps.crypto.signMessage(
|
||||
deps.signingKey,
|
||||
canonical,
|
||||
);
|
||||
const result = await deps.convex.importBatch({
|
||||
actorId: deps.actorId,
|
||||
channelId: mapping.convexChannelId,
|
||||
messages: prepared,
|
||||
authTimestamp,
|
||||
authSignature,
|
||||
});
|
||||
for (const r of result.resolved) {
|
||||
discordIdToConvexId.set(r.discordMessageId, r.messageId);
|
||||
}
|
||||
insertedInChannel += result.inserted;
|
||||
}
|
||||
|
||||
// ---- Step 5: resume cursor + progress ---------------------
|
||||
const lastRow = slice[slice.length - 1];
|
||||
if (lastRow) saveCursor(mapping.discordId, lastRow.id);
|
||||
|
||||
onProgress({
|
||||
stage: 'importing',
|
||||
channelDiscordId: channel.discordId,
|
||||
channelName: channel.name,
|
||||
channelProgress: {
|
||||
inserted: insertedInChannel + skippedInChannel,
|
||||
total: allRows.length,
|
||||
},
|
||||
attachmentsUploaded,
|
||||
attachmentBytesUploaded,
|
||||
});
|
||||
}
|
||||
|
||||
// Channel finished cleanly — wipe its resume cursor so a fresh
|
||||
// re-run starts from scratch when the user wants to re-pull
|
||||
// from a newer backup.
|
||||
if (!cancelSignal.cancelled) {
|
||||
clearCursor(mapping.discordId);
|
||||
}
|
||||
}
|
||||
|
||||
onProgress({
|
||||
stage: 'done',
|
||||
channelDiscordId: null,
|
||||
channelName: null,
|
||||
channelProgress: null,
|
||||
attachmentsUploaded,
|
||||
attachmentBytesUploaded,
|
||||
});
|
||||
}
|
||||
155
packages/shared/src/utils/importSession.ts
Normal file
155
packages/shared/src/utils/importSession.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Module-scope singleton that owns the in-flight backup import.
|
||||
*
|
||||
* Keeping this outside of React state means the ImportTab
|
||||
* component can unmount (modal close, tab switch) without
|
||||
* cancelling the run or losing the progress stream. On remount
|
||||
* the tab re-subscribes and re-renders whatever state the
|
||||
* session is in.
|
||||
*/
|
||||
import type {
|
||||
ImportDeps,
|
||||
ImportOptions,
|
||||
ImportProgress,
|
||||
ParsedBackup,
|
||||
} from './importRunner';
|
||||
import { runImport } from './importRunner';
|
||||
|
||||
export type ImportSessionStatus =
|
||||
| 'idle'
|
||||
| 'running'
|
||||
| 'cancelling'
|
||||
| 'done'
|
||||
| 'error';
|
||||
|
||||
export interface ImportSessionState {
|
||||
status: ImportSessionStatus;
|
||||
progress: ImportProgress | null;
|
||||
error: string | null;
|
||||
/** The backup currently loaded in memory (sql.js DB + summary). */
|
||||
parsed: ParsedBackup | null;
|
||||
/** Absolute path of the backup the user picked, for display. */
|
||||
dbPath: string | null;
|
||||
/** Mapping snapshots so the tab can re-render its dropdowns without
|
||||
* re-querying. */
|
||||
channelMap: Record<string, string>;
|
||||
authorMap: Record<string, string>;
|
||||
}
|
||||
|
||||
type Listener = (state: ImportSessionState) => void;
|
||||
|
||||
class ImportSession {
|
||||
private state: ImportSessionState = {
|
||||
status: 'idle',
|
||||
progress: null,
|
||||
error: null,
|
||||
parsed: null,
|
||||
dbPath: null,
|
||||
channelMap: {},
|
||||
authorMap: {},
|
||||
};
|
||||
private listeners = new Set<Listener>();
|
||||
private cancelSignal = { cancelled: false };
|
||||
|
||||
getState(): ImportSessionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
subscribe(cb: Listener): () => void {
|
||||
this.listeners.add(cb);
|
||||
return () => {
|
||||
this.listeners.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
for (const cb of this.listeners) cb(this.state);
|
||||
}
|
||||
|
||||
setParsed(parsed: ParsedBackup | null, dbPath: string | null): void {
|
||||
this.state = { ...this.state, parsed, dbPath };
|
||||
this.emit();
|
||||
}
|
||||
|
||||
setChannelMap(map: Record<string, string>): void {
|
||||
this.state = { ...this.state, channelMap: map };
|
||||
this.emit();
|
||||
}
|
||||
|
||||
setAuthorMap(map: Record<string, string>): void {
|
||||
this.state = { ...this.state, authorMap: map };
|
||||
this.emit();
|
||||
}
|
||||
|
||||
clearError(): void {
|
||||
if (this.state.error === null) return;
|
||||
this.state = { ...this.state, error: null };
|
||||
this.emit();
|
||||
}
|
||||
|
||||
async start(
|
||||
parsed: ParsedBackup,
|
||||
deps: ImportDeps,
|
||||
options: Omit<ImportOptions, 'onProgress' | 'cancelSignal'>,
|
||||
): Promise<void> {
|
||||
if (this.state.status === 'running' || this.state.status === 'cancelling') {
|
||||
return;
|
||||
}
|
||||
this.cancelSignal = { cancelled: false };
|
||||
this.state = {
|
||||
...this.state,
|
||||
status: 'running',
|
||||
progress: null,
|
||||
error: null,
|
||||
parsed,
|
||||
};
|
||||
this.emit();
|
||||
try {
|
||||
await runImport(parsed, deps, {
|
||||
...options,
|
||||
cancelSignal: this.cancelSignal,
|
||||
onProgress: (p) => {
|
||||
this.state = { ...this.state, progress: p };
|
||||
this.emit();
|
||||
},
|
||||
});
|
||||
this.state = {
|
||||
...this.state,
|
||||
status: this.cancelSignal.cancelled ? 'idle' : 'done',
|
||||
};
|
||||
this.emit();
|
||||
} catch (err: any) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
status: 'error',
|
||||
error: err?.message ?? 'Import failed',
|
||||
};
|
||||
this.emit();
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this.state.status !== 'running') return;
|
||||
this.cancelSignal.cancelled = true;
|
||||
this.state = { ...this.state, status: 'cancelling' };
|
||||
this.emit();
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
if (this.state.status === 'running' || this.state.status === 'cancelling') {
|
||||
return;
|
||||
}
|
||||
this.state = {
|
||||
status: 'idle',
|
||||
progress: null,
|
||||
error: null,
|
||||
parsed: null,
|
||||
dbPath: null,
|
||||
channelMap: {},
|
||||
authorMap: {},
|
||||
};
|
||||
this.emit();
|
||||
}
|
||||
}
|
||||
|
||||
export const importSession = new ImportSession();
|
||||
Reference in New Issue
Block a user