python-zatolox-bot 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_zatolox_bot-0.1.0/PKG-INFO +20 -0
- python_zatolox_bot-0.1.0/python_zatolox_bot.egg-info/PKG-INFO +20 -0
- python_zatolox_bot-0.1.0/python_zatolox_bot.egg-info/SOURCES.txt +9 -0
- python_zatolox_bot-0.1.0/python_zatolox_bot.egg-info/dependency_links.txt +1 -0
- python_zatolox_bot-0.1.0/python_zatolox_bot.egg-info/requires.txt +1 -0
- python_zatolox_bot-0.1.0/python_zatolox_bot.egg-info/top_level.txt +1 -0
- python_zatolox_bot-0.1.0/setup.cfg +4 -0
- python_zatolox_bot-0.1.0/setup.py +25 -0
- python_zatolox_bot-0.1.0/zatolox/__init__.py +7 -0
- python_zatolox_bot-0.1.0/zatolox/bot.py +185 -0
- python_zatolox_bot-0.1.0/zatolox/types.py +112 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-zatolox-bot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SDK ufficiale per costruire bot su Zatolox
|
|
5
|
+
Home-page: https://github.com/zatolox/python-zatolox-bot
|
|
6
|
+
Author: Zatolox
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Requires-Dist: requests>=2.25
|
|
12
|
+
Dynamic: author
|
|
13
|
+
Dynamic: classifier
|
|
14
|
+
Dynamic: description
|
|
15
|
+
Dynamic: home-page
|
|
16
|
+
Dynamic: requires-dist
|
|
17
|
+
Dynamic: requires-python
|
|
18
|
+
Dynamic: summary
|
|
19
|
+
|
|
20
|
+
Wrapper leggero attorno al gateway HTTP dei bot di Zatolox. Registri gli handler dei comandi e delle callback, chiami start_polling(), e il resto e' gestito dall'SDK.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-zatolox-bot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SDK ufficiale per costruire bot su Zatolox
|
|
5
|
+
Home-page: https://github.com/zatolox/python-zatolox-bot
|
|
6
|
+
Author: Zatolox
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Requires-Dist: requests>=2.25
|
|
12
|
+
Dynamic: author
|
|
13
|
+
Dynamic: classifier
|
|
14
|
+
Dynamic: description
|
|
15
|
+
Dynamic: home-page
|
|
16
|
+
Dynamic: requires-dist
|
|
17
|
+
Dynamic: requires-python
|
|
18
|
+
Dynamic: summary
|
|
19
|
+
|
|
20
|
+
Wrapper leggero attorno al gateway HTTP dei bot di Zatolox. Registri gli handler dei comandi e delle callback, chiami start_polling(), e il resto e' gestito dall'SDK.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
setup.py
|
|
2
|
+
python_zatolox_bot.egg-info/PKG-INFO
|
|
3
|
+
python_zatolox_bot.egg-info/SOURCES.txt
|
|
4
|
+
python_zatolox_bot.egg-info/dependency_links.txt
|
|
5
|
+
python_zatolox_bot.egg-info/requires.txt
|
|
6
|
+
python_zatolox_bot.egg-info/top_level.txt
|
|
7
|
+
zatolox/__init__.py
|
|
8
|
+
zatolox/bot.py
|
|
9
|
+
zatolox/types.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.25
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
zatolox
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
# Volutamente minimale per la v1: una sola dipendenza (requests). Un SDK di un bot deve poter
|
|
4
|
+
# entrare in qualsiasi progetto senza trascinarsi dietro un albero di dipendenze — meno cose ci
|
|
5
|
+
# sono, meno se ne rompono.
|
|
6
|
+
setup(
|
|
7
|
+
name="python-zatolox-bot",
|
|
8
|
+
version="0.1.0",
|
|
9
|
+
description="SDK ufficiale per costruire bot su Zatolox",
|
|
10
|
+
long_description=(
|
|
11
|
+
"Wrapper leggero attorno al gateway HTTP dei bot di Zatolox. "
|
|
12
|
+
"Registri gli handler dei comandi e delle callback, chiami start_polling(), "
|
|
13
|
+
"e il resto e' gestito dall'SDK."
|
|
14
|
+
),
|
|
15
|
+
author="Zatolox",
|
|
16
|
+
url="https://github.com/zatolox/python-zatolox-bot",
|
|
17
|
+
packages=find_packages(exclude=["tests", "examples"]),
|
|
18
|
+
python_requires=">=3.8",
|
|
19
|
+
install_requires=["requests>=2.25"],
|
|
20
|
+
classifiers=[
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
],
|
|
25
|
+
)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""python-zatolox-bot: SDK ufficiale per costruire bot su Zatolox."""
|
|
2
|
+
|
|
3
|
+
from .bot import ZatoloxBot, ZatoloxApiError
|
|
4
|
+
from .types import User, Message, CallbackQuery, Update
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
__all__ = ["ZatoloxBot", "ZatoloxApiError", "User", "Message", "CallbackQuery", "Update"]
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Il motore del bot.
|
|
3
|
+
|
|
4
|
+
Un ciclo `getUpdates` -> instrada ogni aggiornamento all'handler giusto -> `sendMessage` quando
|
|
5
|
+
il tuo codice risponde. L'SDK non nasconde il protocollo, lo rende comodo: puoi sempre chiamare i
|
|
6
|
+
metodi grezzi (`send_message`, `get_updates`) se un handler non ti basta.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from typing import Callable, Dict, List, Optional, Any
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
from .types import Update, Message, CallbackQuery
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ZatoloxBot:
|
|
18
|
+
"""
|
|
19
|
+
Un bot Zatolox.
|
|
20
|
+
|
|
21
|
+
Uso minimo::
|
|
22
|
+
|
|
23
|
+
bot = ZatoloxBot("123:ABC...", "http://207.180.237.77:4000")
|
|
24
|
+
|
|
25
|
+
@bot.command("start")
|
|
26
|
+
def start(msg):
|
|
27
|
+
bot.send_message(msg.chat_id, "Ciao! Sono vivo.")
|
|
28
|
+
|
|
29
|
+
bot.start_polling()
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, token: str, base_url: str, timeout: int = 30):
|
|
33
|
+
# base_url senza slash finale: l'URL del gateway e' `<base>/bot<token>/<metodo>`, e uno
|
|
34
|
+
# slash di troppo produrrebbe `//bot...` — un 404 difficile da diagnosticare.
|
|
35
|
+
self.base_url = base_url.rstrip("/")
|
|
36
|
+
self.token = token
|
|
37
|
+
self.timeout = timeout
|
|
38
|
+
self._session = requests.Session()
|
|
39
|
+
|
|
40
|
+
# Il token viaggia nel path, come su Telegram. Costruito una volta sola qui.
|
|
41
|
+
self._api = f"{self.base_url}/bot{self.token}"
|
|
42
|
+
|
|
43
|
+
# comando (senza "/") -> funzione(Message). Un fallback opzionale per i messaggi che non
|
|
44
|
+
# sono comandi, e uno per le callback.
|
|
45
|
+
self.command_handlers: Dict[str, Callable[[Message], None]] = {}
|
|
46
|
+
self._message_handler: Optional[Callable[[Message], None]] = None
|
|
47
|
+
self._callback_handler: Optional[Callable[[CallbackQuery], None]] = None
|
|
48
|
+
|
|
49
|
+
# Offset del prossimo getUpdates. Avanza a `ultimo update_id + 1`: e' cosi' che si conferma
|
|
50
|
+
# al server di aver ricevuto tutto cio' che precede, e non lo si riceve piu'.
|
|
51
|
+
self._offset: Optional[int] = None
|
|
52
|
+
self._running = False
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------ API grezze
|
|
55
|
+
|
|
56
|
+
def _call(self, method: str, payload: Optional[dict] = None, http: str = "post") -> Any:
|
|
57
|
+
"""Una chiamata al gateway. Solleva se il server risponde `ok: false`, cosi' gli errori
|
|
58
|
+
non passano inosservati come un risultato vuoto."""
|
|
59
|
+
url = f"{self._api}/{method}"
|
|
60
|
+
if http == "get":
|
|
61
|
+
resp = self._session.get(url, params=payload, timeout=self.timeout + 10)
|
|
62
|
+
else:
|
|
63
|
+
resp = self._session.post(url, json=payload, timeout=self.timeout + 10)
|
|
64
|
+
data = resp.json()
|
|
65
|
+
if not data.get("ok"):
|
|
66
|
+
raise ZatoloxApiError(data.get("error_code", resp.status_code), data.get("description", "errore sconosciuto"))
|
|
67
|
+
return data.get("result")
|
|
68
|
+
|
|
69
|
+
def get_me(self) -> dict:
|
|
70
|
+
"""Info sul bot. Utile come ping per verificare che il token sia valido."""
|
|
71
|
+
return self._call("getMe", http="get")
|
|
72
|
+
|
|
73
|
+
def send_message(self, chat_id: str, text: str, reply_markup: Optional[Any] = None) -> Message:
|
|
74
|
+
"""
|
|
75
|
+
Invia un messaggio di testo in una chat.
|
|
76
|
+
|
|
77
|
+
`reply_markup` e' la tastiera inline: puoi passare la forma Telegram
|
|
78
|
+
`{"inline_keyboard": [[{"text": "...", "callback_data": "..."}]]}` oppure l'array nudo
|
|
79
|
+
`[[{...}]]` — il client Zatolox accetta entrambe.
|
|
80
|
+
"""
|
|
81
|
+
payload: Dict[str, Any] = {"chat_id": chat_id, "text": text}
|
|
82
|
+
if reply_markup is not None:
|
|
83
|
+
payload["reply_markup"] = reply_markup
|
|
84
|
+
return Message.from_dict(self._call("sendMessage", payload))
|
|
85
|
+
|
|
86
|
+
def get_updates(self, offset: Optional[int] = None, timeout: int = 0, limit: int = 100) -> List[Update]:
|
|
87
|
+
"""Un giro di getUpdates. Di solito non serve chiamarlo a mano: lo fa start_polling()."""
|
|
88
|
+
params: Dict[str, Any] = {"limit": limit, "timeout": timeout}
|
|
89
|
+
if offset is not None:
|
|
90
|
+
params["offset"] = offset
|
|
91
|
+
result = self._call("getUpdates", params, http="get") or []
|
|
92
|
+
return [Update.from_dict(u) for u in result]
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------ handler
|
|
95
|
+
|
|
96
|
+
def add_command_handler(self, command: str, callback: Callable[[Message], None]) -> None:
|
|
97
|
+
"""Registra la funzione per un comando. `command` con o senza la "/" iniziale: la si
|
|
98
|
+
normalizza, cosi' 'start' e '/start' funzionano entrambi."""
|
|
99
|
+
self.command_handlers[command.lstrip("/").lower()] = callback
|
|
100
|
+
|
|
101
|
+
def add_message_handler(self, callback: Callable[[Message], None]) -> None:
|
|
102
|
+
"""Fallback per i messaggi che non sono comandi (es. un echo bot)."""
|
|
103
|
+
self._message_handler = callback
|
|
104
|
+
|
|
105
|
+
def add_callback_handler(self, callback: Callable[[CallbackQuery], None]) -> None:
|
|
106
|
+
"""Registra la funzione chiamata a ogni click su un pulsante inline."""
|
|
107
|
+
self._callback_handler = callback
|
|
108
|
+
|
|
109
|
+
def command(self, name: str):
|
|
110
|
+
"""Decoratore comodo: `@bot.command("start")` sopra la funzione."""
|
|
111
|
+
def deco(fn: Callable[[Message], None]):
|
|
112
|
+
self.add_command_handler(name, fn)
|
|
113
|
+
return fn
|
|
114
|
+
return deco
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------ instradamento
|
|
117
|
+
|
|
118
|
+
def _dispatch(self, update: Update) -> None:
|
|
119
|
+
"""Manda un aggiornamento all'handler giusto. Un'eccezione nel TUO codice viene isolata:
|
|
120
|
+
stampata, non propagata, cosi' un handler difettoso non ferma il polling."""
|
|
121
|
+
try:
|
|
122
|
+
if update.callback_query is not None:
|
|
123
|
+
if self._callback_handler:
|
|
124
|
+
self._callback_handler(update.callback_query)
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
msg = update.message
|
|
128
|
+
if msg is None:
|
|
129
|
+
return
|
|
130
|
+
cmd = msg.command
|
|
131
|
+
if cmd is not None and cmd in self.command_handlers:
|
|
132
|
+
self.command_handlers[cmd](msg)
|
|
133
|
+
elif self._message_handler is not None:
|
|
134
|
+
self._message_handler(msg)
|
|
135
|
+
except Exception as e: # noqa: BLE001 — un handler dell'utente non deve abbattere il bot
|
|
136
|
+
print(f"[zatolox] handler ha sollevato: {e!r}")
|
|
137
|
+
|
|
138
|
+
def start_polling(self, poll_timeout: Optional[int] = None) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Il ciclo principale: chiede aggiornamenti in long-polling e li instrada finche' non si
|
|
141
|
+
chiama stop().
|
|
142
|
+
|
|
143
|
+
L'offset e' la parte importante: dopo aver elaborato un lotto, si avanza a
|
|
144
|
+
`max(update_id) + 1`. Alla chiamata seguente il server considera confermato tutto cio' che
|
|
145
|
+
precede e non lo rimanda. Se un giro fallisce (rete), NON si avanza l'offset: quegli
|
|
146
|
+
aggiornamenti tornano al tentativo dopo, invece di andare persi.
|
|
147
|
+
"""
|
|
148
|
+
self._running = True
|
|
149
|
+
wait = self.timeout if poll_timeout is None else poll_timeout
|
|
150
|
+
print(f"[zatolox] polling avviato (timeout {wait}s). Ctrl-C per fermare.")
|
|
151
|
+
while self._running:
|
|
152
|
+
try:
|
|
153
|
+
updates = self.get_updates(offset=self._offset, timeout=wait)
|
|
154
|
+
except requests.RequestException as e:
|
|
155
|
+
# Errore di rete: pausa breve e riprova. L'offset resta fermo, niente va perso.
|
|
156
|
+
print(f"[zatolox] rete: {e!r}; riprovo fra 3s")
|
|
157
|
+
time.sleep(3)
|
|
158
|
+
continue
|
|
159
|
+
except ZatoloxApiError as e:
|
|
160
|
+
# 401 = token revocato o errato: inutile insistere.
|
|
161
|
+
print(f"[zatolox] API: {e!r}")
|
|
162
|
+
if e.code == 401:
|
|
163
|
+
break
|
|
164
|
+
time.sleep(3)
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
for u in updates:
|
|
168
|
+
self._dispatch(u)
|
|
169
|
+
# Si avanza update per update: se un handler fa cadere il processo, al riavvio si
|
|
170
|
+
# riparte dal PRIMO non ancora confermato, non dal lotto intero.
|
|
171
|
+
self._offset = u.update_id + 1
|
|
172
|
+
|
|
173
|
+
def stop(self) -> None:
|
|
174
|
+
"""Ferma il ciclo di polling al giro successivo."""
|
|
175
|
+
self._running = False
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class ZatoloxApiError(Exception):
|
|
179
|
+
"""Il gateway ha risposto `ok: false`. Porta il codice, cosi' il chiamante puo' distinguere
|
|
180
|
+
un 401 (token) da un 403 (il bot non e' nella chat) da un 500."""
|
|
181
|
+
|
|
182
|
+
def __init__(self, code: int, description: str):
|
|
183
|
+
self.code = code
|
|
184
|
+
self.description = description
|
|
185
|
+
super().__init__(f"[{code}] {description}")
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Modelli dati dell'SDK.
|
|
3
|
+
|
|
4
|
+
Rispecchiano ESATTAMENTE cio' che il gateway di Zatolox restituisce (i metodi in stile Telegram
|
|
5
|
+
`getUpdates`/`sendMessage`), non un formato ideale. Ogni `from_dict` e' tollerante: i campi
|
|
6
|
+
mancanti diventano None invece di sollevare un errore, cosi' una piccola differenza fra versioni
|
|
7
|
+
del server non fa crollare il bot.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class User:
|
|
16
|
+
"""Chi ha originato un aggiornamento: una persona o un altro bot."""
|
|
17
|
+
id: str
|
|
18
|
+
is_bot: bool = False
|
|
19
|
+
first_name: Optional[str] = None
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional["User"]:
|
|
23
|
+
if not d:
|
|
24
|
+
return None
|
|
25
|
+
return cls(
|
|
26
|
+
id=str(d.get("id", "")),
|
|
27
|
+
is_bot=bool(d.get("is_bot", False)),
|
|
28
|
+
first_name=d.get("first_name"),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Message:
|
|
34
|
+
"""Un messaggio in una chat. `chat_id` e' cio' che serve per rispondere."""
|
|
35
|
+
message_id: str
|
|
36
|
+
chat_id: Optional[str]
|
|
37
|
+
text: str = ""
|
|
38
|
+
type: str = "TEXT"
|
|
39
|
+
date: Optional[int] = None
|
|
40
|
+
from_user: Optional[User] = None
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional["Message"]:
|
|
44
|
+
if not d:
|
|
45
|
+
return None
|
|
46
|
+
return cls(
|
|
47
|
+
message_id=str(d.get("message_id", "")),
|
|
48
|
+
chat_id=d.get("chat_id"),
|
|
49
|
+
text=d.get("text", "") or "",
|
|
50
|
+
type=d.get("type", "TEXT"),
|
|
51
|
+
date=d.get("date"),
|
|
52
|
+
from_user=User.from_dict(d.get("from")),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def command(self) -> Optional[str]:
|
|
57
|
+
"""
|
|
58
|
+
Il comando in questo messaggio, senza la barra, o None.
|
|
59
|
+
|
|
60
|
+
Riconosce anche la forma `/comando@nomebot` che compare nei gruppi (dove piu' bot possono
|
|
61
|
+
rispondere e Telegram indirizza il comando col nome): la parte dopo `@` viene scartata.
|
|
62
|
+
"""
|
|
63
|
+
t = self.text.strip()
|
|
64
|
+
if not t.startswith("/"):
|
|
65
|
+
return None
|
|
66
|
+
token = t.split()[0][1:] # primo pezzo, senza la "/"
|
|
67
|
+
return token.split("@")[0] or None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class CallbackQuery:
|
|
72
|
+
"""Un click su un pulsante inline. `data` e' il callback_data del pulsante toccato."""
|
|
73
|
+
id: str
|
|
74
|
+
data: str
|
|
75
|
+
from_user: Optional[User] = None
|
|
76
|
+
message: Optional[Message] = None
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional["CallbackQuery"]:
|
|
80
|
+
if not d:
|
|
81
|
+
return None
|
|
82
|
+
return cls(
|
|
83
|
+
id=str(d.get("id", "")),
|
|
84
|
+
data=d.get("data", "") or "",
|
|
85
|
+
from_user=User.from_dict(d.get("from")),
|
|
86
|
+
message=Message.from_dict(d.get("message")),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def chat_id(self) -> Optional[str]:
|
|
91
|
+
"""Scorciatoia: la chat da cui e' arrivato il click, per rispondere sul posto."""
|
|
92
|
+
return self.message.chat_id if self.message else None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class Update:
|
|
97
|
+
"""
|
|
98
|
+
Contenitore di un aggiornamento. Porta un `update_id` monotono e UNO fra `message` e
|
|
99
|
+
`callback_query`. L'`update_id` e' cio' che il polling usa come offset per confermare la
|
|
100
|
+
ricezione.
|
|
101
|
+
"""
|
|
102
|
+
update_id: int
|
|
103
|
+
message: Optional[Message] = None
|
|
104
|
+
callback_query: Optional[CallbackQuery] = None
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Update":
|
|
108
|
+
return cls(
|
|
109
|
+
update_id=int(d.get("update_id", 0)),
|
|
110
|
+
message=Message.from_dict(d.get("message")),
|
|
111
|
+
callback_query=CallbackQuery.from_dict(d.get("callback_query")),
|
|
112
|
+
)
|