pymaxbot 0.1.0__tar.gz

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.
pymaxbot-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sergey Kurilenko
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,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: pymaxbot
3
+ Version: 0.1.0
4
+ Summary: Python SDK for MAX messenger bot API
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 Sergey Kurilenko
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Project-URL: Homepage, https://github.com/svkurick/max_bot
28
+ Project-URL: Repository, https://github.com/svkurick/max_bot
29
+ Project-URL: Issues, https://github.com/svkurick/max_bot/issues
30
+ Requires-Python: >=3.9
31
+ Description-Content-Type: text/markdown
32
+ License-File: LICENSE
33
+ Requires-Dist: httpx
34
+ Dynamic: license-file
35
+
36
+ # max_bot
@@ -0,0 +1 @@
1
+ # max_bot
@@ -0,0 +1,19 @@
1
+ from importlib.metadata import version, PackageNotFoundError
2
+
3
+ try:
4
+ __version__ = version("max_bot")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0"
7
+
8
+ from .bot import Bot
9
+ from .dispatcher.dispatcher import Dispatcher
10
+ from .polling.polling import run_polling
11
+ from .types.callback import Callback
12
+
13
+ __all__ = [
14
+ "__version__",
15
+ "Bot",
16
+ "Dispatcher",
17
+ "run_polling",
18
+ "Callback",
19
+ ]
@@ -0,0 +1,114 @@
1
+ import asyncio
2
+ import os
3
+ import httpx
4
+
5
+ from .client import MaxClient
6
+ from .types.message import Message
7
+
8
+
9
+ class Bot:
10
+
11
+ def __init__(self, token: str):
12
+ self.client = MaxClient(token)
13
+
14
+ # ─── MESSAGES ─────────────────────────────────────────────────────────────
15
+
16
+ async def send_message(self, chat_id: int, text: str, format=None, buttons=None):
17
+ body = {"text": text}
18
+ if format:
19
+ body["format"] = format
20
+
21
+ if buttons:
22
+ body["attachments"] = [_build_keyboard(buttons)]
23
+
24
+ params = {"user_id": chat_id}
25
+ data = await self.client.request("POST", "/messages", json=body, params=params)
26
+ return Message(data["message"], self)
27
+
28
+ async def delete_message(self, mid: str):
29
+ params = {"message_id": mid}
30
+ return await self.client.request("DELETE", "/messages", params=params)
31
+
32
+ # ─── FILES ────────────────────────────────────────────────────────────────
33
+
34
+ async def upload_file(self, file_path: str):
35
+ r = await self.client.request("POST", "/uploads", type_param="file")
36
+ upload_url = r.get("url")
37
+ with open(file_path, "rb") as f:
38
+ files = {"data": (os.path.basename(file_path), f)}
39
+ return await self.client.request("POST", upload_url, files=files, base_url_blank=True)
40
+
41
+ async def send_document(self, chat_id: int, file_path: str):
42
+ upload = await self.upload_file(file_path)
43
+ file_token = upload["token"]
44
+ body = {
45
+ "attachments": [{"type": "file", "payload": {"token": file_token}}]
46
+ }
47
+ await asyncio.sleep(2)
48
+ data = await self.client.request("POST", "/messages", json=body, params={"user_id": chat_id})
49
+ return Message(data["message"], self)
50
+
51
+ async def send_documents(self, chat_id: int, file_path: list[str]):
52
+ for path in file_path:
53
+ upload = await self.upload_file(path)
54
+ file_token = upload["token"]
55
+ body = {
56
+ "attachments": [{"type": "file", "payload": {"token": file_token}}]
57
+ }
58
+ await self.client.request("POST", "/messages", json=body, params={"user_id": chat_id})
59
+
60
+ # ─── IMAGES ───────────────────────────────────────────────────────────────
61
+
62
+ async def upload_image(self, file_path: str):
63
+ r = await self.client.request("POST", "/uploads", type_param="image")
64
+ upload_url = r.get("url")
65
+ with open(file_path, "rb") as f:
66
+ files = {"data": (os.path.basename(file_path), f)}
67
+ result = await self.client.request("POST", upload_url, files=files, base_url_blank=True)
68
+ # Ответ: {"photos": {"<id>": {"token": "..."}}}
69
+ photos = result.get("photos", {})
70
+ token = next(iter(photos.values()))["token"]
71
+ return {"token": token}
72
+
73
+ async def send_image(self, chat_id: int, file_path: str, text: str = None):
74
+ upload = await self.upload_image(file_path)
75
+ image_token = upload["token"]
76
+ body = {"text": text} if text else {}
77
+ body["attachments"] = [{"type": "image", "payload": {"token": image_token}}]
78
+ data = await self.client.request("POST", "/messages", json=body, params={"user_id": chat_id})
79
+ return Message(data["message"], self)
80
+
81
+ async def send_images(self, chat_id: int, file_paths: list[str], text: str = None):
82
+ tokens = []
83
+ for path in file_paths:
84
+ upload = await self.upload_image(path)
85
+ tokens.append(upload["token"])
86
+ body = {"text": text} if text else {}
87
+ body["attachments"] = [{"type": "image", "payload": {"token": t}} for t in tokens]
88
+ await self.client.request("POST", "/messages", json=body, params={"user_id": chat_id})
89
+
90
+ # ─── CALLBACKS ────────────────────────────────────────────────────────────
91
+
92
+ async def answer_callback(self, callback_id: str, notification: str = None):
93
+ # API требует notification или message — передаём пробел если нечего показывать
94
+ body = {"notification": notification or " "}
95
+ await self.client.request("POST", "/answers", json=body, params={"callback_id": callback_id})
96
+
97
+
98
+ # ─── HELPERS ──────────────────────────────────────────────────────────────────
99
+
100
+ def _build_keyboard(buttons: list[list[dict]]) -> dict:
101
+ rows = []
102
+ for row in buttons:
103
+ btn_row = []
104
+ for btn in row:
105
+ btn_row.append({
106
+ "type": btn.get("type", "callback"),
107
+ "text": btn["text"],
108
+ "payload": btn.get("payload", ""),
109
+ })
110
+ rows.append(btn_row)
111
+ return {
112
+ "type": "inline_keyboard",
113
+ "payload": {"buttons": rows}
114
+ }
@@ -0,0 +1,75 @@
1
+ import httpx
2
+ import asyncio
3
+
4
+ class MaxClient:
5
+
6
+ def __init__(self, token: str, base_url="https://platform-api.max.ru"):
7
+ self.base_url = base_url
8
+ self.token = token
9
+ self.client = httpx.AsyncClient(
10
+ timeout=httpx.Timeout(
11
+ connect=10.0,
12
+ read=60.0,
13
+ write=10.0,
14
+ pool=10.0
15
+ )
16
+ )
17
+
18
+ async def request(
19
+ self,
20
+ method: str,
21
+ path: str,
22
+ json=None,
23
+ params=None,
24
+ files=None,
25
+ data=None,
26
+ type_param=None,
27
+ base_url_blank=False,
28
+ max_retries=5,
29
+ ) -> dict:
30
+ headers = {
31
+ "Authorization": self.token
32
+ }
33
+ if type_param:
34
+ params = {'type': type_param}
35
+
36
+ if base_url_blank:
37
+ path_url = path
38
+ else:
39
+ path_url = f"{self.base_url}{path}"
40
+
41
+ delay = 1
42
+
43
+ for attempt in range(max_retries):
44
+ r = await self.client.request(
45
+ method,
46
+ path_url,
47
+ json=json,
48
+ params=params,
49
+ files=files,
50
+ data=data,
51
+ headers=headers
52
+ )
53
+ try:
54
+ data_resp = r.json()
55
+ except Exception:
56
+ data_resp = {}
57
+
58
+ # 🔥 проверка attachment.not.ready
59
+ if (
60
+ isinstance(data_resp, dict)
61
+ and data_resp.get("code") == "attachment.not.ready"
62
+ ):
63
+ print(f"⏳ attachment not ready, retry {attempt + 1}, sleep {delay}s")
64
+ await asyncio.sleep(delay)
65
+ delay += 3
66
+ continue
67
+
68
+ # если статус плохой — падаем
69
+ if r.is_error:
70
+ print(f"❌ HTTP {r.status_code} response body:", r.text)
71
+ r.raise_for_status()
72
+
73
+ return data_resp
74
+
75
+ raise Exception("❌ Превышено количество попыток (attachment not ready)")
File without changes
@@ -0,0 +1,64 @@
1
+ from ..types.message import Message
2
+ from ..types.callback import Callback
3
+
4
+
5
+ class Dispatcher:
6
+
7
+ def __init__(self, bot):
8
+ self.bot = bot
9
+ self.handlers = []
10
+ self.callback_handlers = []
11
+
12
+ def message(self, commands=None):
13
+ def decorator(func):
14
+ self.handlers.append({
15
+ "func": func,
16
+ "commands": commands,
17
+ })
18
+ return func
19
+ return decorator
20
+
21
+ def callback(self, payloads=None):
22
+ def decorator(func):
23
+ self.callback_handlers.append({
24
+ "func": func,
25
+ "payloads": payloads,
26
+ })
27
+ return func
28
+ return decorator
29
+
30
+ async def process_update(self, update):
31
+ update_type = update.get("update_type")
32
+
33
+ if update_type == "message_callback":
34
+ await self._process_callback(update)
35
+ elif "message" in update:
36
+ await self._process_message(update["message"])
37
+
38
+ async def _process_message(self, raw_message: dict):
39
+ message = Message(raw_message, self.bot)
40
+ for handler in self.handlers:
41
+ commands = handler["commands"]
42
+ if commands:
43
+ if not message.text:
44
+ continue
45
+ cmd_word = message.text.split()[0]
46
+ if not any(cmd_word == f"/{cmd}" for cmd in commands):
47
+ continue
48
+ try:
49
+ await handler["func"](message)
50
+ break
51
+ except Exception as e:
52
+ print("❌ Handler error:", e)
53
+
54
+ async def _process_callback(self, update: dict):
55
+ cb = Callback(update, self.bot)
56
+ for handler in self.callback_handlers:
57
+ payloads = handler["payloads"]
58
+ if payloads and cb.payload not in payloads:
59
+ continue
60
+ try:
61
+ await handler["func"](cb)
62
+ break
63
+ except Exception as e:
64
+ print("❌ Callback handler error:", e)
File without changes
@@ -0,0 +1,37 @@
1
+ import asyncio
2
+ import httpx
3
+
4
+
5
+ async def run_polling(bot, dispatcher):
6
+
7
+ offset = 0
8
+ print("🤖 Бот запущен, ожидаю сообщения...")
9
+
10
+ while True:
11
+ try:
12
+ updates = await bot.client.request(
13
+ "GET",
14
+ f"/updates?offset={offset}"
15
+ )
16
+
17
+ # Извлекаем marker и updates из ответа
18
+ if isinstance(updates, dict):
19
+ marker = updates.get("marker")
20
+ updates = updates.get("updates", [])
21
+ if marker:
22
+ offset = marker
23
+
24
+ # Если пусто — пропускаем
25
+ if not updates:
26
+ await asyncio.sleep(1)
27
+ continue
28
+
29
+ for update in updates:
30
+ await dispatcher.process_update(update)
31
+
32
+ except httpx.ReadTimeout:
33
+ continue
34
+
35
+ except Exception as e:
36
+ print("Polling error:", e)
37
+ await asyncio.sleep(1)
File without changes
@@ -0,0 +1,29 @@
1
+ from .message import Message
2
+
3
+
4
+ class Callback:
5
+
6
+ def __init__(self, update: dict, bot):
7
+ self.bot = bot
8
+ cb_data = update.get("callback", {})
9
+
10
+ self.callback_id = cb_data.get("callback_id")
11
+ self.payload = cb_data.get("payload")
12
+
13
+ user = cb_data.get("user") or {}
14
+ self.user_id = user.get("user_id")
15
+
16
+ # message — сообщение с кнопками, sender в нём — бот, не пользователь.
17
+ # Для ответа используем reply(), который шлёт напрямую на user_id из callback.
18
+ raw_msg = update.get("message")
19
+ self.message = Message(raw_msg, bot) if raw_msg else None
20
+
21
+ async def answer(self, notification: str = None):
22
+ """Ответить на callback (снять состояние загрузки с кнопки)."""
23
+ await self.bot.answer_callback(self.callback_id, notification=notification)
24
+
25
+ async def reply(self, text: str, format=None, buttons=None):
26
+ """Отправить сообщение пользователю, нажавшему кнопку."""
27
+ return await self.bot.send_message(
28
+ chat_id=self.user_id, text=text, format=format, buttons=buttons
29
+ )
@@ -0,0 +1,48 @@
1
+ class Message:
2
+
3
+ def __init__(self, data, bot):
4
+ self.data = data
5
+ self.bot = bot
6
+
7
+ body = data.get("body") or {}
8
+
9
+ self.text = body.get("text")
10
+ self.mid = body.get("mid")
11
+
12
+ sender = data.get("sender") or {}
13
+ self.chat_id = sender.get("user_id")
14
+
15
+ recipient = data.get("recipient") or {}
16
+ self.dialog_chat_id = recipient.get("chat_id")
17
+
18
+ async def answer(self, text: str, format=None, buttons=None):
19
+ if not self.chat_id:
20
+ return
21
+ return await self.bot.send_message(chat_id=self.chat_id, text=text, format=format, buttons=buttons)
22
+
23
+ async def send_document(self, file_path: str):
24
+ if not self.chat_id:
25
+ return
26
+ await self.bot.send_document(chat_id=self.chat_id, file_path=file_path)
27
+
28
+ async def send_documents(self, file_path: list[str]):
29
+ if not self.chat_id:
30
+ return
31
+ await self.bot.send_documents(chat_id=self.chat_id, file_path=file_path)
32
+
33
+ async def send_image(self, file_path: str, text: str = None):
34
+ if not self.chat_id:
35
+ return
36
+ return await self.bot.send_image(chat_id=self.chat_id, file_path=file_path, text=text)
37
+
38
+ async def send_images(self, file_paths: list[str], text: str = None):
39
+ if not self.chat_id:
40
+ return
41
+ await self.bot.send_images(chat_id=self.chat_id, file_paths=file_paths, text=text)
42
+
43
+ async def delete(self):
44
+ if self.mid:
45
+ try:
46
+ await self.bot.delete_message(self.mid)
47
+ except Exception:
48
+ pass
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: pymaxbot
3
+ Version: 0.1.0
4
+ Summary: Python SDK for MAX messenger bot API
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 Sergey Kurilenko
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Project-URL: Homepage, https://github.com/svkurick/max_bot
28
+ Project-URL: Repository, https://github.com/svkurick/max_bot
29
+ Project-URL: Issues, https://github.com/svkurick/max_bot/issues
30
+ Requires-Python: >=3.9
31
+ Description-Content-Type: text/markdown
32
+ License-File: LICENSE
33
+ Requires-Dist: httpx
34
+ Dynamic: license-file
35
+
36
+ # max_bot
@@ -0,0 +1,18 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ max_bot/__init__.py
5
+ max_bot/bot.py
6
+ max_bot/client.py
7
+ max_bot/dispatcher/__init__.py
8
+ max_bot/dispatcher/dispatcher.py
9
+ max_bot/polling/__init__.py
10
+ max_bot/polling/polling.py
11
+ max_bot/types/__init__.py
12
+ max_bot/types/callback.py
13
+ max_bot/types/message.py
14
+ pymaxbot.egg-info/PKG-INFO
15
+ pymaxbot.egg-info/SOURCES.txt
16
+ pymaxbot.egg-info/dependency_links.txt
17
+ pymaxbot.egg-info/requires.txt
18
+ pymaxbot.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ httpx
@@ -0,0 +1 @@
1
+ max_bot
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pymaxbot"
7
+ version = "0.1.0"
8
+ description = "Python SDK for MAX messenger bot API"
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ requires-python = ">=3.9"
12
+ dependencies = [
13
+ "httpx"
14
+ ]
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/svkurick/max_bot"
18
+ Repository = "https://github.com/svkurick/max_bot"
19
+ Issues = "https://github.com/svkurick/max_bot/issues"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+