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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ )