voidbale 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- voidbale/__init__.py +15 -0
- voidbale/analytics.py +30 -0
- voidbale/bot.py +745 -0
- voidbale/bot_manager.py +16 -0
- voidbale/filters/__init__.py +3 -0
- voidbale/filters/bot.py +18 -0
- voidbale/utils/__init__.py +3 -0
- voidbale/utils/keyboard.py +150 -0
- voidbale/webhook/__init__.py +3 -0
- voidbale/webhook/bot_server.py +29 -0
- voidbale-1.0.0.dist-info/METADATA +54 -0
- voidbale-1.0.0.dist-info/RECORD +16 -0
- voidbale-1.0.0.dist-info/WHEEL +5 -0
- voidbale-1.0.0.dist-info/entry_points.txt +2 -0
- voidbale-1.0.0.dist-info/licenses/LICENSE +22 -0
- voidbale-1.0.0.dist-info/top_level.txt +1 -0
voidbale/__init__.py
ADDED
|
@@ -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
|
+
)
|
voidbale/analytics.py
ADDED
|
@@ -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()
|
voidbale/bot.py
ADDED
|
@@ -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))
|
voidbale/bot_manager.py
ADDED
|
@@ -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()
|
voidbale/filters/bot.py
ADDED
|
@@ -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,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,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,16 @@
|
|
|
1
|
+
voidbale/__init__.py,sha256=NlmmTYwOKCX1cRxt8ezda9AQAjr_fgFTU79g6euFs_4,688
|
|
2
|
+
voidbale/analytics.py,sha256=6hJfXZnYxgMfX-U-iHyQ32hZJ9dStkLIeCWLr143-nU,1043
|
|
3
|
+
voidbale/bot.py,sha256=m7I9YzLerv-mh7ogKFnS2nsGFKL2OcvoWZcZfDGwO4o,33030
|
|
4
|
+
voidbale/bot_manager.py,sha256=xpZGyTPSlg6bjAyvTbmCbnOD3CfAbYEqap1oo_H5NI0,469
|
|
5
|
+
voidbale/filters/__init__.py,sha256=QF5mQnK7X_Q7jH8_QIEzbVrMjRikS5UK9GuNGdALWZU,70
|
|
6
|
+
voidbale/filters/bot.py,sha256=YIlIPAlmz-y6ro4s5wFXmy5FcT3ZSa0jkH9mTpUANbs,621
|
|
7
|
+
voidbale/utils/__init__.py,sha256=8X-Oneqrm9_bfrKBcOLrA_JZ9C5P9oAD_HGpElJMRH0,217
|
|
8
|
+
voidbale/utils/keyboard.py,sha256=vSWQQgrShYdEwFhmFulT4lCLLiWP_w1j3bVMEbTBTUU,4780
|
|
9
|
+
voidbale/webhook/__init__.py,sha256=h1-VwRHK6lX42-nBXcBwfdkKjw_FGqyKLXkPZx6dslw,88
|
|
10
|
+
voidbale/webhook/bot_server.py,sha256=w8-9PkqL-Ofx47s6E-usR13nqXHlnNne7JHZtgrFCew,1146
|
|
11
|
+
voidbale-1.0.0.dist-info/licenses/LICENSE,sha256=qxvPB-Wx_YTI1oIVQUZvYIt7G5UlUR3ntE4PYFXTJmM,1123
|
|
12
|
+
voidbale-1.0.0.dist-info/METADATA,sha256=RNoiYtD3sRlUcsiXTDqp4Kg7lH24SiPECp9Aw1B1ICs,1535
|
|
13
|
+
voidbale-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
14
|
+
voidbale-1.0.0.dist-info/entry_points.txt,sha256=6qr1ka_7wC4FFnvTog3uegkABDRA9I1G7RF4ZYwob1g,52
|
|
15
|
+
voidbale-1.0.0.dist-info/top_level.txt,sha256=lZ62KFfA3xMZ5SrkEq-FLIXAS_GjvRXe_S8dD2kyafY,9
|
|
16
|
+
voidbale-1.0.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
voidbale
|