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
| Variable | Meaning |
|---|---|
MARX_TOKEN | The bot's token from @marxbot, required |
MARX_API | The API address, https://api.bot.marx.moscow by default |
WEBHOOK_URL | Webhook: the public https:// address at which Marx reaches the program |
WEBHOOK_SECRET | Webhook: a secret of A-Z, a-z, 0-9, _, -, up to 256 characters |
LISTEN_HOST, LISTEN_PORT | Webhook: where the program takes requests, all addresses and port 8080 by default |
TLS_CERT, TLS_KEY | Webhook: 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))
}