1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
//! clap CLI definitions.
use std::path::PathBuf;
use clap::{Args, Parser, Subcommand, ValueEnum};
#[derive(Clone, Debug, ValueEnum)]
pub enum TimelineGrouping {
Day,
Hour,
}
#[derive(Clone, Debug, ValueEnum)]
pub enum ExportFormat {
Text,
Json,
Yaml,
}
#[derive(Parser, Debug)]
#[command(name = "discord", version, about = "Local-first Discord archival CLI")]
pub struct Cli {
/// Override token resolution (flag wins over env and .env)
#[arg(long, global = true)]
pub token: Option<String>,
/// Override SQLite database path
#[arg(long, global = true)]
pub db: Option<PathBuf>,
/// Disable ANSI color output
#[arg(long, global = true)]
pub no_color: bool,
/// Output results as JSON
#[arg(long, global = true)]
pub json: bool,
#[command(subcommand)]
pub cmd: Cmd,
}
#[derive(Subcommand, Debug)]
pub enum Cmd {
/// Auto-discover and (optionally) save the Discord user token
Auth(AuthArgs),
/// Verify the configured token works
Status,
/// Show profile of the authenticated user
Whoami,
/// Discord API operations (guilds, channels, sync, ...)
#[command(subcommand)]
Dc(DcCmd),
/// Search the local message archive
Search(SearchArgs),
/// Show newest stored messages
Recent(RecentArgs),
/// Per-channel message counts
Stats,
/// Show today's messages grouped by channel
Today(TodayArgs),
/// Show most active senders
Top(TopArgs),
/// Show message activity over time
Timeline(TimelineArgs),
/// Export messages to file
Export(ExportArgs),
/// Delete stored messages for a channel
Purge(PurgeArgs),
}
#[derive(Args, Debug)]
pub struct AuthArgs {
/// Save the discovered token to `./.env`
#[arg(long)]
pub save: bool,
}
#[derive(Subcommand, Debug)]
pub enum DcCmd {
/// List joined guilds
Guilds,
/// List text channels in a guild (id or name)
Channels {
/// Guild ID (digits) or partial guild name
guild: String,
},
/// Incremental sync: fetch only new messages
Sync {
/// Channel ID
channel: String,
/// Maximum messages to fetch
#[arg(short = 'n', long, default_value_t = 5000)]
limit: u32,
},
/// Discover all guilds + text channels and sync each
SyncAll {
/// Maximum messages per channel
#[arg(short = 'n', long, default_value_t = 200)]
limit: u32,
},
/// Fetch historical messages backward into local DB
History {
/// Channel ID
channel: String,
/// Maximum messages to fetch
#[arg(short = 'n', long, default_value_t = 1000)]
limit: u32,
},
/// Stream a channel's messages in real time via Gateway WebSocket
Tail {
/// Channel ID
channel: String,
/// Initial snapshot size
#[arg(short = 'n', long, default_value_t = 20)]
limit: u32,
/// Fetch once then exit (no Gateway connection)
#[arg(long)]
once: bool,
},
/// Search messages on Discord's server (remote search)
#[command(name = "search")]
SearchRemote {
/// Guild ID or partial guild name
guild: String,
/// Search keyword
keyword: String,
/// Filter by channel ID
#[arg(short = 'c', long)]
channel: Option<String>,
/// Maximum results
#[arg(short = 'n', long, default_value_t = 25)]
limit: u32,
},
/// List guild members
Members {
/// Guild ID or partial guild name
guild: String,
/// Maximum members to fetch
#[arg(short = 'n', long, default_value_t = 50)]
limit: u32,
},
/// Show detailed guild information
Info {
/// Guild ID or partial guild name
guild: String,
},
/// Show pinned messages in a channel
Pins {
/// Channel ID
channel: String,
},
/// List active threads in a guild
Threads {
/// Guild ID or partial guild name
guild: String,
},
/// Show relationships (friends, blocked, pending)
Relationships,
/// List roles in a guild
Roles {
/// Guild ID or partial guild name
guild: String,
},
/// List custom emojis in a guild
Emojis {
/// Guild ID or partial guild name
guild: String,
},
/// Show a user's profile
Profile {
/// User ID (defaults to self)
user_id: Option<String>,
},
/// List custom stickers in a guild
Stickers {
/// Guild ID or partial guild name
guild: String,
},
/// Show recent audit log entries
Audit {
/// Guild ID or partial guild name
guild: String,
/// Maximum entries
#[arg(short = 'n', long, default_value_t = 50)]
limit: u8,
},
/// List scheduled events in a guild
Events {
/// Guild ID or partial guild name
guild: String,
},
/// Download attachments from stored messages
Download {
/// Channel ID or local channel name
channel: String,
/// Output directory
#[arg(short = 'o', long, default_value = "downloads")]
output: PathBuf,
/// Only from the last N hours
#[arg(long)]
hours: Option<i64>,
},
/// Export full guild snapshot as JSON
Snapshot {
/// Guild ID or partial guild name
guild: String,
/// Output file (stdout if omitted)
#[arg(short = 'o', long)]
output: Option<PathBuf>,
},
}
#[derive(Args, Debug)]
pub struct SearchArgs {
/// Search keyword (case-insensitive substring)
pub keyword: String,
/// Filter by channel ID or local channel name
#[arg(short = 'c', long)]
pub channel: Option<String>,
/// Maximum results
#[arg(short = 'n', long, default_value_t = 50)]
pub limit: i64,
}
#[derive(Args, Debug)]
pub struct RecentArgs {
/// Filter by channel ID or local channel name
#[arg(short = 'c', long)]
pub channel: Option<String>,
/// Only messages from the last N hours
#[arg(long)]
pub hours: Option<i64>,
/// Maximum results
#[arg(short = 'n', long, default_value_t = 50)]
pub limit: i64,
}
#[derive(Args, Debug)]
pub struct TodayArgs {
/// Filter by channel ID or local channel name
#[arg(short = 'c', long)]
pub channel: Option<String>,
}
#[derive(Args, Debug)]
pub struct TopArgs {
/// Filter by channel ID or local channel name
#[arg(short = 'c', long)]
pub channel: Option<String>,
/// Only messages from the last N hours
#[arg(long)]
pub hours: Option<i64>,
/// Maximum results
#[arg(short = 'n', long, default_value_t = 20)]
pub limit: i64,
}
#[derive(Args, Debug)]
pub struct TimelineArgs {
/// Filter by channel ID or local channel name
#[arg(short = 'c', long)]
pub channel: Option<String>,
/// Only messages from the last N hours
#[arg(long)]
pub hours: Option<i64>,
/// Group by "day" or "hour"
#[arg(long, default_value = "day", value_enum)]
pub by: TimelineGrouping,
}
#[derive(Args, Debug)]
pub struct ExportArgs {
/// Channel ID or local channel name
pub channel: String,
/// Output format: "text" or "json"
#[arg(short = 'f', long, default_value = "text", value_enum)]
pub format: ExportFormat,
/// Output file (stdout if omitted)
#[arg(short = 'o', long)]
pub output: Option<PathBuf>,
/// Only messages from the last N hours
#[arg(long)]
pub hours: Option<i64>,
}
#[derive(Args, Debug)]
pub struct PurgeArgs {
/// Channel ID or local channel name
pub channel: String,
/// Skip confirmation prompt
#[arg(short = 'y', long)]
pub yes: bool,
}