pentool 1.0.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.
Files changed (169) hide show
  1. pentool/__init__.py +4 -0
  2. pentool/__main__.py +17 -0
  3. pentool/api/__init__.py +45 -0
  4. pentool/api/base_api.py +52 -0
  5. pentool/api/comparer_api.py +23 -0
  6. pentool/api/decoder_api.py +28 -0
  7. pentool/api/intruder_api.py +209 -0
  8. pentool/api/proxy_api.py +319 -0
  9. pentool/api/repeater_api.py +118 -0
  10. pentool/api/scanner_api.py +336 -0
  11. pentool/api/sequencer_api.py +21 -0
  12. pentool/api/spider_api.py +126 -0
  13. pentool/api/target_api.py +121 -0
  14. pentool/cli/__init__.py +1 -0
  15. pentool/cli/main.py +100 -0
  16. pentool/cli/project.py +90 -0
  17. pentool/cli/proxy.py +185 -0
  18. pentool/cli/scan.py +118 -0
  19. pentool/core/__init__.py +1 -0
  20. pentool/core/config.py +184 -0
  21. pentool/core/crash_reporter.py +75 -0
  22. pentool/core/database.py +139 -0
  23. pentool/core/event_bus.py +232 -0
  24. pentool/core/events.py +164 -0
  25. pentool/core/features.py +410 -0
  26. pentool/core/license.py +294 -0
  27. pentool/core/logging.py +59 -0
  28. pentool/core/plugin_manager.py +332 -0
  29. pentool/core/project.py +37 -0
  30. pentool/core/storage_interface.py +311 -0
  31. pentool/core/updater.py +96 -0
  32. pentool/core/utils.py +61 -0
  33. pentool/modules/__init__.py +1 -0
  34. pentool/modules/comparer.py +177 -0
  35. pentool/modules/decoder.py +281 -0
  36. pentool/modules/intruder.py +432 -0
  37. pentool/modules/intruder_turbo.py +190 -0
  38. pentool/modules/match_replace.py +146 -0
  39. pentool/modules/proxy.py +803 -0
  40. pentool/modules/repeater.py +237 -0
  41. pentool/modules/scanner/__init__.py +7 -0
  42. pentool/modules/scanner/ai_analyzer.py +230 -0
  43. pentool/modules/scanner/base.py +173 -0
  44. pentool/modules/scanner/baseline.py +101 -0
  45. pentool/modules/scanner/checks/__init__.py +47 -0
  46. pentool/modules/scanner/checks/broken_auth.py +231 -0
  47. pentool/modules/scanner/checks/cors.py +209 -0
  48. pentool/modules/scanner/checks/dom_xss.py +114 -0
  49. pentool/modules/scanner/checks/graphql.py +110 -0
  50. pentool/modules/scanner/checks/header_injection.py +262 -0
  51. pentool/modules/scanner/checks/headers.py +75 -0
  52. pentool/modules/scanner/checks/helpers.py +358 -0
  53. pentool/modules/scanner/checks/info_leak.py +68 -0
  54. pentool/modules/scanner/checks/jwt_none.py +238 -0
  55. pentool/modules/scanner/checks/lfi.py +186 -0
  56. pentool/modules/scanner/checks/nosql_injection.py +92 -0
  57. pentool/modules/scanner/checks/oauth.py +123 -0
  58. pentool/modules/scanner/checks/open_redirect.py +168 -0
  59. pentool/modules/scanner/checks/path_traversal.py +229 -0
  60. pentool/modules/scanner/checks/prototype_pollution.py +88 -0
  61. pentool/modules/scanner/checks/rce.py +338 -0
  62. pentool/modules/scanner/checks/sensitive_data.py +74 -0
  63. pentool/modules/scanner/checks/sqli.py +546 -0
  64. pentool/modules/scanner/checks/ssrf.py +273 -0
  65. pentool/modules/scanner/checks/ssti.py +353 -0
  66. pentool/modules/scanner/checks/xss.py +622 -0
  67. pentool/modules/scanner/checks/xxe.py +240 -0
  68. pentool/modules/scanner/engine.py +407 -0
  69. pentool/modules/scanner/fingerprint.py +169 -0
  70. pentool/modules/scanner/helpers.py +94 -0
  71. pentool/modules/scanner/mutator.py +378 -0
  72. pentool/modules/scanner/oob.py +104 -0
  73. pentool/modules/scanner/passive.py +114 -0
  74. pentool/modules/scanner/payloads.py +224 -0
  75. pentool/modules/scanner/report.py +117 -0
  76. pentool/modules/sequencer.py +500 -0
  77. pentool/modules/spider.py +702 -0
  78. pentool/modules/target.py +190 -0
  79. pentool/modules/websocket_handler.py +259 -0
  80. pentool/plugins/__init__.py +7 -0
  81. pentool/plugins/builtin/__init__.py +1 -0
  82. pentool/plugins/builtin/payloads_pro.py +404 -0
  83. pentool/plugins/builtin/reports_pro.py +450 -0
  84. pentool/plugins/builtin/scanner_pro.py +308 -0
  85. pentool/plugins/example_plugin.py +75 -0
  86. pentool/project_to_txt.py +67 -0
  87. pentool/services/__init__.py +1 -0
  88. pentool/services/base_service.py +64 -0
  89. pentool/services/intruder_service.py +108 -0
  90. pentool/services/proxy_service.py +232 -0
  91. pentool/services/repeater_service.py +111 -0
  92. pentool/services/scan_service.py +340 -0
  93. pentool/storage/__init__.py +0 -0
  94. pentool/storage/http_storage.py +522 -0
  95. pentool/storage/large_body_handler.py +54 -0
  96. pentool/storage/lru_cache.py +49 -0
  97. pentool/t.py +213 -0
  98. pentool/tui/__init__.py +1 -0
  99. pentool/tui/app.py +1438 -0
  100. pentool/tui/constants.py +79 -0
  101. pentool/tui/dialogs/__init__.py +0 -0
  102. pentool/tui/dialogs/cert_dialog.py +81 -0
  103. pentool/tui/dialogs/file_selector.py +227 -0
  104. pentool/tui/dialogs/load_from_proxy.py +125 -0
  105. pentool/tui/dialogs/match_replace_dialog.py +229 -0
  106. pentool/tui/dialogs/project_dialog.py +82 -0
  107. pentool/tui/dialogs/scope_dialog.py +225 -0
  108. pentool/tui/messages.py +121 -0
  109. pentool/tui/mixins/__init__.py +0 -0
  110. pentool/tui/mixins/app_mixin.py +61 -0
  111. pentool/tui/mixins/dialog_cancel.py +25 -0
  112. pentool/tui/mixins/request_context_menu.py +285 -0
  113. pentool/tui/mixins/tab_rename.py +101 -0
  114. pentool/tui/screens/__init__.py +31 -0
  115. pentool/tui/screens/comparer/__init__.py +3 -0
  116. pentool/tui/screens/comparer/screen.py +233 -0
  117. pentool/tui/screens/dashboard/__init__.py +2 -0
  118. pentool/tui/screens/dashboard/live_dashboard.py +741 -0
  119. pentool/tui/screens/dashboard/screen.py +820 -0
  120. pentool/tui/screens/decoder/__init__.py +3 -0
  121. pentool/tui/screens/decoder/screen.py +311 -0
  122. pentool/tui/screens/extensions/__init__.py +3 -0
  123. pentool/tui/screens/extensions/screen.py +24 -0
  124. pentool/tui/screens/intruder/__init__.py +3 -0
  125. pentool/tui/screens/intruder/screen.py +1362 -0
  126. pentool/tui/screens/proxy/__init__.py +3 -0
  127. pentool/tui/screens/proxy/screen.py +1664 -0
  128. pentool/tui/screens/repeater/__init__.py +3 -0
  129. pentool/tui/screens/repeater/screen.py +646 -0
  130. pentool/tui/screens/scanner/__init__.py +3 -0
  131. pentool/tui/screens/scanner/screen.py +1984 -0
  132. pentool/tui/screens/sequencer/__init__.py +3 -0
  133. pentool/tui/screens/sequencer/screen.py +520 -0
  134. pentool/tui/screens/settings/__init__.py +5 -0
  135. pentool/tui/screens/settings/hotkeys.py +134 -0
  136. pentool/tui/screens/settings/screen.py +543 -0
  137. pentool/tui/screens/spider/__init__.py +3 -0
  138. pentool/tui/screens/spider/screen.py +385 -0
  139. pentool/tui/screens/target/__init__.py +4 -0
  140. pentool/tui/screens/target/screen.py +361 -0
  141. pentool/tui/screens/terminal/__init__.py +5 -0
  142. pentool/tui/screens/terminal/screen.py +181 -0
  143. pentool/tui/widgets/__init__.py +1 -0
  144. pentool/tui/widgets/context_menu.py +148 -0
  145. pentool/tui/widgets/filter_bar.py +206 -0
  146. pentool/tui/widgets/inspector_panel.py +112 -0
  147. pentool/tui/widgets/menu.py +86 -0
  148. pentool/tui/widgets/menu_bar.py +238 -0
  149. pentool/tui/widgets/module_tabs.py +73 -0
  150. pentool/tui/widgets/payload_drop_zone.py +66 -0
  151. pentool/tui/widgets/request_editor.py +504 -0
  152. pentool/tui/widgets/resize_handle.py +116 -0
  153. pentool/tui/widgets/search_bar.py +113 -0
  154. pentool/tui/widgets/statusbar.py +99 -0
  155. pentool/tui/widgets/toolbar_button.py +76 -0
  156. pentool/utils/__init__.py +1 -0
  157. pentool/utils/cert.py +361 -0
  158. pentool/utils/coder.py +170 -0
  159. pentool/utils/copy_as.py +251 -0
  160. pentool/utils/diff.py +91 -0
  161. pentool/utils/http_client.py +175 -0
  162. pentool/utils/parser.py +227 -0
  163. pentool/utils/terminal_check.py +32 -0
  164. pentool-1.0.0.dist-info/METADATA +155 -0
  165. pentool-1.0.0.dist-info/RECORD +169 -0
  166. pentool-1.0.0.dist-info/WHEEL +5 -0
  167. pentool-1.0.0.dist-info/entry_points.txt +2 -0
  168. pentool-1.0.0.dist-info/licenses/LICENSE +34 -0
  169. pentool-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,319 @@
1
+ """Публичный API прокси-модуля для TUI и CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Callable
6
+
7
+ from pentool.modules.proxy import InterceptedRequest, MatchReplaceRule, ProxyServer
8
+ from pentool.core.logging import get_logger
9
+
10
+ logger = get_logger(__name__)
11
+
12
+ # Реэкспорт типов — TUI импортирует их отсюда, не из modules.proxy
13
+ __all__ = ["ProxyAPI", "InterceptedRequest", "MatchReplaceRule", "ProxyServer"]
14
+
15
+
16
+ class ProxyAPI:
17
+ """Публичный интерфейс прокси-модуля.
18
+
19
+ Тонкая обёртка над ProxyServer. Не знает о Textual.
20
+ ProxyServer инжектируется через set_proxy() после создания.
21
+ """
22
+
23
+ def __init__(self) -> None:
24
+ self._proxy: ProxyServer | None = None
25
+
26
+ def set_proxy(self, proxy: ProxyServer) -> None:
27
+ """Инжектировать экземпляр ProxyServer.
28
+
29
+ Args:
30
+ proxy: Запущенный экземпляр ProxyServer.
31
+
32
+ Returns:
33
+ None
34
+ """
35
+ logger.debug("ProxyAPI: set_proxy called, proxy=%s", proxy)
36
+ self._proxy = proxy
37
+
38
+ def get_proxy(self) -> ProxyServer | None:
39
+ """Вернуть внутренний ProxyServer (для обратной совместимости).
40
+
41
+ Returns:
42
+ ProxyServer или None.
43
+ """
44
+ return self._proxy
45
+
46
+ @property
47
+ def proxy(self) -> ProxyServer | None:
48
+ """Легитимный доступ к ProxyServer через API-слой.
49
+
50
+ Используется там где нужен прямой доступ к серверу
51
+ (proxy/screen.py для intercept, repeater/screen.py для scope).
52
+ """
53
+ return self._proxy
54
+
55
+ def create_proxy(
56
+ self,
57
+ host: str = "127.0.0.1",
58
+ port: int = 8080,
59
+ cert_dir: str = "/tmp/pentool_certs",
60
+ db_path: str | None = None,
61
+ ) -> ProxyServer:
62
+ """Создать и инжектировать экземпляр ProxyServer.
63
+
64
+ Args:
65
+ host: Хост для прослушивания.
66
+ port: Порт прокси.
67
+ cert_dir: Директория для CA-сертификатов.
68
+ db_path: Путь к SQLite БД (опционально).
69
+
70
+ Returns:
71
+ ProxyServer: Созданный экземпляр (не запущен).
72
+ """
73
+ proxy = ProxyServer(host=host, port=port, cert_dir=cert_dir, db_path=db_path)
74
+ self._proxy = proxy
75
+ return proxy
76
+
77
+ def is_running(self) -> bool:
78
+ """Вернуть True если прокси запущен.
79
+
80
+ Returns:
81
+ bool: Состояние прокси.
82
+ """
83
+ return bool(self._proxy and self._proxy.is_running)
84
+
85
+ def get_port(self) -> int:
86
+ """Вернуть порт прокси.
87
+
88
+ Returns:
89
+ int: Номер порта (8080 по умолчанию если прокси не задан).
90
+ """
91
+ if self._proxy:
92
+ return self._proxy.port
93
+ return 8080
94
+
95
+ def get_host(self) -> str:
96
+ """Вернуть хост прокси.
97
+
98
+ Returns:
99
+ str: Хост (127.0.0.1 по умолчанию).
100
+ """
101
+ if self._proxy:
102
+ return self._proxy.host
103
+ return "127.0.0.1"
104
+
105
+ def get_status(self) -> dict:
106
+ """Вернуть словарь состояния прокси.
107
+
108
+ Returns:
109
+ dict: Ключи: running, host, port, intercept_enabled, scope,
110
+ requests_count, rules_count, waiting_count.
111
+ """
112
+ if self._proxy is None:
113
+ return {
114
+ "running": False,
115
+ "host": "127.0.0.1",
116
+ "port": 8080,
117
+ "intercept_enabled": False,
118
+ "scope": [],
119
+ "rules_count": 0,
120
+ "requests_count": 0,
121
+ "waiting_count": 0,
122
+ }
123
+ return self._proxy.get_status()
124
+
125
+ def get_requests(
126
+ self,
127
+ limit: int = 100,
128
+ method: str | None = None,
129
+ host: str | None = None,
130
+ ) -> list[InterceptedRequest]:
131
+ """Вернуть список перехваченных запросов.
132
+
133
+ Args:
134
+ limit: Максимальное количество записей.
135
+ method: Фильтр по HTTP-методу (регистронезависимо).
136
+ host: Фильтр по хосту (подстрока, регистронезависимо).
137
+
138
+ Returns:
139
+ list[InterceptedRequest]: Список от новых к старым.
140
+ """
141
+ if self._proxy is None:
142
+ return []
143
+ return self._proxy.get_requests(limit=limit, method=method, host=host)
144
+
145
+ def find_request(self, req_id: str) -> InterceptedRequest | None:
146
+ """Найти запрос по ID.
147
+
148
+ Args:
149
+ req_id: UUID запроса (полный или частичный).
150
+
151
+ Returns:
152
+ InterceptedRequest или None.
153
+ """
154
+ if self._proxy is None:
155
+ return None
156
+ return self._proxy._find_request(req_id)
157
+
158
+ def clear_requests(self) -> None:
159
+ """Очистить историю запросов в памяти.
160
+
161
+ Returns:
162
+ None
163
+ """
164
+ if self._proxy:
165
+ self._proxy.clear_requests()
166
+
167
+ def forward(self, req_id: str, modified_raw: str | None = None) -> None:
168
+ """Переслать ожидающий запрос на целевой сервер.
169
+
170
+ Args:
171
+ req_id: ID перехваченного запроса.
172
+ modified_raw: Изменённый сырой HTTP-текст (опционально).
173
+
174
+ Returns:
175
+ None
176
+
177
+ Raises:
178
+ RuntimeError: Если прокси не инициализирован.
179
+ """
180
+ if self._proxy is None:
181
+ raise RuntimeError("ProxyAPI: прокси не инициализирован")
182
+ logger.debug("ProxyAPI: forward req_id=%s, has_modified=%s", req_id, bool(modified_raw))
183
+ self._proxy.forward(req_id, modified_raw)
184
+
185
+ def drop(self, req_id: str) -> None:
186
+ """Сбросить ожидающий запрос (вернуть браузеру 502).
187
+
188
+ Args:
189
+ req_id: ID перехваченного запроса.
190
+
191
+ Returns:
192
+ None
193
+
194
+ Raises:
195
+ RuntimeError: Если прокси не инициализирован.
196
+ """
197
+ if self._proxy is None:
198
+ raise RuntimeError("ProxyAPI: прокси не инициализирован")
199
+ logger.debug("ProxyAPI: drop req_id=%s", req_id)
200
+ self._proxy.drop(req_id)
201
+
202
+ def set_intercept(self, enabled: bool) -> None:
203
+ """Включить или выключить режим перехвата (thread-safe).
204
+
205
+ Args:
206
+ enabled: True — включить, False — выключить.
207
+
208
+ Returns:
209
+ None
210
+ """
211
+ if self._proxy:
212
+ # ProxyServer.set_intercept() использует call_soon_threadsafe —
213
+ # обязательно для безопасного изменения флага из TUI-треда,
214
+ # пока proxy-loop работает в своём asyncio-треде.
215
+ self._proxy.set_intercept(enabled)
216
+
217
+ def get_intercept(self) -> bool:
218
+ """Вернуть текущее состояние режима перехвата.
219
+
220
+ Returns:
221
+ bool: True если перехват включён.
222
+ """
223
+ return bool(self._proxy and self._proxy.intercept_enabled)
224
+
225
+ def set_scope(self, hosts: list[str]) -> None:
226
+ """Установить список хостов в scope.
227
+
228
+ Args:
229
+ hosts: Список паттернов хостов (поддерживается *.example.com).
230
+
231
+ Returns:
232
+ None
233
+ """
234
+ if self._proxy:
235
+ self._proxy.set_scope(hosts)
236
+
237
+ def get_scope(self) -> list[str]:
238
+ """Вернуть текущий список scope.
239
+
240
+ Returns:
241
+ list[str]: Активные паттерны хостов.
242
+ """
243
+ if self._proxy:
244
+ return list(self._proxy.scope)
245
+ return []
246
+
247
+ def get_match_replace_rules(self) -> list[MatchReplaceRule]:
248
+ """Вернуть список правил match/replace.
249
+
250
+ Returns:
251
+ list[MatchReplaceRule]: Копия текущих правил.
252
+ """
253
+ if self._proxy:
254
+ return list(self._proxy.match_replace_rules)
255
+ return []
256
+
257
+ def set_match_replace_rules(self, rules: list[MatchReplaceRule]) -> None:
258
+ """Заменить все правила match/replace.
259
+
260
+ Args:
261
+ rules: Новый список правил MatchReplaceRule.
262
+
263
+ Returns:
264
+ None
265
+ """
266
+ if self._proxy:
267
+ self._proxy.match_replace_rules = rules
268
+
269
+ def export_project_data(self) -> dict:
270
+ """Экспортировать данные прокси в сериализуемый dict для сохранения.
271
+
272
+ Returns:
273
+ dict: Ключи proxy (scope, match_replace) и http_history.
274
+ """
275
+ if self._proxy is None:
276
+ return {"proxy": {"scope": [], "match_replace": []}, "http_history": []}
277
+
278
+ return {
279
+ "proxy": {
280
+ "scope": list(self._proxy.scope),
281
+ "match_replace": [r.to_dict() for r in self._proxy.match_replace_rules],
282
+ },
283
+ "http_history": [r.to_dict() for r in self._proxy.get_requests(limit=10000)],
284
+ }
285
+
286
+ def import_project_data(self, data: dict) -> tuple[int, str]:
287
+ """Импортировать данные из загруженного dict проекта.
288
+
289
+ Args:
290
+ data: Словарь, возвращённый core.project.load_project().
291
+
292
+ Returns:
293
+ (count, error) — количество загруженных запросов, пустая строка если OK.
294
+ """
295
+ if self._proxy is None:
296
+ return 0, "Proxy not initialized"
297
+
298
+ from pentool.utils.parser import ParsedResponse
299
+ from datetime import datetime, timezone
300
+
301
+ # Scope
302
+ self._proxy.scope = data.get("proxy", {}).get("scope", [])
303
+
304
+ # Match/Replace
305
+ mr_data = data.get("proxy", {}).get("match_replace", [])
306
+ self._proxy.match_replace_rules = [MatchReplaceRule.from_dict(r) for r in mr_data]
307
+
308
+ # HTTP History
309
+ self._proxy.requests.clear()
310
+ loaded = 0
311
+ for req_data in data.get("http_history", []):
312
+ try:
313
+ req = InterceptedRequest.from_dict(req_data)
314
+ self._proxy.requests.append(req)
315
+ loaded += 1
316
+ except Exception as e:
317
+ logger.warning("import_project_data: failed to restore request: %s", e)
318
+
319
+ return loaded, ""
@@ -0,0 +1,118 @@
1
+ """Публичный API repeater-модуля для TUI и CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+
7
+ from pentool.modules.repeater import Repeater, RepeaterEntry
8
+ from pentool.utils.parser import ParsedRequest, ParsedResponse
9
+
10
+ # Реэкспорт типов
11
+ __all__ = ["RepeaterAPI", "RepeaterEntry"]
12
+
13
+
14
+ class RepeaterAPI:
15
+ """Публичный интерфейс repeater-модуля.
16
+
17
+ Тонкая асинхронная обёртка над Repeater.
18
+ Не знает о Textual.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ db_path: str,
24
+ project_id: int | None = None,
25
+ timeout: float = 30.0,
26
+ verify_ssl: bool = False,
27
+ ) -> None:
28
+ """Инициализировать RepeaterAPI.
29
+
30
+ Args:
31
+ db_path: Путь к SQLite-базе данных.
32
+ project_id: ID активного проекта (None — без фильтра).
33
+ timeout: Таймаут HTTP-запроса в секундах.
34
+ verify_ssl: Проверять ли SSL-сертификат сервера.
35
+ """
36
+ self._repeater = Repeater(
37
+ db_path=db_path,
38
+ project_id=project_id,
39
+ timeout=timeout,
40
+ verify_ssl=verify_ssl,
41
+ )
42
+
43
+ async def send(
44
+ self,
45
+ request: ParsedRequest,
46
+ tab_name: str = "Tab",
47
+ save: bool = True,
48
+ ) -> ParsedResponse:
49
+ """Отправить HTTP-запрос и опционально сохранить в историю.
50
+
51
+ Args:
52
+ request: Запрос для отправки (ParsedRequest).
53
+ tab_name: Метка для записи в историю.
54
+ save: Если True — сохранить в БД.
55
+
56
+ Returns:
57
+ ParsedResponse: Ответ сервера.
58
+
59
+ Raises:
60
+ Exception: Любая сетевая ошибка или ошибка парсинга из HTTPClient.
61
+ """
62
+ return await self._repeater.send(request, tab_name=tab_name, save=save)
63
+
64
+ async def save_to_history(
65
+ self,
66
+ request: ParsedRequest,
67
+ response: ParsedResponse,
68
+ tab_name: str = "Tab",
69
+ ) -> int:
70
+ """Сохранить пару запрос/ответ в таблицу repeater_entries.
71
+
72
+ Args:
73
+ request: Отправленный запрос.
74
+ response: Полученный ответ.
75
+ tab_name: Метка записи.
76
+
77
+ Returns:
78
+ int: ID созданной записи в БД.
79
+ """
80
+ return await self._repeater.save_to_history(request, response, tab_name)
81
+
82
+ async def get_history(
83
+ self,
84
+ limit: int = 50,
85
+ project_id: int | None = None,
86
+ ) -> list[RepeaterEntry]:
87
+ """Получить историю Repeater из БД.
88
+
89
+ Args:
90
+ limit: Максимальное количество записей.
91
+ project_id: Фильтр по проекту (None = использовать project_id экземпляра или все).
92
+
93
+ Returns:
94
+ list[RepeaterEntry]: Список от новых к старым.
95
+ """
96
+ return await self._repeater.get_history(limit=limit, project_id=project_id)
97
+
98
+ async def get_entry(self, entry_id: int) -> RepeaterEntry | None:
99
+ """Получить одну запись истории по ID.
100
+
101
+ Args:
102
+ entry_id: ID записи в БД.
103
+
104
+ Returns:
105
+ RepeaterEntry или None если не найдена.
106
+ """
107
+ return await self._repeater.get_entry(entry_id)
108
+
109
+ async def delete_entry(self, entry_id: int) -> None:
110
+ """Удалить запись из истории.
111
+
112
+ Args:
113
+ entry_id: ID записи в БД.
114
+
115
+ Returns:
116
+ None
117
+ """
118
+ return await self._repeater.delete_entry(entry_id)