Примеры
Эхо-бот: отвечает тем же текстом на каждое текстовое сообщение, которое получает. В личном чате — на любое, в группе — на команды, упоминания бота и ответы на его сообщения.
Каждый пример запускается без правок, нужен только токен в переменной MARX_TOKEN. Все четыре программы проверяет автоматический тест против сервера Marx: запускает их, пишет боту и ждёт ответа.
Переменные окружения
| Переменная | Что это |
|---|---|
MARX_TOKEN | Токен бота от @marxbot, обязательно |
MARX_API | Адрес API, по умолчанию https://api.bot.marx.moscow |
WEBHOOK_URL | Вебхук: публичный адрес https://, по которому Marx достучится до программы |
WEBHOOK_SECRET | Вебхук: секрет из A-Z, a-z, 0-9, _, -, до 256 символов |
LISTEN_HOST, LISTEN_PORT | Вебхук: где программа принимает запросы, по умолчанию все адреса, порт 8080 |
TLS_CERT, TLS_KEY | Вебхук: файлы сертификата и ключа, если программа сама обслуживает HTTPS. Без них она принимает HTTP, а HTTPS снимает прокси перед ней (nginx, Caddy) |
Python, длинный опрос
Python 3.9 или новее, только стандартная библиотека.
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, вебхук
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, длинный опрос
Go 1.22 или новее, только стандартная библиотека. Модуль из трёх файлов: go.mod, общий пакет marx/marx.go и две программы.
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.
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))
}