ZaloBot.Sdk
1.0.0
dotnet add package ZaloBot.Sdk --version 1.0.0
NuGet\Install-Package ZaloBot.Sdk -Version 1.0.0
<PackageReference Include="ZaloBot.Sdk" Version="1.0.0" />
<PackageVersion Include="ZaloBot.Sdk" Version="1.0.0" />
<PackageReference Include="ZaloBot.Sdk" />
paket add ZaloBot.Sdk --version 1.0.0
#r "nuget: ZaloBot.Sdk, 1.0.0"
#:package ZaloBot.Sdk@1.0.0
#addin nuget:?package=ZaloBot.Sdk&version=1.0.0
#tool nuget:?package=ZaloBot.Sdk&version=1.0.0
ZaloBot.Sdk
Independent .NET 10 client library for the Zalo Bot API (docs: https://bot.zapps.me/docs;
offline copy in docs/ — scraped 2026-09-14, all 21 pages incl. 11 API references).
Adapted from Telegram.Bot (MIT — see
LICENSE-Telegram.Bot.txt), pruned to the 11 methods the Zalo Bot API exposes, and extended
with an official-error-code taxonomy, a retry/backoff pipeline and access-token hygiene.
Leaf project: references no other solution project and no NuGet packages (System.Text.Json
is part of the shared framework). Nothing in the app layers may be referenced from here.
Naming gotcha: the root namespace
ZaloBot.Sdkshadows the app entityZaloBot(QuanLyCongViec.Domain.Entities.ZaloBot). App code that uses the SDK aliases the entity and the app-side interface — seeInfrastructure/External/ZaloBotClient.cs.
Quick start
var options = new ZaloBotClientOptions(accessToken); // token required, base URL optional
options.TimeoutSeconds = 40; // per-request timeout (default 30)
var client = new ZaloBotClient(options, httpClientFactory); // HttpClient from IHttpClientFactory
Message sent = await client.SendMessageAsync("5128471675483426707", "Hello bot");
ZaloBotClientOptions.Token— bot access token. It is used only to build the request URL ({BaseUrl}/bot{Token}/{method}); it never appears in headers, bodies, logs or exceptions.ZaloBotClientOptions.DefaultBaseUrl=https://bot-api.zaloplatforms.com(official server).- Events
OnMakingApiRequest/OnApiResponseReceived(AsyncEventHandler<...>) allow observation of every request/response — used by the app adapter for logging.
API methods (all of them)
Extension methods live in ZaloBotClientExtensions.ApiMethods.cs; request objects in Requests/;
result types in Types/. HTTP verb per docs/call-api: GET for read APIs, POST for writes (the
platform supports both verbs for every API — see "Call API conventions" below).
| API method | Verb | Extension | Returns |
|---|---|---|---|
getMe |
GET | GetMeAsync() |
User |
getUpdates |
POST | GetUpdatesAsync(offset, timeoutSeconds, limit) |
Update[] |
sendMessage |
POST | SendMessageAsync(chatId, text, parseMode?, textStyles?, ct) |
Message |
sendPhoto |
POST | SendPhotoAsync(chatId, photo) |
Message |
sendSticker |
POST | SendStickerAsync(chatId, sticker) |
Message |
sendVoice |
POST | SendVoiceAsync(chatId, voice) |
Message |
sendChatAction |
POST | SendChatActionAsync(chatId, action) |
bool |
setWebhook |
POST | SetWebhookAsync(url, secretToken?) |
SetWebhookResult |
testWebhook |
POST | TestWebhookAsync() |
bool |
deleteWebhook |
POST | DeleteWebhookAsync() |
bool |
getWebhookInfo |
GET | GetWebhookInfoAsync() |
WebhookInfo? |
Message (result of send* calls) carries what the platform guarantees — MessageId (string)
and Date (epoch milliseconds) — plus audit helpers RawJson (platform result JSON) and
RequestJson (the body the SDK sent), both token-free. Extra/unknown fields from the platform are
ignored, never throw.
Call API conventions (docs bot.zapps.me/docs/call-api)
- URL format:
https://bot-api.zaloplatforms.com/bot{token}/{methodName}— token sits directly after/bot(no separator). HTTPS only.ZaloBotClientOptionsenforces this shape. - Method names are case-sensitive —
sendMessage≠sendmessage. The 11 names above are pinned byApiConventionsTests(each verified againstbot.zapps.me/docs/apis/<name>/). - HTTP verbs: the platform accepts GET and POST for every API, and recommends GET when
retrieving data. SDK mapping: pure parameterless reads (
getMe,getWebhookInfo) use GET (base classParameterlessGetRequest<TResult>); everything else — includinggetUpdates(long-poll, its docs page specifies POST) — uses POST. The sender honorsIRequest.HttpMethod. - Parameter passing: the platform accepts query string, form-urlencoded, JSON and
multipart/form-data (uploads). The SDK always sends
application/json; charset=utf-8(UTF-8 is mandatory) — none of the 11 methods need file upload (media is referenced by URL/id). - Response envelope:
{ok, result, error_code, description}— parsing rules in "Wire contract" below. - getUpdates vs webhook are mutually exclusive (docs/apis/getUpdates): if a webhook is
registered, getUpdates returns nothing until
deleteWebhookis called. Docs recommend webhooks for production, polling for local testing.
Wire contract
- URL:
{BaseUrl}/bot{Token}/{methodName}— GET for read APIs (getMe,getWebhookInfo), POST for the rest (see "Call API conventions");Content-Type: application/json; charset=utf-8. - Serialization:
JsonBotAPI.Options—SnakeCaseLowernaming policy,WhenWritingDefaultignore condition (null/0/false fields are omitted),AllowReadingFromStringfor numbers. ChatIdalways serializes as a string (ChatIdConverter) — required by the platform even for numeric ids. Accepts numeric ids,@handlesand any raw Zalo chat id.- Response wrapper:
{"ok":bool,"result":...,"error_code":int,"description":string}. Parsing rules (ZaloSdkHttpSender.ParseWrapper):ok:true→ success.resultmay be absent →boolcalls gettrue,SetWebhookResultcalls get an empty result, others getnull/empty.ok:false(orerror_code/descriptionpresent) → failure → taxonomy below.- Non-object body or object with none of
ok/error_code/description→InvalidJsonExceptionwith a token-sanitized body excerpt.
- Defensive mapping (
Internal/ZaloSdkPayloadMapper): unknown shapes never throw — unrecognized updates map toContentType.Unknown, malformed results to empty models.getUpdateselements and webhook payloads (which arrive wrapped as{ok, result:{...}}) both parse viaParseUpdate.
sendMessage rich text
Per docs https://bot.zapps.me/docs/apis/sendMessage:
| Parameter | Type | Required | Notes |
|---|---|---|---|
chat_id |
String | yes | Recipient / conversation id |
text |
String | yes | 1–2000 chars; validated locally before any network call (MaxMessageTextLength = 2000) |
parse_mode |
String | no | "markdown" or "html" |
text_styles |
Array of style runs | no | Explicit styling; ignored by the platform when parse_mode is also sent (parse_mode wins) |
TextStyle = { Start, Length, Styles } → JSON {"start":..,"len":..,"st":[..]}. Offsets are
UTF-16 code units — the same unit as .NET string indexing, so text.IndexOf(...) /
string.Length values apply directly. Style codes are constants on TextStyleCode:
| Code | Meaning |
|---|---|
b i u s |
bold / italic / underline / strikethrough |
f_13 f_15 f_18 f_20 |
font small / normal / large / very large |
c_050a19 c_15a85f c_f7b503 c_f27806 c_db342e |
default / green / yellow / orange / red |
lst_1 lst_2 |
unordered / ordered list |
ind_1 … ind_5 |
indentation levels 1–5 |
Unknown codes are ignored by the platform; ranges outside the text are auto-adjusted or dropped.
await client.SendMessageAsync("5128471675483426707", "Xin chào bạn", textStyles:
[
TextStyle.Of(0, 7, TextStyleCode.Bold, TextStyleCode.ColorRed),
]);
Polling (adapted from Telegram.Bot's polling — reference tmp/design/TelegramBots)
namespace ZaloBot.Sdk.Polling + ZaloBotClientExtensions.Polling.cs — vòng lặp getUpdates
built into the SDK, mirroring the Telegram.Bot API surface:
ReceiverOptions { Offset (long?), Limit (1–100, default 100), TimeoutSeconds (default 25), DropPendingUpdates }. Zalo-specific deviations: noAllowedUpdates(Zalo getUpdates has no such parameter);UpdateIdis long; long-poll hold is set explicitly viaTimeoutSecondsinstead of reusingclient.Timeout(the reference's approach races — client and server would cancel at the same moment). Rule: clientTimeoutSeconds≥ receiverTimeoutSeconds+ 15.TimeoutSeconds = 0means short polling (tight loop — pace it yourself).IUpdateHandler { HandleUpdateAsync, HandleErrorAsync }+ enumHandleErrorSource(PollingError— getUpdates failed, loop continues;HandleUpdateError— handler threw, loop continues;FatalError— loop stopped) +DefaultUpdateHandlerdelegate wrapper.- Extensions on
IZaloBotClient:StartReceiving(...)(fire-and-forget background loop, several delegate/IUpdateHandler overloads),ReceiveAsync(...)(blocking variant),DropPendingUpdatesAsync()(short-poll + advance offset, returns the next offset). Loop behavior: dispatch each update, advanceoffset = lastUpdateId + 1, errors go to the error handler and the loop continues,OperationCanceledExceptionstops it. - Webhook vs polling are mutually exclusive on the platform (docs/apis/getUpdates) — delete the webhook before using the receiver.
Event sugar on ZaloBotClient (adapted from the reference's TelegramBotClient): subscribing
client.OnUpdate += handler automatically starts the polling loop; removing the last
OnUpdate subscriber automatically stops it. client.OnError += handler receives polling
errors/handler exceptions (without it, errors go to Trace.WriteLine). Zalo has no UpdateType,
so there is no OnMessage event — dispatch on Update.ContentType/Update.Text in your handler.
The app currently does NOT use the built-in receiver: Infrastructure/Services/ZaloBotPollingService
implements its own multi-bot loop (bot-list cache, per-bot DB offsets, settings, counters). The SDK
receiver is for single-token scenarios (tools, scripts, one bot per process).
Errors, retry and token hygiene
Taxonomy (Errors/ZaloSdkErrorTaxonomy.cs) maps the official codes:
| Code | Category | Behavior | Exception thrown |
|---|---|---|---|
| 400 | NonRetryable | fail fast | ApiRequestException |
| 401 | Token | fail fast | ZaloBotTokenException |
| 403 | ServerRetry | retried exactly once | ApiRequestException |
| 404 | NonRetryable | fail fast | ApiRequestException |
| 408 | Retryable | retried with backoff until RetryMaxAttempts |
ZaloBotRetryableException |
| 429 | Retryable | retried with backoff until RetryMaxAttempts |
ZaloBotRetryableException |
| other | Unknown | fail fast | ApiRequestException |
- Retry config on
ZaloBotClientOptions:RetryMaxAttempts(default 3, total attempts),RetryBaseBackoffSeconds(default 2),RetryBackoffMultiplier(default 2.0 — delay before retry i isbase * mult^(i-1)). After retries are exhausted the last attempt's code/description is thrown. - Network / timeout failures throw
ZaloBotNetworkExceptionand are not retried inside the sender (callers decide). - Every exception message (and the
InvalidJsonExceptionbody excerpt) is sanitized throughTokenSanitizer— the access token never leaks through message,ToString()or inner exceptions. - Never log the request URL or raw bodies in app code: the token lives in the URL only.
Long-polling gotcha (getUpdates)
With long polling, the server holds the connection up to timeoutSeconds. If the SDK per-request
timeout is not strictly longer than the long-poll timeout, the client cancels the request just
as the server is about to answer → spurious timeouts. The app therefore sends an explicit
timeoutSeconds (adapter constant LongPollTimeoutSeconds = 25) plus a 15 s buffer as
the per-request timeout (40 s). Hierarchy to preserve: server long-poll < SDK per-request timeout.
Guard tests: ZaloBotClientLongPollTests.
App integration map
ZaloBot.Sdk (this project) ← HTTP, retry, taxonomy, hygiene
▲
│ thin adapter: Infrastructure/External/ZaloBotClient.cs
│ (maps SDK types → app DTOs, translates exceptions, per-request timeouts)
▼
Application.Common.Interfaces.IZaloBotClient ← app-side interface (NOT the SDK one)
The adapter implements the app interface QuanLyCongViec.Application.Common.Interfaces.IZaloBotClient
using the SDK Sdk.ZaloBotClient created per call with the bot's access token
(Infrastructure/DependencyInjection.cs registers it). Bot entity: Domain/Entities/ZaloBot.
Tests
tests/ZaloBot.Sdk.UnitTests — NUnit + Shouldly, naming Should{Behavior}_When{Condition},
AAA pattern. HTTP is faked with FakeHttpMessageHandler (enqueue JSON responses, inspect request
URLs/bodies) via ZaloBotClientTestBase. Run:
dotnet test ZaloBot.slnx -c Release
Adding a new API method (recipe)
- Request type in
Requests/extendingRequestBase<TResponse>(pure parameterless reads →ParameterlessGetRequest<TResult>= GET; otherwise POST), with"methodName"= the documented API name copied exactly (camelCase, case-sensitive) and JSON properties via[JsonPropertyName]or the snake_case policy. - Extension method in
ZaloBotClientExtensions.ApiMethods.cs(local validation of constraints such as text length goes here, before any network call). - New result type? Add a
Types/model and a defensive parser inInternal/ZaloSdkPayloadMapper- a branch in
ZaloSdkHttpSender.MapResult.
- a branch in
- Tests in
tests/ZaloBot.Sdk.UnitTestswithFakeHttpMessageHandler(assert URL, JSON body, mapping, error taxonomy paths). - Update the tables above and the docs links (https://bot.zapps.me/docs/apis/{method}/).
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated | |
|---|---|---|---|
| 1.0.0 | 109 | 11/9/2026 |