-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsetup.py
More file actions
353 lines (290 loc) · 10.8 KB
/
setup.py
File metadata and controls
353 lines (290 loc) · 10.8 KB
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
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
"""Interactive setup wizard for Discord storage bot."""
from __future__ import annotations
import asyncio
import shutil
import sqlite3
import sys
from pathlib import Path
from typing import Optional
from colorama import Fore, Style, init as colorama_init
import discord
from src.config import Config, generate_encryption_key, save_config, validate_token
from src.utils import ConfigError, setup_logging
def _project_root() -> Path:
return Path(__file__).resolve().parent
def _print_banner() -> None:
print(f"{Fore.CYAN}Discord Storage Bot Setup{Style.RESET_ALL}")
print(f"{Fore.CYAN}{'=' * 26}{Style.RESET_ALL}")
print("This wizard will configure your bot token and database.")
def _check_python_version() -> None:
if sys.version_info < (3, 10):
raise ConfigError("Python 3.10 or higher is required.")
def prompt_bot_token() -> str:
"""
Prompt the user for a Discord bot token.
Returns:
Validated bot token.
"""
while True:
token = input("Enter your Discord bot token: ").strip()
if validate_token(token):
return token
print("Invalid token format. Please try again.")
def prompt_encryption_key() -> str:
"""
Prompt the user for an encryption key.
Returns:
Validated encryption key.
"""
print(f"\n{Fore.YELLOW}Encryption Key Setup{Style.RESET_ALL}")
print("=" * 50)
print("Your encryption key secures all uploaded files.")
print(f"{Fore.RED}⚠️ CRITICAL: Without this key, you CANNOT decrypt your files!{Style.RESET_ALL}")
print(f"{Fore.RED}⚠️ Store it somewhere safe (password manager, etc.){Style.RESET_ALL}\n")
choice = input("Do you have an existing encryption key? [y/N]: ").strip().lower()
if choice == "y":
# User provides their own key
while True:
print("\nPaste your encryption key (Fernet format, base64-encoded):")
key = input("> ").strip()
if not key:
print("Key cannot be empty.")
continue
# Validate it's a valid Fernet key
try:
from cryptography.fernet import Fernet
Fernet(key.encode('utf-8'))
print(f"{Fore.GREEN}✓ Valid encryption key.{Style.RESET_ALL}")
return key
except Exception:
print(f"{Fore.RED}✗ Invalid encryption key format.{Style.RESET_ALL}")
print("A valid Fernet key is 44 characters long and base64-encoded.")
retry = input("Try again? [y/N]: ").strip().lower()
if retry != "y":
print("\nGenerating a new key instead...")
break
# Generate new key
print(f"\n{Fore.CYAN}Generating new encryption key...{Style.RESET_ALL}")
new_key = generate_encryption_key()
print(f"\n{Fore.GREEN}{'=' * 50}{Style.RESET_ALL}")
print(f"{Fore.GREEN}Your Encryption Key:{Style.RESET_ALL}")
print(f"{Fore.YELLOW}{new_key}{Style.RESET_ALL}")
print(f"{Fore.GREEN}{'=' * 50}{Style.RESET_ALL}\n")
print(f"{Fore.RED}⚠️ SAVE THIS KEY NOW!{Style.RESET_ALL}")
print("Copy it to a password manager, secure note, or backup location.")
print("This key will be saved to .env, but you should have a backup.\n")
confirm = input("Have you saved the key? Type 'YES' to continue: ").strip()
if confirm != "YES":
print(f"\n{Fore.RED}Setup cancelled. Please save the key and run setup again.{Style.RESET_ALL}")
sys.exit(1)
return new_key
async def test_connection(token: str) -> Optional[int]:
"""
Verify the Discord bot token by connecting.
Args:
token: Discord bot token.
Returns:
Client ID if successful.
"""
intents = discord.Intents(guilds=True)
client = discord.Client(intents=intents)
client_id: Optional[int] = None
async def on_ready() -> None:
nonlocal client_id
if client.user:
client_id = client.user.id
await client.close()
client.event(on_ready)
try:
await client.start(token)
except discord.LoginFailure as exc:
raise ConfigError("Unable to login with the provided token.") from exc
return client_id
async def ensure_channels_setup(
token: str, storage_name: str, index_name: str, backup_name: str
) -> bool:
"""
Ensure required Discord channels exist. Returns True if any were missing.
Args:
token: Discord bot token.
storage_name: Storage channel name for chunks.
index_name: Index channel name for archive cards.
backup_name: Backup channel name for database backups.
Returns:
True if channels were missing and created.
"""
intents = discord.Intents(guilds=True)
client = discord.Client(intents=intents)
created_missing = False
async def on_ready() -> None:
nonlocal created_missing
if not client.guilds:
await client.close()
raise ConfigError("Bot is not connected to any guild.")
guild = client.guilds[0]
storage_channel = discord.utils.get(
guild.text_channels, name=storage_name)
index_channel = discord.utils.get(guild.text_channels, name=index_name)
backup_channel = discord.utils.get(
guild.text_channels, name=backup_name)
if storage_channel is None:
await guild.create_text_channel(storage_name)
created_missing = True
if index_channel is None:
await guild.create_text_channel(index_name)
created_missing = True
if backup_channel is None:
await guild.create_text_channel(backup_name)
created_missing = True
await client.close()
client.event(on_ready)
try:
await client.start(token)
except discord.LoginFailure as exc:
raise ConfigError("Unable to login with the provided token.") from exc
return created_missing
def generate_invite_link(client_id: int) -> str:
"""
Create OAuth2 invite link for the bot.
Args:
client_id: Discord client ID.
Returns:
Invite URL.
"""
permissions = 274877906944 # Manage Threads + Send Messages + Attach Files
return (
"https://discord.com/api/oauth2/authorize"
f"?client_id={client_id}&permissions={permissions}&scope=bot"
)
def run_setup() -> None:
"""
Run the interactive setup wizard.
"""
colorama_init()
setup_logging()
_print_banner()
_check_python_version()
token = prompt_bot_token()
encryption_key = prompt_encryption_key()
config = Config(
discord_bot_token=token,
encryption_key=encryption_key,
storage_channel_name="file-storage-vault",
batch_index_channel_name="batch-index",
backup_channel_name="db-backups",
max_chunk_size=9_500_000,
concurrent_uploads=5,
concurrent_downloads=5,
)
save_config(config)
print(f"\n{Fore.GREEN}✓ Configuration saved to .env{Style.RESET_ALL}")
print(f" Bot Token: {token[:20]}...")
print(f" Encryption Key: {encryption_key[:20]}...")
client_id = asyncio.run(test_connection(token))
if client_id:
invite = generate_invite_link(client_id)
print(f"{Fore.CYAN}Invite link:{Style.RESET_ALL} {invite}")
try:
from src.database import DEFAULT_DB_PATH, init_database
except ImportError:
print("Database module not yet available. Run setup again after Phase 2.")
return
restore_path = prompt_restore_backup()
if restore_path:
if restore_backup(DEFAULT_DB_PATH, restore_path):
print(f"{Fore.GREEN}✓ Database restored from backup.{Style.RESET_ALL}")
else:
print("Backup validation failed. Starting fresh database.")
init_database()
print(f"{Fore.GREEN}✓ Database initialized.{Style.RESET_ALL}")
else:
if DEFAULT_DB_PATH.exists():
DEFAULT_DB_PATH.unlink()
init_database()
print(f"{Fore.GREEN}✓ Database initialized (fresh start).{Style.RESET_ALL}")
channels_missing = asyncio.run(
ensure_channels_setup(
token,
config.storage_channel_name,
config.batch_index_channel_name,
config.backup_channel_name,
)
)
if channels_missing:
if DEFAULT_DB_PATH.exists():
DEFAULT_DB_PATH.unlink()
init_database()
print(
"Channels were missing and have been created. "
"Local database was reset to stay in sync."
)
sync_choice = input(
"Sync local database from Discord now? [y/N]: "
).strip().lower()
if sync_choice == "y":
try:
from src.syncer import sync_from_discord
except ImportError:
print("Sync module not available.")
else:
synced = asyncio.run(sync_from_discord(reset_db=True))
print(
f"{Fore.GREEN}✓ Synced {synced} batches from Discord.{Style.RESET_ALL}")
print(f"{Fore.GREEN}Setup complete.{Style.RESET_ALL} Next step: run `python bot.py`")
def prompt_restore_backup() -> Optional[Path]:
"""
Prompt for an optional database restore.
Returns:
Path to backup file if provided.
"""
choice = input(
"Do you want to restore a database backup? [y/N]: "
).strip().lower()
if choice != "y":
return None
backup_path = input("Enter path to backup file: ").strip()
if not backup_path:
return None
return Path(backup_path).expanduser().resolve()
def validate_backup(backup_path: Path) -> bool:
"""
Validate backup file by checking schema.
Args:
backup_path: Path to backup db file.
Returns:
True if backup contains required tables.
"""
if not backup_path.exists():
print("Backup file not found.")
return False
try:
with sqlite3.connect(backup_path) as conn:
cur = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'")
tables = {row[0] for row in cur.fetchall()}
required = {"batches", "chunks", "files"}
return required.issubset(tables)
except sqlite3.Error:
return False
def restore_backup(db_path: Path, backup_path: Path) -> bool:
"""
Restore database from a backup file if valid.
Args:
db_path: Destination database path.
backup_path: Source backup path.
Returns:
True if restored successfully.
"""
if not validate_backup(backup_path):
return False
try:
db_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(backup_path, db_path)
return True
except Exception:
return False
if __name__ == "__main__":
try:
run_setup()
except ConfigError as exc:
print(f"Setup failed: {exc}")