zalobot-sdk 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.
- zalobot_sdk-0.1.0/CHANGELOG.md +28 -0
- zalobot_sdk-0.1.0/LICENSE +21 -0
- zalobot_sdk-0.1.0/PKG-INFO +226 -0
- zalobot_sdk-0.1.0/README.md +193 -0
- zalobot_sdk-0.1.0/docs/api.md +35 -0
- zalobot_sdk-0.1.0/docs/changelog.md +3 -0
- zalobot_sdk-0.1.0/docs/errors.md +48 -0
- zalobot_sdk-0.1.0/docs/getting-started.md +64 -0
- zalobot_sdk-0.1.0/docs/handlers.md +81 -0
- zalobot_sdk-0.1.0/docs/index.md +29 -0
- zalobot_sdk-0.1.0/docs/polling-webhooks.md +73 -0
- zalobot_sdk-0.1.0/docs/rich-text.md +48 -0
- zalobot_sdk-0.1.0/docs/testing.md +32 -0
- zalobot_sdk-0.1.0/examples/__init__.py +0 -0
- zalobot_sdk-0.1.0/examples/echo_async.py +17 -0
- zalobot_sdk-0.1.0/examples/echo_polling.py +26 -0
- zalobot_sdk-0.1.0/examples/rich_text.py +24 -0
- zalobot_sdk-0.1.0/examples/webhook_fastapi.py +36 -0
- zalobot_sdk-0.1.0/examples/webhook_flask.py +36 -0
- zalobot_sdk-0.1.0/mkdocs.yml +50 -0
- zalobot_sdk-0.1.0/pyproject.toml +143 -0
- zalobot_sdk-0.1.0/pyproject.toml.orig +92 -0
- zalobot_sdk-0.1.0/src/zalobot/__init__.py +79 -0
- zalobot_sdk-0.1.0/src/zalobot/_core.py +309 -0
- zalobot_sdk-0.1.0/src/zalobot/client.py +270 -0
- zalobot_sdk-0.1.0/src/zalobot/dispatcher.py +527 -0
- zalobot_sdk-0.1.0/src/zalobot/errors.py +80 -0
- zalobot_sdk-0.1.0/src/zalobot/filters.py +90 -0
- zalobot_sdk-0.1.0/src/zalobot/py.typed +0 -0
- zalobot_sdk-0.1.0/src/zalobot/testing.py +398 -0
- zalobot_sdk-0.1.0/src/zalobot/text.py +209 -0
- zalobot_sdk-0.1.0/src/zalobot/types.py +234 -0
- zalobot_sdk-0.1.0/src/zalobot/webhook.py +110 -0
- zalobot_sdk-0.1.0/tests/__init__.py +0 -0
- zalobot_sdk-0.1.0/tests/conftest.py +48 -0
- zalobot_sdk-0.1.0/tests/test_client.py +427 -0
- zalobot_sdk-0.1.0/tests/test_dispatcher.py +440 -0
- zalobot_sdk-0.1.0/tests/test_examples.py +122 -0
- zalobot_sdk-0.1.0/tests/test_live.py +182 -0
- zalobot_sdk-0.1.0/tests/test_regressions.py +426 -0
- zalobot_sdk-0.1.0/tests/test_smoke.py +26 -0
- zalobot_sdk-0.1.0/tests/test_testing.py +113 -0
- zalobot_sdk-0.1.0/tests/test_text.py +138 -0
- zalobot_sdk-0.1.0/tests/test_webhook.py +94 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes are listed here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
4
|
+
and versions follow [Semantic Versioning](https://semver.org/). Before 1.0 a minor version may change the API.
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-11-08
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `Bot` (blocking) and `AsyncBot` (asyncio) with every Zalo Bot API method: `get_me`, `get_updates`,
|
|
13
|
+
`set_webhook`, `test_webhook`, `delete_webhook`, `get_webhook_info`, `send_message`, `send_photo`,
|
|
14
|
+
`send_sticker`, `send_voice` and `send_chat_action`, plus `send_long_message`.
|
|
15
|
+
- A typed dataclass for every answer, keeping the raw JSON in `.raw`.
|
|
16
|
+
- An exception for each documented error code, plus `NetworkError`. The bot token is removed from errors,
|
|
17
|
+
`repr()` and httpx log lines.
|
|
18
|
+
- Checks against the documented limits before sending, and an opt-in `RetryPolicy` for quota and network errors.
|
|
19
|
+
- `Text`, a builder for `text_styles` with UTF-16 offsets, and `split_text`.
|
|
20
|
+
- `Dispatcher` and `Router` with command and message handlers, filters `F` (combinable with `&`, `|`, `~`),
|
|
21
|
+
fallback and error hooks, and duplicate-message protection.
|
|
22
|
+
- Long polling (`run_polling`, `start_polling`, `run`) with backoff, a clean stop, and a refusal to start while a
|
|
23
|
+
webhook is set.
|
|
24
|
+
- Webhook helpers that work with any framework: `verify_secret`, `parse_update`, `WebhookHandler` and
|
|
25
|
+
`AsyncWebhookHandler`.
|
|
26
|
+
- `zalobot.testing.FakeZalo`, an in-memory Bot API for tests. It answers like the real API where the real API
|
|
27
|
+
differs from the docs.
|
|
28
|
+
- `InvalidChat` (410) and `BotInfo.display_name`, both seen from the real API but not in the docs.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tâm Ngô
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zalobot-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed Python client and bot framework for the Zalo Bot Platform (sync and async).
|
|
5
|
+
Keywords: zalo,zalobot,bot,chatbot,vietnam,asyncio,webhook
|
|
6
|
+
Author: Tâm Ngô
|
|
7
|
+
Author-email: Tâm Ngô <ngothtam.me@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Natural Language :: English
|
|
14
|
+
Classifier: Natural Language :: Vietnamese
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Communications :: Chat
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Dist: httpx>=0.27,<1
|
|
26
|
+
Requires-Python: >=3.11
|
|
27
|
+
Project-URL: Homepage, https://github.com/nttams/zalobot
|
|
28
|
+
Project-URL: Source, https://github.com/nttams/zalobot
|
|
29
|
+
Project-URL: Issues, https://github.com/nttams/zalobot/issues
|
|
30
|
+
Project-URL: Changelog, https://github.com/nttams/zalobot/blob/main/CHANGELOG.md
|
|
31
|
+
Project-URL: Zalo Bot API, https://bot.zaloplatforms.com/docs/
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# zalobot
|
|
35
|
+
|
|
36
|
+
A typed Python library for the [Zalo Bot Platform](https://bot.zaloplatforms.com/docs/) with blocking and asyncio
|
|
37
|
+
clients, handlers, long polling and webhooks.
|
|
38
|
+
|
|
39
|
+
- **The whole Bot API**: all 11 methods, including `sendVoice`, `testWebhook` and the webhook check `setWebhook`
|
|
40
|
+
now returns.
|
|
41
|
+
- **Typed**: every answer is a dataclass, and the package ships `py.typed` (it passes `mypy --strict`).
|
|
42
|
+
- **Blocking or asyncio**: `Bot` and `AsyncBot` have the same methods.
|
|
43
|
+
- **Rich text that lines up**: `Text` builds Zalo's `text_styles`. Their offsets are counted in UTF-16 units, so
|
|
44
|
+
Vietnamese accents and emoji land in the right place.
|
|
45
|
+
- **Handlers like you know them**: `@dp.command("start")` and `@dp.message(F.photo)`, plus filters, an error hook
|
|
46
|
+
and duplicate-message protection.
|
|
47
|
+
- **Safe by default**: the bot token never shows up in errors, `repr()` or httpx's request logs. Webhook secrets are
|
|
48
|
+
compared in constant time.
|
|
49
|
+
- **Testable offline**: `zalobot.testing.FakeZalo` is an in-memory Bot API you can point a bot at in your own
|
|
50
|
+
tests.
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
uv add zalobot-sdk # or: pip install zalobot-sdk
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The package is `zalobot-sdk` on PyPI, and you import it as `zalobot`. Python 3.11 or newer. The only dependency is [httpx](https://www.python-httpx.org/).
|
|
59
|
+
|
|
60
|
+
## Quickstart
|
|
61
|
+
|
|
62
|
+
Create a bot in the Zalo app: open the official account **Zalo Bot Manager**, tap **Tạo bot**, and give it a name
|
|
63
|
+
starting with "Bot". Zalo then sends you the token in a message.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
import os
|
|
67
|
+
|
|
68
|
+
from zalobot import Bot, Dispatcher, F
|
|
69
|
+
|
|
70
|
+
dp = Dispatcher()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dp.message(F.text)
|
|
74
|
+
def echo(ctx):
|
|
75
|
+
ctx.reply_text(ctx.text)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
dp.run_polling(Bot(os.environ["ZALO_BOT_TOKEN"]))
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Message your bot in Zalo, and it answers with the same text.
|
|
82
|
+
|
|
83
|
+
## Commands, filters, replies
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from zalobot import Context, Dispatcher, F
|
|
87
|
+
|
|
88
|
+
dp = Dispatcher()
|
|
89
|
+
|
|
90
|
+
@dp.command("add") # "/add 50k coffee" (any case)
|
|
91
|
+
def add(ctx: Context) -> None:
|
|
92
|
+
ctx.reply_text(f"Added: {ctx.args}") # "50k coffee"
|
|
93
|
+
|
|
94
|
+
@dp.message(F.photo & F.private)
|
|
95
|
+
def photo(ctx: Context) -> None:
|
|
96
|
+
ctx.reply_text(f"Got your photo: {ctx.message.photo_url}")
|
|
97
|
+
|
|
98
|
+
@dp.fallback
|
|
99
|
+
def other(ctx: Context) -> None:
|
|
100
|
+
ctx.reply_sticker("bfe458bf64fa8da4d4eb")
|
|
101
|
+
|
|
102
|
+
@dp.error
|
|
103
|
+
def on_error(ctx: Context, exc: Exception) -> None:
|
|
104
|
+
ctx.reply_text("Sorry, something went wrong.")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Filters: `F.text`, `F.photo`, `F.sticker`, `F.voice`, `F.unsupported`, `F.private`, `F.group`,
|
|
108
|
+
`F.command(...)`, `F.regex(...)`, `F.from_user(...)` and `F.chat(...)`. Combine them with `&`, `|` and `~`. The first
|
|
109
|
+
matching handler wins. A `Router` groups handlers, and `dp.include(router)` adds the group to the dispatcher.
|
|
110
|
+
|
|
111
|
+
Replies: `ctx.reply_text`, `ctx.reply_long` (splits text over 2,000 characters at line breaks), `ctx.reply_photo`,
|
|
112
|
+
`ctx.reply_sticker`, `ctx.reply_voice` and `ctx.typing()`.
|
|
113
|
+
|
|
114
|
+
## asyncio
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from zalobot import AsyncBot, AsyncContext, Dispatcher, F
|
|
118
|
+
|
|
119
|
+
dp = Dispatcher()
|
|
120
|
+
|
|
121
|
+
@dp.message(F.text)
|
|
122
|
+
async def echo(ctx: AsyncContext) -> None:
|
|
123
|
+
await ctx.typing()
|
|
124
|
+
await ctx.reply_text(ctx.text)
|
|
125
|
+
|
|
126
|
+
dp.run(AsyncBot(token)) # or: await dp.start_polling(bot)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
With an `AsyncBot`, plain `def` handlers still work: they run in a worker thread, and their replies go through
|
|
130
|
+
the same bot.
|
|
131
|
+
|
|
132
|
+
## Polling or webhook
|
|
133
|
+
|
|
134
|
+
Zalo delivers messages in one of two ways, and you can't use both at once:
|
|
135
|
+
|
|
136
|
+
| | Long polling | Webhook |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| How | `dp.run_polling(bot)` | Zalo POSTs to your HTTPS URL |
|
|
139
|
+
| Good for | development, small bots, no public server | production (Zalo's advice) |
|
|
140
|
+
| Setup | nothing | `bot.set_webhook(url, secret)` once |
|
|
141
|
+
|
|
142
|
+
While a webhook is set, Zalo turns `getUpdates` off. `run_polling` checks for this and tells you to call
|
|
143
|
+
`bot.delete_webhook()`.
|
|
144
|
+
|
|
145
|
+
Webhook with FastAPI (see `examples/webhook_flask.py` for Flask):
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
from fastapi import FastAPI, Request, Response
|
|
149
|
+
from zalobot import AsyncBot, AsyncWebhookHandler
|
|
150
|
+
|
|
151
|
+
handler = AsyncWebhookHandler(dp, AsyncBot(token), secret="a-long-random-secret")
|
|
152
|
+
app = FastAPI()
|
|
153
|
+
|
|
154
|
+
@app.post("/zalo/webhook")
|
|
155
|
+
async def webhook(request: Request) -> Response:
|
|
156
|
+
status, body = await handler.handle(request.headers, await request.body())
|
|
157
|
+
return Response(body, status_code=status, media_type="application/json")
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The handler checks the `X-Bot-Api-Secret-Token` header (403 if it's wrong) and the body (400 if it isn't an
|
|
161
|
+
update), then runs your handlers. `bot.set_webhook()` and `bot.test_webhook()` return Zalo's check of your URL, such
|
|
162
|
+
as `outcome="webhook.http.403"` with a hint about what to fix.
|
|
163
|
+
|
|
164
|
+
## Rich text
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
from zalobot import Color, Size, Text
|
|
168
|
+
|
|
169
|
+
msg = (Text().size(Size.LARGE, "Đơn hàng #1024").plain("\n")
|
|
170
|
+
.bullets(["Cà phê sữa đá: 29,000đ", "Bánh mì: 25,000đ"])
|
|
171
|
+
.bold("Tổng: ").plain("54,000đ ").color(Color.GREEN, "✅ đã thanh toán"))
|
|
172
|
+
bot.send_message(chat_id, msg)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
You can also let Zalo parse the markup: `bot.send_message(chat_id, "**bold** {red}red{/red}", parse_mode="markdown")`
|
|
176
|
+
or `parse_mode="html"`.
|
|
177
|
+
|
|
178
|
+
## Errors and retries
|
|
179
|
+
|
|
180
|
+
Each documented error code has its own exception, all subclasses of `zalobot.ZaloError`:
|
|
181
|
+
|
|
182
|
+
| Code | Exception |
|
|
183
|
+
|---|---|
|
|
184
|
+
| 400 | `BadRequest` |
|
|
185
|
+
| 401 | `Unauthorized` (token expired or wrong) |
|
|
186
|
+
| 403 | `Forbidden` |
|
|
187
|
+
| 404 | `NotFound` |
|
|
188
|
+
| 408 | `TimedOut` |
|
|
189
|
+
| 410 | `InvalidChat` (no such chat for this bot; not in Zalo's table) |
|
|
190
|
+
| 429 | `QuotaExceeded` |
|
|
191
|
+
| network failure | `NetworkError` |
|
|
192
|
+
|
|
193
|
+
The real API answers errors with HTTP 200 and the code in the body; zalobot reads the body either way.
|
|
194
|
+
|
|
195
|
+
Retries are off by default. Turn them on with `Bot(token, retry=RetryPolicy(attempts=3))`: quota errors and
|
|
196
|
+
network errors are retried with doubling waits, while 401 never is. A send (`send_message`, `send_photo` ...) is
|
|
197
|
+
retried after a network error only if the request never left (connection refused or timed out): after a read
|
|
198
|
+
timeout Zalo may already have delivered it, and a retry would send it twice. Inputs that break the documented limits raise
|
|
199
|
+
`ValueError` before any request is sent: text over 2,000 characters (counted like Zalo's server, where most emoji
|
|
200
|
+
take 2), a secret shorter than 8, or a voice file that isn't `.aac`.
|
|
201
|
+
|
|
202
|
+
## Testing your bot
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
from zalobot.testing import FakeZalo
|
|
206
|
+
|
|
207
|
+
def test_echo():
|
|
208
|
+
fake = FakeZalo()
|
|
209
|
+
fake.push_text("hello") # a user writes to the bot
|
|
210
|
+
with fake.bot() as bot:
|
|
211
|
+
for update in bot.get_updates():
|
|
212
|
+
dp.feed(bot, update)
|
|
213
|
+
assert fake.sent[0].text == "hello" # what the bot sent back
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`FakeZalo` answers with the shapes from the official docs. It can fail on demand (`fake.fail(429)`), and it can
|
|
217
|
+
deliver queued updates to a webhook handler (`fake.deliver_webhook(handler)`).
|
|
218
|
+
|
|
219
|
+
## Links
|
|
220
|
+
|
|
221
|
+
- Source and issues: https://github.com/nttams/zalobot
|
|
222
|
+
- Zalo Bot Platform docs: https://bot.zaloplatforms.com/docs/
|
|
223
|
+
- Changelog: [CHANGELOG.md](https://github.com/nttams/zalobot/blob/main/CHANGELOG.md)
|
|
224
|
+
- Contributing: [CONTRIBUTING.md](https://github.com/nttams/zalobot/blob/main/CONTRIBUTING.md)
|
|
225
|
+
|
|
226
|
+
zalobot is an independent project, not made or endorsed by Zalo or VNG. MIT licensed.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# zalobot
|
|
2
|
+
|
|
3
|
+
A typed Python library for the [Zalo Bot Platform](https://bot.zaloplatforms.com/docs/) with blocking and asyncio
|
|
4
|
+
clients, handlers, long polling and webhooks.
|
|
5
|
+
|
|
6
|
+
- **The whole Bot API**: all 11 methods, including `sendVoice`, `testWebhook` and the webhook check `setWebhook`
|
|
7
|
+
now returns.
|
|
8
|
+
- **Typed**: every answer is a dataclass, and the package ships `py.typed` (it passes `mypy --strict`).
|
|
9
|
+
- **Blocking or asyncio**: `Bot` and `AsyncBot` have the same methods.
|
|
10
|
+
- **Rich text that lines up**: `Text` builds Zalo's `text_styles`. Their offsets are counted in UTF-16 units, so
|
|
11
|
+
Vietnamese accents and emoji land in the right place.
|
|
12
|
+
- **Handlers like you know them**: `@dp.command("start")` and `@dp.message(F.photo)`, plus filters, an error hook
|
|
13
|
+
and duplicate-message protection.
|
|
14
|
+
- **Safe by default**: the bot token never shows up in errors, `repr()` or httpx's request logs. Webhook secrets are
|
|
15
|
+
compared in constant time.
|
|
16
|
+
- **Testable offline**: `zalobot.testing.FakeZalo` is an in-memory Bot API you can point a bot at in your own
|
|
17
|
+
tests.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uv add zalobot-sdk # or: pip install zalobot-sdk
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The package is `zalobot-sdk` on PyPI, and you import it as `zalobot`. Python 3.11 or newer. The only dependency is [httpx](https://www.python-httpx.org/).
|
|
26
|
+
|
|
27
|
+
## Quickstart
|
|
28
|
+
|
|
29
|
+
Create a bot in the Zalo app: open the official account **Zalo Bot Manager**, tap **Tạo bot**, and give it a name
|
|
30
|
+
starting with "Bot". Zalo then sends you the token in a message.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import os
|
|
34
|
+
|
|
35
|
+
from zalobot import Bot, Dispatcher, F
|
|
36
|
+
|
|
37
|
+
dp = Dispatcher()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dp.message(F.text)
|
|
41
|
+
def echo(ctx):
|
|
42
|
+
ctx.reply_text(ctx.text)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
dp.run_polling(Bot(os.environ["ZALO_BOT_TOKEN"]))
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Message your bot in Zalo, and it answers with the same text.
|
|
49
|
+
|
|
50
|
+
## Commands, filters, replies
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from zalobot import Context, Dispatcher, F
|
|
54
|
+
|
|
55
|
+
dp = Dispatcher()
|
|
56
|
+
|
|
57
|
+
@dp.command("add") # "/add 50k coffee" (any case)
|
|
58
|
+
def add(ctx: Context) -> None:
|
|
59
|
+
ctx.reply_text(f"Added: {ctx.args}") # "50k coffee"
|
|
60
|
+
|
|
61
|
+
@dp.message(F.photo & F.private)
|
|
62
|
+
def photo(ctx: Context) -> None:
|
|
63
|
+
ctx.reply_text(f"Got your photo: {ctx.message.photo_url}")
|
|
64
|
+
|
|
65
|
+
@dp.fallback
|
|
66
|
+
def other(ctx: Context) -> None:
|
|
67
|
+
ctx.reply_sticker("bfe458bf64fa8da4d4eb")
|
|
68
|
+
|
|
69
|
+
@dp.error
|
|
70
|
+
def on_error(ctx: Context, exc: Exception) -> None:
|
|
71
|
+
ctx.reply_text("Sorry, something went wrong.")
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Filters: `F.text`, `F.photo`, `F.sticker`, `F.voice`, `F.unsupported`, `F.private`, `F.group`,
|
|
75
|
+
`F.command(...)`, `F.regex(...)`, `F.from_user(...)` and `F.chat(...)`. Combine them with `&`, `|` and `~`. The first
|
|
76
|
+
matching handler wins. A `Router` groups handlers, and `dp.include(router)` adds the group to the dispatcher.
|
|
77
|
+
|
|
78
|
+
Replies: `ctx.reply_text`, `ctx.reply_long` (splits text over 2,000 characters at line breaks), `ctx.reply_photo`,
|
|
79
|
+
`ctx.reply_sticker`, `ctx.reply_voice` and `ctx.typing()`.
|
|
80
|
+
|
|
81
|
+
## asyncio
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from zalobot import AsyncBot, AsyncContext, Dispatcher, F
|
|
85
|
+
|
|
86
|
+
dp = Dispatcher()
|
|
87
|
+
|
|
88
|
+
@dp.message(F.text)
|
|
89
|
+
async def echo(ctx: AsyncContext) -> None:
|
|
90
|
+
await ctx.typing()
|
|
91
|
+
await ctx.reply_text(ctx.text)
|
|
92
|
+
|
|
93
|
+
dp.run(AsyncBot(token)) # or: await dp.start_polling(bot)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
With an `AsyncBot`, plain `def` handlers still work: they run in a worker thread, and their replies go through
|
|
97
|
+
the same bot.
|
|
98
|
+
|
|
99
|
+
## Polling or webhook
|
|
100
|
+
|
|
101
|
+
Zalo delivers messages in one of two ways, and you can't use both at once:
|
|
102
|
+
|
|
103
|
+
| | Long polling | Webhook |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| How | `dp.run_polling(bot)` | Zalo POSTs to your HTTPS URL |
|
|
106
|
+
| Good for | development, small bots, no public server | production (Zalo's advice) |
|
|
107
|
+
| Setup | nothing | `bot.set_webhook(url, secret)` once |
|
|
108
|
+
|
|
109
|
+
While a webhook is set, Zalo turns `getUpdates` off. `run_polling` checks for this and tells you to call
|
|
110
|
+
`bot.delete_webhook()`.
|
|
111
|
+
|
|
112
|
+
Webhook with FastAPI (see `examples/webhook_flask.py` for Flask):
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from fastapi import FastAPI, Request, Response
|
|
116
|
+
from zalobot import AsyncBot, AsyncWebhookHandler
|
|
117
|
+
|
|
118
|
+
handler = AsyncWebhookHandler(dp, AsyncBot(token), secret="a-long-random-secret")
|
|
119
|
+
app = FastAPI()
|
|
120
|
+
|
|
121
|
+
@app.post("/zalo/webhook")
|
|
122
|
+
async def webhook(request: Request) -> Response:
|
|
123
|
+
status, body = await handler.handle(request.headers, await request.body())
|
|
124
|
+
return Response(body, status_code=status, media_type="application/json")
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The handler checks the `X-Bot-Api-Secret-Token` header (403 if it's wrong) and the body (400 if it isn't an
|
|
128
|
+
update), then runs your handlers. `bot.set_webhook()` and `bot.test_webhook()` return Zalo's check of your URL, such
|
|
129
|
+
as `outcome="webhook.http.403"` with a hint about what to fix.
|
|
130
|
+
|
|
131
|
+
## Rich text
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from zalobot import Color, Size, Text
|
|
135
|
+
|
|
136
|
+
msg = (Text().size(Size.LARGE, "Đơn hàng #1024").plain("\n")
|
|
137
|
+
.bullets(["Cà phê sữa đá: 29,000đ", "Bánh mì: 25,000đ"])
|
|
138
|
+
.bold("Tổng: ").plain("54,000đ ").color(Color.GREEN, "✅ đã thanh toán"))
|
|
139
|
+
bot.send_message(chat_id, msg)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
You can also let Zalo parse the markup: `bot.send_message(chat_id, "**bold** {red}red{/red}", parse_mode="markdown")`
|
|
143
|
+
or `parse_mode="html"`.
|
|
144
|
+
|
|
145
|
+
## Errors and retries
|
|
146
|
+
|
|
147
|
+
Each documented error code has its own exception, all subclasses of `zalobot.ZaloError`:
|
|
148
|
+
|
|
149
|
+
| Code | Exception |
|
|
150
|
+
|---|---|
|
|
151
|
+
| 400 | `BadRequest` |
|
|
152
|
+
| 401 | `Unauthorized` (token expired or wrong) |
|
|
153
|
+
| 403 | `Forbidden` |
|
|
154
|
+
| 404 | `NotFound` |
|
|
155
|
+
| 408 | `TimedOut` |
|
|
156
|
+
| 410 | `InvalidChat` (no such chat for this bot; not in Zalo's table) |
|
|
157
|
+
| 429 | `QuotaExceeded` |
|
|
158
|
+
| network failure | `NetworkError` |
|
|
159
|
+
|
|
160
|
+
The real API answers errors with HTTP 200 and the code in the body; zalobot reads the body either way.
|
|
161
|
+
|
|
162
|
+
Retries are off by default. Turn them on with `Bot(token, retry=RetryPolicy(attempts=3))`: quota errors and
|
|
163
|
+
network errors are retried with doubling waits, while 401 never is. A send (`send_message`, `send_photo` ...) is
|
|
164
|
+
retried after a network error only if the request never left (connection refused or timed out): after a read
|
|
165
|
+
timeout Zalo may already have delivered it, and a retry would send it twice. Inputs that break the documented limits raise
|
|
166
|
+
`ValueError` before any request is sent: text over 2,000 characters (counted like Zalo's server, where most emoji
|
|
167
|
+
take 2), a secret shorter than 8, or a voice file that isn't `.aac`.
|
|
168
|
+
|
|
169
|
+
## Testing your bot
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from zalobot.testing import FakeZalo
|
|
173
|
+
|
|
174
|
+
def test_echo():
|
|
175
|
+
fake = FakeZalo()
|
|
176
|
+
fake.push_text("hello") # a user writes to the bot
|
|
177
|
+
with fake.bot() as bot:
|
|
178
|
+
for update in bot.get_updates():
|
|
179
|
+
dp.feed(bot, update)
|
|
180
|
+
assert fake.sent[0].text == "hello" # what the bot sent back
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`FakeZalo` answers with the shapes from the official docs. It can fail on demand (`fake.fail(429)`), and it can
|
|
184
|
+
deliver queued updates to a webhook handler (`fake.deliver_webhook(handler)`).
|
|
185
|
+
|
|
186
|
+
## Links
|
|
187
|
+
|
|
188
|
+
- Source and issues: https://github.com/nttams/zalobot
|
|
189
|
+
- Zalo Bot Platform docs: https://bot.zaloplatforms.com/docs/
|
|
190
|
+
- Changelog: [CHANGELOG.md](https://github.com/nttams/zalobot/blob/main/CHANGELOG.md)
|
|
191
|
+
- Contributing: [CONTRIBUTING.md](https://github.com/nttams/zalobot/blob/main/CONTRIBUTING.md)
|
|
192
|
+
|
|
193
|
+
zalobot is an independent project, not made or endorsed by Zalo or VNG. MIT licensed.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# API reference
|
|
2
|
+
|
|
3
|
+
## Clients
|
|
4
|
+
|
|
5
|
+
::: zalobot.client.Bot
|
|
6
|
+
|
|
7
|
+
::: zalobot.client.AsyncBot
|
|
8
|
+
|
|
9
|
+
::: zalobot._core.RetryPolicy
|
|
10
|
+
|
|
11
|
+
## Types
|
|
12
|
+
|
|
13
|
+
::: zalobot.types
|
|
14
|
+
|
|
15
|
+
## Handlers and polling
|
|
16
|
+
|
|
17
|
+
::: zalobot.dispatcher
|
|
18
|
+
|
|
19
|
+
::: zalobot.filters
|
|
20
|
+
|
|
21
|
+
## Webhooks
|
|
22
|
+
|
|
23
|
+
::: zalobot.webhook
|
|
24
|
+
|
|
25
|
+
## Rich text
|
|
26
|
+
|
|
27
|
+
::: zalobot.text
|
|
28
|
+
|
|
29
|
+
## Errors
|
|
30
|
+
|
|
31
|
+
::: zalobot.errors
|
|
32
|
+
|
|
33
|
+
## Testing
|
|
34
|
+
|
|
35
|
+
::: zalobot.testing
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Errors and retries
|
|
2
|
+
|
|
3
|
+
Every failed call raises a subclass of `zalobot.ZaloError`, with `.code` (Zalo's `error_code`), `.description`
|
|
4
|
+
and `.method`:
|
|
5
|
+
|
|
6
|
+
| Code | Exception | Usually |
|
|
7
|
+
|---|---|---|
|
|
8
|
+
| 400 | `BadRequest` | invalid fields, wrong method |
|
|
9
|
+
| 401 | `Unauthorized` | the token expired or is wrong (retrying won't help) |
|
|
10
|
+
| 403 | `Forbidden` | Zalo's table says "internal server error" |
|
|
11
|
+
| 404 | `NotFound` | unknown method (Zalo's table says 400; the real API answers 404) |
|
|
12
|
+
| 408 | `TimedOut` | the request took too long (`get_updates` turns it into `[]`) |
|
|
13
|
+
| 410 | `InvalidChat` | no chat with that id for this bot (the real API's code; not in Zalo's table) |
|
|
14
|
+
| 429 | `QuotaExceeded` | over the API quota: slow down |
|
|
15
|
+
| none | `NetworkError` | DNS, TLS, connection or timeout before Zalo answered |
|
|
16
|
+
|
|
17
|
+
## Checks before sending
|
|
18
|
+
|
|
19
|
+
These raise `ValueError` without making a request:
|
|
20
|
+
|
|
21
|
+
- `text` and `caption` must be 1 to 2,000 characters, counted in UTF-16 units as Zalo's Java server counts them
|
|
22
|
+
(most emoji take 2).
|
|
23
|
+
- `secret_token` must be 8 to 256 characters.
|
|
24
|
+
- The webhook URL must use `https://`.
|
|
25
|
+
- `photo` and `voice_url` must be `http(s)` URLs, and `voice_url` must end in `.aac`.
|
|
26
|
+
|
|
27
|
+
## Retries
|
|
28
|
+
|
|
29
|
+
Retries are off by default. Turn them on per bot:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from zalobot import Bot, RetryPolicy
|
|
33
|
+
|
|
34
|
+
bot = Bot(token, retry=RetryPolicy(attempts=4, base_delay=1, max_delay=30))
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`QuotaExceeded` and `NetworkError` are retried, waiting 1 s, then 2 s, then 4 s. Other errors are raised at once.
|
|
38
|
+
Set `retry_network=False` to retry quota errors only.
|
|
39
|
+
|
|
40
|
+
Sends are the exception: after a network error, `send_message`, `send_photo`, `send_sticker` and `send_voice` are
|
|
41
|
+
retried only when the request never left (`NetworkError.request_sent` is False: connection refused, connect or pool
|
|
42
|
+
timeout). After a read timeout or a dropped connection, Zalo may already have delivered the message, and a retry
|
|
43
|
+
would send it twice. Reads (`get_me`, `get_webhook_info` ...) are always safe to retry.
|
|
44
|
+
|
|
45
|
+
## The token stays secret
|
|
46
|
+
|
|
47
|
+
The token is part of every request URL. zalobot removes it from error messages and `repr(bot)`, and filters it out
|
|
48
|
+
of httpx's request log lines (which log URLs at INFO level). `bot.token` still gives it to you when you need it.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Getting started
|
|
2
|
+
|
|
3
|
+
## 1. Make a bot
|
|
4
|
+
|
|
5
|
+
In the Zalo app, open the official account **Zalo Bot Manager** and tap **Tạo bot**. Name the bot (the name must
|
|
6
|
+
start with "Bot") and confirm. Zalo then sends you the bot **token** in a message. Keep it secret: anyone with the
|
|
7
|
+
token controls the bot.
|
|
8
|
+
|
|
9
|
+
## 2. Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv add zalobot-sdk # or: pip install zalobot-sdk
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The package is `zalobot-sdk` on PyPI; in code it is `import zalobot`.
|
|
16
|
+
|
|
17
|
+
## 3. Check the token
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from zalobot import Bot
|
|
21
|
+
|
|
22
|
+
with Bot("YOUR_TOKEN") as bot:
|
|
23
|
+
me = bot.get_me()
|
|
24
|
+
print(me.id, me.account_name)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
A wrong or expired token raises `zalobot.Unauthorized`.
|
|
28
|
+
|
|
29
|
+
## 4. Answer messages
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from zalobot import Bot, Context, Dispatcher, F
|
|
33
|
+
|
|
34
|
+
dp = Dispatcher()
|
|
35
|
+
|
|
36
|
+
@dp.command("start")
|
|
37
|
+
def start(ctx: Context) -> None:
|
|
38
|
+
ctx.reply_text(f"Xin chào {ctx.message.from_user.display_name}!")
|
|
39
|
+
|
|
40
|
+
@dp.message(F.text)
|
|
41
|
+
def echo(ctx: Context) -> None:
|
|
42
|
+
ctx.reply_text(ctx.text or "")
|
|
43
|
+
|
|
44
|
+
dp.run_polling(Bot("YOUR_TOKEN"))
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Message your bot in Zalo. A bot can only answer people who wrote to it: there is no API for starting a
|
|
48
|
+
conversation.
|
|
49
|
+
|
|
50
|
+
## 5. Or with asyncio
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from zalobot import AsyncBot, AsyncContext, Dispatcher, F
|
|
54
|
+
|
|
55
|
+
dp = Dispatcher()
|
|
56
|
+
|
|
57
|
+
@dp.message(F.text)
|
|
58
|
+
async def echo(ctx: AsyncContext) -> None:
|
|
59
|
+
await ctx.reply_text(ctx.text or "")
|
|
60
|
+
|
|
61
|
+
dp.run(AsyncBot("YOUR_TOKEN"))
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Next: [handlers and filters](handlers.md), then [polling or webhooks](polling-webhooks.md).
|