[](https://crates.io/crates/maxoxide)
[](https://docs.rs/maxoxide/)
[](https://choosealicense.com/licenses/mit/)
[](https://github.com/mammothcoding/maxoxide/actions/workflows/rust.yml)
[](https://www.rust-lang.org/)
Readme in different languages:
[EN](README.md) ยท [RU](README.ru.md)
#  maxoxide
๐ฆ An async Rust library for building bots on the [Max messenger](https://max.ru) platform,
inspired by [teloxide](https://github.com/teloxide/teloxide).
## Features
- โ
Coverage of the published Max Bot REST API
- โ
Long polling (dev & test) and **Webhook** via [axum](https://github.com/tokio-rs/axum) (production)
- โ
Strongly-typed events (`Update`, `Message`, `Callback`, โฆ)
- โ
`Dispatcher` with fluent handler registration and filters
- โ
Inline keyboards (all documented button types: `callback`, `link`, `message`, `request_contact`, `request_geo_location`)
- โ
File uploads โ multipart, correct token flow for video/audio
- โ
Markdown / HTML message formatting
- โ
Webhook secret verification (`X-Max-Bot-Api-Secret`)
- โ
Tokio async throughout
## Quick start
```toml
[dependencies]
maxoxide = "1.0.0"
tokio = { version = "1", features = ["full"] }
# For webhook support (production):
# maxoxide = { version = "1.0.0", features = ["webhook"] }
```
```rust
use maxoxide::{Bot, Context, Dispatcher};
use maxoxide::types::Update;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let bot = Bot::from_env(); // reads MAX_BOT_TOKEN env var
let mut dp = Dispatcher::new(bot);
dp.on_command("/start", |ctx: Context| async move {
if let Update::MessageCreated { message, .. } = &ctx.update {
ctx.bot
.send_markdown_to_chat(message.chat_id(), "Hello! ๐")
.await?;
}
Ok(())
});
dp.on_message(|ctx: Context| async move {
if let Update::MessageCreated { message, .. } = &ctx.update {
let text = message.text().unwrap_or("(no text)").to_string();
ctx.bot.send_text_to_chat(message.chat_id(), text).await?;
}
Ok(())
});
dp.start_polling().await;
}
```
```bash
MAX_BOT_TOKEN=your_token cargo run --example echo_bot
```
## API methods
| `bot.get_me()` | Bot info |
| `bot.send_text_to_chat(chat_id, text)` | Send plain text to a dialog/group/channel by `chat_id` |
| `bot.send_text_to_user(user_id, text)` | Send plain text to a user by global MAX `user_id` |
| `bot.send_markdown_to_chat(chat_id, text)` | Send Markdown to a dialog/group/channel by `chat_id` |
| `bot.send_markdown_to_user(user_id, text)` | Send Markdown to a user by global MAX `user_id` |
| `bot.send_message_to_chat(chat_id, body)` | Send message with attachments / keyboard by `chat_id` |
| `bot.send_message_to_user(user_id, body)` | Send message with attachments / keyboard by global MAX `user_id` |
| `bot.edit_message(mid, body)` | Edit a message |
| `bot.delete_message(mid)` | Delete a message |
| `bot.answer_callback(body)` | Answer an inline button press |
| `bot.get_chat(chat_id)` | Chat info |
| `bot.get_chats(โฆ)` | List all group chats |
| `bot.edit_chat(chat_id, body)` | Edit chat title / description |
| `bot.leave_chat(chat_id)` | Leave a chat |
| `bot.get_members(โฆ)` | List members |
| `bot.add_members(โฆ)` | Add members |
| `bot.remove_member(โฆ)` | Remove a member |
| `bot.get_admins(chat_id)` | List admins |
| `bot.pin_message(โฆ)` | Pin a message |
| `bot.unpin_message(โฆ)` | Unpin |
| `bot.send_action(chat_id, "typing_on")` | Typing-indicator request; API call works, but client visibility is not confirmed in live MAX tests |
| `bot.subscribe(body)` | Register a webhook |
| `bot.get_upload_url(type)` | Get upload URL |
| `bot.upload_file(type, path, name, mime)` | Full two-step file upload |
| `bot.upload_bytes(type, bytes, name, mime)` | Same, from bytes |
| `bot.set_my_commands(commands)` | Experimental: public MAX API currently returns `404` for `/me/commands` |
## User ID vs Chat ID
These two IDs are different and should not be used interchangeably:
- `user_id` is the global MAX ID of a user.
- `chat_id` is the ID of a concrete dialog, group, or channel.
- In a private chat, `message.sender.user_id` identifies the user, while `message.chat_id()` identifies that specific dialog with the bot.
- Use `send_text_to_chat(chat_id, ...)` / `send_message_to_chat(chat_id, ...)` when you already know the dialog or group.
- Use `send_text_to_user(user_id, ...)` / `send_message_to_user(user_id, ...)` when you only know the user's global MAX ID.
## Known MAX platform gaps
As of March 25, 2026, the crate can send these requests, but live behavior on the MAX side is still inconsistent:
- `Button::RequestContact` is documented by MAX, but live tests received a contact attachment with empty `contact_id` and `vcf_phone`. Sending the button works; receiving the user's phone number is not confirmed on the MAX side.
- `Button::RequestGeoLocation` is documented by MAX, and the mobile client shows a sent location card, but live polling tests did not observe a matching update on the bot side. End-to-end delivery is not confirmed on the MAX side.
- `bot.send_action(chat_id, "typing_on")` returns success from the API, but live MAX tests did not confirm a visible typing indicator in the client.
- `bot.set_my_commands` is kept as an experimental helper, but the public MAX REST docs do not list a write endpoint for bot commands, and live `POST /me/commands` requests return `404 Path /me/commands is not recognized`.
## Dispatcher filters
```rust
dp.on_command("/start", handler); // specific command
dp.on_message(handler); // any new message
dp.on_edited_message(handler); // edited message
dp.on_callback(handler); // any callback button
dp.on_callback_payload("btn:ok", handler); // specific payload
dp.on_bot_started(handler); // user starts bot
dp.on_filter(|u| { โฆ }, handler); // custom predicate
dp.on(handler); // every update
```
First matching handler wins. Register more specific filters before general ones.
## Inline keyboard
```rust
use maxoxide::types::{Button, KeyboardPayload, NewMessageBody};
let keyboard = KeyboardPayload {
buttons: vec![
vec![
Button::callback("Yes โ
", "answer:yes"),
Button::callback("No โ", "answer:no"),
],
vec![Button::link("๐ Website", "https://max.ru")],
],
};
let body = NewMessageBody::text("Are you sure?").with_keyboard(keyboard);
bot.send_message_to_chat(chat_id, body).await?;
```
## File upload
Max uses a two-step upload flow. `upload_file` / `upload_bytes` handle it automatically:
```rust
use maxoxide::types::{NewAttachment, NewMessageBody, UploadType, UploadedToken};
let token = bot
.upload_file(UploadType::Image, "./photo.jpg", "photo.jpg", "image/jpeg")
.await?;
let body = NewMessageBody {
text: Some("Here's the photo!".into()),
attachments: Some(vec![NewAttachment::Image {
payload: UploadedToken { token },
}]),
..Default::default()
};
bot.send_message_to_chat(chat_id, body).await?;
// or:
// bot.send_message_to_user(user_id, body).await?;
```
> **Note:** `type=photo` was removed from the Max API. Always use `UploadType::Image`.
## Webhook server (`features = ["webhook"]`)
```rust
use maxoxide::webhook::WebhookServer;
use maxoxide::types::SubscribeBody;
bot.subscribe(SubscribeBody {
url: "https://your-domain.com/webhook".into(),
update_types: None,
version: None,
secret: Some("my_secret_123".into()),
}).await?;
WebhookServer::new(dp)
.secret("my_secret_123")
.path("/webhook")
.serve("0.0.0.0:8443")
.await;
```
> Max requires HTTPS on port 443 and does **not** support self-signed certificates.
## Project layout
```
maxoxide/
โโโ Cargo.toml
โโโ src/
โ โโโ lib.rs โ public API & re-exports
โ โโโ bot.rs โ Bot + all HTTP methods
โ โโโ uploader.rs โ two-step file upload helpers
โ โโโ dispatcher.rs โ Dispatcher, Filter, Context
โ โโโ errors.rs โ MaxError
โ โโโ webhook.rs โ axum webhook server (feature = "webhook")
โ โโโ tests.rs โ unit tests
โ โโโ types/
โ โโโ mod.rs โ all types (User, Chat, Message, Update, โฆ)
โโโ examples/
โโโ echo_bot.rs
โโโ keyboard_bot.rs
โโโ live_api_test.rs
โโโ webhook_bot.rs (feature = "webhook")
```
## Running tests
```bash
cargo test
```
## Live API test
For real-data verification there is a separate interactive harness:
```bash
cargo run --example live_api_test
```
At startup it asks in the terminal for:
- bot token
- bot URL for the tester
- optional webhook URL and secret
- optional local file path for `upload_file`
- HTTP timeout, polling timeout and delay between requests
The harness then walks the tester through Max-client actions and records `PASS` / `FAIL` / `SKIP` for real API calls. It uses small delays between requests, drains the long-poll backlog before the run, and asks for explicit confirmation before destructive or non-reversible steps such as:
- `set_my_commands`
- `delete_chat`
- `leave_chat`
- visible group title edits
## License
[MIT](https://choosealicense.com/licenses/mit/)