Examples

An echo bot: it answers every text message it gets with the same text. In a direct chat, any message; in a group, commands, mentions of the bot and replies to its messages.

Each example runs unchanged; it needs only the token in MARX_TOKEN. An automated test runs all four programs against the Marx server: it starts them, writes to the bot and waits for the answer.

Environment variables

VariableMeaning
MARX_TOKENThe bot's token from @marxbot, required
MARX_APIThe API address, https://api.bot.marx.moscow by default
WEBHOOK_URLWebhook: the public https:// address at which Marx reaches the program
WEBHOOK_SECRETWebhook: a secret of A-Z, a-z, 0-9, _, -, up to 256 characters
LISTEN_HOST, LISTEN_PORTWebhook: where the program takes requests, all addresses and port 8080 by default
TLS_CERT, TLS_KEYWebhook: certificate and key files, when the program serves HTTPS itself. Without them it takes HTTP, and a proxy in front (nginx, Caddy) terminates HTTPS

Python, long polling

Python 3.9 or later, standard library only.

MARX_TOKEN=marx_pat_... python3 echo_longpoll.py

echo_longpoll.py

#!/usr/bin/env python3
"""Echo bot for Marx: takes updates by long polling and repeats every text.

    MARX_TOKEN=marx_pat_... python3 echo_longpoll.py

Python 3.9 or later, standard library only.
"""

import json
import os
import time
import urllib.error
import urllib.request

API = os.environ.get("MARX_API", "https://api.bot.marx.moscow") + "/api/bot/v1"
TOKEN = os.environ["MARX_TOKEN"]
POLL_SECONDS = 30


def call(method, path, body=None):
    data = None if body is None else json.dumps(body).encode()
    request = urllib.request.Request(API + path, data=data, method=method)
    request.add_header("Authorization", "Bearer " + TOKEN)
    if data is not None:
        request.add_header("Content-Type", "application/json")
    while True:
        try:
            with urllib.request.urlopen(request, timeout=POLL_SECONDS + 10) as response:
                return json.load(response)
        except urllib.error.HTTPError as error:
            if error.code not in (429, 503):
                raise
            time.sleep(json.load(error).get("retry_after", 1))


def echo(update):
    message = update["message"]
    if message["type"] != "text" or not message["body"]:
        return
    call("POST", "/messages", {
        "chat_id": update["chat"]["id"],
        "body": message["body"],
        # The same client_id twice sends one message, so a retry is safe.
        "client_id": "echo-%d" % update["update_id"],
    })


def main():
    me = call("GET", "/me")
    print("running as @%s" % me["username"], flush=True)
    offset = 0
    while True:
        try:
            updates = call("GET", "/updates?offset=%d&timeout=%d" % (offset, POLL_SECONDS))
        except urllib.error.HTTPError:
            raise
        except OSError as error:
            print("poll failed: %s" % error, flush=True)
            time.sleep(1)
            continue
        for update in updates:
            echo(update)
            offset = update["update_id"] + 1


if __name__ == "__main__":
    main()

Python, webhook

MARX_TOKEN=marx_pat_... WEBHOOK_URL=https://bot.example.org/marx \
WEBHOOK_SECRET=long-random-string python3 echo_webhook.py

echo_webhook.py

#!/usr/bin/env python3
"""Echo bot for Marx: takes updates on a webhook and repeats every text.

    MARX_TOKEN=marx_pat_... WEBHOOK_URL=https://bot.example.org/marx \\
    WEBHOOK_SECRET=long-random-string python3 echo_webhook.py

WEBHOOK_URL is the public https address that reaches this program. Either a
proxy in front terminates TLS and forwards to LISTEN_PORT, or TLS_CERT and
TLS_KEY name the certificate this program serves itself.

Python 3.9 or later, standard library only.
"""

import hmac
import json
import os
import ssl
import time
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

API = os.environ.get("MARX_API", "https://api.bot.marx.moscow") + "/api/bot/v1"
TOKEN = os.environ["MARX_TOKEN"]
WEBHOOK_URL = os.environ["WEBHOOK_URL"]
SECRET = os.environ["WEBHOOK_SECRET"]
LISTEN_HOST = os.environ.get("LISTEN_HOST", "0.0.0.0")
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "8080"))


def call(method, path, body=None):
    data = None if body is None else json.dumps(body).encode()
    request = urllib.request.Request(API + path, data=data, method=method)
    request.add_header("Authorization", "Bearer " + TOKEN)
    if data is not None:
        request.add_header("Content-Type", "application/json")
    while True:
        try:
            with urllib.request.urlopen(request, timeout=30) as response:
                return json.load(response)
        except urllib.error.HTTPError as error:
            if error.code not in (429, 503):
                raise
            time.sleep(json.load(error).get("retry_after", 1))


def echo(update):
    message = update["message"]
    if message["type"] != "text" or not message["body"]:
        return
    call("POST", "/messages", {
        "chat_id": update["chat"]["id"],
        "body": message["body"],
        # Marx posts an update again until it gets 2xx; the same client_id sends one message.
        "client_id": "echo-%d" % update["update_id"],
    })


class Hook(BaseHTTPRequestHandler):
    def do_POST(self):
        if not hmac.compare_digest(self.headers.get("X-Marx-Bot-Secret", ""), SECRET):
            self.send_response(403)
            self.end_headers()
            return
        update = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
        try:
            echo(update)
        except Exception as error:
            print("update %s failed: %s" % (update["update_id"], error), flush=True)
            self.send_response(500)
            self.end_headers()
            return
        self.send_response(200)
        self.end_headers()


def main():
    server = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Hook)
    if os.environ.get("TLS_CERT"):
        context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
        context.load_cert_chain(os.environ["TLS_CERT"], os.environ["TLS_KEY"])
        server.socket = context.wrap_socket(server.socket, server_side=True)
    call("POST", "/webhook", {"url": WEBHOOK_URL, "secret": SECRET})
    print("webhook set to %s" % WEBHOOK_URL, flush=True)
    server.serve_forever()


if __name__ == "__main__":
    main()

Go, long polling

Go 1.22 or later, standard library only. A module of go.mod, the shared package marx/marx.go and two programs.

MARX_TOKEN=marx_pat_... go run ./longpoll

go.mod

module marx.example/bots

go 1.22

marx/marx.go

// Package marx is the part of the Bot API the echo examples use.
package marx

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"strconv"
	"time"
)

type Client struct {
	API   string
	Token string
	HTTP  *http.Client
}

// FromEnv reads MARX_TOKEN and MARX_API.
func FromEnv() *Client {
	api := os.Getenv("MARX_API")
	if api == "" {
		api = "https://api.bot.marx.moscow"
	}
	token := os.Getenv("MARX_TOKEN")
	if token == "" {
		fmt.Fprintln(os.Stderr, "MARX_TOKEN is not set")
		os.Exit(2)
	}
	return &Client{API: api + "/api/bot/v1", Token: token, HTTP: &http.Client{Timeout: 60 * time.Second}}
}

type Update struct {
	UpdateID int64 `json:"update_id"`
	Chat     struct {
		ID    int64   `json:"id"`
		Type  string  `json:"type"`
		Title *string `json:"title"`
	} `json:"chat"`
	Message struct {
		ID     int64  `json:"id"`
		Type   string `json:"type"`
		Body   string `json:"body"`
		Author struct {
			ID          int64  `json:"id"`
			DisplayName string `json:"display_name"`
		} `json:"author"`
	} `json:"message"`
}

type Me struct {
	UserID   int64  `json:"user_id"`
	Username string `json:"username"`
}

// Error is an answer other than 2xx.
type Error struct {
	Status int
	Body   string
}

func (e *Error) Error() string { return fmt.Sprintf("status %d: %s", e.Status, e.Body) }

// Call sends body as JSON and decodes the answer into out; 429 and 503 wait retry_after and try again.
func (c *Client) Call(method, path string, body, out any) error {
	var raw []byte
	if body != nil {
		var err error
		if raw, err = json.Marshal(body); err != nil {
			return err
		}
	}
	for {
		req, err := http.NewRequest(method, c.API+path, bytes.NewReader(raw))
		if err != nil {
			return err
		}
		req.Header.Set("Authorization", "Bearer "+c.Token)
		if body != nil {
			req.Header.Set("Content-Type", "application/json")
		}
		resp, err := c.HTTP.Do(req)
		if err != nil {
			return err
		}
		answer, err := io.ReadAll(resp.Body)
		resp.Body.Close()
		if err != nil {
			return err
		}
		switch {
		case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable:
			var wait struct {
				RetryAfter int `json:"retry_after"`
			}
			_ = json.Unmarshal(answer, &wait)
			time.Sleep(time.Duration(max(wait.RetryAfter, 1)) * time.Second)
			continue
		case resp.StatusCode/100 != 2:
			return &Error{resp.StatusCode, string(answer)}
		case out == nil || len(answer) == 0:
			return nil
		}
		return json.Unmarshal(answer, out)
	}
}

// Echo repeats a text message into its chat.
func (c *Client) Echo(u Update) error {
	if u.Message.Type != "text" || u.Message.Body == "" {
		return nil
	}
	return c.Call("POST", "/messages", map[string]any{
		"chat_id": u.Chat.ID,
		"body":    u.Message.Body,
		// The same client_id twice sends one message, so a retry is safe.
		"client_id": "echo-" + strconv.FormatInt(u.UpdateID, 10),
	}, nil)
}

longpoll/main.go

// Echo bot for Marx: takes updates by long polling and repeats every text.
//
//	MARX_TOKEN=marx_pat_... go run ./longpoll
package main

import (
	"errors"
	"fmt"
	"log"
	"time"

	"marx.example/bots/marx"
)

const pollSeconds = 30

func main() {
	c := marx.FromEnv()
	var me marx.Me
	if err := c.Call("GET", "/me", nil, &me); err != nil {
		log.Fatal(err)
	}
	log.Printf("running as @%s", me.Username)
	var offset int64
	for {
		var updates []marx.Update
		err := c.Call("GET", fmt.Sprintf("/updates?offset=%d&timeout=%d", offset, pollSeconds), nil, &updates)
		var answer *marx.Error
		switch {
		case errors.As(err, &answer):
			log.Fatal(err)
		case err != nil:
			log.Printf("poll failed: %v", err)
			time.Sleep(time.Second)
			continue
		}
		for _, u := range updates {
			if err := c.Echo(u); err != nil {
				log.Printf("update %d: %v", u.UpdateID, err)
			}
			offset = u.UpdateID + 1
		}
	}
}

Go, webhook

The same module, the program webhook.

MARX_TOKEN=marx_pat_... WEBHOOK_URL=https://bot.example.org/marx \
WEBHOOK_SECRET=long-random-string go run ./webhook

webhook/main.go

// Echo bot for Marx: takes updates on a webhook and repeats every text.
//
//	MARX_TOKEN=marx_pat_... WEBHOOK_URL=https://bot.example.org/marx \
//	WEBHOOK_SECRET=long-random-string go run ./webhook
//
// WEBHOOK_URL is the public https address that reaches this program. Either a
// proxy in front terminates TLS and forwards to LISTEN_PORT, or TLS_CERT and
// TLS_KEY name the certificate this program serves itself.
package main

import (
	"crypto/subtle"
	"encoding/json"
	"log"
	"net"
	"net/http"
	"os"

	"marx.example/bots/marx"
)

func main() {
	c := marx.FromEnv()
	hookURL, secret := os.Getenv("WEBHOOK_URL"), os.Getenv("WEBHOOK_SECRET")
	if hookURL == "" || secret == "" {
		log.Fatal("WEBHOOK_URL and WEBHOOK_SECRET must be set")
	}
	host, port := os.Getenv("LISTEN_HOST"), os.Getenv("LISTEN_PORT")
	if port == "" {
		port = "8080"
	}
	ln, err := net.Listen("tcp", net.JoinHostPort(host, port))
	if err != nil {
		log.Fatal(err)
	}
	http.HandleFunc("POST /", func(w http.ResponseWriter, r *http.Request) {
		if subtle.ConstantTimeCompare([]byte(r.Header.Get("X-Marx-Bot-Secret")), []byte(secret)) != 1 {
			w.WriteHeader(http.StatusForbidden)
			return
		}
		var u marx.Update
		if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
			w.WriteHeader(http.StatusBadRequest)
			return
		}
		// Marx posts an update again until it gets 2xx; Echo's client_id keeps that to one message.
		if err := c.Echo(u); err != nil {
			log.Printf("update %d: %v", u.UpdateID, err)
			w.WriteHeader(http.StatusInternalServerError)
			return
		}
		w.WriteHeader(http.StatusOK)
	})
	if err := c.Call("POST", "/webhook", map[string]string{"url": hookURL, "secret": secret}, nil); err != nil {
		log.Fatal(err)
	}
	log.Printf("webhook set to %s", hookURL)
	if cert := os.Getenv("TLS_CERT"); cert != "" {
		log.Fatal(http.ServeTLS(ln, nil, cert, os.Getenv("TLS_KEY")))
	}
	log.Fatal(http.Serve(ln, nil))
}