pentool 0.1.0__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.
- pentool/__init__.py +4 -0
- pentool/__main__.py +16 -0
- pentool/api/__init__.py +41 -0
- pentool/api/base_api.py +48 -0
- pentool/api/comparer_api.py +19 -0
- pentool/api/decoder_api.py +24 -0
- pentool/api/intruder_api.py +171 -0
- pentool/api/proxy_api.py +210 -0
- pentool/api/repeater_api.py +57 -0
- pentool/api/scanner_api.py +266 -0
- pentool/api/sequencer_api.py +17 -0
- pentool/api/spider_api.py +106 -0
- pentool/api/target_api.py +87 -0
- pentool/cli/__init__.py +1 -0
- pentool/cli/main.py +98 -0
- pentool/cli/project.py +88 -0
- pentool/cli/proxy.py +173 -0
- pentool/cli/scan.py +115 -0
- pentool/core/__init__.py +1 -0
- pentool/core/config.py +171 -0
- pentool/core/crash_reporter.py +75 -0
- pentool/core/database.py +139 -0
- pentool/core/event_bus.py +207 -0
- pentool/core/events.py +160 -0
- pentool/core/features.py +373 -0
- pentool/core/license.py +274 -0
- pentool/core/logging.py +54 -0
- pentool/core/plugin_manager.py +312 -0
- pentool/core/project.py +31 -0
- pentool/core/storage_interface.py +307 -0
- pentool/core/updater.py +96 -0
- pentool/core/utils.py +36 -0
- pentool/modules/__init__.py +1 -0
- pentool/modules/comparer.py +177 -0
- pentool/modules/decoder.py +281 -0
- pentool/modules/intruder.py +428 -0
- pentool/modules/intruder_turbo.py +176 -0
- pentool/modules/match_replace.py +142 -0
- pentool/modules/proxy.py +797 -0
- pentool/modules/repeater.py +195 -0
- pentool/modules/sequencer.py +492 -0
- pentool/modules/spider.py +679 -0
- pentool/modules/target.py +185 -0
- pentool/modules/websocket_handler.py +249 -0
- pentool/plugins/__init__.py +1 -0
- pentool/plugins/builtin/__init__.py +1 -0
- pentool/plugins/builtin/payloads_pro.py +381 -0
- pentool/plugins/builtin/reports_pro.py +436 -0
- pentool/plugins/builtin/scanner_pro.py +291 -0
- pentool/plugins/example_plugin.py +39 -0
- pentool/services/__init__.py +1 -0
- pentool/services/base_service.py +60 -0
- pentool/services/intruder_service.py +94 -0
- pentool/services/proxy_service.py +178 -0
- pentool/services/repeater_service.py +85 -0
- pentool/services/scan_service.py +330 -0
- pentool/storage/__init__.py +0 -0
- pentool/storage/http_storage.py +506 -0
- pentool/storage/large_body_handler.py +50 -0
- pentool/storage/lru_cache.py +45 -0
- pentool/t.py +213 -0
- pentool/tui/__init__.py +1 -0
- pentool/tui/app.py +1429 -0
- pentool/tui/constants.py +79 -0
- pentool/tui/dialogs/__init__.py +0 -0
- pentool/tui/dialogs/cert_dialog.py +81 -0
- pentool/tui/dialogs/file_selector.py +226 -0
- pentool/tui/dialogs/load_from_proxy.py +125 -0
- pentool/tui/dialogs/match_replace_dialog.py +229 -0
- pentool/tui/dialogs/project_dialog.py +82 -0
- pentool/tui/dialogs/scope_dialog.py +224 -0
- pentool/tui/messages.py +103 -0
- pentool/tui/mixins/__init__.py +0 -0
- pentool/tui/mixins/app_mixin.py +59 -0
- pentool/tui/mixins/dialog_cancel.py +21 -0
- pentool/tui/mixins/request_context_menu.py +247 -0
- pentool/tui/mixins/tab_rename.py +89 -0
- pentool/tui/screens/__init__.py +31 -0
- pentool/tui/screens/comparer/__init__.py +3 -0
- pentool/tui/screens/comparer/screen.py +229 -0
- pentool/tui/screens/dashboard/__init__.py +2 -0
- pentool/tui/screens/dashboard/live_dashboard.py +730 -0
- pentool/tui/screens/dashboard/screen.py +771 -0
- pentool/tui/screens/decoder/__init__.py +3 -0
- pentool/tui/screens/decoder/screen.py +306 -0
- pentool/tui/screens/extensions/__init__.py +3 -0
- pentool/tui/screens/extensions/screen.py +24 -0
- pentool/tui/screens/intruder/__init__.py +3 -0
- pentool/tui/screens/intruder/screen.py +1356 -0
- pentool/tui/screens/proxy/__init__.py +3 -0
- pentool/tui/screens/proxy/screen.py +1554 -0
- pentool/tui/screens/repeater/__init__.py +3 -0
- pentool/tui/screens/repeater/screen.py +621 -0
- pentool/tui/screens/scanner/__init__.py +3 -0
- pentool/tui/screens/scanner/screen.py +1956 -0
- pentool/tui/screens/sequencer/__init__.py +3 -0
- pentool/tui/screens/sequencer/screen.py +516 -0
- pentool/tui/screens/settings/__init__.py +5 -0
- pentool/tui/screens/settings/hotkeys.py +134 -0
- pentool/tui/screens/settings/screen.py +540 -0
- pentool/tui/screens/spider/__init__.py +3 -0
- pentool/tui/screens/spider/screen.py +380 -0
- pentool/tui/screens/target/__init__.py +4 -0
- pentool/tui/screens/target/screen.py +358 -0
- pentool/tui/screens/terminal/__init__.py +5 -0
- pentool/tui/screens/terminal/screen.py +179 -0
- pentool/tui/widgets/__init__.py +1 -0
- pentool/tui/widgets/context_menu.py +148 -0
- pentool/tui/widgets/filter_bar.py +204 -0
- pentool/tui/widgets/inspector_panel.py +111 -0
- pentool/tui/widgets/menu.py +86 -0
- pentool/tui/widgets/menu_bar.py +238 -0
- pentool/tui/widgets/module_tabs.py +68 -0
- pentool/tui/widgets/payload_drop_zone.py +65 -0
- pentool/tui/widgets/request_editor.py +495 -0
- pentool/tui/widgets/resize_handle.py +116 -0
- pentool/tui/widgets/search_bar.py +112 -0
- pentool/tui/widgets/statusbar.py +96 -0
- pentool/tui/widgets/toolbar_button.py +68 -0
- pentool/utils/__init__.py +1 -0
- pentool/utils/cert.py +314 -0
- pentool/utils/coder.py +170 -0
- pentool/utils/copy_as.py +250 -0
- pentool/utils/diff.py +82 -0
- pentool/utils/http_client.py +145 -0
- pentool/utils/parser.py +227 -0
- pentool/utils/terminal_check.py +31 -0
- pentool-0.1.0.dist-info/METADATA +231 -0
- pentool-0.1.0.dist-info/RECORD +133 -0
- pentool-0.1.0.dist-info/WHEEL +5 -0
- pentool-0.1.0.dist-info/entry_points.txt +2 -0
- pentool-0.1.0.dist-info/licenses/LICENSE +34 -0
- pentool-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Event Bus — внутренняя шина событий приложения."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import threading
|
|
7
|
+
from collections import defaultdict, deque
|
|
8
|
+
from typing import Callable, TypeVar
|
|
9
|
+
|
|
10
|
+
from pentool.core.events import AppEvent
|
|
11
|
+
from pentool.core.logging import get_logger
|
|
12
|
+
|
|
13
|
+
logger = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T", bound=AppEvent)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EventBus:
|
|
19
|
+
"""Thread-safe шина событий с историей.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
_subscribers: Словарь type → список обработчиков.
|
|
23
|
+
_history: Кольцевой буфер всех эмиченных событий.
|
|
24
|
+
_lock: RLock для thread-safe операций.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, max_history: int = 10_000) -> None:
|
|
28
|
+
self._subscribers: dict[type, list[Callable]] = defaultdict(list)
|
|
29
|
+
self._history: deque[AppEvent] = deque(maxlen=max_history)
|
|
30
|
+
self._lock = threading.RLock()
|
|
31
|
+
|
|
32
|
+
# ── subscribe / unsubscribe ────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
def subscribe(self, event_type: type[T], handler: Callable[[T], None]) -> None:
|
|
35
|
+
"""Подписаться на события заданного типа.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
event_type: Класс события (подкласс AppEvent).
|
|
39
|
+
handler: Функция-обработчик, принимающая экземпляр события.
|
|
40
|
+
"""
|
|
41
|
+
with self._lock:
|
|
42
|
+
if handler not in self._subscribers[event_type]:
|
|
43
|
+
self._subscribers[event_type].append(handler)
|
|
44
|
+
|
|
45
|
+
def unsubscribe(self, event_type: type, handler: Callable) -> None:
|
|
46
|
+
"""Отписаться от событий.
|
|
47
|
+
|
|
48
|
+
Безопасен при повторном вызове — не бросает исключение если
|
|
49
|
+
обработчик не был подписан.
|
|
50
|
+
"""
|
|
51
|
+
with self._lock:
|
|
52
|
+
try:
|
|
53
|
+
self._subscribers[event_type].remove(handler)
|
|
54
|
+
except ValueError:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
def unsubscribe_all(self, handler: Callable) -> None:
|
|
58
|
+
"""Отписать обработчик от всех типов событий сразу."""
|
|
59
|
+
with self._lock:
|
|
60
|
+
for handlers in self._subscribers.values():
|
|
61
|
+
try:
|
|
62
|
+
handlers.remove(handler)
|
|
63
|
+
except ValueError:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
# ── emit ──────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
def emit(self, event: AppEvent) -> None:
|
|
69
|
+
"""Синхронный emit из основного потока.
|
|
70
|
+
|
|
71
|
+
Вызывает всех подписчиков немедленно (в том же потоке).
|
|
72
|
+
Сохраняет событие в историю.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
event: Экземпляр события (подкласс AppEvent).
|
|
76
|
+
"""
|
|
77
|
+
with self._lock:
|
|
78
|
+
self._history.append(event)
|
|
79
|
+
handlers = list(self._subscribers.get(type(event), []))
|
|
80
|
+
|
|
81
|
+
for handler in handlers:
|
|
82
|
+
try:
|
|
83
|
+
handler(event)
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
logger.warning(
|
|
86
|
+
"EventBus: handler %s raised %s for event %s",
|
|
87
|
+
handler, exc, type(event).__name__,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def emit_threadsafe(
|
|
91
|
+
self,
|
|
92
|
+
event: AppEvent,
|
|
93
|
+
loop: asyncio.AbstractEventLoop,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Thread-safe emit из worker-потока.
|
|
96
|
+
|
|
97
|
+
Сохраняет событие в историю немедленно (под локом),
|
|
98
|
+
затем планирует вызов обработчиков в event loop через
|
|
99
|
+
call_soon_threadsafe.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
event: Экземпляр события.
|
|
103
|
+
loop: Event loop основного потока (app._loop).
|
|
104
|
+
"""
|
|
105
|
+
with self._lock:
|
|
106
|
+
self._history.append(event)
|
|
107
|
+
handlers = list(self._subscribers.get(type(event), []))
|
|
108
|
+
|
|
109
|
+
if not handlers:
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
def _dispatch() -> None:
|
|
113
|
+
for handler in handlers:
|
|
114
|
+
try:
|
|
115
|
+
handler(event)
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
logger.warning(
|
|
118
|
+
"EventBus: threadsafe handler %s raised %s for %s",
|
|
119
|
+
handler, exc, type(event).__name__,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
loop.call_soon_threadsafe(_dispatch)
|
|
124
|
+
except RuntimeError:
|
|
125
|
+
# loop закрыт — приложение завершается
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
# ── history / replay ──────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
def get_history(
|
|
131
|
+
self,
|
|
132
|
+
event_type: type | None = None,
|
|
133
|
+
limit: int = 0,
|
|
134
|
+
) -> list[AppEvent]:
|
|
135
|
+
with self._lock:
|
|
136
|
+
events = list(self._history)
|
|
137
|
+
|
|
138
|
+
if event_type is not None:
|
|
139
|
+
events = [e for e in events if isinstance(e, event_type)]
|
|
140
|
+
|
|
141
|
+
if limit > 0:
|
|
142
|
+
events = events[-limit:]
|
|
143
|
+
|
|
144
|
+
return events
|
|
145
|
+
|
|
146
|
+
def replay(
|
|
147
|
+
self,
|
|
148
|
+
handler: Callable,
|
|
149
|
+
event_type: type,
|
|
150
|
+
limit: int = 0,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Воспроизвести историю событий для нового подписчика.
|
|
153
|
+
|
|
154
|
+
Вызывает handler для каждого сохранённого события заданного типа.
|
|
155
|
+
Полезно при инициализации экрана, который подключился позже
|
|
156
|
+
(например, Dashboard после запуска сканирования).
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
handler: Функция-обработчик.
|
|
160
|
+
event_type: Тип событий для replay.
|
|
161
|
+
limit: Максимум событий (0 = все).
|
|
162
|
+
"""
|
|
163
|
+
events = self.get_history(event_type=event_type, limit=limit)
|
|
164
|
+
for event in events:
|
|
165
|
+
try:
|
|
166
|
+
handler(event)
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
logger.warning(
|
|
169
|
+
"EventBus.replay: handler %s raised %s", handler, exc
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def clear_history(self) -> None:
|
|
173
|
+
with self._lock:
|
|
174
|
+
self._history.clear()
|
|
175
|
+
|
|
176
|
+
# ── stats ─────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
def stats(self) -> dict[str, int]:
|
|
179
|
+
"""Статистика для отладки."""
|
|
180
|
+
with self._lock:
|
|
181
|
+
return {
|
|
182
|
+
"history_size": len(self._history),
|
|
183
|
+
"subscriber_types": len(self._subscribers),
|
|
184
|
+
"total_handlers": sum(len(v) for v in self._subscribers.values()),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ── Глобальный синглтон ────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
_bus: EventBus | None = None
|
|
191
|
+
_bus_lock = threading.Lock()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def get_event_bus() -> EventBus:
|
|
195
|
+
global _bus
|
|
196
|
+
if _bus is None:
|
|
197
|
+
with _bus_lock:
|
|
198
|
+
if _bus is None:
|
|
199
|
+
_bus = EventBus()
|
|
200
|
+
return _bus
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def reset_event_bus() -> None:
|
|
204
|
+
"""Сбросить глобальный синглтон (только для тестов)."""
|
|
205
|
+
global _bus
|
|
206
|
+
with _bus_lock:
|
|
207
|
+
_bus = None
|
pentool/core/events.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Типизированные события приложения для Event Bus."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class AppEvent:
|
|
12
|
+
"""Базовый класс для всех событий."""
|
|
13
|
+
timestamp: float = field(default_factory=time.time)
|
|
14
|
+
source: str = "" # имя модуля-источника, для отладки
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ── Scanner events ─────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ScanStarted(AppEvent):
|
|
21
|
+
"""Сканирование запущено."""
|
|
22
|
+
targets: list[str] = field(default_factory=list)
|
|
23
|
+
checks: list[str] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ScanFinished(AppEvent):
|
|
28
|
+
"""Сканирование завершено (нормально или по стопу)."""
|
|
29
|
+
total_findings: int = 0
|
|
30
|
+
stopped_early: bool = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ScanProgressEvent(AppEvent):
|
|
35
|
+
"""Прогресс сканирования."""
|
|
36
|
+
done: int = 0
|
|
37
|
+
total: int = 0
|
|
38
|
+
scanning: bool = True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class FindingDiscovered(AppEvent):
|
|
43
|
+
"""Обнаружена уязвимость (активный или пассивный скан)."""
|
|
44
|
+
finding: Any = None
|
|
45
|
+
scan_source: str = "active" # "active" | "passive"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── Spider events ──────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class UrlCrawled(AppEvent):
|
|
52
|
+
"""Spider нашёл новый URL."""
|
|
53
|
+
url: str = ""
|
|
54
|
+
base_target: str = ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class SpiderFinished(AppEvent):
|
|
59
|
+
"""Краулинг завершён."""
|
|
60
|
+
base_url: str = ""
|
|
61
|
+
pages_count: int = 0
|
|
62
|
+
forms_count: int = 0
|
|
63
|
+
endpoints_count: int = 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── Intruder events ────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class IntruderResultAdded(AppEvent):
|
|
70
|
+
"""Получен результат одного запроса атаки."""
|
|
71
|
+
result: Any = None # IntruderResult
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class IntruderFinished(AppEvent):
|
|
76
|
+
"""Атака завершена."""
|
|
77
|
+
total_results: int = 0
|
|
78
|
+
stopped_early: bool = False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── Proxy events ───────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class ProxyRequestCaptured(AppEvent):
|
|
85
|
+
"""Прокси перехватил новый запрос.
|
|
86
|
+
|
|
87
|
+
request: полный объект InterceptedRequest (Any чтобы не создавать
|
|
88
|
+
циклических импортов modules → core).
|
|
89
|
+
"""
|
|
90
|
+
request_id: str = ""
|
|
91
|
+
method: str = ""
|
|
92
|
+
url: str = ""
|
|
93
|
+
host: str = ""
|
|
94
|
+
request: Any = None # InterceptedRequest
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class ProxyRequestCompleted(AppEvent):
|
|
99
|
+
"""Запрос через прокси завершён (получен ответ).
|
|
100
|
+
|
|
101
|
+
request: полный объект InterceptedRequest (Any чтобы не создавать
|
|
102
|
+
циклических импортов modules → core).
|
|
103
|
+
"""
|
|
104
|
+
request_id: str = ""
|
|
105
|
+
status_code: int = 0
|
|
106
|
+
request: Any = None # InterceptedRequest
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# Алиас для обратной совместимости: Sequencer подписывается на это событие
|
|
110
|
+
ProxyRequestDoneEvent = ProxyRequestCompleted
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ── Target / SiteMap events ────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class TargetUrlAdded(AppEvent):
|
|
117
|
+
"""URL добавлен в SiteMap/Target."""
|
|
118
|
+
url: str = ""
|
|
119
|
+
host: str = ""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ── Project events ─────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class ProjectSaved(AppEvent):
|
|
126
|
+
"""Проект сохранён."""
|
|
127
|
+
path: str = ""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class ProjectLoaded(AppEvent):
|
|
132
|
+
"""Проект загружен."""
|
|
133
|
+
path: str = ""
|
|
134
|
+
findings_count: int = 0
|
|
135
|
+
history_count: int = 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── Scanner passive events ──────────────────���───────────────────────────────────
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class PassiveScanToggled(AppEvent):
|
|
142
|
+
"""Пассивный скан включён/выключен."""
|
|
143
|
+
enabled: bool = False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ── WebSocket events ───────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class WebSocketFrameEvent(AppEvent):
|
|
150
|
+
"""Перехвачен WebSocket-фрейм (отдельное сообщение).
|
|
151
|
+
|
|
152
|
+
direction: "client→server" или "server→client"
|
|
153
|
+
opcode: 0x1=text, 0x2=binary, 0x8=close, 0x9=ping, 0xA=pong
|
|
154
|
+
payload: тело фрейма (уже без маски)
|
|
155
|
+
"""
|
|
156
|
+
request_id: str = "" # ID родительского WS-соединения (upgrade-запрос)
|
|
157
|
+
direction: str = "" # "client→server" | "server→client"
|
|
158
|
+
opcode: int = 0x1 # 1=text, 2=binary, 8=close, 9=ping, 10=pong
|
|
159
|
+
payload: bytes = field(default_factory=bytes)
|
|
160
|
+
payload_text: str = "" # UTF-8 декодированный payload (для text-фреймов)
|