telethon-webproxy 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.
@@ -0,0 +1,78 @@
1
+ """
2
+ telethon-webproxy — Telegram WEB Proxy connector for Telethon.
3
+
4
+ Supports all four carrier modes:
5
+ • ``websocket`` — single multiplexed WebSocket (default)
6
+ • ``websocket-lanes`` — one WebSocket per stream (best isolation)
7
+ • ``https`` — HTTP long-polling (widest compatibility)
8
+ • ``https-lanes`` — per-stream HTTP long-polling
9
+
10
+ Auto-reconnect is available via :class:`ReconnectingCarrier`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ __version__ = "0.2.0"
16
+
17
+ # ── Protocol core ─────────────────────────────────────────────────────────────
18
+ from .protocol import (
19
+ FrameType,
20
+ Frame,
21
+ compute_capability,
22
+ compute_capability_from_hex,
23
+ encode_frame,
24
+ encode_hello,
25
+ parse_frames,
26
+ )
27
+
28
+ # ── Carrier base ──────────────────────────────────────────────────────────────
29
+ from .carrier_base import (
30
+ BaseCarrier,
31
+ CarrierError,
32
+ HandshakeError,
33
+ StreamClosedError,
34
+ RelayByeError,
35
+ )
36
+
37
+ # ── Carrier implementations ──────────────────────────────────────────────────
38
+ from .carrier import WebSocketCarrier
39
+ from .carrier_https import HTTPSCarrier
40
+ from .carrier_lanes import WebSocketLanesCarrier
41
+ from .reconnect import ReconnectingCarrier
42
+
43
+ # ── Telethon connectors ──────────────────────────────────────────────────────
44
+ from .connector_v1 import ConnectionWebProxy
45
+ from .connector_v2 import make_web_proxy_connector, WebProxyStream
46
+
47
+ # ── Version auto-detect ──────────────────────────────────────────────────────
48
+ _telethon_major: int | None = None
49
+
50
+ try:
51
+ import importlib.metadata as _meta
52
+ _tv = _meta.version("telethon")
53
+ _telethon_major = int(_tv.split(".")[0])
54
+ except Exception:
55
+ pass
56
+
57
+ if _telethon_major is not None and _telethon_major >= 2:
58
+ WebProxyConnector = make_web_proxy_connector
59
+ else:
60
+ WebProxyConnector = ConnectionWebProxy
61
+
62
+ __all__ = [
63
+ # Protocol
64
+ "FrameType", "Frame",
65
+ "compute_capability", "compute_capability_from_hex",
66
+ "encode_frame", "encode_hello", "parse_frames",
67
+ # Carrier base
68
+ "BaseCarrier", "CarrierError", "HandshakeError",
69
+ "StreamClosedError", "RelayByeError",
70
+ # Carriers
71
+ "WebSocketCarrier", "HTTPSCarrier",
72
+ "WebSocketLanesCarrier", "ReconnectingCarrier",
73
+ # Telethon connectors
74
+ "ConnectionWebProxy", "make_web_proxy_connector",
75
+ "WebProxyStream", "WebProxyConnector",
76
+ # Meta
77
+ "__version__",
78
+ ]
@@ -0,0 +1,141 @@
1
+ """
2
+ Multiplexed WebSocket carrier (``websocket`` carrier mode).
3
+
4
+ Reference: PROTOCOL.md §Multiplexed WebSocket
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ import ssl
12
+ from typing import Optional
13
+
14
+ import aiohttp
15
+
16
+ from .carrier_base import BaseCarrier, StreamClosedError
17
+ from .protocol import (
18
+ DATA_CHUNK,
19
+ WS_SUBPROTOCOL_PREFIX,
20
+ WS_UPGRADE_PATH,
21
+ FrameType,
22
+ encode_frame,
23
+ parse_frames,
24
+ )
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+
29
+ class WebSocketCarrier(BaseCarrier):
30
+ """Single multiplexed WebSocket carrier.
31
+
32
+ All streams share one WSS connection. A WebSocket loss closes the
33
+ entire relay session (per the spec).
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ host: str,
39
+ secret_hex: str,
40
+ *,
41
+ ssl_context: Optional[ssl.SSLContext] = None,
42
+ ) -> None:
43
+ super().__init__(host, secret_hex, ssl_context=ssl_context)
44
+ self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
45
+ self._reader_task: Optional[asyncio.Task] = None
46
+
47
+ # ── Transport lifecycle ───────────────────────────────────────────────
48
+
49
+ async def _start_transport(self) -> None:
50
+ si = self._session_info
51
+ subprotocol = WS_SUBPROTOCOL_PREFIX + si.token
52
+ self._ws = await self._http.ws_connect(
53
+ f"wss://{si.host}{WS_UPGRADE_PATH}",
54
+ protocols=[subprotocol],
55
+ ssl=self._ssl,
56
+ max_msg_size=4 * 1024 * 1024,
57
+ origin=f"https://{si.host}",
58
+ )
59
+ self._reader_task = asyncio.get_event_loop().create_task(
60
+ self._reader_loop()
61
+ )
62
+ log.info("WebSocket carrier connected to %s", si.host)
63
+
64
+ async def _stop_transport(self) -> None:
65
+ if self._reader_task:
66
+ self._reader_task.cancel()
67
+ try:
68
+ await self._reader_task
69
+ except (asyncio.CancelledError, Exception):
70
+ pass
71
+ self._reader_task = None
72
+ if self._ws and not self._ws.closed:
73
+ await self._ws.close()
74
+ self._ws = None
75
+
76
+ # ── Stream operations ─────────────────────────────────────────────────
77
+
78
+ async def open_stream(self) -> int:
79
+ sid = self._alloc_stream()
80
+ await self._ws_send(encode_frame(FrameType.OPEN, sid))
81
+ return sid
82
+
83
+ async def close_stream(self, stream_id: int) -> None:
84
+ if stream_id in self._open_streams:
85
+ self._open_streams.discard(stream_id)
86
+ await self._ws_send(encode_frame(FrameType.CLOSE, stream_id))
87
+
88
+ async def send_data(self, stream_id: int, data: bytes) -> None:
89
+ offset = 0
90
+ while offset < len(data):
91
+ chunk_size = min(DATA_CHUNK, len(data) - offset)
92
+ await self._wait_send_window(stream_id, chunk_size)
93
+ chunk = data[offset : offset + chunk_size]
94
+ self._send_windows[stream_id] -= len(chunk)
95
+ await self._ws_send(encode_frame(FrameType.DATA, stream_id, chunk))
96
+ offset += chunk_size
97
+
98
+ async def _send_control(self, frame_bytes: bytes) -> None:
99
+ await self._ws_send(frame_bytes)
100
+
101
+ # ── Internals ─────────────────────────────────────────────────────────
102
+
103
+ async def _ws_send(self, data: bytes) -> None:
104
+ if self._ws and not self._ws.closed:
105
+ await self._ws.send_bytes(data)
106
+
107
+ async def _reader_loop(self) -> None:
108
+ try:
109
+ async for msg in self._ws:
110
+ if msg.type == aiohttp.WSMsgType.BINARY:
111
+ await self._on_batch(msg.data)
112
+ elif msg.type == aiohttp.WSMsgType.PING:
113
+ await self._ws.pong(msg.data)
114
+ elif msg.type in (
115
+ aiohttp.WSMsgType.CLOSE,
116
+ aiohttp.WSMsgType.CLOSING,
117
+ aiohttp.WSMsgType.CLOSED,
118
+ aiohttp.WSMsgType.ERROR,
119
+ ):
120
+ break
121
+ except asyncio.CancelledError:
122
+ return
123
+ except Exception as exc:
124
+ log.warning("WebSocket reader error: %s", exc)
125
+ finally:
126
+ if self._connected:
127
+ await self.disconnect()
128
+
129
+ async def _on_batch(self, data: bytes) -> None:
130
+ try:
131
+ frames = parse_frames(data)
132
+ except ValueError as exc:
133
+ log.warning("Malformed relay batch: %s", exc)
134
+ return
135
+ for frame in frames:
136
+ pong = self._dispatch_frame(frame.type, frame.stream_id, frame.payload)
137
+ if pong:
138
+ await self._ws_send(pong)
139
+ if frame.type == FrameType.BYE:
140
+ await self.disconnect()
141
+ return
@@ -0,0 +1,347 @@
1
+ """
2
+ Base carrier ABC and shared bootstrap logic.
3
+
4
+ All four carrier modes (https, https-lanes, websocket, websocket-lanes)
5
+ share the same HTTPS bootstrap: GET bridge → POST /session → mode-specific transport.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import abc
11
+ import asyncio
12
+ import logging
13
+ import re
14
+ import ssl
15
+ from typing import Optional
16
+
17
+ import aiohttp
18
+
19
+ from .protocol import (
20
+ INITIAL_WINDOW,
21
+ SESSION_PATH,
22
+ FrameType,
23
+ compute_capability_from_hex,
24
+ encode_frame,
25
+ encode_hello,
26
+ encode_window,
27
+ parse_frames,
28
+ parse_window,
29
+ )
30
+
31
+ log = logging.getLogger(__name__)
32
+
33
+ # ── Exceptions ────────────────────────────────────────────────────────────────
34
+
35
+
36
+ class CarrierError(Exception):
37
+ """Base exception for carrier-level errors."""
38
+
39
+
40
+ class HandshakeError(CarrierError):
41
+ """The relay rejected the HELLO or returned an unexpected WELCOME."""
42
+
43
+
44
+ class StreamClosedError(CarrierError):
45
+ """The relay sent CLOSE for a stream we care about."""
46
+
47
+
48
+ class RelayByeError(CarrierError):
49
+ """The relay sent BYE, tearing down the whole session."""
50
+
51
+
52
+ # ── Bootstrap result ──────────────────────────────────────────────────────────
53
+
54
+
55
+ class SessionInfo:
56
+ """Result of a successful HTTPS bootstrap."""
57
+
58
+ __slots__ = ("token", "carrier_mode", "base_url", "host")
59
+
60
+ def __init__(self, token: str, carrier_mode: str, base_url: str, host: str):
61
+ self.token = token
62
+ self.carrier_mode = carrier_mode
63
+ self.base_url = base_url
64
+ self.host = host
65
+
66
+
67
+ # ── Stream bookkeeping mixin ─────────────────────────────────────────────────
68
+
69
+
70
+ class StreamManager:
71
+ """Per-stream state shared by all carrier implementations."""
72
+
73
+ def __init__(self) -> None:
74
+ self._next_stream_id: int = 1
75
+ self._recv_queues: dict[int, asyncio.Queue[bytes]] = {}
76
+ self._send_windows: dict[int, int] = {}
77
+ self._recv_windows: dict[int, int] = {}
78
+ self._open_streams: set[int] = set()
79
+ self._window_events: dict[int, asyncio.Event] = {}
80
+
81
+ def _alloc_stream(self) -> int:
82
+ sid = self._next_stream_id
83
+ self._next_stream_id += 1
84
+ self._recv_queues[sid] = asyncio.Queue()
85
+ self._send_windows[sid] = INITIAL_WINDOW
86
+ self._recv_windows[sid] = INITIAL_WINDOW
87
+ self._window_events[sid] = asyncio.Event()
88
+ self._window_events[sid].set()
89
+ self._open_streams.add(sid)
90
+ return sid
91
+
92
+ def _remove_stream(self, sid: int) -> None:
93
+ self._open_streams.discard(sid)
94
+ self._recv_queues.pop(sid, None)
95
+ self._send_windows.pop(sid, None)
96
+ self._recv_windows.pop(sid, None)
97
+ ev = self._window_events.pop(sid, None)
98
+ if ev:
99
+ ev.set() # unblock any waiters
100
+
101
+ def _clear_streams(self) -> None:
102
+ for q in self._recv_queues.values():
103
+ try:
104
+ q.put_nowait(b"")
105
+ except asyncio.QueueFull:
106
+ pass
107
+ for ev in self._window_events.values():
108
+ ev.set()
109
+ self._open_streams.clear()
110
+ self._recv_queues.clear()
111
+ self._send_windows.clear()
112
+ self._recv_windows.clear()
113
+ self._window_events.clear()
114
+
115
+ def _dispatch_frame(self, frame_type, sid: int, payload: bytes) -> Optional[bytes]:
116
+ """Handle one parsed frame, returns PONG bytes to send or None."""
117
+ if frame_type == FrameType.DATA and sid in self._recv_queues:
118
+ self._recv_queues[sid].put_nowait(payload)
119
+
120
+ elif frame_type == FrameType.WINDOW and sid in self._send_windows:
121
+ delta = parse_window(payload)
122
+ self._send_windows[sid] += delta
123
+ ev = self._window_events.get(sid)
124
+ if ev:
125
+ ev.set()
126
+
127
+ elif frame_type == FrameType.CLOSE and sid in self._open_streams:
128
+ self._open_streams.discard(sid)
129
+ q = self._recv_queues.get(sid)
130
+ if q:
131
+ try:
132
+ q.put_nowait(b"")
133
+ except asyncio.QueueFull:
134
+ pass
135
+
136
+ elif frame_type == FrameType.PING and sid == 0:
137
+ return encode_frame(FrameType.PONG, 0, payload)
138
+
139
+ elif frame_type == FrameType.BYE:
140
+ log.warning("Relay BYE: %s", payload.decode(errors="replace"))
141
+ return None # caller should disconnect
142
+
143
+ return None
144
+
145
+ async def _wait_send_window(self, sid: int, needed: int, timeout: float = 30.0) -> None:
146
+ """Block until the send window for *sid* has at least *needed* bytes."""
147
+ deadline = asyncio.get_event_loop().time() + timeout
148
+ while self._send_windows.get(sid, 0) < needed:
149
+ if sid not in self._open_streams:
150
+ raise StreamClosedError(f"Stream {sid} closed while waiting for window")
151
+ ev = self._window_events.get(sid)
152
+ if not ev:
153
+ raise StreamClosedError(f"Stream {sid} gone")
154
+ ev.clear()
155
+ remaining = deadline - asyncio.get_event_loop().time()
156
+ if remaining <= 0:
157
+ raise CarrierError("Send window timeout")
158
+ try:
159
+ await asyncio.wait_for(ev.wait(), timeout=remaining)
160
+ except asyncio.TimeoutError:
161
+ raise CarrierError("Send window timeout")
162
+
163
+ async def _recv_data_from_queue(self, sid: int) -> bytes:
164
+ """Dequeue one DATA payload and return WINDOW credit to grant."""
165
+ q = self._recv_queues.get(sid)
166
+ if q is None:
167
+ raise StreamClosedError(f"Stream {sid} not open")
168
+
169
+ data = await q.get()
170
+ if not data:
171
+ raise StreamClosedError(f"Stream {sid} closed")
172
+
173
+ self._recv_windows[sid] -= len(data)
174
+ return data
175
+
176
+ def _compute_window_grant(self, sid: int) -> Optional[bytes]:
177
+ """If enough credit has been consumed, return a WINDOW frame to send."""
178
+ current = self._recv_windows.get(sid, INITIAL_WINDOW)
179
+ consumed = INITIAL_WINDOW - current
180
+ if consumed >= INITIAL_WINDOW // 4:
181
+ self._recv_windows[sid] = INITIAL_WINDOW
182
+ return encode_window(sid, consumed)
183
+ return None
184
+
185
+
186
+ # ── HTTPS bootstrap ───────────────────────────────────────────────────────────
187
+
188
+
189
+ async def bootstrap_session(
190
+ host: str,
191
+ secret_hex: str,
192
+ session: aiohttp.ClientSession,
193
+ ssl_ctx: Optional[ssl.SSLContext] = None,
194
+ ) -> SessionInfo:
195
+ """Perform the HTTPS bootstrap common to all carrier modes.
196
+
197
+ 1. GET /?bridge=<cap> → extract bootstrap token from the bridge page.
198
+ 2. POST /api/v1/session with HELLO → receive session token + WELCOME.
199
+ """
200
+ cap = compute_capability_from_hex(host, secret_hex)
201
+ base_url = f"https://{host}"
202
+
203
+ # 1) Bridge page
204
+ async with session.get(
205
+ f"{base_url}/?bridge={cap}",
206
+ ssl=ssl_ctx,
207
+ ) as resp:
208
+ if resp.status != 200:
209
+ raise HandshakeError(f"Bridge page returned HTTP {resp.status}")
210
+ body = await resp.read()
211
+ bootstrap = _extract_bootstrap(body, cap)
212
+ if not bootstrap:
213
+ raise HandshakeError("Could not extract bootstrap token from bridge page")
214
+
215
+ # 2) Session creation
216
+ async with session.post(
217
+ f"{base_url}{SESSION_PATH}",
218
+ headers={
219
+ "Authorization": f"Bearer {bootstrap}",
220
+ "Content-Type": "application/octet-stream",
221
+ },
222
+ data=encode_hello(),
223
+ ssl=ssl_ctx,
224
+ ) as resp:
225
+ if resp.status != 200:
226
+ raise HandshakeError(f"Session creation returned HTTP {resp.status}")
227
+
228
+ token = resp.headers.get("X-Session-Token")
229
+ carrier_mode = resp.headers.get("X-Carrier-Mode", "https")
230
+ welcome_body = await resp.read()
231
+
232
+ frames = parse_frames(welcome_body)
233
+ if (
234
+ len(frames) != 1
235
+ or frames[0].type != FrameType.WELCOME
236
+ or frames[0].stream_id != 0
237
+ ):
238
+ raise HandshakeError("Invalid WELCOME from relay")
239
+
240
+ log.info(
241
+ "Session bootstrapped (mode=%s, token=%s…)",
242
+ carrier_mode,
243
+ token[:8] if token else "?",
244
+ )
245
+ return SessionInfo(token, carrier_mode, base_url, host)
246
+
247
+
248
+ def _extract_bootstrap(page_body: bytes, bridge_cap: str) -> Optional[str]:
249
+ """Extract the bootstrap bearer token from the bridge HTML."""
250
+ text = page_body.decode("utf-8", errors="replace")
251
+ match = re.search(r'Bearer\s+([A-Za-z0-9_-]{43})', text)
252
+ if match:
253
+ return match.group(1)
254
+ for m in re.finditer(r'"([A-Za-z0-9_-]{43})"', text):
255
+ candidate = m.group(1)
256
+ if candidate != bridge_cap:
257
+ return candidate
258
+ return None
259
+
260
+
261
+ # ── Abstract Carrier ──────────────────────────────────────────────────────────
262
+
263
+
264
+ class BaseCarrier(StreamManager, abc.ABC):
265
+ """Abstract base for all carrier modes."""
266
+
267
+ def __init__(
268
+ self,
269
+ host: str,
270
+ secret_hex: str,
271
+ *,
272
+ ssl_context: Optional[ssl.SSLContext] = None,
273
+ ) -> None:
274
+ super().__init__()
275
+ self._host = host
276
+ self._secret_hex = secret_hex
277
+ self._ssl = ssl_context
278
+ self._http: Optional[aiohttp.ClientSession] = None
279
+ self._session_info: Optional[SessionInfo] = None
280
+ self._connected = False
281
+
282
+ @property
283
+ def connected(self) -> bool:
284
+ return self._connected
285
+
286
+ @property
287
+ def carrier_mode(self) -> Optional[str]:
288
+ return self._session_info.carrier_mode if self._session_info else None
289
+
290
+ async def connect(self) -> None:
291
+ """Bootstrap and start the carrier-specific transport."""
292
+ self._http = aiohttp.ClientSession()
293
+ try:
294
+ self._session_info = await bootstrap_session(
295
+ self._host, self._secret_hex, self._http, self._ssl,
296
+ )
297
+ await self._start_transport()
298
+ self._connected = True
299
+ except Exception:
300
+ await self._http.close()
301
+ self._http = None
302
+ raise
303
+
304
+ async def disconnect(self) -> None:
305
+ """Tear down everything."""
306
+ self._connected = False
307
+ try:
308
+ await self._stop_transport()
309
+ except Exception:
310
+ pass
311
+ self._clear_streams()
312
+ if self._http and not self._http.closed:
313
+ await self._http.close()
314
+ self._http = None
315
+ self._session_info = None
316
+
317
+ @abc.abstractmethod
318
+ async def _start_transport(self) -> None:
319
+ """Start the carrier-specific transport after bootstrap."""
320
+
321
+ @abc.abstractmethod
322
+ async def _stop_transport(self) -> None:
323
+ """Stop the carrier-specific transport."""
324
+
325
+ @abc.abstractmethod
326
+ async def open_stream(self) -> int:
327
+ """Open a new stream. Returns stream id."""
328
+
329
+ @abc.abstractmethod
330
+ async def close_stream(self, stream_id: int) -> None:
331
+ """Send CLOSE for a stream."""
332
+
333
+ @abc.abstractmethod
334
+ async def send_data(self, stream_id: int, data: bytes) -> None:
335
+ """Send DATA on a stream, respecting flow control."""
336
+
337
+ async def recv_data(self, stream_id: int) -> bytes:
338
+ """Receive one DATA payload from a stream."""
339
+ data = await self._recv_data_from_queue(stream_id)
340
+ grant = self._compute_window_grant(stream_id)
341
+ if grant:
342
+ await self._send_control(grant)
343
+ return data
344
+
345
+ @abc.abstractmethod
346
+ async def _send_control(self, frame_bytes: bytes) -> None:
347
+ """Send a control frame (WINDOW, PONG) via the transport."""