A mock Telegram Bot API server for testing bots and bot libraries. Inspired by stripe/stripe-mock.
- tg-mock
Testing Telegram bots is challenging because:
- The real Telegram API requires network connectivity
- Simulating errors and edge cases is difficult
- You can't control the timing and content of incoming updates
tg-mock solves these problems by providing a drop-in replacement for api.telegram.org that:
- Validates requests against the official Bot API spec
- Generates realistic responses for all Bot API methods
- Supports scenario-based error simulation
- Provides a control API for injecting updates and managing test scenarios
- Records all requests for inspection and assertion in tests
- Includes built-in error responses for common Telegram API errors
- Handles file uploads with configurable storage
- Simulates webhook delivery for testing webhook-based bots
docker pull ghcr.io/watzon/tg-mock:latest
# Run with defaults
docker run -p 8081:8081 ghcr.io/watzon/tg-mock
# With custom config and persistent storage
docker run -p 8081:8081 \
-v ./config.yaml:/config.yaml \
-v ./data:/data \
ghcr.io/watzon/tg-mock --config /config.yamlgo install github.com/watzon/tg-mock/cmd/tg-mock@latestgit clone https://github.com/watzon/tg-mock.git
cd tg-mock
go build -o tg-mock ./cmd/tg-mock# Start with defaults (port 8081)
tg-mock
# Custom port
tg-mock --port 9090
# With config file
tg-mock --config config.yaml
# Verbose logging
tg-mock --verbose
# Custom file storage directory
tg-mock --storage-dir /tmp/tg-mock-files| Flag | Description | Default |
|---|---|---|
--port |
HTTP server port | 8081 |
--config |
Path to YAML config file | (none) |
--verbose |
Enable verbose logging | false |
--storage-dir |
Directory for file storage | (temp dir) |
--faker-seed |
Seed for faker (0 = random, >0 = deterministic) | 0 |
Point your bot library to the mock server:
http://localhost:8081/bot<TOKEN>/<METHOD>
For example:
curl http://localhost:8081/bot123456789:ABC-xyz/getMeCreate a YAML configuration file for persistent settings:
server:
port: 8081
verbose: true
faker_seed: 12345 # Fixed seed for reproducible tests (0 = random)
storage:
dir: /tmp/tg-mock-files
tokens:
"123456789:ABC-xyz":
status: active
bot_name: MyTestBot
webhook: # Optional pre-configured webhook
url: "https://mybot.example.com/webhook"
secret_token: "my_secret_token"
max_connections: 40
allowed_updates: ["message", "callback_query"]
"987654321:XYZ-abc":
status: revoked
scenarios:
# Error scenario
- method: sendMessage
match:
chat_id: 999
response:
error_code: 400
description: "Bad Request: chat not found"
# Success override scenario
- method: getMe
response_data:
id: 123456789
first_name: "MyTestBot"
username: "my_test_bot"tg-mock generates realistic mock responses for all Telegram Bot API methods using a smart faker system.
The faker uses field name heuristics to generate appropriate values:
| Field Pattern | Generated Value |
|---|---|
*_id, *Id |
Large random int64 |
date, *_date |
Recent Unix timestamp |
username |
@user_xxxx format |
first_name, last_name |
Realistic names |
title, name |
Title-case strings |
text, caption |
Lorem-like text |
url, *_url |
https://example.com/... |
latitude |
-90 to 90 |
longitude |
-180 to 180 |
file_path |
files/document.ext |
is_*, can_*, has_* |
Boolean |
language_code |
IETF tag (e.g., en, ru) |
The faker also reflects request parameters back into responses. For example, when you call sendMessage with chat_id: 12345, the response Message.chat.id will be 12345.
For reproducible tests, use a fixed faker seed:
# CLI flag
tg-mock --faker-seed 12345
# Or in config file
server:
faker_seed: 12345With a fixed seed, the same sequence of API calls will always produce identical responses. This is essential for snapshot testing and debugging flaky tests.
When faker_seed is 0 (the default), responses are randomized on each server start.
The control API allows you to manage scenarios and inject updates during tests.
Add test scenarios to simulate specific responses:
# Add a scenario that returns an error once
curl -X POST http://localhost:8081/__control/scenarios \
-H "Content-Type: application/json" \
-d '{
"method": "sendMessage",
"times": 1,
"response": {
"error_code": 429,
"description": "Too Many Requests: retry after 30",
"retry_after": 30
}
}'
# Add a scenario with request matching
curl -X POST http://localhost:8081/__control/scenarios \
-H "Content-Type: application/json" \
-d '{
"method": "sendMessage",
"match": {"chat_id": 999},
"times": 1,
"response": {
"error_code": 400,
"description": "Bad Request: chat not found"
}
}'
# List all active scenarios
curl http://localhost:8081/__control/scenarios
# Clear all scenarios
curl -X DELETE http://localhost:8081/__control/scenariosScenarios can also override specific fields in successful responses without triggering errors. This is useful for testing specific data conditions:
# Override specific fields in the response
curl -X POST http://localhost:8081/__control/scenarios \
-H "Content-Type: application/json" \
-d '{
"method": "getChat",
"match": {"chat_id": 12345},
"times": 1,
"response_data": {
"id": 12345,
"type": "supergroup",
"title": "My Custom Group",
"username": "mycustomgroup"
}
}'
# The next getChat call for chat_id 12345 will return a supergroup
# with the specified title and username, while other fields are faker-generatedYou can combine response_data with match to create conditional responses:
# Different response based on chat_id
curl -X POST http://localhost:8081/__control/scenarios \
-H "Content-Type: application/json" \
-d '{
"method": "sendMessage",
"match": {"chat_id": 999},
"response_data": {
"message_id": 42,
"text": "Custom reply for chat 999"
}
}'Inject updates to simulate incoming messages, callbacks, etc.:
# Inject a message update
curl -X POST http://localhost:8081/__control/updates \
-H "Content-Type: application/json" \
-d '{
"message": {
"message_id": 1,
"text": "Hello from test!",
"chat": {"id": 123, "type": "private"},
"from": {"id": 456, "is_bot": false, "first_name": "Test"}
}
}'
# View pending updates
curl http://localhost:8081/__control/updatestg-mock supports webhook simulation, allowing you to test webhook-based bots. When a webhook is registered for a token, injected updates are POSTed to the webhook URL instead of being queued for polling.
Bot API Methods:
The standard Telegram webhook methods work as expected:
# Register a webhook
curl -X POST http://localhost:8081/bot123:abc/setWebhook \
-H "Content-Type: application/json" \
-d '{
"url": "http://localhost:3000/webhook",
"secret_token": "my_secret"
}'
# Check webhook status
curl http://localhost:8081/bot123:abc/getWebhookInfo
# Delete webhook
curl -X POST http://localhost:8081/bot123:abc/deleteWebhookWhen a webhook is active, calling getUpdates returns a 409 Conflict error, matching real Telegram behavior.
Control API:
Manage webhooks directly via the control API:
# List all registered webhooks
curl http://localhost:8081/__control/webhooks
# Set webhook for a token (bypasses URL validation)
curl -X PUT http://localhost:8081/__control/webhooks/123:abc \
-H "Content-Type: application/json" \
-d '{"url": "http://localhost:3000/webhook", "secret_token": "secret"}'
# Get webhook for a specific token
curl http://localhost:8081/__control/webhooks/123:abc
# Delete webhook
curl -X DELETE http://localhost:8081/__control/webhooks/123:abc
# Clear all webhooks
curl -X DELETE http://localhost:8081/__control/webhooksInjecting Updates with Webhook Delivery:
Use the per-token update injection endpoint to automatically route updates to webhooks:
# Inject an update for a token with an active webhook
curl -X POST http://localhost:8081/__control/tokens/123:abc/updates \
-H "Content-Type: application/json" \
-d '{
"message": {
"message_id": 1,
"text": "/start",
"chat": {"id": 456, "type": "private"},
"from": {"id": 789, "is_bot": false, "first_name": "User"}
}
}'
# Response when webhook is active:
# {"delivered": true, "success": true, "status_code": 200, "duration_ms": 15}
# Response when no webhook (queued for polling):
# {"queued": true, "update_id": 1}When delivered via webhook, tg-mock:
- POSTs the update JSON to the registered URL
- Includes the
X-Telegram-Bot-Api-Secret-Tokenheader if a secret is configured - Tracks delivery errors in
getWebhookInfo(last_error_date, last_error_message)
The request inspector records all Bot API requests made to the mock server. This is invaluable for verifying your bot's behavior in tests—you can assert that your bot made the expected API calls with the correct parameters.
# List all recorded requests
curl http://localhost:8081/__control/requests
# Filter by method
curl "http://localhost:8081/__control/requests?method=sendMessage"
# Filter by token
curl "http://localhost:8081/__control/requests?token=123:abc"
# Combine filters with limit
curl "http://localhost:8081/__control/requests?method=sendMessage&token=123:abc&limit=10"
# Clear recorded requests
curl -X DELETE http://localhost:8081/__control/requestsEach recorded request includes:
| Field | Description |
|---|---|
id |
Unique request ID |
timestamp |
When the request was received |
token |
Bot token used |
method |
API method called |
params |
Request parameters |
scenario_id |
Matched scenario ID (if any) |
response |
Response returned to the bot |
is_error |
Whether the response was an error |
status_code |
HTTP status code returned |
The inspector records all requests, including those that fail authentication. This helps debug client-side issues like malformed tokens.
When a header-based scenario is triggered, the scenario_id is prefixed with header: (e.g., header:rate_limit).
Use the X-TG-Mock-Scenario header to trigger built-in error responses:
# Trigger rate limiting
curl -H "X-TG-Mock-Scenario: rate_limit" \
http://localhost:8081/bot123:abc/sendMessage
# Trigger bot blocked error
curl -H "X-TG-Mock-Scenario: bot_blocked" \
http://localhost:8081/bot123:abc/sendMessage
# Trigger chat not found
curl -H "X-TG-Mock-Scenario: chat_not_found" \
http://localhost:8081/bot123:abc/sendMessage400 Bad Request - Chat Errors
| Scenario | Description |
|---|---|
bad_request |
Bad Request |
chat_not_found |
Bad Request: chat not found |
chat_admin_required |
Bad Request: CHAT_ADMIN_REQUIRED |
chat_not_modified |
Bad Request: CHAT_NOT_MODIFIED |
chat_restricted |
Bad Request: CHAT_RESTRICTED |
chat_write_forbidden |
Bad Request: CHAT_WRITE_FORBIDDEN |
channel_private |
Bad Request: CHANNEL_PRIVATE |
group_deactivated |
Bad Request: group is deactivated |
group_upgraded |
Bad Request: group chat was upgraded to a supergroup chat |
supergroup_channel_only |
Bad Request: method is available for supergroup and channel chats only |
not_in_chat |
Bad Request: not in the chat |
topic_not_modified |
Bad Request: TOPIC_NOT_MODIFIED |
400 Bad Request - User Errors
| Scenario | Description |
|---|---|
user_not_found |
Bad Request: user not found |
user_id_invalid |
Bad Request: USER_ID_INVALID |
user_is_admin |
Bad Request: user is an administrator of the chat |
participant_id_invalid |
Bad Request: PARTICIPANT_ID_INVALID |
cant_remove_owner |
Bad Request: can't remove chat owner |
400 Bad Request - Message Errors
| Scenario | Description |
|---|---|
message_not_found |
Bad Request: message to edit not found |
message_not_modified |
Bad Request: message is not modified |
message_text_empty |
Bad Request: message text is empty |
message_too_long |
Bad Request: message is too long |
message_cant_be_edited |
Bad Request: message can't be edited |
message_cant_be_deleted |
Bad Request: message can't be deleted |
message_to_delete_not_found |
Bad Request: message to delete not found |
message_id_invalid |
Bad Request: MESSAGE_ID_INVALID |
message_thread_not_found |
Bad Request: message thread not found |
reply_message_not_found |
Bad Request: reply message not found |
400 Bad Request - Permission Errors
| Scenario | Description |
|---|---|
no_rights_to_send |
Bad Request: have no rights to send a message |
not_enough_rights |
Bad Request: not enough rights |
not_enough_rights_pin |
Bad Request: not enough rights to manage pinned messages in the chat |
not_enough_rights_restrict |
Bad Request: not enough rights to restrict/unrestrict chat member |
not_enough_rights_send_text |
Bad Request: not enough rights to send text messages to the chat |
admin_rank_emoji_not_allowed |
Bad Request: ADMIN_RANK_EMOJI_NOT_ALLOWED |
400 Bad Request - Other
| Scenario | Description |
|---|---|
button_url_invalid |
Bad Request: BUTTON_URL_INVALID |
inline_button_url_invalid |
Bad Request: inline keyboard button URL |
file_too_big |
Bad Request: file is too big |
invalid_file_id |
Bad Request: invalid file id |
entities_too_long |
Bad Request: entities too long |
member_not_found |
Bad Request: member not found |
peer_id_invalid |
Bad Request: PEER_ID_INVALID |
wrong_parameter_action |
Bad Request: wrong parameter action in request |
hide_requester_missing |
Bad Request: HIDE_REQUESTER_MISSING |
401 Unauthorized
| Scenario | Description |
|---|---|
unauthorized |
Unauthorized |
403 Forbidden
| Scenario | Description |
|---|---|
forbidden |
Forbidden |
bot_blocked |
Forbidden: bot was blocked by the user |
bot_kicked |
Forbidden: bot was kicked from the chat |
bot_kicked_channel |
Forbidden: bot was kicked from the channel chat |
bot_kicked_group |
Forbidden: bot was kicked from the group chat |
bot_kicked_supergroup |
Forbidden: bot was kicked from the supergroup chat |
not_member_channel |
Forbidden: bot is not a member of the channel chat |
not_member_supergroup |
Forbidden: bot is not a member of the supergroup chat |
cant_initiate |
Forbidden: bot can't initiate conversation with a user |
cant_send_to_bots |
Forbidden: bot can't send messages to bots |
user_deactivated |
Forbidden: user is deactivated |
not_enough_rights_text |
Forbidden: not enough rights to send text messages |
not_enough_rights_photo |
Forbidden: not enough rights to send photos |
409 Conflict
| Scenario | Description |
|---|---|
webhook_active |
Conflict: can't use getUpdates method while webhook is active |
terminated_by_long_poll |
Conflict: terminated by other long poll |
429 Rate Limit
| Scenario | Description |
|---|---|
rate_limit |
Too Many Requests: retry after 30 |
flood_wait |
Flood control exceeded. Retry in 60 seconds |
# Start the mock server
tg-mock --verbose &
# Test that your bot handles rate limiting correctly
curl -X POST http://localhost:8081/__control/scenarios \
-d '{"method":"sendMessage","times":3,"response":{"error_code":429,"description":"Too Many Requests","retry_after":5}}'
# Your bot should now get rate limited on the next 3 sendMessage calls# Inject a /start command
curl -X POST http://localhost:8081/__control/updates \
-H "Content-Type: application/json" \
-d '{
"message": {
"message_id": 1,
"text": "/start",
"chat": {"id": 123, "type": "private"},
"from": {"id": 456, "is_bot": false, "first_name": "User"},
"entities": [{"type": "bot_command", "offset": 0, "length": 6}]
}
}'
# Your bot can now receive this via getUpdates# Start with deterministic mode for reproducible tests
tg-mock --faker-seed 12345 &
# Set up a scenario that returns a specific user for getMe
curl -X POST http://localhost:8081/__control/scenarios \
-H "Content-Type: application/json" \
-d '{
"method": "getMe",
"response_data": {
"id": 123456789,
"is_bot": true,
"first_name": "TestBot",
"username": "my_test_bot",
"can_join_groups": true,
"can_read_all_group_messages": false,
"supports_inline_queries": true
}
}'
# Now getMe returns your custom bot info
curl http://localhost:8081/bot123:abc/getMe
# Returns: {"ok":true,"result":{"id":123456789,"is_bot":true,"first_name":"TestBot",...}}
# Set up photo responses with specific dimensions
curl -X POST http://localhost:8081/__control/scenarios \
-H "Content-Type: application/json" \
-d '{
"method": "sendPhoto",
"response_data": {
"photo": [
{"file_id": "small_photo_id", "width": 90, "height": 90},
{"file_id": "medium_photo_id", "width": 320, "height": 320},
{"file_id": "large_photo_id", "width": 800, "height": 800}
]
}
}'Use the request inspector to verify your bot makes the correct API calls:
# Start the mock server
tg-mock &
# Clear any previous requests
curl -X DELETE http://localhost:8081/__control/requests
# Inject a /start command for your bot to process
curl -X POST http://localhost:8081/__control/updates \
-H "Content-Type: application/json" \
-d '{
"message": {
"message_id": 1,
"text": "/start",
"chat": {"id": 123, "type": "private"},
"from": {"id": 456, "is_bot": false, "first_name": "User"}
}
}'
# Let your bot process the update (it calls getUpdates and responds)
sleep 1
# Verify your bot sent the expected welcome message
curl -s "http://localhost:8081/__control/requests?method=sendMessage" | jq '.requests[] | select(.params.chat_id == 123) | .params.text'
# Should output your bot's welcome message
# Check the full request details
curl -s http://localhost:8081/__control/requests | jq '.requests[-1]'
# Returns:
# {
# "id": 2,
# "timestamp": "2024-01-15T10:30:00Z",
# "token": "123:abc",
# "method": "sendMessage",
# "params": {"chat_id": 123, "text": "Welcome! I'm your bot."},
# "response": {"ok": true, "result": {...}},
# "is_error": false,
# "status_code": 200
# }This pattern is especially useful for integration tests:
// Go test example
func TestBotRespondsToStart(t *testing.T) {
// Clear requests
http.NewRequest("DELETE", mockURL+"/__control/requests", nil)
// Inject /start command
injectUpdate(startCommand)
// Wait for bot to process
time.Sleep(100 * time.Millisecond)
// Verify bot sent welcome message
resp, _ := http.Get(mockURL + "/__control/requests?method=sendMessage")
var result struct {
Requests []struct {
Params map[string]interface{} `json:"params"`
} `json:"requests"`
}
json.NewDecoder(resp.Body).Decode(&result)
assert.Equal(t, "Welcome!", result.Requests[0].Params["text"])
}PRs are welcome! Please open an issue first to discuss any major changes.
If you find a bug or have a feature request, please open an issue.
MIT © watzon