termicord 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +2 -0
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/backend.ts +428 -0
- package/bun.lock +213 -0
- package/index.ts +605 -0
- package/middleware.ts +51 -0
- package/package.json +19 -0
- package/public/termicord.jpg +0 -0
- package/public/termicord.png +0 -0
- package/tsconfig.json +29 -0
package/.gitattributes
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dilukshan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<img src="public/termicord.jpg" alt="termicord logo">
|
|
4
|
+
<br />
|
|
5
|
+
<h2>A beautiful, terminal-native Discord attachment downloader</h2>
|
|
6
|
+
<p>Built with TypeScript · Powered by Bun · UI rendered by OpenTUI</p>
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
[](https://bun.sh)
|
|
11
|
+
[](https://www.typescriptlang.org)
|
|
12
|
+
[](https://github.com/anomalyco/opentui)
|
|
13
|
+
[](https://discord.com/developers/docs/intro)
|
|
14
|
+
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Overview
|
|
20
|
+
|
|
21
|
+
`termicord` is a sleek, fully terminal-native tool to **bulk-download all attachments from any Discord channel** you have access to. It talks directly to the Discord REST API v10, handles rate-limiting gracefully, and wraps everything in a gorgeous animated TUI — no browser, no Electron, no nonsense.
|
|
22
|
+
|
|
23
|
+
The interface is powered by **[OpenTUI](https://github.com/anomalyco/opentui)**, a Zig-native terminal UI library exposed to the JavaScript ecosystem via `@opentui/core`. This means pixel-perfect box rendering, smooth animations, mouse support, and focus management — all at near-native speed.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
| Feature | Details |
|
|
30
|
+
|---|---|
|
|
31
|
+
| **Animated TUI** | Staggered banner + cascading panel reveal on startup |
|
|
32
|
+
| **Bulk Attachment Download** | Fetches every attachment from an entire channel history |
|
|
33
|
+
| **Auto Rate-Limit Handling** | Detects Discord 429 responses and backs off automatically |
|
|
34
|
+
| **Per-Message Folders** | Optional mode to create one folder per message, named by date + author + snippet |
|
|
35
|
+
| **Extension Filtering** | Skip specific file types (e.g. `.jpg .png .gif`) before any download begins |
|
|
36
|
+
| **Abort Support** | Press `Esc` at any time to cleanly cancel an in-flight download |
|
|
37
|
+
| **Live Log Tab** | Real-time timestamped log output in a scrollable panel |
|
|
38
|
+
| **Responsive Layout** | Full-width banner on wide terminals, compact mode on narrow ones |
|
|
39
|
+
| **Mouse Support** | Click the Download button directly |
|
|
40
|
+
| **Duplicate Detection** | Skips files that already exist on disk |
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## UI Powered by OpenTUI + Zig
|
|
45
|
+
|
|
46
|
+
The entire terminal interface is driven by **`@opentui/core`**, which is built on top of a **Zig**-native rendering engine. This gives the TUI:
|
|
47
|
+
|
|
48
|
+
- **Sub-millisecond render cycles** via Zig's zero-overhead abstractions
|
|
49
|
+
- **True-color (24-bit) support** — every hex color you see (`#c4b5fd`, `#f0abfc`, etc.) is rendered natively
|
|
50
|
+
- **Composable renderables** — `BoxRenderable`, `TextRenderable`, `InputRenderable` — each a self-contained layout node
|
|
51
|
+
- **Event-driven input** — keyboard and mouse events propagate through a typed event bus backed by native I/O
|
|
52
|
+
- **Flex-style layout engine** — `flexDirection`, `justifyContent`, `alignItems`, `overflow: scroll` — a real layout system, not ASCII hacks
|
|
53
|
+
|
|
54
|
+
> Zig's `comptime` and manual memory model allow OpenTUI to avoid GC pauses entirely, making the UI feel instant even on low-spec hardware.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Architecture
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
termicord/
|
|
62
|
+
├── index.ts # TUI shell — all UI logic, layout, key bindings, animation
|
|
63
|
+
├── middleware.ts # Thin adapter — bridges raw config from the UI to the backend
|
|
64
|
+
├── backend.ts # Core engine — Discord API calls, download logic, abort support
|
|
65
|
+
├── package.json # Bun project manifest
|
|
66
|
+
└── tsconfig.json # TypeScript config
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Data Flow
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
[ User Input (TUI) ]
|
|
73
|
+
│
|
|
74
|
+
▼
|
|
75
|
+
[ middleware.ts: startDownloadTask() ]
|
|
76
|
+
↳ Parses raw string config (extensions, paths)
|
|
77
|
+
↳ Creates AbortController
|
|
78
|
+
│
|
|
79
|
+
▼
|
|
80
|
+
[ backend.ts: runDownload() ]
|
|
81
|
+
↳ Authenticates with Discord API v10
|
|
82
|
+
↳ Paginates all messages (100/page, cursor-based)
|
|
83
|
+
↳ Filters attachments by extension
|
|
84
|
+
↳ Downloads binaries with timeout + retry
|
|
85
|
+
↳ Emits typed DownloadProgress events
|
|
86
|
+
│
|
|
87
|
+
▼
|
|
88
|
+
[ index.ts: addLog() → logsText.content ]
|
|
89
|
+
↳ Timestamped lines rendered live in the Logs tab
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Requirements
|
|
95
|
+
|
|
96
|
+
| Dependency | Version |
|
|
97
|
+
|---|---|
|
|
98
|
+
| [Bun](https://bun.sh) | `>= 1.3.10` |
|
|
99
|
+
| [TypeScript](https://www.typescriptlang.org) | `^5.x` |
|
|
100
|
+
| [`@opentui/core`](https://www.npmjs.com/package/@opentui/core) | `^0.1.87` |
|
|
101
|
+
|
|
102
|
+
> **Node.js is not required.** This project runs exclusively on Bun.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Installation
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
# 1. Clone the repository
|
|
110
|
+
git clone https://github.com/dilukshann7/termicord.git
|
|
111
|
+
cd termicord
|
|
112
|
+
|
|
113
|
+
# 2. Install dependencies
|
|
114
|
+
bun install
|
|
115
|
+
|
|
116
|
+
# 3. Launch
|
|
117
|
+
bun run index.ts
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Usage
|
|
123
|
+
|
|
124
|
+
When the TUI launches, you will see four input fields and a checkbox:
|
|
125
|
+
|
|
126
|
+
| Field | Description |
|
|
127
|
+
|---|---|
|
|
128
|
+
| **Discord Token** | Your user or bot token (`Authorization` header value) |
|
|
129
|
+
| **Channel ID** | The numeric ID of the target channel |
|
|
130
|
+
| **Download Location** | Local path where files will be saved (default: `./downloads`) |
|
|
131
|
+
| **Extensions to Skip** | Space or comma-separated extensions to ignore (e.g. `.jpg .gif`) |
|
|
132
|
+
| **Folder per message** | When checked, each message gets its own named subfolder |
|
|
133
|
+
|
|
134
|
+
### Keyboard Shortcuts
|
|
135
|
+
|
|
136
|
+
| Key | Action |
|
|
137
|
+
|---|---|
|
|
138
|
+
| `Tab` | Focus next field |
|
|
139
|
+
| `Shift + Tab` | Focus previous field |
|
|
140
|
+
| `Space` | Toggle the folder-per-message checkbox |
|
|
141
|
+
| `Enter` | Start download (from any field) |
|
|
142
|
+
| `Ctrl + E` | Switch to Logs tab |
|
|
143
|
+
| `Ctrl + Q` | Switch to Config tab |
|
|
144
|
+
| `Esc` | Abort an in-progress download |
|
|
145
|
+
| `Ctrl + C` | Exit the application |
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## How It Works
|
|
150
|
+
|
|
151
|
+
### Message Pagination
|
|
152
|
+
|
|
153
|
+
Discord's API returns a maximum of **100 messages per request**. The backend uses a cursor-based pagination loop — each batch uses the snowflake ID of the last message as the `before` parameter — until an empty page signals the end of history.
|
|
154
|
+
|
|
155
|
+
### Rate Limiting
|
|
156
|
+
|
|
157
|
+
When Discord returns a `429 Too Many Requests` response, the backend parses the `retry_after` field from the JSON body and sleeps for exactly that duration before retrying the same request. A 500ms inter-page sleep is also applied proactively.
|
|
158
|
+
|
|
159
|
+
### Abort / Cancellation
|
|
160
|
+
|
|
161
|
+
Every download task receives a standard [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). The inner loop checks `signal.aborted` before each message and before each file download, so cancellation is clean and immediate.
|
|
162
|
+
|
|
163
|
+
### File Safety
|
|
164
|
+
|
|
165
|
+
Before writing any file, the engine checks `fs.existsSync(destPath)`. If the file already exists, it is skipped with a `↩ Already exists` log line, preventing duplicate downloads across multiple runs.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Security Notes
|
|
170
|
+
|
|
171
|
+
> ⚠️ **Your Discord token is sensitive.** Treat it like a password.
|
|
172
|
+
|
|
173
|
+
- Your token is **never written to disk** by this tool.
|
|
174
|
+
- It is held only in the in-memory TUI input field for the duration of the session.
|
|
175
|
+
- It is transmitted exclusively to `discord.com` over HTTPS.
|
|
176
|
+
- The token field displays characters as-entered (not masked) — run this tool in a private terminal session.
|
|
177
|
+
|
|
178
|
+
**Do not share your token.** Account tokens grant full access to your Discord account. Bot tokens should be scoped to only the required permissions.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Contributing
|
|
183
|
+
|
|
184
|
+
Pull requests are welcome! For major changes, please open an issue first to discuss what you'd like to change.
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
# Fork → clone your fork → create a branch
|
|
188
|
+
git checkout -b feat/my-improvement
|
|
189
|
+
|
|
190
|
+
# Make changes, then push
|
|
191
|
+
git push origin feat/my-improvement
|
|
192
|
+
|
|
193
|
+
# Open a PR on GitHub
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
Released under the [MIT License](./LICENSE).
|
|
201
|
+
Developed & maintained with ♡ by **[@dilukshann7](https://github.com/dilukshann7)**
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
<div align="center">
|
|
206
|
+
|
|
207
|
+
*Built with* ♡ *using* **TypeScript** · **Bun** · **OpenTUI** · **Zig**
|
|
208
|
+
|
|
209
|
+
</div>
|
package/backend.ts
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import https from "https";
|
|
4
|
+
import http from "http";
|
|
5
|
+
import { URL } from "url";
|
|
6
|
+
|
|
7
|
+
export interface DownloadConfig {
|
|
8
|
+
token: string;
|
|
9
|
+
channelId: string;
|
|
10
|
+
outputDir: string;
|
|
11
|
+
skipExtensions: string[]; // e.g. [".jpg", ".png"]
|
|
12
|
+
foldersPerMessage: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DownloadProgress {
|
|
16
|
+
type:
|
|
17
|
+
| "log"
|
|
18
|
+
| "progress"
|
|
19
|
+
| "done"
|
|
20
|
+
| "error"
|
|
21
|
+
| "fetching"
|
|
22
|
+
| "rate_limit"
|
|
23
|
+
| "file_skip"
|
|
24
|
+
| "file_start"
|
|
25
|
+
| "file_done"
|
|
26
|
+
| "file_fail";
|
|
27
|
+
message: string;
|
|
28
|
+
totalMessages?: number;
|
|
29
|
+
messagesWithAttachments?: number;
|
|
30
|
+
filesDownloaded?: number;
|
|
31
|
+
filesTotal?: number;
|
|
32
|
+
currentFile?: string;
|
|
33
|
+
outputDir?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type ProgressCallback = (progress: DownloadProgress) => void;
|
|
37
|
+
|
|
38
|
+
function sanitize(name: string, maxLen = 60): string {
|
|
39
|
+
const cleaned = name
|
|
40
|
+
.replace(/[\\/*?:"<>|]/g, "")
|
|
41
|
+
.trim()
|
|
42
|
+
.replace(/\n/g, " ");
|
|
43
|
+
return cleaned.slice(0, maxLen) || "untitled";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseSkipExtensions(raw: string): string[] {
|
|
47
|
+
return raw
|
|
48
|
+
.split(/[\s,]+/)
|
|
49
|
+
.map((e) => e.trim().toLowerCase())
|
|
50
|
+
.filter((e) => e.length > 0)
|
|
51
|
+
.map((e) => (e.startsWith(".") ? e : `.${e}`));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sleep(ms: number): Promise<void> {
|
|
55
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface HttpResponse {
|
|
59
|
+
statusCode: number;
|
|
60
|
+
body: string;
|
|
61
|
+
headers: Record<string, string | string[] | undefined>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function httpGet(
|
|
65
|
+
urlStr: string,
|
|
66
|
+
headers: Record<string, string>,
|
|
67
|
+
): Promise<HttpResponse> {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const parsed = new URL(urlStr);
|
|
70
|
+
const mod = parsed.protocol === "https:" ? https : http;
|
|
71
|
+
|
|
72
|
+
const req = mod.get(
|
|
73
|
+
{
|
|
74
|
+
hostname: parsed.hostname,
|
|
75
|
+
path: parsed.pathname + parsed.search,
|
|
76
|
+
headers,
|
|
77
|
+
},
|
|
78
|
+
(res) => {
|
|
79
|
+
const chunks: Buffer[] = [];
|
|
80
|
+
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
81
|
+
res.on("end", () =>
|
|
82
|
+
resolve({
|
|
83
|
+
statusCode: res.statusCode ?? 0,
|
|
84
|
+
body: Buffer.concat(chunks).toString("utf8"),
|
|
85
|
+
headers: res.headers as Record<
|
|
86
|
+
string,
|
|
87
|
+
string | string[] | undefined
|
|
88
|
+
>,
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
res.on("error", reject);
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
req.on("error", reject);
|
|
95
|
+
req.setTimeout(30_000, () => {
|
|
96
|
+
req.destroy(new Error("Request timed out"));
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function downloadBinaryFile(
|
|
102
|
+
urlStr: string,
|
|
103
|
+
destPath: string,
|
|
104
|
+
headers: Record<string, string>,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
const parsed = new URL(urlStr);
|
|
108
|
+
const mod = parsed.protocol === "https:" ? https : http;
|
|
109
|
+
|
|
110
|
+
const req = mod.get(
|
|
111
|
+
{
|
|
112
|
+
hostname: parsed.hostname,
|
|
113
|
+
path: parsed.pathname + parsed.search,
|
|
114
|
+
headers,
|
|
115
|
+
},
|
|
116
|
+
(res) => {
|
|
117
|
+
if (res.statusCode !== 200) {
|
|
118
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const stream = fs.createWriteStream(destPath);
|
|
122
|
+
res.pipe(stream);
|
|
123
|
+
stream.on("finish", () => resolve());
|
|
124
|
+
stream.on("error", reject);
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
req.on("error", reject);
|
|
128
|
+
req.setTimeout(60_000, () => {
|
|
129
|
+
req.destroy(new Error("Download timed out"));
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface DiscordAttachment {
|
|
135
|
+
id: string;
|
|
136
|
+
filename: string;
|
|
137
|
+
size: number;
|
|
138
|
+
url: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface DiscordMessage {
|
|
142
|
+
id: string;
|
|
143
|
+
timestamp: string;
|
|
144
|
+
content: string;
|
|
145
|
+
author: { username: string };
|
|
146
|
+
attachments: DiscordAttachment[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function fetchAllMessages(
|
|
150
|
+
channelId: string,
|
|
151
|
+
headers: Record<string, string>,
|
|
152
|
+
onProgress: ProgressCallback,
|
|
153
|
+
): Promise<DiscordMessage[]> {
|
|
154
|
+
const messages: DiscordMessage[] = [];
|
|
155
|
+
let lastId: string | null = null;
|
|
156
|
+
|
|
157
|
+
onProgress({
|
|
158
|
+
type: "fetching",
|
|
159
|
+
message: "Fetching messages from Discord...",
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
while (true) {
|
|
163
|
+
let url = `https://discord.com/api/v10/channels/${channelId}/messages?limit=100`;
|
|
164
|
+
if (lastId) url += `&before=${lastId}`;
|
|
165
|
+
|
|
166
|
+
let resp: HttpResponse;
|
|
167
|
+
try {
|
|
168
|
+
resp = await httpGet(url, headers);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
onProgress({
|
|
171
|
+
type: "error",
|
|
172
|
+
message: `Network error: ${(err as Error).message}`,
|
|
173
|
+
});
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (resp.statusCode === 429) {
|
|
178
|
+
let retryAfter = 5;
|
|
179
|
+
try {
|
|
180
|
+
const json = JSON.parse(resp.body);
|
|
181
|
+
retryAfter = json.retry_after ?? 5;
|
|
182
|
+
} catch {}
|
|
183
|
+
onProgress({
|
|
184
|
+
type: "rate_limit",
|
|
185
|
+
message: `Rate limited — waiting ${retryAfter}s...`,
|
|
186
|
+
});
|
|
187
|
+
await sleep(retryAfter * 1000);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (resp.statusCode === 401) {
|
|
192
|
+
onProgress({
|
|
193
|
+
type: "error",
|
|
194
|
+
message: "Invalid token (401 Unauthorized).",
|
|
195
|
+
});
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (resp.statusCode === 403) {
|
|
200
|
+
onProgress({
|
|
201
|
+
type: "error",
|
|
202
|
+
message: "Missing access to channel (403 Forbidden).",
|
|
203
|
+
});
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (resp.statusCode !== 200) {
|
|
208
|
+
onProgress({
|
|
209
|
+
type: "error",
|
|
210
|
+
message: `Discord API error ${resp.statusCode}: ${resp.body.slice(0, 120)}`,
|
|
211
|
+
});
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let batch: DiscordMessage[];
|
|
216
|
+
try {
|
|
217
|
+
batch = JSON.parse(resp.body);
|
|
218
|
+
} catch {
|
|
219
|
+
onProgress({
|
|
220
|
+
type: "error",
|
|
221
|
+
message: "Failed to parse Discord API response.",
|
|
222
|
+
});
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!batch.length) break;
|
|
227
|
+
|
|
228
|
+
messages.push(...batch);
|
|
229
|
+
lastId = batch[batch.length - 1]!.id;
|
|
230
|
+
|
|
231
|
+
onProgress({
|
|
232
|
+
type: "log",
|
|
233
|
+
message: ` Fetched ${messages.length} messages so far...`,
|
|
234
|
+
totalMessages: messages.length,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await sleep(500);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return messages;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function runDownload(
|
|
244
|
+
config: DownloadConfig,
|
|
245
|
+
onProgress: ProgressCallback,
|
|
246
|
+
signal?: AbortSignal,
|
|
247
|
+
): Promise<void> {
|
|
248
|
+
const { token, channelId, outputDir, skipExtensions, foldersPerMessage } =
|
|
249
|
+
config;
|
|
250
|
+
|
|
251
|
+
// Validate
|
|
252
|
+
if (!token.trim()) {
|
|
253
|
+
onProgress({ type: "error", message: "✗ Discord token is required." });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (!channelId.trim()) {
|
|
257
|
+
onProgress({ type: "error", message: "✗ Channel ID is required." });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const headers: Record<string, string> = { Authorization: token.trim() };
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
265
|
+
} catch (err) {
|
|
266
|
+
onProgress({
|
|
267
|
+
type: "error",
|
|
268
|
+
message: `✗ Cannot create output directory: ${(err as Error).message}`,
|
|
269
|
+
});
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const messages = await fetchAllMessages(channelId, headers, onProgress);
|
|
274
|
+
|
|
275
|
+
if (signal?.aborted) {
|
|
276
|
+
onProgress({ type: "log", message: "⊘ Download aborted." });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const withAttachments = messages.filter((m) => m.attachments?.length > 0);
|
|
281
|
+
|
|
282
|
+
onProgress({
|
|
283
|
+
type: "log",
|
|
284
|
+
message: `\n Found ${messages.length} messages total.`,
|
|
285
|
+
totalMessages: messages.length,
|
|
286
|
+
});
|
|
287
|
+
onProgress({
|
|
288
|
+
type: "log",
|
|
289
|
+
message: ` ${withAttachments.length} messages have attachments.`,
|
|
290
|
+
messagesWithAttachments: withAttachments.length,
|
|
291
|
+
});
|
|
292
|
+
onProgress({
|
|
293
|
+
type: "log",
|
|
294
|
+
message: " ──────────────────────────────────────────",
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
if (withAttachments.length === 0) {
|
|
298
|
+
onProgress({
|
|
299
|
+
type: "done",
|
|
300
|
+
message: "✓ No attachments found.",
|
|
301
|
+
filesDownloaded: 0,
|
|
302
|
+
outputDir,
|
|
303
|
+
});
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let filesTotal = 0;
|
|
308
|
+
for (const msg of withAttachments) {
|
|
309
|
+
for (const att of msg.attachments) {
|
|
310
|
+
const ext = path.extname(att.filename).toLowerCase();
|
|
311
|
+
if (!skipExtensions.includes(ext)) filesTotal++;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
onProgress({
|
|
316
|
+
type: "log",
|
|
317
|
+
message: ` ${filesTotal} file(s) to download (after skip filter).`,
|
|
318
|
+
filesTotal,
|
|
319
|
+
});
|
|
320
|
+
onProgress({
|
|
321
|
+
type: "log",
|
|
322
|
+
message: " ──────────────────────────────────────────",
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
let downloaded = 0;
|
|
326
|
+
let skippedExt = 0;
|
|
327
|
+
|
|
328
|
+
for (const msg of withAttachments) {
|
|
329
|
+
if (signal?.aborted) {
|
|
330
|
+
onProgress({ type: "log", message: "⊘ Download aborted by user." });
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const ts = msg.timestamp.slice(0, 10); // "2025-10-31"
|
|
335
|
+
const author = sanitize(msg.author.username);
|
|
336
|
+
const snippet = sanitize(msg.content.slice(0, 40)) || "no-text";
|
|
337
|
+
|
|
338
|
+
let folderPath: string;
|
|
339
|
+
|
|
340
|
+
if (foldersPerMessage) {
|
|
341
|
+
const folderName = `${ts}_${author}_${snippet}`;
|
|
342
|
+
folderPath = path.join(outputDir, folderName);
|
|
343
|
+
onProgress({ type: "log", message: `\n 📁 ${folderName}` });
|
|
344
|
+
} else {
|
|
345
|
+
folderPath = outputDir;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
fs.mkdirSync(folderPath, { recursive: true });
|
|
350
|
+
} catch (err) {
|
|
351
|
+
onProgress({
|
|
352
|
+
type: "error",
|
|
353
|
+
message: ` ✗ Cannot create folder: ${(err as Error).message}`,
|
|
354
|
+
});
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
for (const att of msg.attachments) {
|
|
359
|
+
if (signal?.aborted) break;
|
|
360
|
+
|
|
361
|
+
const ext = path.extname(att.filename).toLowerCase();
|
|
362
|
+
|
|
363
|
+
if (skipExtensions.includes(ext)) {
|
|
364
|
+
skippedExt++;
|
|
365
|
+
onProgress({
|
|
366
|
+
type: "file_skip",
|
|
367
|
+
message: ` ↷ Skipped (${ext}): ${att.filename}`,
|
|
368
|
+
currentFile: att.filename,
|
|
369
|
+
});
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const destPath = path.join(folderPath, att.filename);
|
|
374
|
+
|
|
375
|
+
if (fs.existsSync(destPath)) {
|
|
376
|
+
onProgress({
|
|
377
|
+
type: "file_skip",
|
|
378
|
+
message: ` ↩ Already exists: ${att.filename}`,
|
|
379
|
+
currentFile: att.filename,
|
|
380
|
+
});
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const sizeKb = Math.round(att.size / 1024);
|
|
385
|
+
onProgress({
|
|
386
|
+
type: "file_start",
|
|
387
|
+
message: ` ↓ ${att.filename} (${sizeKb} KB)`,
|
|
388
|
+
currentFile: att.filename,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
await downloadBinaryFile(att.url, destPath, headers);
|
|
393
|
+
downloaded++;
|
|
394
|
+
onProgress({
|
|
395
|
+
type: "file_done",
|
|
396
|
+
message: ` ✓ Saved: ${att.filename}`,
|
|
397
|
+
currentFile: att.filename,
|
|
398
|
+
filesDownloaded: downloaded,
|
|
399
|
+
filesTotal,
|
|
400
|
+
progress: Math.round((downloaded / filesTotal) * 100),
|
|
401
|
+
} as DownloadProgress & { progress: number });
|
|
402
|
+
} catch (err) {
|
|
403
|
+
onProgress({
|
|
404
|
+
type: "file_fail",
|
|
405
|
+
message: ` ✗ Failed: ${att.filename} — ${(err as Error).message}`,
|
|
406
|
+
currentFile: att.filename,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
await sleep(200);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const absPath = path.resolve(outputDir);
|
|
415
|
+
onProgress({
|
|
416
|
+
type: "log",
|
|
417
|
+
message: " ──────────────────────────────────────────",
|
|
418
|
+
});
|
|
419
|
+
onProgress({
|
|
420
|
+
type: "done",
|
|
421
|
+
message: ` ✓ Done! Downloaded ${downloaded} file(s) → ${absPath}`,
|
|
422
|
+
filesDownloaded: downloaded,
|
|
423
|
+
filesTotal,
|
|
424
|
+
outputDir: absPath,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export { parseSkipExtensions };
|