voidbale 1.0.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.
voidbale-1.0.0/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Alireza Jahani | Enalite LD
4
+ Copyright (c) 2026 Mohammadamin Madani
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: voidbale
3
+ Version: 1.0.0
4
+ Summary: Modern, fast, fully asynchronous Python framework for Bale Messenger.
5
+ Author-email: Amin Madani <aminmadani112@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/aminmadaniofficial/aiobale
8
+ Project-URL: Repository, https://github.com/aminmadaniofficial/aiobale
9
+ Project-URL: Documentation, https://aminmadaniofficial.github.io/aiobale/
10
+ Project-URL: PyPI, https://pypi.org/project/voidbale/
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: aiohttp<4.0,>=3.9
15
+ Requires-Dist: pydantic<3.0,>=2.0
16
+ Requires-Dist: typing_extensions<5.0,>=4.0
17
+ Dynamic: license-file
18
+
19
+ # VoidBale
20
+
21
+ VoidBale یک کتابخانه پایتون برای ساخت ربات‌های Bale با Bot Token است.
22
+
23
+ ## Polling
24
+
25
+ ```python
26
+ import asyncio
27
+ from voidbale import Bot
28
+
29
+ bot = Bot("YOUR_BALE_BOT_TOKEN")
30
+
31
+ @bot.command("start")
32
+ async def start(message):
33
+ await message.answer("سلام!")
34
+
35
+ asyncio.run(bot.start_polling())
36
+ ```
37
+
38
+ ## Webhook
39
+
40
+ ```python
41
+ from voidbale import AiohttpBotWebhookServer, Bot
42
+
43
+ bot = Bot("YOUR_BALE_BOT_TOKEN")
44
+
45
+ @bot.command("start")
46
+ async def start(message):
47
+ await message.answer("سلام!")
48
+
49
+ await bot.set_webhook("https://example.com/webhook")
50
+ server = AiohttpBotWebhookServer(bot, path="/webhook")
51
+ server.run(host="0.0.0.0", port=8080)
52
+ ```
53
+
54
+ این بسته فقط برای Bale Bot API و Webhook طراحی شده است و قابلیت‌های حساب کاربری یا self-bot ندارد.
@@ -0,0 +1,36 @@
1
+ # VoidBale
2
+
3
+ VoidBale یک کتابخانه پایتون برای ساخت ربات‌های Bale با Bot Token است.
4
+
5
+ ## Polling
6
+
7
+ ```python
8
+ import asyncio
9
+ from voidbale import Bot
10
+
11
+ bot = Bot("YOUR_BALE_BOT_TOKEN")
12
+
13
+ @bot.command("start")
14
+ async def start(message):
15
+ await message.answer("سلام!")
16
+
17
+ asyncio.run(bot.start_polling())
18
+ ```
19
+
20
+ ## Webhook
21
+
22
+ ```python
23
+ from voidbale import AiohttpBotWebhookServer, Bot
24
+
25
+ bot = Bot("YOUR_BALE_BOT_TOKEN")
26
+
27
+ @bot.command("start")
28
+ async def start(message):
29
+ await message.answer("سلام!")
30
+
31
+ await bot.set_webhook("https://example.com/webhook")
32
+ server = AiohttpBotWebhookServer(bot, path="/webhook")
33
+ server.run(host="0.0.0.0", port=8080)
34
+ ```
35
+
36
+ این بسته فقط برای Bale Bot API و Webhook طراحی شده است و قابلیت‌های حساب کاربری یا self-bot ندارد.
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "voidbale"
3
+ version = "1.0.0"
4
+ description = "Modern, fast, fully asynchronous Python framework for Bale Messenger."
5
+ authors = [
6
+ { name = "Amin Madani", email = "aminmadani112@gmail.com" }
7
+ ]
8
+ readme = "README.md"
9
+ license = "MIT"
10
+ license-files = ["LICENSE"]
11
+ requires-python = ">=3.8"
12
+ dependencies = [
13
+ "aiohttp>=3.9,<4.0",
14
+ "pydantic>=2.0,<3.0",
15
+ "typing_extensions>=4.0,<5.0",
16
+ ]
17
+
18
+ [project.scripts]
19
+ voidbale = "voidbale.cli.main:main"
20
+
21
+ [project.urls]
22
+ "Homepage" = "https://github.com/aminmadaniofficial/aiobale"
23
+ "Repository" = "https://github.com/aminmadaniofficial/aiobale"
24
+ "Documentation" = "https://aminmadaniofficial.github.io/aiobale/"
25
+ "PyPI" = "https://pypi.org/project/voidbale/"
26
+
27
+ [build-system]
28
+ requires = ["setuptools>=61.0"]
29
+ build-backend = "setuptools.build_meta"
30
+
31
+ [tool.setuptools.packages.find]
32
+ include = ["voidbale*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,15 @@
1
+ from .bot import Bot, BotMessage, BotUser, BotChat, CallbackQuery, CommandInfo, Scheduler
2
+ from .bot_manager import BotManager
3
+ from .analytics import Analytics
4
+ from .webhook.bot_server import AiohttpBotWebhookServer
5
+ from .filters.bot import BotText, BotRegex
6
+ from .utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder, InlineKeyboardButton, ReplyKeyboardButton
7
+
8
+ __version__ = "0.5.1"
9
+
10
+ __all__ = (
11
+ "Bot", "BotMessage", "BotUser", "BotChat", "CallbackQuery", "CommandInfo",
12
+ "Scheduler", "BotManager", "Analytics", "AiohttpBotWebhookServer",
13
+ "BotText", "BotRegex", "InlineKeyboardBuilder", "ReplyKeyboardBuilder",
14
+ "InlineKeyboardButton", "ReplyKeyboardButton",
15
+ )
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+ from collections import Counter
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ @dataclass
8
+ class AnalyticsEvent:
9
+ name: str
10
+ timestamp: str
11
+ data: Dict[str, Any] = field(default_factory=dict)
12
+
13
+ class Analytics:
14
+ def __init__(self, max_events: int = 10000):
15
+ self.max_events = max_events
16
+ self.events: List[AnalyticsEvent] = []
17
+ self.counters = Counter()
18
+ def track(self, name: str, **data: Any) -> AnalyticsEvent:
19
+ event = AnalyticsEvent(name, datetime.now(timezone.utc).isoformat(), data)
20
+ self.events.append(event)
21
+ self.counters[name] += 1
22
+ if len(self.events) > self.max_events:
23
+ self.events.pop(0)
24
+ return event
25
+ def count(self, name: str) -> int:
26
+ return self.counters[name]
27
+ def snapshot(self) -> Dict[str, int]:
28
+ return dict(self.counters)
29
+ def clear(self) -> None:
30
+ self.events.clear(); self.counters.clear()
@@ -0,0 +1,745 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import logging
6
+ import os
7
+ import re
8
+ import time
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, Awaitable, Callable, Dict, Iterable, Iterator, List, Optional, Sequence, Union
12
+
13
+ import aiohttp
14
+
15
+ from .analytics import Analytics
16
+
17
+ logger = logging.getLogger("voidbale.bot")
18
+
19
+
20
+ @dataclass
21
+ class BotUser:
22
+ raw: Dict[str, Any]
23
+
24
+ @property
25
+ def id(self) -> Optional[int]:
26
+ return self.raw.get("id")
27
+
28
+ @property
29
+ def username(self) -> Optional[str]:
30
+ return self.raw.get("username")
31
+
32
+ @property
33
+ def first_name(self) -> str:
34
+ return self.raw.get("first_name") or ""
35
+
36
+ @property
37
+ def last_name(self) -> str:
38
+ return self.raw.get("last_name") or ""
39
+
40
+ @property
41
+ def full_name(self) -> str:
42
+ return " ".join(x for x in (self.first_name, self.last_name) if x)
43
+
44
+
45
+ @dataclass
46
+ class BotChat:
47
+ raw: Dict[str, Any]
48
+
49
+ @property
50
+ def id(self) -> Optional[int]:
51
+ return self.raw.get("id")
52
+
53
+ @property
54
+ def type(self) -> Optional[str]:
55
+ return self.raw.get("type")
56
+
57
+ @property
58
+ def title(self) -> Optional[str]:
59
+ return self.raw.get("title")
60
+
61
+ @property
62
+ def username(self) -> Optional[str]:
63
+ return self.raw.get("username")
64
+
65
+
66
+ class BotMessage:
67
+ def __init__(self, bot: "Bot", raw: Dict[str, Any]):
68
+ self.bot = bot
69
+ self.raw = raw if isinstance(raw, dict) else {}
70
+
71
+ def __getitem__(self, key: str) -> Any:
72
+ return self.raw[key]
73
+
74
+ def get(self, key: str, default: Any = None) -> Any:
75
+ return self.raw.get(key, default)
76
+
77
+ def __contains__(self, key: str) -> bool:
78
+ return key in self.raw
79
+
80
+ def __iter__(self) -> Iterator[str]:
81
+ return iter(self.raw)
82
+
83
+ def __len__(self) -> int:
84
+ return len(self.raw)
85
+
86
+ def __repr__(self) -> str:
87
+ return f"BotMessage({self.raw!r})"
88
+
89
+ @property
90
+ def message_id(self) -> Optional[int]:
91
+ return self.raw.get("message_id")
92
+
93
+ @property
94
+ def text(self) -> str:
95
+ return self.raw.get("text") or self.raw.get("caption") or ""
96
+
97
+ @property
98
+ def caption(self) -> str:
99
+ return self.raw.get("caption") or ""
100
+
101
+ @property
102
+ def chat(self) -> BotChat:
103
+ return BotChat(self.raw.get("chat") or {})
104
+
105
+ @property
106
+ def chat_id(self) -> Optional[int]:
107
+ return self.chat.id
108
+
109
+ @property
110
+ def from_user(self) -> BotUser:
111
+ return BotUser(self.raw.get("from") or {})
112
+
113
+ @property
114
+ def user_id(self) -> Optional[int]:
115
+ return self.from_user.id
116
+
117
+ @property
118
+ def date(self) -> Optional[int]:
119
+ return self.raw.get("date")
120
+
121
+ @property
122
+ def photo(self) -> list:
123
+ return self.raw.get("photo") or []
124
+
125
+ @property
126
+ def document(self) -> Optional[Dict[str, Any]]:
127
+ return self.raw.get("document")
128
+
129
+ @property
130
+ def audio(self) -> Optional[Dict[str, Any]]:
131
+ return self.raw.get("audio")
132
+
133
+ @property
134
+ def video(self) -> Optional[Dict[str, Any]]:
135
+ return self.raw.get("video")
136
+
137
+ @property
138
+ def voice(self) -> Optional[Dict[str, Any]]:
139
+ return self.raw.get("voice")
140
+
141
+ @property
142
+ def sticker(self) -> Optional[Dict[str, Any]]:
143
+ return self.raw.get("sticker")
144
+
145
+ @property
146
+ def location(self) -> Optional[Dict[str, Any]]:
147
+ return self.raw.get("location")
148
+
149
+ @property
150
+ def contact(self) -> Optional[Dict[str, Any]]:
151
+ return self.raw.get("contact")
152
+
153
+ @property
154
+ def reply_to_message(self) -> Optional["BotMessage"]:
155
+ raw = self.raw.get("reply_to_message")
156
+ return BotMessage(self.bot, raw) if isinstance(raw, dict) else None
157
+
158
+ @property
159
+ def target_user_id(self) -> Optional[int]:
160
+ reply = self.reply_to_message
161
+ return reply.user_id if reply else self.user_id
162
+
163
+ async def ban(self, user_id: Any = None, **kwargs: Any) -> Any:
164
+ return await self.bot.ban_chat_member(self.chat_id, user_id if user_id is not None else self.target_user_id, **kwargs)
165
+
166
+ async def unban(self, user_id: Any = None, **kwargs: Any) -> Any:
167
+ return await self.bot.unban_chat_member(self.chat_id, user_id if user_id is not None else self.target_user_id, **kwargs)
168
+
169
+ async def mute(self, user_id: Any = None, **kwargs: Any) -> Any:
170
+ permissions = kwargs.pop("permissions", {"can_send_messages": False})
171
+ return await self.bot.restrict_chat_member(self.chat_id, user_id if user_id is not None else self.target_user_id, permissions, **kwargs)
172
+
173
+ async def promote(self, user_id: Any = None, **kwargs: Any) -> Any:
174
+ return await self.bot.promote_chat_member(self.chat_id, user_id if user_id is not None else self.target_user_id, **kwargs)
175
+
176
+ async def pin(self) -> Any:
177
+ return await self.bot.pin_chat_message(self.chat_id, self.message_id)
178
+
179
+ async def unpin(self) -> Any:
180
+ return await self.bot.unpin_chat_message(self.chat_id, self.message_id)
181
+
182
+ async def answer(self, text: str, **kwargs: Any) -> BotMessage:
183
+ return await self.bot.send_message(self.chat_id, text, **kwargs)
184
+
185
+ async def reply(self, text: str, **kwargs: Any) -> BotMessage:
186
+ return await self.bot.send_message(
187
+ self.chat_id, text, reply_to_message_id=self.message_id, **kwargs
188
+ )
189
+
190
+ async def edit(self, text: str, **kwargs: Any) -> BotMessage:
191
+ return await self.bot.edit_message(self.chat_id, self.message_id, text, **kwargs)
192
+
193
+ async def delete(self) -> Any:
194
+ return await self.bot.delete_message(self.chat_id, self.message_id)
195
+
196
+
197
+ @dataclass
198
+ class CallbackQuery:
199
+ bot: "Bot"
200
+ raw: Dict[str, Any]
201
+
202
+ @property
203
+ def id(self) -> Optional[str]:
204
+ return self.raw.get("id")
205
+
206
+ @property
207
+ def data(self) -> Optional[str]:
208
+ return self.raw.get("data")
209
+
210
+ @property
211
+ def message(self) -> Optional[BotMessage]:
212
+ raw = self.raw.get("message")
213
+ return BotMessage(self.bot, raw) if isinstance(raw, dict) else None
214
+
215
+ @property
216
+ def from_user(self) -> BotUser:
217
+ return BotUser(self.raw.get("from") or {})
218
+
219
+ async def answer(self, text: Optional[str] = None, **kwargs: Any) -> Dict[str, Any]:
220
+ return await self.bot.answer_callback_query(self.id, text=text, **kwargs)
221
+
222
+
223
+ @dataclass
224
+ class CommandInfo:
225
+ command: str
226
+ args: str = ""
227
+
228
+ @property
229
+ def args_list(self) -> List[str]:
230
+ return self.args.split() if self.args else []
231
+
232
+
233
+ class Scheduler:
234
+ def __init__(self) -> None:
235
+ self._tasks: set[asyncio.Task] = set()
236
+ self._running = True
237
+
238
+ def every(self, seconds: float, callback: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any) -> asyncio.Task:
239
+ async def loop() -> None:
240
+ while self._running:
241
+ await asyncio.sleep(seconds)
242
+ await callback(*args, **kwargs)
243
+ task = asyncio.create_task(loop())
244
+ self._tasks.add(task)
245
+ task.add_done_callback(self._tasks.discard)
246
+ return task
247
+
248
+ def once(self, delay: float, callback: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any) -> asyncio.Task:
249
+ async def run_once() -> None:
250
+ await asyncio.sleep(delay)
251
+ await callback(*args, **kwargs)
252
+ task = asyncio.create_task(run_once())
253
+ self._tasks.add(task)
254
+ task.add_done_callback(self._tasks.discard)
255
+ return task
256
+
257
+ async def close(self) -> None:
258
+ self._running = False
259
+ for task in list(self._tasks):
260
+ task.cancel()
261
+ if self._tasks:
262
+ await asyncio.gather(*self._tasks, return_exceptions=True)
263
+ self._tasks.clear()
264
+
265
+
266
+ class Bot:
267
+ """Async Bale Bot API client with polling, webhooks, routing, media, keyboards and helpers."""
268
+
269
+ def __init__(
270
+ self,
271
+ token: str,
272
+ *,
273
+ base_url: str = "https://tapi.bale.ai/bot",
274
+ request_timeout: float = 45.0,
275
+ max_retries: int = 3,
276
+ retry_delay: float = 1.0,
277
+ ) -> None:
278
+ if not token or not isinstance(token, str):
279
+ raise ValueError("A valid Bale bot token is required")
280
+ self.token = token
281
+ self.base_url = base_url.rstrip("/")
282
+ self.request_timeout = request_timeout
283
+ self.max_retries = max(1, max_retries)
284
+ self.retry_delay = retry_delay
285
+ self._handlers: List[tuple[Callable[[Any], Any], Callable[..., Awaitable[Any]]]] = []
286
+ self._callback_handlers: List[tuple[Callable[[CallbackQuery], Any], Callable[..., Awaitable[Any]]]] = []
287
+ self._error_handlers: List[Callable[..., Awaitable[Any]]] = []
288
+ self._offset = 0
289
+ self._session: Optional[aiohttp.ClientSession] = None
290
+ self._running = False
291
+ self.scheduler = Scheduler()
292
+ self.analytics = Analytics()
293
+ self.user: Optional[Dict[str, Any]] = None
294
+ self.metrics = {"updates": 0, "messages": 0, "callbacks": 0, "errors": 0, "requests": 0}
295
+ self._waiters: List[tuple[asyncio.Future, Optional[Callable[[Any], Any]], str]] = []
296
+
297
+ def _url(self, method: str) -> str:
298
+ return f"{self.base_url}{self.token}/{method}"
299
+
300
+ def on_message(self, predicate: Optional[Callable[[BotMessage], Any]] = None):
301
+ def decorator(func: Callable[..., Awaitable[Any]]):
302
+ self._handlers.append((predicate or (lambda message: True), func))
303
+ return func
304
+ return decorator
305
+
306
+ def message(self, *filters: Any):
307
+ def predicate(message: BotMessage) -> bool:
308
+ for item in filters:
309
+ if callable(item):
310
+ result = item(message)
311
+ if inspect.isawaitable(result):
312
+ return False
313
+ if not result:
314
+ return False
315
+ elif isinstance(item, str) and message.text.lower() != item.lower():
316
+ return False
317
+ return True
318
+ return self.on_message(predicate)
319
+
320
+ def command(self, *names: str):
321
+ normalized = {name.lstrip("/").lower() for name in names}
322
+ def predicate(message: BotMessage) -> bool:
323
+ text = message.text.strip()
324
+ if not text.startswith("/"):
325
+ return False
326
+ first = text.split(maxsplit=1)[0][1:]
327
+ command = first.split("@", 1)[0].lower()
328
+ return command in normalized
329
+ return self.on_message(predicate)
330
+
331
+ def text(self, value: Union[str, re.Pattern[str], Callable[[Any], bool]]):
332
+ def predicate(message: BotMessage) -> bool:
333
+ text = message.text
334
+ if isinstance(value, str):
335
+ return text.lower() == value.lower()
336
+ if hasattr(value, "search"):
337
+ return bool(value.search(text))
338
+ try:
339
+ return bool(value(message))
340
+ except (AttributeError, TypeError):
341
+ return bool(value(text))
342
+ return self.on_message(predicate)
343
+
344
+ def callback_query(self, predicate: Optional[Callable[[CallbackQuery], Any]] = None):
345
+ def decorator(func: Callable[..., Awaitable[Any]]):
346
+ self._callback_handlers.append((predicate or (lambda query: True), func))
347
+ return func
348
+ return decorator
349
+
350
+ async def wait_for(self, event: str = "message", *, check: Optional[Callable[[Any], Any]] = None, timeout: Optional[float] = None) -> Any:
351
+ future = asyncio.get_running_loop().create_future()
352
+ self._waiters.append((future, check, event))
353
+ try:
354
+ return await asyncio.wait_for(future, timeout=timeout)
355
+ finally:
356
+ self._waiters = [item for item in self._waiters if item[0] is not future]
357
+
358
+ def on_error(self, func: Callable[..., Awaitable[Any]]):
359
+ self._error_handlers.append(func)
360
+ return func
361
+
362
+ async def _request_once(self, method: str, *, payload: Optional[Dict[str, Any]] = None, form: Optional[aiohttp.FormData] = None) -> Dict[str, Any]:
363
+ if self._session is None or self._session.closed:
364
+ timeout = aiohttp.ClientTimeout(total=self.request_timeout)
365
+ self._session = aiohttp.ClientSession(timeout=timeout)
366
+ self.metrics["requests"] += 1
367
+ self.analytics.track("request", method=method)
368
+ async with self._session.post(self._url(method), json=payload if form is None else None, data=form) as response:
369
+ data = await response.json(content_type=None)
370
+ if response.status >= 400 or not data.get("ok", False):
371
+ raise RuntimeError(data.get("description", f"Bale API HTTP {response.status}"))
372
+ return data
373
+
374
+ async def request(self, method: str, **payload: Any) -> Dict[str, Any]:
375
+ last_error: Optional[Exception] = None
376
+ for attempt in range(self.max_retries):
377
+ try:
378
+ return await self._request_once(method, payload=payload)
379
+ except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc:
380
+ last_error = exc
381
+ if attempt + 1 >= self.max_retries:
382
+ break
383
+ await asyncio.sleep(self.retry_delay * (2 ** attempt))
384
+ self.metrics["errors"] += 1
385
+ self.analytics.track("error", error=str(last_error) if last_error else "unknown")
386
+ assert last_error is not None
387
+ raise last_error
388
+
389
+ async def request_multipart(self, method: str, fields: Dict[str, Any], files: Dict[str, Any]) -> Dict[str, Any]:
390
+ last_error: Optional[Exception] = None
391
+ for attempt in range(self.max_retries):
392
+ form = aiohttp.FormData()
393
+ opened = []
394
+ try:
395
+ for key, value in fields.items():
396
+ form.add_field(key, str(value))
397
+ for key, value in files.items():
398
+ if isinstance(value, (str, os.PathLike)):
399
+ fp = open(value, "rb")
400
+ opened.append(fp)
401
+ form.add_field(key, fp, filename=Path(value).name)
402
+ elif isinstance(value, tuple) and len(value) >= 2:
403
+ content, filename = value[0], value[1]
404
+ form.add_field(key, content, filename=filename)
405
+ else:
406
+ form.add_field(key, value)
407
+ return await self._request_once(method, form=form)
408
+ except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc:
409
+ last_error = exc
410
+ if attempt + 1 < self.max_retries:
411
+ await asyncio.sleep(self.retry_delay * (2 ** attempt))
412
+ finally:
413
+ for fp in opened:
414
+ fp.close()
415
+ self.metrics["errors"] += 1
416
+ self.analytics.track("error", error=str(last_error) if last_error else "unknown")
417
+ assert last_error is not None
418
+ raise last_error
419
+
420
+ async def get_me(self) -> Dict[str, Any]:
421
+ self.user = await self.request("getMe")
422
+ result = self.user.get("result", {})
423
+ self.analytics.track("get_me")
424
+ return result
425
+
426
+ async def get_updates(self, *, timeout: int = 30, limit: int = 100, allowed_updates: Optional[Sequence[str]] = None) -> List[Dict[str, Any]]:
427
+ payload: Dict[str, Any] = {"offset": self._offset, "timeout": timeout, "limit": limit}
428
+ if allowed_updates is not None:
429
+ payload["allowed_updates"] = list(allowed_updates)
430
+ return (await self.request("getUpdates", **payload)).get("result", [])
431
+
432
+ async def send_message(self, chat_id: Any, text: str, *, parse_mode: Optional[str] = None, reply_to_message_id: Optional[int] = None, reply_markup: Any = None, **kwargs: Any) -> BotMessage:
433
+ payload: Dict[str, Any] = {"chat_id": chat_id, "text": text, **kwargs}
434
+ if parse_mode: payload["parse_mode"] = parse_mode
435
+ if reply_to_message_id is not None: payload["reply_to_message_id"] = reply_to_message_id
436
+ if reply_markup is not None: payload["reply_markup"] = reply_markup
437
+ result = (await self.request("sendMessage", **payload)).get("result", {})
438
+ message = BotMessage(self, result)
439
+ self.analytics.track("send_message", chat_id=chat_id, message_id=message.message_id)
440
+ return message
441
+
442
+ async def edit_message(self, chat_id: Any, message_id: int, text: str, **kwargs: Any) -> BotMessage:
443
+ result = (await self.request("editMessageText", chat_id=chat_id, message_id=message_id, text=text, **kwargs)).get("result", {})
444
+ return BotMessage(self, result) if isinstance(result, dict) else BotMessage(self, {"result": result, "chat": {"id": chat_id}, "message_id": message_id, "text": text})
445
+
446
+ async def delete_message(self, chat_id: Any, message_id: int) -> Any:
447
+ return (await self.request("deleteMessage", chat_id=chat_id, message_id=message_id)).get("result")
448
+
449
+ async def forward_message(self, chat_id: Any, from_chat_id: Any, message_id: int, **kwargs: Any) -> BotMessage:
450
+ result = (await self.request("forwardMessage", chat_id=chat_id, from_chat_id=from_chat_id, message_id=message_id, **kwargs)).get("result", {})
451
+ return BotMessage(self, result)
452
+
453
+ async def copy_message(self, chat_id: Any, from_chat_id: Any, message_id: int, **kwargs: Any) -> BotMessage:
454
+ result = (await self.request("copyMessage", chat_id=chat_id, from_chat_id=from_chat_id, message_id=message_id, **kwargs)).get("result", {})
455
+ if isinstance(result, dict) and "message_id" in result:
456
+ return BotMessage(self, result)
457
+ return BotMessage(self, {"message_id": result, "chat": {"id": chat_id}})
458
+
459
+ async def get_chat(self, chat_id: Any) -> Dict[str, Any]:
460
+ return (await self.request("getChat", chat_id=chat_id)).get("result", {})
461
+
462
+ async def get_chat_member(self, chat_id: Any, user_id: Any) -> Dict[str, Any]:
463
+ return (await self.request("getChatMember", chat_id=chat_id, user_id=user_id)).get("result", {})
464
+
465
+ async def get_chat_administrators(self, chat_id: Any) -> List[Dict[str, Any]]:
466
+ return (await self.request("getChatAdministrators", chat_id=chat_id)).get("result", [])
467
+
468
+ async def get_chat_member_count(self, chat_id: Any) -> int:
469
+ return (await self.request("getChatMemberCount", chat_id=chat_id)).get("result", 0)
470
+
471
+ async def leave_chat(self, chat_id: Any) -> Any:
472
+ return (await self.request("leaveChat", chat_id=chat_id)).get("result")
473
+
474
+ async def set_webhook(self, url: str, **kwargs: Any) -> Any:
475
+ return (await self.request("setWebhook", url=url, **kwargs)).get("result")
476
+
477
+ async def delete_webhook(self, **kwargs: Any) -> Any:
478
+ return (await self.request("deleteWebhook", **kwargs)).get("result")
479
+
480
+ async def get_webhook_info(self) -> Dict[str, Any]:
481
+ return (await self.request("getWebhookInfo")).get("result", {})
482
+
483
+ async def send_chat_action(self, chat_id: Any, action: str) -> Any:
484
+ if isinstance(chat_id, BotMessage):
485
+ chat_id = chat_id.chat_id
486
+ elif isinstance(chat_id, BotChat):
487
+ chat_id = chat_id.id
488
+ elif isinstance(chat_id, dict):
489
+ chat_id = chat_id.get("id") or chat_id.get("chat_id")
490
+
491
+ if chat_id is None or str(chat_id).strip() == "":
492
+ raise ValueError("chat id is empty")
493
+
494
+ return (
495
+ await self.request(
496
+ "sendChatAction",
497
+ chat_id=str(chat_id),
498
+ action=str(action),
499
+ )
500
+ ).get("result")
501
+
502
+ async def answer_callback_query(self, callback_query_id: Any, text: Optional[str] = None, **kwargs: Any) -> Any:
503
+ payload = {"callback_query_id": callback_query_id, **kwargs}
504
+ if text is not None: payload["text"] = text
505
+ return (await self.request("answerCallbackQuery", **payload)).get("result")
506
+
507
+ async def send_photo(self, chat_id: Any, photo: Any, *, caption: Optional[str] = None, **kwargs: Any) -> Dict[str, Any]:
508
+ if isinstance(photo, (str, os.PathLike)) and Path(photo).exists():
509
+ fields = {"chat_id": chat_id, **kwargs}
510
+ if caption is not None: fields["caption"] = caption
511
+ return (await self.request_multipart("sendPhoto", fields, {"photo": photo})).get("result", {})
512
+ return (await self.request("sendPhoto", chat_id=chat_id, photo=photo, caption=caption, **kwargs)).get("result", {})
513
+
514
+ async def send_document(self, chat_id: Any, document: Any, *, caption: Optional[str] = None, **kwargs: Any) -> Dict[str, Any]:
515
+ if isinstance(document, (str, os.PathLike)) and Path(document).exists():
516
+ fields = {"chat_id": chat_id, **kwargs}
517
+ if caption is not None: fields["caption"] = caption
518
+ return (await self.request_multipart("sendDocument", fields, {"document": document})).get("result", {})
519
+ return (await self.request("sendDocument", chat_id=chat_id, document=document, caption=caption, **kwargs)).get("result", {})
520
+
521
+ async def _send_media(self, method: str, field: str, chat_id: Any, media: Any, *, caption: Optional[str] = None, **kwargs: Any) -> Dict[str, Any]:
522
+ if isinstance(media, (str, os.PathLike)) and Path(media).exists():
523
+ fields = {"chat_id": chat_id, **kwargs}
524
+ if caption is not None: fields["caption"] = caption
525
+ return (await self.request_multipart(method, fields, {field: media})).get("result", {})
526
+ return (await self.request(method, chat_id=chat_id, **{field: media}, caption=caption, **kwargs)).get("result", {})
527
+
528
+ async def send_audio(self, chat_id: Any, audio: Any, **kwargs: Any) -> Dict[str, Any]: return await self._send_media("sendAudio", "audio", chat_id, audio, **kwargs)
529
+ async def send_video(self, chat_id: Any, video: Any, **kwargs: Any) -> Dict[str, Any]: return await self._send_media("sendVideo", "video", chat_id, video, **kwargs)
530
+ async def send_animation(self, chat_id: Any, animation: Any, **kwargs: Any) -> Dict[str, Any]: return await self._send_media("sendAnimation", "animation", chat_id, animation, **kwargs)
531
+ async def send_voice(self, chat_id: Any, voice: Any, **kwargs: Any) -> Dict[str, Any]: return await self._send_media("sendVoice", "voice", chat_id, voice, **kwargs)
532
+ async def send_video_note(self, chat_id: Any, video_note: Any, **kwargs: Any) -> Dict[str, Any]: return await self._send_media("sendVideoNote", "video_note", chat_id, video_note, **kwargs)
533
+ async def send_sticker(self, chat_id: Any, sticker: Any, **kwargs: Any) -> Dict[str, Any]: return await self._send_media("sendSticker", "sticker", chat_id, sticker, **kwargs)
534
+
535
+ async def send_location(self, chat_id: Any, latitude: float, longitude: float, **kwargs: Any) -> Dict[str, Any]:
536
+ return (await self.request("sendLocation", chat_id=chat_id, latitude=latitude, longitude=longitude, **kwargs)).get("result", {})
537
+
538
+ async def send_contact(self, chat_id: Any, phone_number: str, first_name: str, **kwargs: Any) -> Dict[str, Any]:
539
+ return (await self.request("sendContact", chat_id=chat_id, phone_number=phone_number, first_name=first_name, **kwargs)).get("result", {})
540
+
541
+ async def send_venue(self, chat_id: Any, latitude: float, longitude: float, title: str, address: str, **kwargs: Any) -> Dict[str, Any]:
542
+ return (await self.request("sendVenue", chat_id=chat_id, latitude=latitude, longitude=longitude, title=title, address=address, **kwargs)).get("result", {})
543
+
544
+ async def send_media_group(self, chat_id: Any, media: Sequence[Dict[str, Any]], **kwargs: Any) -> List[Dict[str, Any]]:
545
+ return (await self.request("sendMediaGroup", chat_id=chat_id, media=list(media), **kwargs)).get("result", [])
546
+
547
+ async def get_file(self, file_id: str) -> Dict[str, Any]:
548
+ return (await self.request("getFile", file_id=file_id)).get("result", {})
549
+
550
+ async def download_file(self, file_path: str, destination: Union[str, os.PathLike]) -> Path:
551
+ if self._session is None or self._session.closed:
552
+ self._session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=self.request_timeout))
553
+ url = f"https://tapi.bale.ai/file/bot{self.token}/{file_path}"
554
+ target = Path(destination)
555
+ target.parent.mkdir(parents=True, exist_ok=True)
556
+ async with self._session.get(url) as response:
557
+ response.raise_for_status()
558
+ target.write_bytes(await response.read())
559
+ return target
560
+
561
+ async def pin_chat_message(self, chat_id: Any, message_id: int, **kwargs: Any) -> Any:
562
+ return (await self.request("pinChatMessage", chat_id=chat_id, message_id=message_id, **kwargs)).get("result")
563
+
564
+ async def unpin_chat_message(self, chat_id: Any, message_id: Optional[int] = None, **kwargs: Any) -> Any:
565
+ payload = {"chat_id": chat_id, **kwargs}
566
+ if message_id is not None: payload["message_id"] = message_id
567
+ return (await self.request("unpinChatMessage", **payload)).get("result")
568
+
569
+ async def ban_chat_member(self, chat_id: Any, user_id: Any, **kwargs: Any) -> Any:
570
+ return (await self.request("banChatMember", chat_id=chat_id, user_id=user_id, **kwargs)).get("result")
571
+
572
+ async def unban_chat_member(self, chat_id: Any, user_id: Any, **kwargs: Any) -> Any:
573
+ return (await self.request("unbanChatMember", chat_id=chat_id, user_id=user_id, **kwargs)).get("result")
574
+
575
+ async def restrict_chat_member(self, chat_id: Any, user_id: Any, permissions: Any, **kwargs: Any) -> Any:
576
+ if isinstance(chat_id, BotMessage):
577
+ chat_id = chat_id.chat_id
578
+ elif isinstance(chat_id, BotChat):
579
+ chat_id = chat_id.id
580
+ elif isinstance(chat_id, dict):
581
+ chat_id = chat_id.get("id") or chat_id.get("chat_id")
582
+
583
+ if chat_id is None or str(chat_id).strip() == "":
584
+ raise ValueError("chat id is empty")
585
+
586
+ if user_id is None or str(user_id).strip() == "":
587
+ raise ValueError("user id is empty")
588
+
589
+ if not isinstance(permissions, dict):
590
+ raise TypeError("permissions must be a dictionary")
591
+
592
+ try:
593
+ result = await self.request(
594
+ "getChatMember",
595
+ chat_id=str(chat_id),
596
+ user_id=int(user_id),
597
+ )
598
+ member = result.get("result")
599
+ if not member:
600
+ raise RuntimeError("کاربر وجود ندارد")
601
+ except RuntimeError as exc:
602
+ error_text = str(exc).lower()
603
+ if (
604
+ "no such group or user" in error_text
605
+ or "user not found" in error_text
606
+ or "user does not exist" in error_text
607
+ ):
608
+ raise RuntimeError("کاربر وجود ندارد") from exc
609
+ raise
610
+
611
+ return (
612
+ await self.request(
613
+ "restrictChatMember",
614
+ chat_id=str(chat_id),
615
+ user_id=int(user_id),
616
+ permissions=permissions,
617
+ **kwargs,
618
+ )
619
+ ).get("result")
620
+
621
+ async def promote_chat_member(self, chat_id: Any, user_id: Any, **kwargs: Any) -> Any:
622
+ return (await self.request("promoteChatMember", chat_id=chat_id, user_id=user_id, **kwargs)).get("result")
623
+
624
+ async def kick_chat_member(self, chat_id: Any, user_id: Any, **kwargs: Any) -> Any:
625
+ return await self.ban_chat_member(chat_id, user_id, **kwargs)
626
+
627
+ async def mute_chat_member(self, chat_id: Any, user_id: Any, *, permissions: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Any:
628
+ if permissions is None:
629
+ permissions = {"can_send_messages": False}
630
+ return await self.restrict_chat_member(chat_id, user_id, permissions, **kwargs)
631
+
632
+ async def unmute_chat_member(self, chat_id: Any, user_id: Any, *, permissions: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Any:
633
+ if permissions is None:
634
+ permissions = {"can_send_messages": True}
635
+ return await self.restrict_chat_member(chat_id, user_id, permissions, **kwargs)
636
+
637
+ async def set_chat_title(self, chat_id: Any, title: str) -> Any:
638
+ return (await self.request("setChatTitle", chat_id=chat_id, title=title)).get("result")
639
+
640
+ async def set_chat_description(self, chat_id: Any, description: str = "") -> Any:
641
+ return (await self.request("setChatDescription", chat_id=chat_id, description=description)).get("result")
642
+
643
+ async def get_user_profile_photos(self, user_id: Any, **kwargs: Any) -> Dict[str, Any]:
644
+ return (await self.request("getUserProfilePhotos", user_id=user_id, **kwargs)).get("result", {})
645
+
646
+ async def edit_reply_markup(self, chat_id: Any, message_id: int, reply_markup: Any) -> Any:
647
+ return (await self.request("editMessageReplyMarkup", chat_id=chat_id, message_id=message_id, reply_markup=reply_markup)).get("result")
648
+
649
+ async def broadcast(self, chat_ids: Iterable[Any], text: str, *, delay: float = 0.05, concurrency: int = 1) -> Dict[str, Any]:
650
+ semaphore = asyncio.Semaphore(max(1, concurrency))
651
+ success = 0
652
+ failed: Dict[str, str] = {}
653
+ start = time.monotonic()
654
+
655
+ async def send(chat_id: Any) -> None:
656
+ nonlocal success
657
+ async with semaphore:
658
+ try:
659
+ await self.send_message(chat_id, text)
660
+ success += 1
661
+ except Exception as exc:
662
+ failed[str(chat_id)] = str(exc)
663
+ if delay:
664
+ await asyncio.sleep(delay)
665
+
666
+ await asyncio.gather(*(send(chat_id) for chat_id in chat_ids))
667
+ return {"success": success, "failed": failed, "duration": time.monotonic() - start}
668
+
669
+ async def _dispatch(self, update: Dict[str, Any]) -> None:
670
+ self.metrics["updates"] += 1
671
+ raw_message = update.get("message") or update.get("edited_message") or update.get("channel_post")
672
+ if isinstance(raw_message, dict):
673
+ self.metrics["messages"] += 1
674
+ message = BotMessage(self, raw_message)
675
+ for future, check, event_name in list(self._waiters):
676
+ if event_name == "message" and not future.done():
677
+ try:
678
+ matched = check(message) if check else True
679
+ if inspect.isawaitable(matched):
680
+ matched = await matched
681
+ if matched:
682
+ future.set_result(message)
683
+ except Exception as exc:
684
+ if not future.done():
685
+ future.set_exception(exc)
686
+ for predicate, handler in list(self._handlers):
687
+ try:
688
+ result = predicate(message)
689
+ if inspect.isawaitable(result):
690
+ result = await result
691
+ if result:
692
+ await handler(message)
693
+ except Exception as exc:
694
+ await self._handle_error(exc, message)
695
+ callback = update.get("callback_query")
696
+ if isinstance(callback, dict):
697
+ self.metrics["callbacks"] += 1
698
+ query = CallbackQuery(self, callback)
699
+ for predicate, handler in list(self._callback_handlers):
700
+ try:
701
+ result = predicate(query)
702
+ if inspect.isawaitable(result): result = await result
703
+ if result:
704
+ await handler(query)
705
+ except Exception as exc:
706
+ await self._handle_error(exc, query)
707
+
708
+ async def _handle_error(self, exc: Exception, event: Any) -> None:
709
+ self.metrics["errors"] += 1
710
+ if self._error_handlers:
711
+ for handler in self._error_handlers:
712
+ try:
713
+ await handler(exc, event)
714
+ except Exception:
715
+ logger.exception("VoidBale error handler failed")
716
+ else:
717
+ logger.exception("VoidBale handler error", exc_info=exc)
718
+
719
+ async def start_polling(self, *, timeout: int = 30, limit: int = 100, allowed_updates: Optional[Sequence[str]] = None) -> None:
720
+ self._running = True
721
+ if self.user is None:
722
+ try:
723
+ await self.get_me()
724
+ except Exception:
725
+ logger.warning("Could not validate bot token before polling", exc_info=True)
726
+ try:
727
+ while self._running:
728
+ updates = await self.get_updates(timeout=timeout, limit=limit, allowed_updates=allowed_updates)
729
+ for update in updates:
730
+ update_id = update.get("update_id")
731
+ if isinstance(update_id, int):
732
+ self._offset = update_id + 1
733
+ await self._dispatch(update)
734
+ finally:
735
+ await self.close()
736
+
737
+ async def close(self) -> None:
738
+ self._running = False
739
+ await self.scheduler.close()
740
+ if self._session is not None and not self._session.closed:
741
+ await self._session.close()
742
+ self._session = None
743
+
744
+ def run(self, **kwargs: Any) -> None:
745
+ asyncio.run(self.start_polling(**kwargs))
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+ from typing import Dict
3
+ from .bot import Bot
4
+
5
+ class BotManager:
6
+ def __init__(self):
7
+ self.bots: Dict[str, Bot] = {}
8
+ def add(self, name: str, token: str, **kwargs) -> Bot:
9
+ bot = Bot(token, **kwargs)
10
+ self.bots[name] = bot
11
+ return bot
12
+ def get(self, name: str) -> Bot:
13
+ return self.bots[name]
14
+ async def close(self):
15
+ for bot in self.bots.values():
16
+ await bot.close()
@@ -0,0 +1,3 @@
1
+ from .bot import BotText, BotRegex
2
+
3
+ __all__ = ("BotText", "BotRegex")
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+ from typing import Any
3
+ import re
4
+
5
+ class BotFilter:
6
+ def __init__(self, predicate):
7
+ self.predicate = predicate
8
+ def __call__(self, message):
9
+ return self.predicate(message)
10
+
11
+ class BotText(BotFilter):
12
+ def __init__(self, text: str, ignore_case: bool = True):
13
+ super().__init__(lambda m: (m.text.lower() if ignore_case else m.text) == (text.lower() if ignore_case else text))
14
+
15
+ class BotRegex(BotFilter):
16
+ def __init__(self, pattern: str, flags: int = 0):
17
+ rx = re.compile(pattern, flags)
18
+ super().__init__(lambda m: bool(rx.search(m.text)))
@@ -0,0 +1,3 @@
1
+ from .keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder, InlineKeyboardButton, ReplyKeyboardButton
2
+
3
+ __all__ = ("InlineKeyboardBuilder", "ReplyKeyboardBuilder", "InlineKeyboardButton", "ReplyKeyboardButton")
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Dict, List, Optional, Union
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class InlineKeyboardButton(BaseModel):
7
+ text: str
8
+ url: Optional[str] = None
9
+ callback_data: Optional[str] = None
10
+ copy_text: Optional[str] = None
11
+
12
+
13
+ class ReplyKeyboardButton(BaseModel):
14
+ text: str
15
+
16
+
17
+ class InlineKeyboardBuilder:
18
+ """
19
+ Fluid builder for constructing Inline Keyboards.
20
+
21
+ Example:
22
+ builder = InlineKeyboardBuilder()
23
+ builder.button(text="وبسایت", url="https://voidbale.ir")
24
+ builder.button(text="کلیک", callback_data="btn_click")
25
+ builder.button(text="کپی کد", copy_text="CODE123")
26
+ builder.adjust(2)
27
+ markup = builder.as_markup()
28
+ """
29
+
30
+ def __init__(self) -> None:
31
+ self._buttons: List[InlineKeyboardButton] = []
32
+ self._sizes: List[int] = []
33
+
34
+ @property
35
+ def buttons(self) -> List[InlineKeyboardButton]:
36
+ return self._buttons
37
+
38
+ def button(
39
+ self,
40
+ text: str,
41
+ url: Optional[str] = None,
42
+ callback_data: Optional[str] = None,
43
+ copy_text: Optional[str] = None,
44
+ **kwargs: Any,
45
+ ) -> InlineKeyboardBuilder:
46
+ self._buttons.append(
47
+ InlineKeyboardButton(
48
+ text=text,
49
+ url=url,
50
+ callback_data=callback_data,
51
+ copy_text=copy_text,
52
+ **kwargs,
53
+ )
54
+ )
55
+ return self
56
+
57
+ def add(self, *buttons: InlineKeyboardButton) -> InlineKeyboardBuilder:
58
+ self._buttons.extend(buttons)
59
+ return self
60
+
61
+ def row(self, *buttons: InlineKeyboardButton) -> InlineKeyboardBuilder:
62
+ self._buttons.extend(buttons)
63
+ return self
64
+
65
+ def attach(self, builder: InlineKeyboardBuilder) -> InlineKeyboardBuilder:
66
+ self._buttons.extend(builder._buttons)
67
+ return self
68
+
69
+ def adjust(self, *sizes: int) -> InlineKeyboardBuilder:
70
+ self._sizes = list(sizes) if sizes else [1]
71
+ return self
72
+
73
+ def export(self, *sizes: int) -> List[List[InlineKeyboardButton]]:
74
+ layout_sizes = list(sizes) if sizes else (self._sizes if self._sizes else [1])
75
+ result: List[List[InlineKeyboardButton]] = []
76
+ buttons_copy = self._buttons.copy()
77
+ size_idx = 0
78
+
79
+ while buttons_copy:
80
+ current_size = layout_sizes[size_idx % len(layout_sizes)]
81
+ chunk = buttons_copy[:current_size]
82
+ result.append(chunk)
83
+ buttons_copy = buttons_copy[current_size:]
84
+ size_idx += 1
85
+
86
+ return result
87
+
88
+ def as_markup(self, *sizes: int) -> List[List[Dict[str, Any]]]:
89
+ grid = self.export(*sizes)
90
+ return [[b.model_dump(exclude_none=True) for b in row] for row in grid]
91
+
92
+
93
+ class ReplyKeyboardBuilder:
94
+ """
95
+ Fluid builder for constructing Reply/Menu Keyboards.
96
+
97
+ Example:
98
+ builder = ReplyKeyboardBuilder()
99
+ builder.button(text="ارسال موقعیت")
100
+ builder.button(text="تماس با پشتیبانی")
101
+ builder.adjust(2)
102
+ markup = builder.as_markup()
103
+ """
104
+
105
+ def __init__(self) -> None:
106
+ self._buttons: List[ReplyKeyboardButton] = []
107
+ self._sizes: List[int] = []
108
+
109
+ @property
110
+ def buttons(self) -> List[ReplyKeyboardButton]:
111
+ return self._buttons
112
+
113
+ def button(self, text: str, **kwargs: Any) -> ReplyKeyboardBuilder:
114
+ self._buttons.append(ReplyKeyboardButton(text=text, **kwargs))
115
+ return self
116
+
117
+ def add(self, *buttons: ReplyKeyboardButton) -> ReplyKeyboardBuilder:
118
+ self._buttons.extend(buttons)
119
+ return self
120
+
121
+ def row(self, *buttons: ReplyKeyboardButton) -> ReplyKeyboardBuilder:
122
+ self._buttons.extend(buttons)
123
+ return self
124
+
125
+ def attach(self, builder: ReplyKeyboardBuilder) -> ReplyKeyboardBuilder:
126
+ self._buttons.extend(builder._buttons)
127
+ return self
128
+
129
+ def adjust(self, *sizes: int) -> ReplyKeyboardBuilder:
130
+ self._sizes = list(sizes) if sizes else [1]
131
+ return self
132
+
133
+ def export(self, *sizes: int) -> List[List[ReplyKeyboardButton]]:
134
+ layout_sizes = list(sizes) if sizes else (self._sizes if self._sizes else [1])
135
+ result: List[List[ReplyKeyboardButton]] = []
136
+ buttons_copy = self._buttons.copy()
137
+ size_idx = 0
138
+
139
+ while buttons_copy:
140
+ current_size = layout_sizes[size_idx % len(layout_sizes)]
141
+ chunk = buttons_copy[:current_size]
142
+ result.append(chunk)
143
+ buttons_copy = buttons_copy[current_size:]
144
+ size_idx += 1
145
+
146
+ return result
147
+
148
+ def as_markup(self, *sizes: int) -> List[List[Dict[str, Any]]]:
149
+ grid = self.export(*sizes)
150
+ return [[b.model_dump(exclude_none=True) for b in row] for row in grid]
@@ -0,0 +1,3 @@
1
+ from .bot_server import AiohttpBotWebhookServer
2
+
3
+ __all__ = ("AiohttpBotWebhookServer",)
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+ import asyncio
3
+ from aiohttp import web
4
+ from typing import Optional
5
+
6
+ class AiohttpBotWebhookServer:
7
+ def __init__(self, bot, path: str = "/webhook", secret_token: Optional[str] = None):
8
+ self.bot = bot
9
+ self.path = path
10
+ self.secret_token = secret_token
11
+ self.app = web.Application()
12
+ self.app.router.add_post(path, self.handle)
13
+ self.app.router.add_get("/health", self.health)
14
+
15
+ async def health(self, request):
16
+ return web.json_response({"ok": True, "service": "voidbale-bot-webhook"})
17
+
18
+ async def handle(self, request):
19
+ if self.secret_token and request.headers.get("X-Bale-Bot-Api-Secret-Token") != self.secret_token:
20
+ return web.Response(status=403, text="Forbidden")
21
+ try:
22
+ update = await request.json()
23
+ except Exception:
24
+ return web.Response(status=400, text="Expected JSON")
25
+ asyncio.create_task(self.bot._dispatch(update))
26
+ return web.json_response({"ok": True})
27
+
28
+ def run(self, host: str = "0.0.0.0", port: int = 8080):
29
+ web.run_app(self.app, host=host, port=port)
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: voidbale
3
+ Version: 1.0.0
4
+ Summary: Modern, fast, fully asynchronous Python framework for Bale Messenger.
5
+ Author-email: Amin Madani <aminmadani112@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/aminmadaniofficial/aiobale
8
+ Project-URL: Repository, https://github.com/aminmadaniofficial/aiobale
9
+ Project-URL: Documentation, https://aminmadaniofficial.github.io/aiobale/
10
+ Project-URL: PyPI, https://pypi.org/project/voidbale/
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: aiohttp<4.0,>=3.9
15
+ Requires-Dist: pydantic<3.0,>=2.0
16
+ Requires-Dist: typing_extensions<5.0,>=4.0
17
+ Dynamic: license-file
18
+
19
+ # VoidBale
20
+
21
+ VoidBale یک کتابخانه پایتون برای ساخت ربات‌های Bale با Bot Token است.
22
+
23
+ ## Polling
24
+
25
+ ```python
26
+ import asyncio
27
+ from voidbale import Bot
28
+
29
+ bot = Bot("YOUR_BALE_BOT_TOKEN")
30
+
31
+ @bot.command("start")
32
+ async def start(message):
33
+ await message.answer("سلام!")
34
+
35
+ asyncio.run(bot.start_polling())
36
+ ```
37
+
38
+ ## Webhook
39
+
40
+ ```python
41
+ from voidbale import AiohttpBotWebhookServer, Bot
42
+
43
+ bot = Bot("YOUR_BALE_BOT_TOKEN")
44
+
45
+ @bot.command("start")
46
+ async def start(message):
47
+ await message.answer("سلام!")
48
+
49
+ await bot.set_webhook("https://example.com/webhook")
50
+ server = AiohttpBotWebhookServer(bot, path="/webhook")
51
+ server.run(host="0.0.0.0", port=8080)
52
+ ```
53
+
54
+ این بسته فقط برای Bale Bot API و Webhook طراحی شده است و قابلیت‌های حساب کاربری یا self-bot ندارد.
@@ -0,0 +1,19 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ voidbale/__init__.py
5
+ voidbale/analytics.py
6
+ voidbale/bot.py
7
+ voidbale/bot_manager.py
8
+ voidbale.egg-info/PKG-INFO
9
+ voidbale.egg-info/SOURCES.txt
10
+ voidbale.egg-info/dependency_links.txt
11
+ voidbale.egg-info/entry_points.txt
12
+ voidbale.egg-info/requires.txt
13
+ voidbale.egg-info/top_level.txt
14
+ voidbale/filters/__init__.py
15
+ voidbale/filters/bot.py
16
+ voidbale/utils/__init__.py
17
+ voidbale/utils/keyboard.py
18
+ voidbale/webhook/__init__.py
19
+ voidbale/webhook/bot_server.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ voidbale = voidbale.cli.main:main
@@ -0,0 +1,3 @@
1
+ aiohttp<4.0,>=3.9
2
+ pydantic<3.0,>=2.0
3
+ typing_extensions<5.0,>=4.0
@@ -0,0 +1 @@
1
+ voidbale