dc-securex 2.29.7__py3-none-any.whl
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.
- dc_securex-2.29.7.dist-info/METADATA +1028 -0
- dc_securex-2.29.7.dist-info/RECORD +28 -0
- dc_securex-2.29.7.dist-info/WHEEL +5 -0
- dc_securex-2.29.7.dist-info/licenses/LICENSE +21 -0
- dc_securex-2.29.7.dist-info/top_level.txt +2 -0
- securex/__init__.py +18 -0
- securex/backup/__init__.py +5 -0
- securex/backup/manager.py +792 -0
- securex/client.py +329 -0
- securex/handlers/__init__.py +8 -0
- securex/handlers/channel.py +97 -0
- securex/handlers/member.py +110 -0
- securex/handlers/role.py +74 -0
- securex/models.py +156 -0
- securex/utils/__init__.py +5 -0
- securex/utils/punishment.py +124 -0
- securex/utils/whitelist.py +129 -0
- securex/workers/__init__.py +8 -0
- securex/workers/action_worker.py +115 -0
- securex/workers/cleanup_worker.py +94 -0
- securex/workers/guild_worker.py +186 -0
- securex/workers/log_worker.py +88 -0
- tests/__init__.py +9 -0
- tests/test_coverage_client.py +210 -0
- tests/test_coverage_handlers.py +341 -0
- tests/test_coverage_manager.py +504 -0
- tests/test_coverage_utils.py +177 -0
- tests/test_coverage_workers.py +297 -0
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Complete backup and restoration manager.
|
|
3
|
+
Extracted from cogs/antinuke.py backup logic.
|
|
4
|
+
"""
|
|
5
|
+
import discord
|
|
6
|
+
import json
|
|
7
|
+
import asyncio
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Optional, Dict, List, Union
|
|
11
|
+
from ..models import BackupInfo, RestoreResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BackupManager:
|
|
15
|
+
"""Manages server backups and restoration"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, sdk):
|
|
18
|
+
self.sdk = sdk
|
|
19
|
+
self.bot = sdk.bot
|
|
20
|
+
self.backup_dir = sdk.backup_dir
|
|
21
|
+
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
self._channel_cache: Dict[int, Dict] = {}
|
|
25
|
+
self._role_cache: Dict[int, Dict] = {}
|
|
26
|
+
self._guild_cache: Dict[int, Dict] = {}
|
|
27
|
+
self._cache_loaded = False
|
|
28
|
+
self._refresh_task = None
|
|
29
|
+
self._guild_locks: Dict[int, asyncio.Lock] = {}
|
|
30
|
+
|
|
31
|
+
def _get_guild_lock(self, guild_id: int) -> asyncio.Lock:
|
|
32
|
+
"""Get or create an async lock for a specific guild"""
|
|
33
|
+
if guild_id not in self._guild_locks:
|
|
34
|
+
self._guild_locks[guild_id] = asyncio.Lock()
|
|
35
|
+
return self._guild_locks[guild_id]
|
|
36
|
+
|
|
37
|
+
async def create_backup(self, guild_id: int) -> BackupInfo:
|
|
38
|
+
"""Create complete backup for a guild"""
|
|
39
|
+
try:
|
|
40
|
+
guild = self.bot.get_guild(guild_id)
|
|
41
|
+
if not guild:
|
|
42
|
+
return BackupInfo(
|
|
43
|
+
guild_id=guild_id,
|
|
44
|
+
timestamp=datetime.now(timezone.utc),
|
|
45
|
+
channel_count=0,
|
|
46
|
+
role_count=0,
|
|
47
|
+
backup_path="",
|
|
48
|
+
success=False
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
channel_count = await self._backup_channels(guild)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
role_count = await self._backup_roles(guild)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
await self._backup_guild_settings(guild)
|
|
59
|
+
|
|
60
|
+
backup_info = BackupInfo(
|
|
61
|
+
guild_id=guild_id,
|
|
62
|
+
timestamp=datetime.now(timezone.utc),
|
|
63
|
+
channel_count=channel_count,
|
|
64
|
+
role_count=role_count,
|
|
65
|
+
backup_path=str(self.backup_dir),
|
|
66
|
+
success=True
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
await self._update_guild_cache(guild_id)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
await self.sdk._trigger_callbacks('backup_completed', backup_info)
|
|
74
|
+
|
|
75
|
+
return backup_info
|
|
76
|
+
|
|
77
|
+
except Exception as e:
|
|
78
|
+
print(f"Error creating backup: {e}")
|
|
79
|
+
return BackupInfo(
|
|
80
|
+
guild_id=guild_id,
|
|
81
|
+
timestamp=datetime.now(timezone.utc),
|
|
82
|
+
channel_count=0,
|
|
83
|
+
role_count=0,
|
|
84
|
+
backup_path="",
|
|
85
|
+
success=False
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
async def _backup_channels(self, guild: discord.Guild) -> int:
|
|
89
|
+
"""Backup all channels"""
|
|
90
|
+
backup_data = {
|
|
91
|
+
"guild_id": guild.id,
|
|
92
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
93
|
+
"channels": []
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for channel in guild.channels:
|
|
97
|
+
channel_data = {
|
|
98
|
+
"id": channel.id,
|
|
99
|
+
"name": channel.name,
|
|
100
|
+
"type": str(channel.type),
|
|
101
|
+
"position": channel.position,
|
|
102
|
+
"category_id": channel.category.id if channel.category else None,
|
|
103
|
+
"permissions": {}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
for target, overwrite in channel.overwrites.items():
|
|
108
|
+
target_id = str(target.id)
|
|
109
|
+
channel_data["permissions"][target_id] = {
|
|
110
|
+
"type": "role" if isinstance(target, discord.Role) else "member",
|
|
111
|
+
"allow": overwrite.pair()[0].value,
|
|
112
|
+
"deny": overwrite.pair()[1].value
|
|
113
|
+
}
|
|
114
|
+
# Type-specific backup data
|
|
115
|
+
if channel.type == discord.ChannelType.text:
|
|
116
|
+
channel_data["text_properties"] = {
|
|
117
|
+
"topic": getattr(channel, 'topic', None),
|
|
118
|
+
"slowmode_delay": getattr(channel, 'slowmode_delay', 0),
|
|
119
|
+
"nsfw": getattr(channel, 'nsfw', False)
|
|
120
|
+
}
|
|
121
|
+
elif channel.type == discord.ChannelType.voice:
|
|
122
|
+
channel_data["voice_properties"] = {
|
|
123
|
+
"bitrate": getattr(channel, 'bitrate', 64000),
|
|
124
|
+
"user_limit": getattr(channel, 'user_limit', 0)
|
|
125
|
+
}
|
|
126
|
+
elif channel.type == discord.ChannelType.stage_voice:
|
|
127
|
+
channel_data["stage_properties"] = {
|
|
128
|
+
"bitrate": getattr(channel, 'bitrate', 64000)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
backup_data["channels"].append(channel_data)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
backup_file = self.backup_dir / f"channels_{guild.id}.json"
|
|
135
|
+
with open(backup_file, 'w') as f:
|
|
136
|
+
json.dump(backup_data, f, indent=2)
|
|
137
|
+
|
|
138
|
+
return len(backup_data["channels"])
|
|
139
|
+
|
|
140
|
+
async def _backup_roles(self, guild: discord.Guild) -> int:
|
|
141
|
+
"""Backup all roles using fetch_roles() for correct ordering"""
|
|
142
|
+
backup_data = {
|
|
143
|
+
"guild_id": guild.id,
|
|
144
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
145
|
+
"roles": []
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
for role in guild.roles:
|
|
150
|
+
if role.is_default():
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
role_data = {
|
|
154
|
+
"id": role.id,
|
|
155
|
+
"name": role.name,
|
|
156
|
+
"permissions": role.permissions.value,
|
|
157
|
+
"color": role.color.value,
|
|
158
|
+
"hoist": role.hoist,
|
|
159
|
+
"mentionable": role.mentionable,
|
|
160
|
+
"position": role.position
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
backup_data["roles"].append(role_data)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
backup_file = self.backup_dir / f"roles_{guild.id}.json"
|
|
167
|
+
with open(backup_file, 'w') as f:
|
|
168
|
+
json.dump(backup_data, f, indent=2)
|
|
169
|
+
|
|
170
|
+
return len(backup_data["roles"])
|
|
171
|
+
|
|
172
|
+
async def restore_category_children(self, guild: discord.Guild, old_category_id: int, new_category: discord.CategoryChannel):
|
|
173
|
+
"""
|
|
174
|
+
Restore all child channels that belonged to a category.
|
|
175
|
+
Called after a category is restored to recreate its children.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
guild: The guild containing the category
|
|
179
|
+
old_category_id: The ID of the deleted category (from backup)
|
|
180
|
+
new_category: The newly created category object (to set as parent)
|
|
181
|
+
"""
|
|
182
|
+
try:
|
|
183
|
+
backup_file = self.backup_dir / f"channels_{guild.id}.json"
|
|
184
|
+
if not backup_file.exists():
|
|
185
|
+
print(f"No channel backup found for guild {guild.id}")
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
with open(backup_file, 'r') as f:
|
|
189
|
+
backup = json.load(f)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
child_channels = [
|
|
193
|
+
ch for ch in backup["channels"]
|
|
194
|
+
if ch.get("category_id") == old_category_id
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
if not child_channels:
|
|
198
|
+
print(f"No child channels found for category {new_category.name} (old ID: {old_category_id})")
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
child_channels = sorted(child_channels, key=lambda x: x.get("position", 0))
|
|
203
|
+
|
|
204
|
+
print(f"Restoring {len(child_channels)} child channels for category: {new_category.name}")
|
|
205
|
+
print(f" Creating in sorted order (positions: {[ch.get('position') for ch in child_channels]})")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
for ch_data in child_channels:
|
|
209
|
+
try:
|
|
210
|
+
|
|
211
|
+
existing = guild.get_channel(ch_data["id"])
|
|
212
|
+
if existing:
|
|
213
|
+
|
|
214
|
+
if existing.category != new_category:
|
|
215
|
+
|
|
216
|
+
await existing.edit(category=new_category, reason="SecureX: Move orphaned channel to restored category")
|
|
217
|
+
print(f"✅ Moved existing channel to category: {existing.name}")
|
|
218
|
+
else:
|
|
219
|
+
print(f"Channel {ch_data['name']} already in correct category, skipping")
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
channel_type_str = ch_data["type"]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
overwrites = {}
|
|
227
|
+
for target_id, perm_data in ch_data.get("permissions", {}).items():
|
|
228
|
+
try:
|
|
229
|
+
target_id = int(target_id)
|
|
230
|
+
if perm_data["type"] == "role":
|
|
231
|
+
target = guild.get_role(target_id)
|
|
232
|
+
else:
|
|
233
|
+
target = guild.get_member(target_id)
|
|
234
|
+
|
|
235
|
+
if target:
|
|
236
|
+
overwrites[target] = discord.PermissionOverwrite.from_pair(
|
|
237
|
+
discord.Permissions(perm_data["allow"]),
|
|
238
|
+
discord.Permissions(perm_data["deny"])
|
|
239
|
+
)
|
|
240
|
+
except Exception as e:
|
|
241
|
+
print(f"Error preparing permissions for {target_id}: {e}")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
new_channel = None
|
|
245
|
+
|
|
246
|
+
if "text" in channel_type_str.lower():
|
|
247
|
+
|
|
248
|
+
props = ch_data.get("text_properties", {})
|
|
249
|
+
new_channel = await guild.create_text_channel(
|
|
250
|
+
name=ch_data["name"],
|
|
251
|
+
category=new_category,
|
|
252
|
+
topic=props.get("topic"),
|
|
253
|
+
slowmode_delay=props.get("slowmode_delay", 0),
|
|
254
|
+
nsfw=props.get("nsfw", False),
|
|
255
|
+
overwrites=overwrites,
|
|
256
|
+
reason="SecureX: Restore category child"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
elif "voice" in channel_type_str.lower():
|
|
260
|
+
|
|
261
|
+
props = ch_data.get("voice_properties", {})
|
|
262
|
+
new_channel = await guild.create_voice_channel(
|
|
263
|
+
name=ch_data["name"],
|
|
264
|
+
category=new_category,
|
|
265
|
+
bitrate=props.get("bitrate", 64000),
|
|
266
|
+
user_limit=props.get("user_limit", 0),
|
|
267
|
+
overwrites=overwrites,
|
|
268
|
+
reason="SecureX: Restore category child"
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
elif "stage" in channel_type_str.lower():
|
|
272
|
+
|
|
273
|
+
props = ch_data.get("stage_properties", {})
|
|
274
|
+
new_channel = await guild.create_stage_channel(
|
|
275
|
+
name=ch_data["name"],
|
|
276
|
+
category=new_category,
|
|
277
|
+
overwrites=overwrites,
|
|
278
|
+
reason="SecureX: Restore category child"
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
if new_channel:
|
|
282
|
+
print(f"✅ Restored child channel: {new_channel.name} (auto-position: {new_channel.position})")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
await asyncio.sleep(0.3)
|
|
286
|
+
|
|
287
|
+
except Exception as e:
|
|
288
|
+
print(f"Error restoring child channel {ch_data.get('name')}: {e}")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
print(f"📝 Updating backup with new category ID: {old_category_id} → {new_category.id}")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
for ch_data in backup["channels"]:
|
|
296
|
+
if ch_data.get("category_id") == old_category_id:
|
|
297
|
+
|
|
298
|
+
ch_data["category_id"] = new_category.id
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
existing = guild.get_channel(ch_data["id"])
|
|
302
|
+
if existing and existing.category == new_category:
|
|
303
|
+
|
|
304
|
+
ch_data["id"] = existing.id
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
with open(backup_file, 'w') as f:
|
|
308
|
+
json.dump(backup, f, indent=2)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
if guild.id in self._channel_cache:
|
|
312
|
+
self._channel_cache[guild.id] = backup
|
|
313
|
+
|
|
314
|
+
print(f"✅ Backup updated - child channels now reference category {new_category.id}")
|
|
315
|
+
print(f"✅ Finished restoring children for category: {new_category.name}")
|
|
316
|
+
|
|
317
|
+
except Exception as e:
|
|
318
|
+
print(f"Error in restore_category_children: {e}")
|
|
319
|
+
|
|
320
|
+
def start_auto_backup(self, interval_minutes: int = 30):
|
|
321
|
+
"""Start automatic backup task"""
|
|
322
|
+
async def auto_backup_task():
|
|
323
|
+
await self.bot.wait_until_ready()
|
|
324
|
+
while not self.bot.is_closed():
|
|
325
|
+
try:
|
|
326
|
+
|
|
327
|
+
for guild in self.bot.guilds:
|
|
328
|
+
await self.create_backup(guild.id)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
await asyncio.sleep(interval_minutes * 60)
|
|
332
|
+
except Exception as e:
|
|
333
|
+
print(f"Error in auto backup: {e}")
|
|
334
|
+
await asyncio.sleep(60)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
self.bot.loop.create_task(auto_backup_task())
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
async def restore_channel(self, guild: discord.Guild, channel: discord.abc.GuildChannel) -> Optional[discord.abc.GuildChannel]:
|
|
342
|
+
"""Restore a deleted channel from backup"""
|
|
343
|
+
try:
|
|
344
|
+
print(f"🛠️ [Debug] Attempting to restore channel: {channel.name} ({channel.id})")
|
|
345
|
+
|
|
346
|
+
backup = self._channel_cache.get(guild.id)
|
|
347
|
+
if not backup:
|
|
348
|
+
print(f"📂 [Debug] Cache miss for guild {guild.id}. Loading from file...")
|
|
349
|
+
|
|
350
|
+
backup_file = self.backup_dir / f"channels_{guild.id}.json"
|
|
351
|
+
if not backup_file.exists():
|
|
352
|
+
print(f"❌ [Debug] Backup file not found: {backup_file}")
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
with open(backup_file, 'r') as f:
|
|
356
|
+
backup = json.load(f)
|
|
357
|
+
self._channel_cache[guild.id] = backup
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
ch_data = None
|
|
361
|
+
channel_index = None
|
|
362
|
+
for i, data in enumerate(backup["channels"]):
|
|
363
|
+
if data["id"] == channel.id:
|
|
364
|
+
ch_data = data
|
|
365
|
+
channel_index = i
|
|
366
|
+
break
|
|
367
|
+
|
|
368
|
+
if not ch_data:
|
|
369
|
+
print(f"❌ [Debug] Channel ID {channel.id} not found in backup data.")
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
print(f"📦 [Debug] Found channel data in backup. Type: {ch_data['type']}")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
category = None
|
|
376
|
+
if ch_data.get("category_id"):
|
|
377
|
+
category = guild.get_channel(ch_data["category_id"])
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
new_channel = None
|
|
381
|
+
channel_type = ch_data["type"]
|
|
382
|
+
|
|
383
|
+
if "text" in channel_type.lower():
|
|
384
|
+
text_props = ch_data.get("text_properties", {})
|
|
385
|
+
new_channel = await guild.create_text_channel(
|
|
386
|
+
name=ch_data["name"],
|
|
387
|
+
category=category,
|
|
388
|
+
topic=text_props.get("topic"),
|
|
389
|
+
slowmode_delay=text_props.get("slowmode_delay", 0),
|
|
390
|
+
nsfw=text_props.get("nsfw", False),
|
|
391
|
+
reason="SecureX: Restoring deleted channel"
|
|
392
|
+
)
|
|
393
|
+
elif "voice" in channel_type.lower():
|
|
394
|
+
voice_props = ch_data.get("voice_properties", {})
|
|
395
|
+
new_channel = await guild.create_voice_channel(
|
|
396
|
+
name=ch_data["name"],
|
|
397
|
+
category=category,
|
|
398
|
+
bitrate=voice_props.get("bitrate", 64000),
|
|
399
|
+
user_limit=voice_props.get("user_limit", 0),
|
|
400
|
+
reason="SecureX: Restoring deleted channel"
|
|
401
|
+
)
|
|
402
|
+
elif "category" in channel_type.lower():
|
|
403
|
+
new_channel = await guild.create_category(
|
|
404
|
+
name=ch_data["name"],
|
|
405
|
+
reason="SecureX: Restoring deleted category"
|
|
406
|
+
)
|
|
407
|
+
elif "stage" in channel_type.lower():
|
|
408
|
+
stage_props = ch_data.get("stage_properties", {})
|
|
409
|
+
new_channel = await guild.create_stage_channel(
|
|
410
|
+
name=ch_data["name"],
|
|
411
|
+
category=category,
|
|
412
|
+
bitrate=stage_props.get("bitrate", 64000),
|
|
413
|
+
reason="SecureX: Restoring deleted channel"
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
if not new_channel:
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
target_position = ch_data.get("order_index", ch_data["position"])
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
try:
|
|
424
|
+
await new_channel.edit(position=target_position)
|
|
425
|
+
print(f"✅ Restored channel: {new_channel.name} ({channel_type}) at order_index {target_position}")
|
|
426
|
+
except discord.Forbidden:
|
|
427
|
+
print(f"⚠️ Cannot set position for {new_channel.name}")
|
|
428
|
+
except Exception as e:
|
|
429
|
+
print(f"⚠️ Failed to set position for {new_channel.name}: {e}")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
if ch_data.get("permissions"):
|
|
433
|
+
for target_id, perm_data in ch_data["permissions"].items():
|
|
434
|
+
try:
|
|
435
|
+
target_id = int(target_id)
|
|
436
|
+
target = guild.get_role(target_id) if perm_data["type"] == "role" else guild.get_member(target_id)
|
|
437
|
+
|
|
438
|
+
if target:
|
|
439
|
+
overwrite = discord.PermissionOverwrite.from_pair(
|
|
440
|
+
discord.Permissions(perm_data["allow"]),
|
|
441
|
+
discord.Permissions(perm_data["deny"])
|
|
442
|
+
)
|
|
443
|
+
await new_channel.set_permissions(target, overwrite=overwrite)
|
|
444
|
+
except Exception as e:
|
|
445
|
+
print(f"Error restoring permission: {e}")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
if guild.id in self._channel_cache and channel_index is not None:
|
|
449
|
+
self._channel_cache[guild.id]["channels"][channel_index]["id"] = new_channel.id
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
backup_file = self.backup_dir / f"channels_{guild.id}.json"
|
|
453
|
+
with open(backup_file, 'w') as f:
|
|
454
|
+
json.dump(self._channel_cache[guild.id], f, indent=2)
|
|
455
|
+
|
|
456
|
+
return new_channel
|
|
457
|
+
|
|
458
|
+
except Exception as e:
|
|
459
|
+
print(f"Error restoring channel: {e}")
|
|
460
|
+
return None
|
|
461
|
+
|
|
462
|
+
async def restore_channel_permissions(self, guild: discord.Guild, channel_id: int) -> bool:
|
|
463
|
+
"""Restore channel permissions from backup"""
|
|
464
|
+
try:
|
|
465
|
+
backup_file = self.backup_dir / f"channels_{guild.id}.json"
|
|
466
|
+
if not backup_file.exists():
|
|
467
|
+
return False
|
|
468
|
+
|
|
469
|
+
with open(backup_file, 'r') as f:
|
|
470
|
+
backup = json.load(f)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
ch_data = None
|
|
474
|
+
for data in backup["channels"]:
|
|
475
|
+
if data["id"] == channel_id:
|
|
476
|
+
ch_data = data
|
|
477
|
+
break
|
|
478
|
+
|
|
479
|
+
if not ch_data:
|
|
480
|
+
return False
|
|
481
|
+
|
|
482
|
+
channel = guild.get_channel(channel_id)
|
|
483
|
+
if not channel:
|
|
484
|
+
return False
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
if ch_data.get("permissions"):
|
|
488
|
+
for target_id, perm_data in ch_data["permissions"].items():
|
|
489
|
+
try:
|
|
490
|
+
target_id = int(target_id)
|
|
491
|
+
target = guild.get_role(target_id) if perm_data["type"] == "role" else guild.get_member(target_id)
|
|
492
|
+
|
|
493
|
+
if target:
|
|
494
|
+
overwrite = discord.PermissionOverwrite.from_pair(
|
|
495
|
+
discord.Permissions(perm_data["allow"]),
|
|
496
|
+
discord.Permissions(perm_data["deny"])
|
|
497
|
+
)
|
|
498
|
+
await channel.set_permissions(target, overwrite=overwrite)
|
|
499
|
+
except Exception:
|
|
500
|
+
pass
|
|
501
|
+
|
|
502
|
+
return True
|
|
503
|
+
|
|
504
|
+
except Exception as e:
|
|
505
|
+
print(f"Error restoring channel permissions: {e}")
|
|
506
|
+
return False
|
|
507
|
+
|
|
508
|
+
async def restore_role(self, guild: discord.Guild, role_id: int) -> bool:
|
|
509
|
+
"""Restore a deleted role from backup"""
|
|
510
|
+
try:
|
|
511
|
+
|
|
512
|
+
backup = self._role_cache.get(guild.id)
|
|
513
|
+
if not backup:
|
|
514
|
+
|
|
515
|
+
backup_file = self.backup_dir / f"roles_{guild.id}.json"
|
|
516
|
+
if not backup_file.exists():
|
|
517
|
+
return False
|
|
518
|
+
|
|
519
|
+
with open(backup_file, 'r') as f:
|
|
520
|
+
backup = json.load(f)
|
|
521
|
+
self._role_cache[guild.id] = backup
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
target_role_data = None
|
|
525
|
+
role_index = None
|
|
526
|
+
for i, role_data in enumerate(backup["roles"]):
|
|
527
|
+
if role_data["id"] == role_id:
|
|
528
|
+
target_role_data = role_data
|
|
529
|
+
role_index = i
|
|
530
|
+
break
|
|
531
|
+
|
|
532
|
+
if not target_role_data:
|
|
533
|
+
return False
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
new_role = await guild.create_role(
|
|
537
|
+
name=target_role_data["name"],
|
|
538
|
+
permissions=discord.Permissions(target_role_data["permissions"]),
|
|
539
|
+
color=discord.Color(target_role_data["color"]),
|
|
540
|
+
hoist=target_role_data["hoist"],
|
|
541
|
+
mentionable=target_role_data["mentionable"],
|
|
542
|
+
reason="SecureX: Restoring deleted role"
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
try:
|
|
547
|
+
await guild.edit_role_positions(positions={new_role: target_role_data["position"]})
|
|
548
|
+
print(f"✅ Restored role: {new_role.name} at position {target_role_data['position']}")
|
|
549
|
+
except discord.Forbidden:
|
|
550
|
+
print(f"⚠️ Cannot set position for {new_role.name} - bot hierarchy too low")
|
|
551
|
+
except Exception as e:
|
|
552
|
+
print(f"⚠️ Failed to set position for {new_role.name}: {e}")
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
if guild.id in self._role_cache and role_index is not None:
|
|
556
|
+
|
|
557
|
+
self._role_cache[guild.id]["roles"][role_index]["id"] = new_role.id
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
backup_file = self.backup_dir / f"roles_{guild.id}.json"
|
|
561
|
+
with open(backup_file, 'w') as f:
|
|
562
|
+
json.dump(self._role_cache[guild.id], f, indent=2)
|
|
563
|
+
|
|
564
|
+
return True
|
|
565
|
+
except Exception as e:
|
|
566
|
+
print(f"Error restoring role: {e}")
|
|
567
|
+
return False
|
|
568
|
+
|
|
569
|
+
async def restore_role_permissions(self, guild: discord.Guild, role_id: int) -> bool:
|
|
570
|
+
"""Restore role permissions from backup"""
|
|
571
|
+
try:
|
|
572
|
+
backup_file = self.backup_dir / f"roles_{guild.id}.json"
|
|
573
|
+
if not backup_file.exists():
|
|
574
|
+
return False
|
|
575
|
+
|
|
576
|
+
with open(backup_file, 'r') as f:
|
|
577
|
+
backup = json.load(f)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
for role_data in backup["roles"]:
|
|
581
|
+
if role_data["id"] == role_id:
|
|
582
|
+
role = guild.get_role(role_id)
|
|
583
|
+
if not role:
|
|
584
|
+
return False
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
await role.edit(
|
|
588
|
+
permissions=discord.Permissions(role_data["permissions"]),
|
|
589
|
+
position=role_data["position"],
|
|
590
|
+
reason="SecureX: Restoring role permissions"
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
return True
|
|
594
|
+
|
|
595
|
+
return False
|
|
596
|
+
except Exception as e:
|
|
597
|
+
print(f"Error restoring role permissions: {e}")
|
|
598
|
+
return False
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
async def preload_all(self):
|
|
606
|
+
"""
|
|
607
|
+
Preload ALL backups into memory on startup.
|
|
608
|
+
Call this in enable() to warm the cache.
|
|
609
|
+
"""
|
|
610
|
+
if self._cache_loaded:
|
|
611
|
+
return
|
|
612
|
+
|
|
613
|
+
print("🔄 Preloading backups into cache...")
|
|
614
|
+
channel_count = 0
|
|
615
|
+
role_count = 0
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
for file_path in self.backup_dir.glob("channels_*.json"):
|
|
619
|
+
try:
|
|
620
|
+
guild_id = int(file_path.stem.replace("channels_", ""))
|
|
621
|
+
with open(file_path, 'r') as f:
|
|
622
|
+
self._channel_cache[guild_id] = json.load(f)
|
|
623
|
+
channel_count += 1
|
|
624
|
+
except Exception as e:
|
|
625
|
+
print(f"Error loading channel backup {file_path}: {e}")
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
for file_path in self.backup_dir.glob("roles_*.json"):
|
|
629
|
+
try:
|
|
630
|
+
guild_id = int(file_path.stem.replace("roles_", ""))
|
|
631
|
+
with open(file_path, 'r') as f:
|
|
632
|
+
self._role_cache[guild_id] = json.load(f)
|
|
633
|
+
role_count += 1
|
|
634
|
+
except Exception as e:
|
|
635
|
+
print(f"Error loading role backup {file_path}: {e}")
|
|
636
|
+
|
|
637
|
+
self._cache_loaded = True
|
|
638
|
+
print(f"✅ Cached {channel_count} channel backups and {role_count} role backups")
|
|
639
|
+
|
|
640
|
+
async def _update_guild_cache(self, guild_id: int):
|
|
641
|
+
"""Update cache for a specific guild after backup"""
|
|
642
|
+
try:
|
|
643
|
+
|
|
644
|
+
channel_file = self.backup_dir / f"channels_{guild_id}.json"
|
|
645
|
+
if channel_file.exists():
|
|
646
|
+
with open(channel_file, 'r') as f:
|
|
647
|
+
self._channel_cache[guild_id] = json.load(f)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
role_file = self.backup_dir / f"roles_{guild_id}.json"
|
|
651
|
+
if role_file.exists():
|
|
652
|
+
with open(role_file, 'r') as f:
|
|
653
|
+
self._role_cache[guild_id] = json.load(f)
|
|
654
|
+
except Exception as e:
|
|
655
|
+
print(f"Error updating cache for guild {guild_id}: {e}")
|
|
656
|
+
|
|
657
|
+
"""Start background task to refresh cache every 10 minutes"""
|
|
658
|
+
if self._refresh_task is None:
|
|
659
|
+
self._refresh_task = asyncio.create_task(self._auto_refresh_loop())
|
|
660
|
+
print("🔄 Started backup cache auto-refresh (every 10 minutes)")
|
|
661
|
+
|
|
662
|
+
async def _auto_refresh_loop(self):
|
|
663
|
+
"""Background task: refresh cache every 10 minutes"""
|
|
664
|
+
while True:
|
|
665
|
+
try:
|
|
666
|
+
await asyncio.sleep(600)
|
|
667
|
+
|
|
668
|
+
print("🔄 Refreshing backup cache...")
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
for guild in self.bot.guilds:
|
|
672
|
+
await self._update_guild_cache(guild.id)
|
|
673
|
+
|
|
674
|
+
# Also refresh guild settings (vanity, name, etc.)
|
|
675
|
+
await self._backup_guild_settings(guild)
|
|
676
|
+
|
|
677
|
+
await asyncio.sleep(0.1)
|
|
678
|
+
|
|
679
|
+
print("✅ Backup cache refreshed")
|
|
680
|
+
|
|
681
|
+
except asyncio.CancelledError:
|
|
682
|
+
break
|
|
683
|
+
except Exception as e:
|
|
684
|
+
print(f"Error in auto-refresh loop: {e}")
|
|
685
|
+
|
|
686
|
+
def start_auto_refresh(self):
|
|
687
|
+
"""Start background cache refresh task"""
|
|
688
|
+
if self._refresh_task is None or self._refresh_task.done():
|
|
689
|
+
self._refresh_task = asyncio.create_task(self._auto_refresh_loop())
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
async def _backup_guild_settings(self, guild: discord.Guild) -> Dict:
|
|
694
|
+
"""Backup guild settings (vanity URL, name, etc.) to consolidated file"""
|
|
695
|
+
try:
|
|
696
|
+
backup_data = {
|
|
697
|
+
"guild_id": guild.id,
|
|
698
|
+
"vanity_url_code": guild.vanity_url_code,
|
|
699
|
+
"name": guild.name,
|
|
700
|
+
"icon": str(guild.icon) if guild.icon else None,
|
|
701
|
+
"banner": str(guild.banner) if guild.banner else None,
|
|
702
|
+
"description": guild.description,
|
|
703
|
+
"timestamp": datetime.now(timezone.utc).isoformat()
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
# Update cache
|
|
707
|
+
self._guild_cache[guild.id] = backup_data
|
|
708
|
+
|
|
709
|
+
# Save to consolidated file
|
|
710
|
+
await self._save_all_guild_settings()
|
|
711
|
+
|
|
712
|
+
return backup_data
|
|
713
|
+
|
|
714
|
+
except Exception as e:
|
|
715
|
+
print(f"Error backing up guild settings: {e}")
|
|
716
|
+
return {}
|
|
717
|
+
|
|
718
|
+
async def _save_all_guild_settings(self):
|
|
719
|
+
"""Save all guild settings to single file"""
|
|
720
|
+
try:
|
|
721
|
+
settings_file = self.backup_dir / "guild_settings.json"
|
|
722
|
+
# Convert int keys to string for JSON
|
|
723
|
+
data = {str(k): v for k, v in self._guild_cache.items()}
|
|
724
|
+
with open(settings_file, 'w') as f:
|
|
725
|
+
json.dump(data, f, indent=2)
|
|
726
|
+
except Exception as e:
|
|
727
|
+
print(f"Error saving guild settings: {e}")
|
|
728
|
+
|
|
729
|
+
async def _load_all_guild_settings(self):
|
|
730
|
+
"""Load all guild settings from single file into cache"""
|
|
731
|
+
try:
|
|
732
|
+
settings_file = self.backup_dir / "guild_settings.json"
|
|
733
|
+
if settings_file.exists():
|
|
734
|
+
with open(settings_file, 'r') as f:
|
|
735
|
+
data = json.load(f)
|
|
736
|
+
# Convert string keys back to int
|
|
737
|
+
self._guild_cache = {int(k): v for k, v in data.items()}
|
|
738
|
+
print(f"✅ Loaded guild settings for {len(self._guild_cache)} server(s)")
|
|
739
|
+
except Exception as e:
|
|
740
|
+
print(f"Error loading guild settings: {e}")
|
|
741
|
+
|
|
742
|
+
async def update_guild_vanity(self, guild_id: int, vanity_code: str):
|
|
743
|
+
"""Update vanity URL in backup and cache (for authorized changes)"""
|
|
744
|
+
try:
|
|
745
|
+
if guild_id in self._guild_cache:
|
|
746
|
+
self._guild_cache[guild_id]["vanity_url_code"] = vanity_code
|
|
747
|
+
self._guild_cache[guild_id]["timestamp"] = datetime.now(timezone.utc).isoformat()
|
|
748
|
+
else:
|
|
749
|
+
self._guild_cache[guild_id] = {
|
|
750
|
+
"guild_id": guild_id,
|
|
751
|
+
"vanity_url_code": vanity_code,
|
|
752
|
+
"timestamp": datetime.now(timezone.utc).isoformat()
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
await self._save_all_guild_settings()
|
|
756
|
+
print(f"✅ Updated vanity backup: discord.gg/{vanity_code}")
|
|
757
|
+
|
|
758
|
+
except Exception as e:
|
|
759
|
+
print(f"Error updating guild vanity: {e}")
|
|
760
|
+
|
|
761
|
+
async def get_guild_vanity(self, guild_id: int) -> Optional[str]:
|
|
762
|
+
"""Get backed up vanity URL"""
|
|
763
|
+
try:
|
|
764
|
+
if guild_id in self._guild_cache:
|
|
765
|
+
return self._guild_cache[guild_id].get("vanity_url_code")
|
|
766
|
+
|
|
767
|
+
await self._load_all_guild_settings()
|
|
768
|
+
|
|
769
|
+
if guild_id in self._guild_cache:
|
|
770
|
+
return self._guild_cache[guild_id].get("vanity_url_code")
|
|
771
|
+
|
|
772
|
+
return None
|
|
773
|
+
|
|
774
|
+
except Exception as e:
|
|
775
|
+
print(f"Error getting guild vanity: {e}")
|
|
776
|
+
return None
|
|
777
|
+
|
|
778
|
+
async def get_guild_settings(self, guild_id: int) -> Optional[Dict]:
|
|
779
|
+
"""Get backed up guild settings"""
|
|
780
|
+
try:
|
|
781
|
+
if guild_id in self._guild_cache:
|
|
782
|
+
return self._guild_cache[guild_id]
|
|
783
|
+
|
|
784
|
+
await self._load_all_guild_settings()
|
|
785
|
+
|
|
786
|
+
if guild_id in self._guild_cache:
|
|
787
|
+
return self._guild_cache[guild_id]
|
|
788
|
+
|
|
789
|
+
return None
|
|
790
|
+
|
|
791
|
+
except Exception as e:
|
|
792
|
+
print(f"Error getting guild settings: {e}")
|