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.
@@ -0,0 +1,186 @@
1
+ """
2
+ Guild Restoration Worker - Restores server name and vanity URL only
3
+ Triggered by GuildHandler when unauthorized changes are detected
4
+ Supports per-guild user tokens with file storage and memory caching
5
+ """
6
+ import asyncio
7
+ import discord
8
+ import aiohttp
9
+ import json
10
+ from pathlib import Path
11
+ from datetime import datetime
12
+ from typing import Dict, Any, Optional
13
+
14
+
15
+ class GuildWorker:
16
+ """Background worker for guild settings restoration"""
17
+
18
+ def __init__(self, sdk):
19
+ self.sdk = sdk
20
+ self.bot = sdk.bot
21
+ self._worker_task = None
22
+
23
+ # Per-guild user tokens
24
+ self._user_tokens: Dict[int, str] = {} # guild_id -> user_token (cache)
25
+ self._tokens_file = sdk.backup_dir / "user_tokens.json"
26
+
27
+ async def _load_tokens(self):
28
+ """Load user tokens from file into cache"""
29
+ try:
30
+ if self._tokens_file.exists():
31
+ with open(self._tokens_file, 'r') as f:
32
+ data = json.load(f)
33
+ # Convert string keys back to int
34
+ self._user_tokens = {int(k): v for k, v in data.items()}
35
+ print(f"✅ Loaded user tokens for {len(self._user_tokens)} guild(s)")
36
+ except Exception as e:
37
+ print(f"Error loading user tokens: {e}")
38
+
39
+ async def _save_tokens(self):
40
+ """Save user tokens to file"""
41
+ try:
42
+ # Convert int keys to string for JSON
43
+ data = {str(k): v for k, v in self._user_tokens.items()}
44
+ with open(self._tokens_file, 'w') as f:
45
+ json.dump(data, f, indent=2)
46
+ except Exception as e:
47
+ print(f"Error saving user tokens: {e}")
48
+
49
+ async def set_user_token(self, guild_id: int, token: str):
50
+ """Set user token for a specific guild"""
51
+ self._user_tokens[guild_id] = token
52
+ await self._save_tokens()
53
+ print(f"✅ User token set for guild {guild_id}")
54
+
55
+ def get_user_token(self, guild_id: int) -> Optional[str]:
56
+ """Get user token for a guild (from cache)"""
57
+ return self._user_tokens.get(guild_id)
58
+
59
+ async def remove_user_token(self, guild_id: int):
60
+ """Remove user token for a guild"""
61
+ if guild_id in self._user_tokens:
62
+ del self._user_tokens[guild_id]
63
+ await self._save_tokens()
64
+ print(f"❌ User token removed for guild {guild_id}")
65
+
66
+ async def start(self):
67
+ """Start the guild restoration worker"""
68
+ # Load tokens first
69
+ await self._load_tokens()
70
+
71
+ if self._worker_task is None or self._worker_task.done():
72
+ self._worker_task = asyncio.create_task(self._restoration_loop())
73
+ print("⚡ Guild Restoration Worker started")
74
+
75
+ async def stop(self):
76
+ """Stop the worker"""
77
+ if self._worker_task:
78
+ self._worker_task.cancel()
79
+ self._worker_task = None
80
+
81
+ async def _restoration_loop(self):
82
+ """Main worker loop - processes guild_update entries from queue"""
83
+ while True:
84
+ try:
85
+ entry = await self.sdk.guild_queue.get()
86
+
87
+ # Only process guild_update actions
88
+ if entry.action != discord.AuditLogAction.guild_update:
89
+ continue
90
+
91
+ # Extract changes from entry
92
+ guild = entry.guild
93
+ executor = entry.user
94
+
95
+ # Skip if bot did it
96
+ if executor.id == self.bot.user.id:
97
+ continue
98
+
99
+ # Check authorization
100
+ if executor.id == guild.owner_id:
101
+ continue
102
+
103
+ whitelist_set = self.sdk.whitelist_cache.get(guild.id, set())
104
+ if executor.id in whitelist_set:
105
+ continue
106
+
107
+ # Unauthorized change - restore from backup
108
+ print(f"🔍 Unauthorized guild update by {executor.name}")
109
+ await self._restore_guild_settings(guild)
110
+
111
+ except asyncio.CancelledError:
112
+ break
113
+ except Exception as e:
114
+ print(f"Error in guild restoration worker: {e}")
115
+
116
+ async def _restore_vanity_via_api(self, guild_id: int, vanity_code: str) -> bool:
117
+ """Restore vanity URL using user token and Discord API"""
118
+ user_token = self.get_user_token(guild_id)
119
+
120
+ if not user_token:
121
+ print(f"⚠️ No user token set for guild {guild_id}! Cannot restore vanity URL.")
122
+ print(f" Use: await sx.guild_worker.set_user_token({guild_id}, 'USER_TOKEN')")
123
+ return False
124
+
125
+ try:
126
+ url = f"https://discord.com/api/v9/guilds/{guild_id}/vanity-url"
127
+ headers = {
128
+ "authorization": user_token,
129
+ "content-type": "application/json"
130
+ }
131
+ payload = {"code": vanity_code}
132
+
133
+ async with aiohttp.ClientSession() as session:
134
+ async with session.patch(url, headers=headers, json=payload) as response:
135
+ if response.status == 200:
136
+ return True
137
+ else:
138
+ error_text = await response.text()
139
+ print(f"⚠️ Failed to restore vanity: {response.status} - {error_text}")
140
+ return False
141
+ except Exception as e:
142
+ print(f"Error restoring vanity via API: {e}")
143
+ return False
144
+
145
+ async def _restore_guild_settings(self, guild):
146
+ """Restore guild settings (name and vanity) from backup"""
147
+ try:
148
+ print(f"🔧 [DEBUG] Starting restoration for guild: {guild.name}")
149
+ start_time = datetime.now()
150
+
151
+ backup_data = await self.sdk.backup_manager.get_guild_settings(guild.id)
152
+ print(f"📦 [DEBUG] Backup data retrieved: {backup_data}")
153
+ if not backup_data:
154
+ print(f"⚠️ No backup found for guild {guild.name}")
155
+ return
156
+
157
+ restore_params = {}
158
+ restored_items = []
159
+
160
+ # Restore vanity URL if we have it in backup
161
+ if backup_data.get("vanity_url_code"):
162
+ old_vanity = backup_data["vanity_url_code"]
163
+ vanity_success = await self._restore_vanity_via_api(guild.id, old_vanity)
164
+ if vanity_success:
165
+ restored_items.append(f"vanity (discord.gg/{old_vanity})")
166
+
167
+ # Restore server name if different from backup
168
+ if backup_data.get("name") and backup_data["name"] != guild.name:
169
+ restore_params["name"] = backup_data["name"]
170
+ restored_items.append(f"name ({backup_data['name']})")
171
+
172
+ if restore_params:
173
+ await guild.edit(**restore_params, reason="SecureX: Restoring unauthorized guild changes")
174
+
175
+ if restored_items:
176
+ elapsed = (datetime.now() - start_time).total_seconds() * 1000
177
+ items_str = ", ".join(restored_items)
178
+ print(f"⚡ Restored guild settings in {elapsed:.0f}ms: {items_str}")
179
+
180
+ except discord.errors.HTTPException as e:
181
+ if e.code == 50035:
182
+ print(f"⚠️ Some settings could not be restored: {e}")
183
+ else:
184
+ print(f"Error restoring guild settings: {e}")
185
+ except Exception as e:
186
+ print(f"Error in guild restoration: {e}")
@@ -0,0 +1,88 @@
1
+ """
2
+ Log Worker - Logs threats and fires callbacks
3
+ """
4
+ import discord
5
+ import asyncio
6
+ from ..models import ThreatEvent
7
+
8
+
9
+ class LogWorker:
10
+ """Worker that logs threats and triggers callbacks"""
11
+
12
+ def __init__(self, sdk):
13
+ self.sdk = sdk
14
+ self.bot = sdk.bot
15
+ self.log_queue = asyncio.Queue()
16
+ self._worker_task = None
17
+
18
+ self.action_map = {
19
+ discord.AuditLogAction.bot_add: "bot_add",
20
+ discord.AuditLogAction.channel_create: "channel_create",
21
+ discord.AuditLogAction.channel_delete: "channel_delete",
22
+ discord.AuditLogAction.channel_update: "channel_update",
23
+ discord.AuditLogAction.role_create: "role_create",
24
+ discord.AuditLogAction.role_delete: "role_delete",
25
+ discord.AuditLogAction.role_update: "role_update",
26
+ discord.AuditLogAction.kick: "member_kick",
27
+ discord.AuditLogAction.ban: "member_ban",
28
+ discord.AuditLogAction.unban: "member_unban",
29
+ discord.AuditLogAction.member_update: "member_update",
30
+ discord.AuditLogAction.webhook_create: "webhook_create",
31
+ discord.AuditLogAction.guild_update: "guild_update",
32
+ }
33
+
34
+ async def start(self):
35
+ """Start the log worker"""
36
+ if self._worker_task is None or self._worker_task.done():
37
+ self._worker_task = asyncio.create_task(self._worker_loop())
38
+ print("📝 Log Worker started")
39
+
40
+ async def stop(self):
41
+ """Stop the worker"""
42
+ if self._worker_task:
43
+ self._worker_task.cancel()
44
+ self._worker_task = None
45
+
46
+ async def _worker_loop(self):
47
+ """Main worker loop"""
48
+ while True:
49
+ try:
50
+ entry = await self.log_queue.get()
51
+ await self._process_entry(entry)
52
+ except asyncio.CancelledError:
53
+ break
54
+ except Exception as e:
55
+ print(f"Error in log worker: {e}")
56
+
57
+ async def _process_entry(self, entry):
58
+ """Process log entry and fire callbacks"""
59
+ try:
60
+ guild = entry.guild
61
+ executor = entry.user
62
+
63
+ if executor.id == self.bot.user.id or executor.id == guild.owner_id:
64
+ return
65
+
66
+ violation_type = self.action_map.get(entry.action)
67
+ if not violation_type:
68
+ return
69
+
70
+ guild_punishments = self.sdk.punishment_cache.get(guild.id, self.sdk.punishments)
71
+ punishment_action = guild_punishments.get(violation_type, "none")
72
+
73
+ threat_event = ThreatEvent(
74
+ guild_id=guild.id,
75
+ actor_id=executor.id,
76
+ type=violation_type,
77
+ target_id=getattr(entry.target, 'id', None),
78
+ target_name=getattr(entry.target, 'name', 'Unknown'),
79
+ prevented=True,
80
+ restored=True,
81
+ punishment_action=punishment_action,
82
+ timestamp=entry.created_at
83
+ )
84
+
85
+ await self.sdk._trigger_callbacks('threat_detected', threat_event)
86
+
87
+ except Exception as e:
88
+ print(f"Error processing log entry: {e}")
tests/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """
2
+ Test Suite for SecureX SDK
3
+ Run all tests with: pytest tests/
4
+ """
5
+ import sys
6
+ import os
7
+
8
+ # Add parent directory to path
9
+ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
@@ -0,0 +1,210 @@
1
+ import pytest
2
+ import asyncio
3
+ import discord
4
+ from securex import SecureX
5
+ from unittest.mock import Mock, AsyncMock, patch, MagicMock
6
+
7
+ @pytest.mark.asyncio
8
+ class TestClientExhaustive:
9
+ """Exhaustive tests for securex/client.py to reach 100% coverage"""
10
+
11
+ async def test_audit_log_listener_success(self):
12
+ """Test on_audit_log_entry_create when everything is normal"""
13
+ bot = Mock(spec=discord.Client)
14
+ sx = SecureX(bot)
15
+
16
+ # Mock queues
17
+ sx.action_queue = Mock(spec=asyncio.Queue)
18
+ sx.action_queue.put = AsyncMock()
19
+ sx.log_queue = Mock(spec=asyncio.Queue)
20
+ sx.log_queue.put = AsyncMock()
21
+ sx.cleanup_queue = Mock(spec=asyncio.Queue)
22
+ sx.cleanup_queue.put = AsyncMock()
23
+ sx.guild_queue = Mock(spec=asyncio.Queue)
24
+ sx.guild_queue.put = AsyncMock()
25
+
26
+ # Get the listener
27
+ listeners = bot.event.call_args_list
28
+ listener_func = None
29
+ for call in listeners:
30
+ if call[0][0].__name__ == 'on_audit_log_entry_create':
31
+ listener_func = call[0][0]
32
+ break
33
+
34
+ entry = Mock(spec=discord.AuditLogEntry)
35
+ await listener_func(entry)
36
+
37
+ sx.action_queue.put.assert_called_once_with(entry)
38
+ sx.log_queue.put.assert_called_once_with(entry)
39
+ sx.cleanup_queue.put.assert_called_once_with(entry)
40
+ sx.guild_queue.put.assert_called_once_with(entry)
41
+
42
+ async def test_audit_log_listener_queue_full(self):
43
+ """Test on_audit_log_entry_create when queues are full"""
44
+ bot = Mock(spec=discord.Client)
45
+ sx = SecureX(bot)
46
+
47
+ # Mock queues to raise QueueFull
48
+ sx.action_queue = Mock(spec=asyncio.Queue)
49
+ sx.action_queue.put = AsyncMock(side_effect=asyncio.QueueFull)
50
+
51
+ # Get the listener
52
+ listeners = bot.event.call_args_list
53
+ listener_func = None
54
+ for call in listeners:
55
+ if call[0][0].__name__ == 'on_audit_log_entry_create':
56
+ listener_func = call[0][0]
57
+ break
58
+
59
+ assert listener_func is not None
60
+
61
+ entry = Mock(spec=discord.AuditLogEntry)
62
+ # Should catch QueueFull and print warning
63
+ await listener_func(entry)
64
+
65
+ async def test_registered_events(self):
66
+ """Test all registered Discord event listeners in client.py"""
67
+ bot = Mock(spec=discord.Client)
68
+ sx = SecureX(bot)
69
+
70
+ event_mappers = {}
71
+ for call in bot.event.call_args_list:
72
+ func = call[0][0]
73
+ event_mappers[func.__name__] = func
74
+
75
+ # 1. on_guild_channel_delete
76
+ channel = Mock()
77
+ sx.channel_handler.on_channel_delete = AsyncMock()
78
+ await event_mappers['on_guild_channel_delete'](channel)
79
+ sx.channel_handler.on_channel_delete.assert_called_once_with(channel)
80
+
81
+ # 2. on_guild_channel_update
82
+ before, after = Mock(), Mock()
83
+ sx.channel_handler.on_channel_update = AsyncMock()
84
+ await event_mappers['on_guild_channel_update'](before, after)
85
+ sx.channel_handler.on_channel_update.assert_called_once_with(before, after)
86
+
87
+ # 3. on_guild_role_delete
88
+ role = Mock()
89
+ sx.role_handler.on_role_delete = AsyncMock()
90
+ await event_mappers['on_guild_role_delete'](role)
91
+ sx.role_handler.on_role_delete.assert_called_once_with(role)
92
+
93
+ # 4. on_guild_role_update
94
+ before_r, after_r = Mock(), Mock()
95
+ sx.role_handler.on_role_update = AsyncMock()
96
+ await event_mappers['on_guild_role_update'](before_r, after_r)
97
+ sx.role_handler.on_role_update.assert_called_once_with(before_r, after_r)
98
+
99
+ # 5. on_member_ban
100
+ guild, user = Mock(), Mock()
101
+ sx.member_handler.on_member_ban = AsyncMock()
102
+ await event_mappers['on_member_ban'](guild, user)
103
+ sx.member_handler.on_member_ban.assert_called_once_with(guild, user)
104
+
105
+ # 6. on_member_update
106
+ before_m, after_m = Mock(), Mock()
107
+ sx.member_handler.on_member_update = AsyncMock()
108
+ await event_mappers['on_member_update'](before_m, after_m)
109
+ sx.member_handler.on_member_update.assert_called_once_with(before_m, after_m)
110
+
111
+ async def test_callback_decorators_and_triggering(self):
112
+ """Test @sx.on decorators and _trigger_callbacks logic"""
113
+ bot = Mock()
114
+ sx = SecureX(bot)
115
+
116
+ # Test async callback
117
+ mock_async_cb = AsyncMock()
118
+ sx.on('threat_detected')(mock_async_cb)
119
+
120
+ # Test sync callback
121
+ mock_sync_cb = MagicMock()
122
+ sx.on('threat_detected')(mock_sync_cb)
123
+
124
+ await sx._trigger_callbacks('threat_detected', "arg1", extra="data")
125
+
126
+ mock_async_cb.assert_called_once_with("arg1", extra="data")
127
+ mock_sync_cb.assert_called_once_with("arg1", extra="data")
128
+
129
+ # Test callback with exception
130
+ fail_cb = Mock(side_effect=Exception("Callback failed"))
131
+ sx.on('threat_detected')(fail_cb)
132
+ # Should not raise exception
133
+ await sx._trigger_callbacks('threat_detected')
134
+
135
+ # Test triggering non-existent event
136
+ await sx._trigger_callbacks('non_existent')
137
+
138
+ # Test convenience properties
139
+ sx.on_threat_detected(lambda x: x)
140
+ sx.on_backup_completed(lambda x: x)
141
+ sx.on_restore_completed(lambda x: x)
142
+ sx.on_whitelist_changed(lambda x: x)
143
+
144
+ assert len(sx._callbacks['threat_detected']) == 4 # 2 from before + 1 fail + 1 lambda
145
+ assert len(sx._callbacks['backup_completed']) == 1
146
+
147
+ async def test_enable_full(self):
148
+ """Test sx.enable with full set of parameters for max coverage"""
149
+ bot = Mock()
150
+ sx = SecureX(bot)
151
+
152
+ sx.whitelist.preload_all = AsyncMock()
153
+ sx.whitelist.get_all = AsyncMock(return_value=[1, 2])
154
+ sx.whitelist.add = AsyncMock()
155
+ sx.backup_manager.preload_all = AsyncMock()
156
+ sx.backup_manager.create_backup = AsyncMock(return_value=Mock(success=True, channel_count=5, role_count=10))
157
+
158
+ # Mock start_auto_backup and start_auto_refresh manually
159
+ sx.backup_manager.start_auto_backup = MagicMock()
160
+ sx.backup_manager.start_auto_refresh = MagicMock()
161
+
162
+ await sx.enable(
163
+ guild_id=123,
164
+ whitelist=[3],
165
+ auto_backup=True,
166
+ punishments={"channel_delete": "kick"},
167
+ notify_user=True
168
+ )
169
+
170
+ assert sx.whitelist_cache[123] == {1, 2, 3}
171
+ assert sx.punishment_cache[123]["channel_delete"] == "kick"
172
+ sx.backup_manager.start_auto_backup.assert_called_once()
173
+ sx.backup_manager.start_auto_refresh.assert_called_once()
174
+
175
+ async def test_enable_minimal(self):
176
+ """Test sx.enable with minimal parameters"""
177
+ bot = Mock()
178
+ sx = SecureX(bot)
179
+
180
+ # Mocking to avoid real side effects
181
+ sx.whitelist.preload_all = AsyncMock()
182
+ sx.backup_manager.preload_all = AsyncMock()
183
+ sx.backup_manager.start_auto_backup = MagicMock()
184
+ sx.backup_manager.start_auto_refresh = MagicMock()
185
+
186
+ await sx.enable(auto_backup=False)
187
+
188
+ # These should NOT be called if auto_backup is False
189
+ sx.backup_manager.start_auto_backup.assert_not_called()
190
+ sx.backup_manager.start_auto_refresh.assert_not_called()
191
+
192
+ async def test_proxy_methods(self):
193
+ """Test create_backup, restore_channel, and restore_role proxy methods"""
194
+ bot = Mock()
195
+ sx = SecureX(bot)
196
+
197
+ # restore_channel
198
+ sx.backup_manager.restore_channel = AsyncMock(return_value="new_channel")
199
+ res = await sx.restore_channel(Mock(), Mock())
200
+ assert res == "new_channel"
201
+
202
+ # restore_role
203
+ sx.backup_manager.restore_role = AsyncMock(return_value=True)
204
+ res = await sx.restore_role(Mock(), 999)
205
+ assert res is True
206
+
207
+ # create_backup
208
+ sx.backup_manager.create_backup = AsyncMock(return_value="backup_info")
209
+ res = await sx.create_backup(123)
210
+ assert res == "backup_info"