maxbridge-client 0.2.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.
- maxbridge/__init__.py +6 -0
- maxbridge/client.py +504 -0
- maxbridge/exceptions.py +17 -0
- maxbridge/models.py +80 -0
- maxbridge/packet.py +12 -0
- maxbridge_client-0.2.0.dist-info/METADATA +299 -0
- maxbridge_client-0.2.0.dist-info/RECORD +10 -0
- maxbridge_client-0.2.0.dist-info/WHEEL +5 -0
- maxbridge_client-0.2.0.dist-info/licenses/LICENSE +21 -0
- maxbridge_client-0.2.0.dist-info/top_level.txt +1 -0
maxbridge/__init__.py
ADDED
maxbridge/client.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import itertools
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any ,Callable ,Optional
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
import websockets
|
|
10
|
+
from websockets .asyncio .client import ClientConnection
|
|
11
|
+
|
|
12
|
+
from functools import wraps
|
|
13
|
+
|
|
14
|
+
from .exceptions import APIError ,ConnectionError
|
|
15
|
+
from . import models
|
|
16
|
+
|
|
17
|
+
WS_HOST ="wss://ws-api.oneme.ru/websocket"
|
|
18
|
+
RPC_VERSION =11
|
|
19
|
+
# Match current MAX web client fingerprint from captured traffic
|
|
20
|
+
APP_VERSION ="26.3.6"
|
|
21
|
+
USER_AGENT ="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"
|
|
22
|
+
|
|
23
|
+
_logger =logging .getLogger (__name__ )
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ensure_connected (method :Callable ):
|
|
27
|
+
@wraps (method )
|
|
28
|
+
def wrapper (self ,*args ,**kwargs ):
|
|
29
|
+
if self ._connection is None :
|
|
30
|
+
raise RuntimeError ("WebSocket not connected. Call .connect() first.")
|
|
31
|
+
return method (self ,*args ,**kwargs )
|
|
32
|
+
|
|
33
|
+
return wrapper
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MaxClient :
|
|
37
|
+
def __init__ (self ):
|
|
38
|
+
self ._connection :Optional [ClientConnection ]=None
|
|
39
|
+
self ._http_pool :Optional [aiohttp .ClientSession ]=None
|
|
40
|
+
self ._is_logged_in :bool =False
|
|
41
|
+
self ._device_id :Optional [str ]=None
|
|
42
|
+
self ._seq =itertools .count (1 )
|
|
43
|
+
self ._keepalive_task :Optional [asyncio .Task ]=None
|
|
44
|
+
self ._recv_task :Optional [asyncio .Task ]=None
|
|
45
|
+
self ._incoming_event_callback =None
|
|
46
|
+
self ._reconnect_callback =None
|
|
47
|
+
self ._closed :bool =False
|
|
48
|
+
self ._pending ={}
|
|
49
|
+
self ._video_pending ={}
|
|
50
|
+
self ._file_pending ={}
|
|
51
|
+
|
|
52
|
+
self ._cached_chats :dict [int ,dict ]={}
|
|
53
|
+
self ._cached_users :dict [int ,dict ]={}
|
|
54
|
+
self ._profile :Optional [dict ]=None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def connect (self ):
|
|
59
|
+
if self ._connection :
|
|
60
|
+
raise Exception ("Already connected")
|
|
61
|
+
|
|
62
|
+
self ._closed =False
|
|
63
|
+
_logger .info (f'Connecting to {WS_HOST }...')
|
|
64
|
+
self ._connection =await websockets .connect (
|
|
65
|
+
WS_HOST ,
|
|
66
|
+
origin =websockets .Origin ('https://web.max.ru'),
|
|
67
|
+
user_agent_header =USER_AGENT
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
self ._recv_task =asyncio .create_task (self ._recv_loop ())
|
|
71
|
+
_logger .info ('Connected. Receive task started.')
|
|
72
|
+
return self ._connection
|
|
73
|
+
|
|
74
|
+
@ensure_connected
|
|
75
|
+
async def disconnect (self ):
|
|
76
|
+
self ._closed =True
|
|
77
|
+
await self ._stop_keepalive_task ()
|
|
78
|
+
if self ._recv_task :
|
|
79
|
+
self ._recv_task .cancel ()
|
|
80
|
+
await self ._connection .close ()
|
|
81
|
+
self ._connection =None
|
|
82
|
+
if self ._http_pool :
|
|
83
|
+
await self ._http_pool .close ()
|
|
84
|
+
self ._http_pool =None
|
|
85
|
+
|
|
86
|
+
@ensure_connected
|
|
87
|
+
async def invoke_method (self ,opcode :int ,payload :dict [str ,Any ],retries :int =2 ):
|
|
88
|
+
seq =next (self ._seq )
|
|
89
|
+
|
|
90
|
+
request ={
|
|
91
|
+
"ver":RPC_VERSION ,
|
|
92
|
+
"cmd":0 ,
|
|
93
|
+
"seq":seq ,
|
|
94
|
+
"opcode":opcode ,
|
|
95
|
+
"payload":payload
|
|
96
|
+
}
|
|
97
|
+
_logger .info (f'-> REQUEST: {request }')
|
|
98
|
+
|
|
99
|
+
future =asyncio .get_running_loop ().create_future ()
|
|
100
|
+
self ._pending [seq ]=future
|
|
101
|
+
|
|
102
|
+
try :
|
|
103
|
+
await self ._connection .send (
|
|
104
|
+
json .dumps (request )
|
|
105
|
+
)
|
|
106
|
+
except websockets .exceptions .ConnectionClosed :
|
|
107
|
+
_logger .warning ('got ws disconnect in invoke_method')
|
|
108
|
+
if self ._reconnect_callback :
|
|
109
|
+
_logger .info ('reconnecting')
|
|
110
|
+
await self ._reconnect_callback ()
|
|
111
|
+
if retries >0 :
|
|
112
|
+
_logger .info ('retrying invoke_method after reconnect')
|
|
113
|
+
await self .invoke_method (opcode ,payload ,retries -1 )
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
try :
|
|
117
|
+
response =await future
|
|
118
|
+
except asyncio .CancelledError :
|
|
119
|
+
self ._pending .pop (seq ,None )
|
|
120
|
+
raise
|
|
121
|
+
_logger .info (f'<- RESPONSE: {response }')
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if "error" in response .get ("payload", {}):
|
|
125
|
+
payload = response.get("payload", {})
|
|
126
|
+
error = payload.get("error")
|
|
127
|
+
# Some responses return a string error instead of an object
|
|
128
|
+
if isinstance(error, dict):
|
|
129
|
+
code = error.get("code", -1)
|
|
130
|
+
message = error.get("message", payload.get("message", "Unknown error"))
|
|
131
|
+
else:
|
|
132
|
+
code = -1
|
|
133
|
+
message = payload.get("message") or str(error)
|
|
134
|
+
|
|
135
|
+
raise APIError(code, message)
|
|
136
|
+
|
|
137
|
+
return response
|
|
138
|
+
|
|
139
|
+
async def set_callback (self ,function ):
|
|
140
|
+
import warnings
|
|
141
|
+
warnings .warn ('switch to set_packet_callback',category =DeprecationWarning )
|
|
142
|
+
self .set_packet_callback (function )
|
|
143
|
+
|
|
144
|
+
def set_packet_callback (self ,function ):
|
|
145
|
+
if not asyncio .iscoroutinefunction (function ):
|
|
146
|
+
raise TypeError ('callback must be async')
|
|
147
|
+
self ._incoming_event_callback =function
|
|
148
|
+
|
|
149
|
+
def set_reconnect_callback (self ,function ):
|
|
150
|
+
if not asyncio .iscoroutinefunction (function ):
|
|
151
|
+
raise TypeError ('callback must be async')
|
|
152
|
+
self ._reconnect_callback =function
|
|
153
|
+
async def debug_invoke (self ,opcode :int ,payload :dict | None =None ,timeout :float =5.0 ):
|
|
154
|
+
"""Invoke an opcode for debugging.
|
|
155
|
+
|
|
156
|
+
This helper wraps `invoke_method` and catches exceptions, returning a
|
|
157
|
+
structured report instead of raising.
|
|
158
|
+
"""
|
|
159
|
+
payload = payload or {}
|
|
160
|
+
try :
|
|
161
|
+
response =await asyncio .wait_for (self .invoke_method (opcode ,payload ),timeout )
|
|
162
|
+
return {
|
|
163
|
+
"opcode":opcode ,
|
|
164
|
+
"payload":payload ,
|
|
165
|
+
"response":response ,
|
|
166
|
+
"error":None
|
|
167
|
+
}
|
|
168
|
+
except Exception as e :
|
|
169
|
+
return {
|
|
170
|
+
"opcode":opcode ,
|
|
171
|
+
"payload":payload ,
|
|
172
|
+
"response":None ,
|
|
173
|
+
"error":repr (e )
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async def discover_opcodes (self ,start :int =1 ,end :int =120 ,payloads :dict | None =None ,delay :float =0.1 ):
|
|
177
|
+
"""Probe a range of opcodes to see what the server returns.
|
|
178
|
+
|
|
179
|
+
**Warning**: This may trigger rate limiting or disconnects.
|
|
180
|
+
"""
|
|
181
|
+
payloads = payloads or {}
|
|
182
|
+
results = {}
|
|
183
|
+
for opcode in range (start ,end +1 ):
|
|
184
|
+
payload = payloads .get (opcode ,{})
|
|
185
|
+
results [opcode ]=await self .debug_invoke (opcode ,payload )
|
|
186
|
+
await asyncio .sleep (delay )
|
|
187
|
+
return results
|
|
188
|
+
async def _recv_loop (self ):
|
|
189
|
+
while not self ._closed :
|
|
190
|
+
try :
|
|
191
|
+
packet =await self ._connection .recv ()
|
|
192
|
+
packet =json .loads (packet )
|
|
193
|
+
|
|
194
|
+
except asyncio .CancelledError :
|
|
195
|
+
_logger .info ('receiver cancelled')
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
except websockets .exceptions .ConnectionClosedError as err :
|
|
199
|
+
_logger .warning ('got ws disconnect in receiver')
|
|
200
|
+
if not self ._is_logged_in :
|
|
201
|
+
raise err
|
|
202
|
+
if self ._reconnect_callback :
|
|
203
|
+
_logger .info ('reconnecting')
|
|
204
|
+
asyncio .create_task (self ._reconnect_callback ())
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
except websockets .exceptions .ConnectionClosedOK :
|
|
208
|
+
_logger .info ('connection closed by server')
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
except json .JSONDecodeError :
|
|
212
|
+
_logger .warning ('could not decode packet')
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
seq =packet ["seq"]
|
|
216
|
+
future =self ._pending .pop (seq ,None )
|
|
217
|
+
if future :
|
|
218
|
+
future .set_result (packet )
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
if packet .get ("opcode")==136 :
|
|
222
|
+
payload =packet .get ("payload",{})
|
|
223
|
+
future =None
|
|
224
|
+
|
|
225
|
+
if "videoId"in payload :
|
|
226
|
+
future =self ._video_pending .pop (payload ["videoId"],None )
|
|
227
|
+
elif "fileId"in payload :
|
|
228
|
+
future =self ._file_pending .pop (payload ["fileId"],None )
|
|
229
|
+
|
|
230
|
+
if future :
|
|
231
|
+
future .set_result (None )
|
|
232
|
+
|
|
233
|
+
if self ._incoming_event_callback :
|
|
234
|
+
asyncio .create_task (self ._incoming_event_callback (self ,packet ))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@ensure_connected
|
|
239
|
+
async def _send_keepalive_packet (self ):
|
|
240
|
+
try :
|
|
241
|
+
async with asyncio .timeout (15 ):
|
|
242
|
+
await self .invoke_method (
|
|
243
|
+
opcode =1 ,
|
|
244
|
+
payload ={"interactive":True }
|
|
245
|
+
)
|
|
246
|
+
except asyncio .TimeoutError :
|
|
247
|
+
_logger .warning ('keepalive ping timed out')
|
|
248
|
+
if self ._reconnect_callback :
|
|
249
|
+
_logger .info ('reconnecting')
|
|
250
|
+
asyncio .create_task (self ._reconnect_callback ())
|
|
251
|
+
|
|
252
|
+
@ensure_connected
|
|
253
|
+
async def _keepalive_loop (self ):
|
|
254
|
+
_logger .info (f'keepalive task started')
|
|
255
|
+
try :
|
|
256
|
+
while True :
|
|
257
|
+
await self ._send_keepalive_packet ()
|
|
258
|
+
await asyncio .sleep (30 )
|
|
259
|
+
except asyncio .CancelledError :
|
|
260
|
+
_logger .info ('keepalive task stopped')
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
@ensure_connected
|
|
264
|
+
async def _start_keepalive_task (self ):
|
|
265
|
+
if self ._keepalive_task :
|
|
266
|
+
raise Exception ('Keepalive task already started')
|
|
267
|
+
|
|
268
|
+
self ._keepalive_task =asyncio .create_task (self ._keepalive_loop ())
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
async def _stop_keepalive_task (self ):
|
|
272
|
+
if not self ._keepalive_task :
|
|
273
|
+
raise Exception ('Keepalive task is not running')
|
|
274
|
+
|
|
275
|
+
self ._keepalive_task .cancel ()
|
|
276
|
+
self ._keepalive_task =None
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@ensure_connected
|
|
282
|
+
async def _send_hello_packet (self ,device_id :Optional [str ]=None ):
|
|
283
|
+
self ._device_id =device_id or f'{uuid .uuid4 ()}'
|
|
284
|
+
return await self .invoke_method (
|
|
285
|
+
opcode =6 ,
|
|
286
|
+
payload ={
|
|
287
|
+
"userAgent":{
|
|
288
|
+
"deviceType":"WEB",
|
|
289
|
+
"locale":"ru",
|
|
290
|
+
"deviceLocale":"ru",
|
|
291
|
+
"osVersion":"Linux",
|
|
292
|
+
"deviceName":"Chrome",
|
|
293
|
+
"headerUserAgent":USER_AGENT ,
|
|
294
|
+
"appVersion":APP_VERSION ,
|
|
295
|
+
"screen":"720x1280 1.0x",
|
|
296
|
+
"timezone":"Asia/Yekaterinburg"
|
|
297
|
+
},
|
|
298
|
+
"deviceId":self ._device_id ,
|
|
299
|
+
}
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
@ensure_connected
|
|
303
|
+
async def send_code (self ,phone :str )->str :
|
|
304
|
+
""":returns: Login token."""
|
|
305
|
+
await self ._send_hello_packet ()
|
|
306
|
+
start_auth_response =await self .invoke_method (
|
|
307
|
+
opcode =17 ,
|
|
308
|
+
payload ={
|
|
309
|
+
"phone":phone ,
|
|
310
|
+
"type":"START_AUTH",
|
|
311
|
+
"language":"ru"
|
|
312
|
+
}
|
|
313
|
+
)
|
|
314
|
+
return start_auth_response ["payload"]["token"]
|
|
315
|
+
|
|
316
|
+
@ensure_connected
|
|
317
|
+
async def sign_in (self ,sms_token :str ,sms_code :int ):
|
|
318
|
+
"""
|
|
319
|
+
Auth token for further login is at ['payload']['tokenAttrs']['LOGIN']['token']
|
|
320
|
+
:param login_token: Must be obtained via `send_code`.
|
|
321
|
+
"""
|
|
322
|
+
verification_response =await self .invoke_method (
|
|
323
|
+
opcode =18 ,
|
|
324
|
+
payload ={
|
|
325
|
+
"token":sms_token ,
|
|
326
|
+
"verifyCode":str (sms_code ),
|
|
327
|
+
"authTokenType":"CHECK_CODE"
|
|
328
|
+
}
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
if "error"in verification_response ["payload"]:
|
|
332
|
+
raise Exception (verification_response ["payload"]["error"])
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
if "profile"in verification_response ["payload"]:
|
|
336
|
+
self ._profile =verification_response ["payload"]["profile"]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
if "chats"in verification_response ["payload"]:
|
|
340
|
+
for chat in verification_response ["payload"]["chats"]:
|
|
341
|
+
self ._cached_chats [chat ["id"]]=chat
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
if "chats"in verification_response ["payload"]:
|
|
345
|
+
user_ids =set ()
|
|
346
|
+
for chat in verification_response ["payload"]["chats"]:
|
|
347
|
+
if "participants"in chat :
|
|
348
|
+
for uid in chat ["participants"].keys ():
|
|
349
|
+
user_ids .add (int (uid ))
|
|
350
|
+
if user_ids :
|
|
351
|
+
|
|
352
|
+
try :
|
|
353
|
+
users_response =await self .invoke_method (
|
|
354
|
+
opcode =32 ,
|
|
355
|
+
payload ={"contactIds":list (user_ids )}
|
|
356
|
+
)
|
|
357
|
+
if "payload"in users_response and "contacts"in users_response ["payload"]:
|
|
358
|
+
for user in users_response ["payload"]["contacts"]:
|
|
359
|
+
self ._cached_users [user ["id"]]=user
|
|
360
|
+
except Exception as e :
|
|
361
|
+
_logger .warning (f'Failed to fetch users: {e }')
|
|
362
|
+
|
|
363
|
+
try :
|
|
364
|
+
phone =verification_response ["payload"]["profile"]["contact"]["phone"]
|
|
365
|
+
except :
|
|
366
|
+
phone ='[?]'
|
|
367
|
+
_logger .warning ('Got no phone number in server response')
|
|
368
|
+
_logger .info (f'Successfully logged in as {phone }')
|
|
369
|
+
|
|
370
|
+
self ._is_logged_in =True
|
|
371
|
+
await self ._start_keepalive_task ()
|
|
372
|
+
|
|
373
|
+
return verification_response
|
|
374
|
+
|
|
375
|
+
@ensure_connected
|
|
376
|
+
async def login_by_token (self ,token :str ,device_id :Optional [str ]=None ):
|
|
377
|
+
await self ._send_hello_packet (device_id )
|
|
378
|
+
_logger .info ("using session")
|
|
379
|
+
login_response =await self .invoke_method (
|
|
380
|
+
opcode =19 ,
|
|
381
|
+
payload ={
|
|
382
|
+
"interactive":True ,
|
|
383
|
+
"token":token ,
|
|
384
|
+
"chatsCount":40 ,
|
|
385
|
+
"chatsSync":0 ,
|
|
386
|
+
"contactsSync":0 ,
|
|
387
|
+
"presenceSync":-1 ,
|
|
388
|
+
"draftsSync":0
|
|
389
|
+
}
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
if "error"in login_response ["payload"]:
|
|
393
|
+
raise Exception (login_response ["payload"]["error"])
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
if "profile"in login_response ["payload"]:
|
|
397
|
+
self ._profile =login_response ["payload"]["profile"]
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
if "chats"in login_response ["payload"]:
|
|
401
|
+
for chat in login_response ["payload"]["chats"]:
|
|
402
|
+
self ._cached_chats [chat ["id"]]=chat
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
if "chats"in login_response ["payload"]:
|
|
406
|
+
user_ids =set ()
|
|
407
|
+
for chat in login_response ["payload"]["chats"]:
|
|
408
|
+
if "participants"in chat :
|
|
409
|
+
for uid in chat ["participants"].keys ():
|
|
410
|
+
user_ids .add (int (uid ))
|
|
411
|
+
if user_ids :
|
|
412
|
+
|
|
413
|
+
try :
|
|
414
|
+
users_response =await self .invoke_method (
|
|
415
|
+
opcode =32 ,
|
|
416
|
+
payload ={"contactIds":list (user_ids )}
|
|
417
|
+
)
|
|
418
|
+
if "payload"in users_response and "contacts"in users_response ["payload"]:
|
|
419
|
+
for user in users_response ["payload"]["contacts"]:
|
|
420
|
+
self ._cached_users [user ["id"]]=user
|
|
421
|
+
except Exception as e :
|
|
422
|
+
_logger .warning (f'Failed to fetch users: {e }')
|
|
423
|
+
|
|
424
|
+
try :
|
|
425
|
+
phone =login_response ["payload"]["profile"]["contact"]["phone"]
|
|
426
|
+
except :
|
|
427
|
+
phone ='[?]'
|
|
428
|
+
_logger .warning ('Got no phone number in server response')
|
|
429
|
+
_logger .info (f'Successfully logged in as {phone }')
|
|
430
|
+
|
|
431
|
+
self ._is_logged_in =True
|
|
432
|
+
await self ._start_keepalive_task ()
|
|
433
|
+
|
|
434
|
+
return login_response
|
|
435
|
+
|
|
436
|
+
@property
|
|
437
|
+
def device_id (self )->Optional [str ]:
|
|
438
|
+
return self ._device_id
|
|
439
|
+
|
|
440
|
+
@property
|
|
441
|
+
def profile (self )->Optional [dict ]:
|
|
442
|
+
return self ._profile
|
|
443
|
+
|
|
444
|
+
def get_cached_chats (self )->dict [int ,dict ]:
|
|
445
|
+
return self ._cached_chats
|
|
446
|
+
|
|
447
|
+
def get_cached_users (self )->dict [int ,dict ]:
|
|
448
|
+
return self ._cached_users
|
|
449
|
+
|
|
450
|
+
def get_chats_structured (self )->dict [int ,models .Chat ]:
|
|
451
|
+
"""Return cached chats as `Chat` models."""
|
|
452
|
+
return {cid :models .Chat .from_raw (data )for cid ,data in self ._cached_chats .items ()}
|
|
453
|
+
|
|
454
|
+
def get_users_structured (self )->dict [int ,models .User ]:
|
|
455
|
+
"""Return cached users as `User` models."""
|
|
456
|
+
return {uid :models .User .from_raw (data )for uid ,data in self ._cached_users .items ()}
|
|
457
|
+
|
|
458
|
+
@ensure_connected
|
|
459
|
+
async def get_chat_messages (self ,chat_id :int ,from_ts :Optional [int ]=None ,backward :int =30 ,forward :int =0 ,get_messages :bool =True ):
|
|
460
|
+
"""Get messages from chat using the same opcode/shape as web client.
|
|
461
|
+
|
|
462
|
+
:param chat_id: Chat identifier.
|
|
463
|
+
:param from_ts: Anchor timestamp (ms). If not provided, tries to use lastEventTime/lastMessage.time from cache.
|
|
464
|
+
:param backward: How many messages to request before ``from_ts``.
|
|
465
|
+
:param forward: How many messages to request after ``from_ts``.
|
|
466
|
+
:param get_messages: Whether to include messages in the response (web uses ``true``).
|
|
467
|
+
"""
|
|
468
|
+
if from_ts is None :
|
|
469
|
+
cached =self ._cached_chats .get (chat_id )
|
|
470
|
+
if cached :
|
|
471
|
+
from_ts =cached .get ("lastEventTime")or cached .get ("lastMessage" ,{}).get ("time")
|
|
472
|
+
if from_ts is None :
|
|
473
|
+
# Fallback to zero – server will interpret according to its defaults
|
|
474
|
+
from_ts =0
|
|
475
|
+
|
|
476
|
+
return await self .invoke_method (
|
|
477
|
+
opcode =49 ,
|
|
478
|
+
payload ={
|
|
479
|
+
"chatId":chat_id ,
|
|
480
|
+
"from":from_ts ,
|
|
481
|
+
"forward":forward ,
|
|
482
|
+
"backward":backward ,
|
|
483
|
+
"getMessages":get_messages
|
|
484
|
+
}
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
@ensure_connected
|
|
488
|
+
async def get_chat_messages_structured (self ,chat_id :int ,from_ts :Optional [int ]=None ,backward :int =30 ,forward :int =0 )->list [models .Message ]:
|
|
489
|
+
"""Wrapper over `get_chat_messages` that returns a list of `Message` models."""
|
|
490
|
+
raw =await self .get_chat_messages (chat_id ,from_ts =from_ts ,backward =backward ,forward =forward ,get_messages =True )
|
|
491
|
+
payload =raw .get ("payload", {})
|
|
492
|
+
msgs =payload .get ("messages")or []
|
|
493
|
+
if isinstance (msgs ,dict ):
|
|
494
|
+
iterable =msgs .values ()
|
|
495
|
+
else :
|
|
496
|
+
iterable =msgs
|
|
497
|
+
return [models .Message .from_raw (m ,chat_id =chat_id )for m in iterable ]
|
|
498
|
+
|
|
499
|
+
async def __aenter__ (self ):
|
|
500
|
+
await self .connect ()
|
|
501
|
+
return self
|
|
502
|
+
|
|
503
|
+
async def __aexit__ (self ,exc_type ,exc_val ,exc_tb ):
|
|
504
|
+
await self .disconnect ()
|
maxbridge/exceptions.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class MaxException(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConnectionError(MaxException):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuthenticationError(MaxException):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class APIError(MaxException):
|
|
14
|
+
def __init__(self, error_code: int, message: str):
|
|
15
|
+
self.error_code = error_code
|
|
16
|
+
self.message = message
|
|
17
|
+
super().__init__(f"API Error {error_code}: {message}")
|
maxbridge/models.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional ,List ,Dict ,Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class User :
|
|
7
|
+
id :int
|
|
8
|
+
name :str
|
|
9
|
+
username :Optional [str ]=None
|
|
10
|
+
avatar :Optional [str ]=None
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def from_raw (cls ,raw :Dict [str ,Any ]):
|
|
14
|
+
contact_id =raw .get ("id")
|
|
15
|
+
names =raw .get ("names")or []
|
|
16
|
+
name =names [0 ].get ("name") if names else str (contact_id )
|
|
17
|
+
username =raw .get ("username")
|
|
18
|
+
base_url =raw .get ("baseUrl") or raw .get ("baseRawUrl")
|
|
19
|
+
avatar =None
|
|
20
|
+
if base_url :
|
|
21
|
+
avatar =base_url
|
|
22
|
+
return cls (
|
|
23
|
+
id =contact_id ,
|
|
24
|
+
name =name ,
|
|
25
|
+
username =username ,
|
|
26
|
+
avatar =avatar ,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Chat :
|
|
32
|
+
id :int
|
|
33
|
+
title :str
|
|
34
|
+
type :str
|
|
35
|
+
participants_count :Optional [int ]=None
|
|
36
|
+
avatar :Optional [str ]=None
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_raw (cls ,raw :Dict [str ,Any ]):
|
|
40
|
+
chat_id =raw .get ("id")
|
|
41
|
+
title =raw .get ("title")
|
|
42
|
+
chat_type =raw .get ("type","UNKNOWN")
|
|
43
|
+
participants =raw .get ("participants")or {}
|
|
44
|
+
participants_count =len (participants )
|
|
45
|
+
avatar =raw .get ("avatar") or raw .get ("baseUrl") or raw .get ("baseRawUrl")
|
|
46
|
+
if not title and chat_type =='DIALOG':
|
|
47
|
+
# диалоги обычно без своего title, его удобно подставлять снаружи по собеседнику
|
|
48
|
+
title =f"Dialog {chat_id }"
|
|
49
|
+
return cls (
|
|
50
|
+
id =chat_id ,
|
|
51
|
+
title =title or "" ,
|
|
52
|
+
type =chat_type ,
|
|
53
|
+
participants_count =participants_count or None ,
|
|
54
|
+
avatar =avatar ,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Message :
|
|
60
|
+
id :str
|
|
61
|
+
chat_id :int
|
|
62
|
+
user_id :int
|
|
63
|
+
text :str
|
|
64
|
+
timestamp :int
|
|
65
|
+
attaches :List [Dict [str ,Any ]]=None
|
|
66
|
+
|
|
67
|
+
def __post_init__ (self ):
|
|
68
|
+
if self .attaches is None :
|
|
69
|
+
self .attaches =[]
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_raw (cls ,raw :Dict [str ,Any ] ,chat_id :Optional [int ]=None ):
|
|
73
|
+
return cls (
|
|
74
|
+
id =raw .get ("id" ),
|
|
75
|
+
chat_id =chat_id if chat_id is not None else int (raw .get ("chatId" ,0 )),
|
|
76
|
+
user_id =raw .get ("sender" ),
|
|
77
|
+
text =raw .get ("text" ,""),
|
|
78
|
+
timestamp =raw .get ("time" ,0 ),
|
|
79
|
+
attaches =raw .get ("attaches" )or [],
|
|
80
|
+
)
|
maxbridge/packet.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: maxbridge-client
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Асинхронная Python библиотека для MAX API
|
|
5
|
+
Home-page: https://github.com/username/max-bridge
|
|
6
|
+
Author: VK Max Client Team
|
|
7
|
+
Author-email:
|
|
8
|
+
Maintainer: VK Max Client Team
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 MaxBridge contributors
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
Project-URL: Homepage, https://github.com/username/max-bridge
|
|
31
|
+
Project-URL: Documentation, https://github.com/username/max-bridge/docs/
|
|
32
|
+
Project-URL: Repository, https://github.com/username/max-bridge
|
|
33
|
+
Project-URL: Bug Reports, https://github.com/username/max-bridge/issues
|
|
34
|
+
Project-URL: Source, https://github.com/username/max-bridge
|
|
35
|
+
Keywords: vk,max,messenger,api,websocket,async
|
|
36
|
+
Classifier: Development Status :: 3 - Alpha
|
|
37
|
+
Classifier: Intended Audience :: Developers
|
|
38
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
39
|
+
Classifier: Operating System :: OS Independent
|
|
40
|
+
Classifier: Programming Language :: Python :: 3
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
44
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
45
|
+
Classifier: Topic :: Communications :: Chat
|
|
46
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
47
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
48
|
+
Requires-Python: >=3.9
|
|
49
|
+
Description-Content-Type: text/markdown
|
|
50
|
+
License-File: LICENSE
|
|
51
|
+
Requires-Dist: aiohttp>=3.8.0
|
|
52
|
+
Requires-Dist: websockets>=12.0
|
|
53
|
+
Provides-Extra: dev
|
|
54
|
+
Requires-Dist: black; extra == "dev"
|
|
55
|
+
Requires-Dist: pytest; extra == "dev"
|
|
56
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
57
|
+
Requires-Dist: mypy; extra == "dev"
|
|
58
|
+
Dynamic: home-page
|
|
59
|
+
Dynamic: license-file
|
|
60
|
+
Dynamic: requires-python
|
|
61
|
+
|
|
62
|
+
# MaxBridge
|
|
63
|
+
|
|
64
|
+
[](https://www.python.org/)
|
|
65
|
+
[](https://opensource.org/licenses/MIT)
|
|
66
|
+
[](https://docs.python.org/3/library/asyncio.html)
|
|
67
|
+
|
|
68
|
+
Асинхронная Python библиотека для взаимодействия с API мессенджера MAX через WebSocket соединение.
|
|
69
|
+
|
|
70
|
+
## Описание
|
|
71
|
+
|
|
72
|
+
MaxBridge предоставляет удобный интерфейс для работы с мессенджером MAX, позволяя отправлять и получать сообщения, управлять чатами, пользователями и файлами в асинхронном режиме.
|
|
73
|
+
|
|
74
|
+
## 🚀 Быстрый старт
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import asyncio
|
|
78
|
+
from maxbridge import MaxClient
|
|
79
|
+
|
|
80
|
+
async def main():
|
|
81
|
+
async with MaxClient() as client:
|
|
82
|
+
# Авторизация по токену
|
|
83
|
+
await client.login_by_token("ваш_токен")
|
|
84
|
+
|
|
85
|
+
# Получение списка чатов
|
|
86
|
+
chats = client.get_cached_chats()
|
|
87
|
+
print(f"Найдено чатов: {len(chats)}")
|
|
88
|
+
|
|
89
|
+
asyncio.run(main())
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## 📦 Установка
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
# Установка из PyPI (предполагается, что пакет опубликован)
|
|
96
|
+
pip install maxbridge
|
|
97
|
+
|
|
98
|
+
# Или из исходников
|
|
99
|
+
git clone <repository-url>
|
|
100
|
+
cd max-bridge
|
|
101
|
+
pip install -e .
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## 🔐 Авторизация
|
|
105
|
+
|
|
106
|
+
### По токену
|
|
107
|
+
|
|
108
|
+
**Важно:** Для авторизации необходимо получить токен доступа из веб-версии MAX.
|
|
109
|
+
|
|
110
|
+
#### Как получить токен:
|
|
111
|
+
|
|
112
|
+
1. Откройте https://web.max.ru в браузере
|
|
113
|
+
2. Авторизуйтесь в вашем аккаунте
|
|
114
|
+
3. Откройте DevTools (F12) → Application → Local Storage → https://web.max.ru
|
|
115
|
+
4. Найдите ключ `token` и скопируйте его значение
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
async with MaxClient() as client:
|
|
119
|
+
await client.login_by_token("ваш_длинный_токен")
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**Примечание:** Токен является конфиденциальной информацией. Храните его securely.
|
|
123
|
+
|
|
124
|
+
## 💬 Работа с чатами
|
|
125
|
+
|
|
126
|
+
### Получение списка чатов
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
chats = client.get_cached_chats()
|
|
130
|
+
for chat_id, chat_info in chats.items():
|
|
131
|
+
print(f"ID: {chat_id}, Тип: {chat_info['type']}, Название: {chat_info.get('title', 'Диалог')}")
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Получение сообщений чата
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
messages = await client.get_chat_messages(chat_id=123456, count=50, offset=0)
|
|
138
|
+
if "payload" in messages and "messages" in messages["payload"]:
|
|
139
|
+
for msg_id, msg in messages["payload"]["messages"].items():
|
|
140
|
+
print(f"{msg['sender']}: {msg['text']}")
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## 📨 Отправка сообщений
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from maxbridge.functions import messages
|
|
147
|
+
|
|
148
|
+
# Текстовое сообщение
|
|
149
|
+
await messages.send_message(client, chat_id=123456, text="Привет!")
|
|
150
|
+
|
|
151
|
+
# С фото
|
|
152
|
+
await messages.send_photo(client, chat_id=123456, image_path="photo.jpg", caption="Фото")
|
|
153
|
+
|
|
154
|
+
# Реплай
|
|
155
|
+
await messages.reply_message(client, chat_id=123456, text="Ответ", reply_to_message_id="msg_id")
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## 👥 Управление пользователями
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from maxbridge.functions import users
|
|
162
|
+
|
|
163
|
+
# Информация о пользователях
|
|
164
|
+
user_info = await users.resolve_users(client, user_ids=[12345, 67890])
|
|
165
|
+
|
|
166
|
+
# Добавить в контакты
|
|
167
|
+
await users.add_to_contacts(client, user_id=12345)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## 📁 Работа с файлами
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
from maxbridge.functions import messages, uploads
|
|
174
|
+
|
|
175
|
+
# Отправка файла
|
|
176
|
+
await messages.send_file(client, chat_id=123456, file_path="document.pdf", caption="Документ")
|
|
177
|
+
|
|
178
|
+
# Скачивание файла
|
|
179
|
+
download_url = await uploads.download_file(
|
|
180
|
+
client, chat_id=123456, message_id="msg_id", file_id=789
|
|
181
|
+
)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## 📺 Работа с медиа
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
# Скачивание видео
|
|
188
|
+
video_url = await uploads.download_video(
|
|
189
|
+
client, chat_id=123456, message_id="msg_id", video_id=101112
|
|
190
|
+
)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## 👥 Группы и каналы
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
from maxbridge.functions import groups, channels
|
|
197
|
+
|
|
198
|
+
# Создание группы
|
|
199
|
+
await groups.create_group(client, "Название группы", participant_ids=[123, 456])
|
|
200
|
+
|
|
201
|
+
# Присоединение к каналу
|
|
202
|
+
await channels.join_channel(client, "username_канала")
|
|
203
|
+
|
|
204
|
+
# Информация о канале
|
|
205
|
+
channel_info = await channels.resolve_channel_username(client, "username")
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## 🛠️ Обработка ошибок
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from maxbridge.exceptions import APIError, ConnectionError
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
await client.login_by_token("токен")
|
|
215
|
+
except APIError as e:
|
|
216
|
+
print(f"Ошибка API {e.error_code}: {e.message}")
|
|
217
|
+
except ConnectionError:
|
|
218
|
+
print("Ошибка подключения")
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## 📊 Модели данных
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
from maxbridge.models import User, Chat, Message
|
|
225
|
+
|
|
226
|
+
# Примеры
|
|
227
|
+
user = User(id=123, name="Имя", username="username")
|
|
228
|
+
chat = Chat(id=456, title="Название", type="DIALOG")
|
|
229
|
+
message = Message(id="msg_id", chat_id=456, user_id=123, text="Текст")
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## 🔧 Продвинутые возможности
|
|
233
|
+
|
|
234
|
+
### Обработка событий в реальном времени
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
def event_handler(client, packet):
|
|
238
|
+
if packet.get("opcode") == 64: # Новое сообщение
|
|
239
|
+
print("Новое сообщение!")
|
|
240
|
+
|
|
241
|
+
client.set_packet_callback(event_handler)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Кастомные настройки
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
# Профиль
|
|
248
|
+
profile = client.profile
|
|
249
|
+
|
|
250
|
+
# Кэшированные пользователи
|
|
251
|
+
users = client.get_cached_users()
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## 📚 Документация
|
|
255
|
+
|
|
256
|
+
- [API Reference](docs/API.md)
|
|
257
|
+
- [Примеры](examples/)
|
|
258
|
+
- [Contributing](CONTRIBUTING.md)
|
|
259
|
+
- [Changelog](CHANGELOG.md)
|
|
260
|
+
|
|
261
|
+
## 🏗️ Структура проекта
|
|
262
|
+
|
|
263
|
+
```
|
|
264
|
+
maxbridge/
|
|
265
|
+
├── __init__.py
|
|
266
|
+
├── client.py # WebSocket клиент
|
|
267
|
+
├── models.py # Модели данных
|
|
268
|
+
├── exceptions.py # Исключения
|
|
269
|
+
├── packet.py # Обработка пакетов
|
|
270
|
+
└── functions/ # API функции
|
|
271
|
+
├── __init__.py
|
|
272
|
+
├── messages.py # Сообщения
|
|
273
|
+
├── users.py # Пользователи
|
|
274
|
+
├── groups.py # Группы
|
|
275
|
+
├── channels.py # Каналы
|
|
276
|
+
├── profile.py # Профиль
|
|
277
|
+
└── uploads.py # Загрузки
|
|
278
|
+
|
|
279
|
+
docs/ # Документация
|
|
280
|
+
examples/ # Примеры
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## ⚠️ Важные замечания
|
|
284
|
+
|
|
285
|
+
- Все методы асинхронные — используйте `await`
|
|
286
|
+
- Рекомендуется `async with MaxClient() as client:`
|
|
287
|
+
- Токены имеют срок действия
|
|
288
|
+
- Соблюдайте лимиты API
|
|
289
|
+
|
|
290
|
+
## 🐛 Отладка
|
|
291
|
+
|
|
292
|
+
```python
|
|
293
|
+
import logging
|
|
294
|
+
logging.basicConfig(level=logging.INFO)
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
## 📄 Лицензия
|
|
298
|
+
|
|
299
|
+
MIT License
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
maxbridge/__init__.py,sha256=_V805CBVvD7aq0tLHkDVj29-AJero0AmLhV8GvEwIiY,161
|
|
2
|
+
maxbridge/client.py,sha256=MM_UZobtThTDw6Ph0c170SlFUqf4WxzMTbKCQC4sGu4,17792
|
|
3
|
+
maxbridge/exceptions.py,sha256=3SxYA381VWcJE4SkIsd1UWVTeoXAgZJYlbahEZ_jfCo,357
|
|
4
|
+
maxbridge/models.py,sha256=OzElV11wezwdFj4zmelBYVP0NwIeAHKvrkKO0sBDNAs,2318
|
|
5
|
+
maxbridge/packet.py,sha256=gXiE1p1NbcoBveUk0aQjO28JNUtjtTv4eZ-q_CiNnd8,195
|
|
6
|
+
maxbridge_client-0.2.0.dist-info/licenses/LICENSE,sha256=Piagsiu7OcH67ZrpAJBxR_-tPhJQHi07CMWgALgmLyU,1078
|
|
7
|
+
maxbridge_client-0.2.0.dist-info/METADATA,sha256=g_W_uTVWOgEyJ11QuVdnzsYHsZcuvxA9awVRy1WZ_dg,10116
|
|
8
|
+
maxbridge_client-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
maxbridge_client-0.2.0.dist-info/top_level.txt,sha256=pcPHYIca6HlkF7wuoRfuXgzXDXty6V3-VtGMha7Jjd4,10
|
|
10
|
+
maxbridge_client-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MaxBridge contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
maxbridge
|