Skip to content

watzon/tg-mock

Repository files navigation

tg-mock

A mock Telegram Bot API server for testing bots and bot libraries. Inspired by stripe/stripe-mock.

Table of Contents

Background

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

Install

Docker

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.yaml

Using Go

go install github.com/watzon/tg-mock/cmd/tg-mock@latest

From Source

git clone https://github.com/watzon/tg-mock.git
cd tg-mock
go build -o tg-mock ./cmd/tg-mock

Usage

# 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

CLI Flags

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

Connecting Your Bot

Point your bot library to the mock server:

http://localhost:8081/bot<TOKEN>/<METHOD>

For example:

curl http://localhost:8081/bot123456789:ABC-xyz/getMe

Configuration

Create 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"

Response Generation

tg-mock generates realistic mock responses for all Telegram Bot API methods using a smart faker system.

Smart Faker

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.

Deterministic Mode

For reproducible tests, use a fixed faker seed:

# CLI flag
tg-mock --faker-seed 12345

# Or in config file
server:
  faker_seed: 12345

With 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.

Control API

The control API allows you to manage scenarios and inject updates during tests.

Scenarios

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/scenarios

Response Data Overrides

Scenarios 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-generated

You 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"
    }
  }'

Updates

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/updates

Webhooks

tg-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/deleteWebhook

When 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/webhooks

Injecting 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-Token header if a secret is configured
  • Tracks delivery errors in getWebhookInfo (last_error_date, last_error_message)

Request Inspector

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/requests

Each 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).

Header-based Errors

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/sendMessage

Available Built-in Scenarios

400 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

Examples

Testing Error Handling

# 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

Simulating Incoming Messages

# 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

Custom Response Data

# 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}
      ]
    }
  }'

Verifying Bot Behavior

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"])
}

Contributing

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.

License

MIT © watzon

About

Mock telegram bot API for integration testing

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Languages