max_webhook.go
Rama main · réplica de lectura del repositorio original.
package main
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"slices"
"time"
"github.com/max-messenger/max-bot-api-client-go/v2/model"
)
const (
maxEvent = "message_created"
maxEdit = "message_edited"
maxDelete = "message_removed"
)
type maxMessage struct {
Recipient model.Recipient `json:"recipient"`
Body maxMessageBody `json:"body"`
Link *struct {
Type string `json:"type"`
Message maxMessageBody `json:"message"`
} `json:"link"`
URL string `json:"url"`
}
type maxMessageBody struct {
Mid string `json:"mid"`
Text string `json:"text"`
Attachments []maxMedia `json:"attachments"`
Markup []maxMarkup `json:"markup"`
}
type maxMedia struct {
model.Attachment
Payload struct {
model.Payload
FileName string `json:"filename"`
Size int64 `json:"size"`
} `json:"payload"`
}
type maxMarkup struct {
Type string `json:"type"`
From int `json:"from"`
Length int `json:"length"`
URL string `json:"url"`
UserLink string `json:"user_link"`
}
var errInvalidUpdate = errors.New("некорректное событие вебхука")
func receiveWebhook(header, secret string, accept func(context.Context, []byte) error) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if subtle.ConstantTimeCompare([]byte(req.Header.Get(header)), []byte(secret)) != 1 {
w.WriteHeader(http.StatusUnauthorized)
return
}
payload, err := io.ReadAll(http.MaxBytesReader(w, req.Body, 2<<20))
if err != nil {
if _, tooLarge := errors.AsType[*http.MaxBytesError](err); tooLarge {
w.WriteHeader(http.StatusRequestEntityTooLarge)
} else {
w.WriteHeader(http.StatusBadRequest)
}
return
}
if err := accept(req.Context(), payload); err != nil {
if errors.Is(err, errInvalidUpdate) {
w.WriteHeader(http.StatusBadRequest)
} else {
slog.Error("не удалось записать событие", "err", err)
w.WriteHeader(http.StatusServiceUnavailable)
}
return
}
w.WriteHeader(http.StatusOK)
})
}
func maxWebhook(secret string, db *store, sources map[int64]*maxSource) http.Handler {
return receiveWebhook("X-Max-Bot-Api-Secret", secret, func(ctx context.Context, payload []byte) error {
var update struct {
Type string `json:"update_type"`
Message json.RawMessage `json:"message"`
Timestamp int64 `json:"timestamp"`
ChatID int64 `json:"chat_id"`
MessageID string `json:"message_id"`
}
if err := json.Unmarshal(payload, &update); err != nil {
slog.Warn("не удалось разобрать событие MAX", "type", update.Type, "err", err)
return errInvalidUpdate
}
if update.Type == maxDelete {
if src := sources[update.ChatID]; src != nil && src.hasChange(maxDelete) && update.MessageID != "" {
return db.enqueueChange(ctx, channelKey{maxPlatform, update.ChatID}, change{Kind: maxDelete, Mid: update.MessageID, At: update.Timestamp})
}
return nil
}
if update.Type != maxEvent && update.Type != maxEdit {
return nil
}
var head struct {
Recipient struct {
ChatID int64 `json:"chat_id"`
ChatType model.ChatType `json:"chat_type"`
PostID json.RawMessage `json:"post_id"`
} `json:"recipient"`
Body struct{ Mid string } `json:"body"`
}
if err := json.Unmarshal(update.Message, &head); err != nil {
slog.Warn("не удалось разобрать заголовок поста MAX", "type", update.Type, "err", err)
return errInvalidUpdate
}
id := head.Recipient.ChatID
src := sources[id]
if src == nil || head.Recipient.ChatType != model.ChatTypeChannel {
return nil
}
mid := head.Body.Mid
if mid == "" && len(head.Recipient.PostID) > 0 {
if err := json.Unmarshal(head.Recipient.PostID, &mid); err != nil {
var number json.Number
if err := json.Unmarshal(head.Recipient.PostID, &number); err != nil {
slog.Warn("не удалось разобрать идентификатор поста MAX", "type", update.Type, "err", err)
return errInvalidUpdate
}
mid = number.String()
}
}
if mid == "" {
slog.Warn("MAX прислал пост без идентификатора", "chat_id", id)
return nil
}
key := channelKey{maxPlatform, id}
if update.Type == maxEdit {
if !src.hasChange(maxEdit) {
return nil
}
return db.enqueueChange(ctx, key, change{Kind: maxEdit, Mid: mid, At: update.Timestamp, Message: update.Message})
}
return db.enqueue(ctx, key, mid, "message:"+mid, update.Message, time.Now())
})
}
func (m *maxClient) webhook(ctx context.Context, address, secret string, events []string, register bool) error {
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
existing, err := m.api.Subscriptions.GetSubscriptions(ctx)
if err != nil {
return fmt.Errorf("не удалось проверить вебхук MAX: %w", err)
}
var ours bool
var subscriptionVersion string
for _, sub := range existing.Subscriptions {
if sub.URL == address {
if hasEvents(sub.UpdateTypes, events) {
return nil
}
ours = true
subscriptionVersion = sub.Version
events = unionEvents(sub.UpdateTypes, events)
break
}
}
if !register || (!ours && len(existing.Subscriptions) > 0) {
return errors.New("подписка MAX по MAX_WEBHOOK_URL не найдена или в ней отсутствуют нужные события")
}
result, err := m.api.Subscriptions.Subscribe(ctx, address, secret, events, subscriptionVersion)
if err != nil {
return fmt.Errorf("не удалось подключить вебхук MAX: %w", err)
}
if !result.Success {
return errors.New("MAX отклонил регистрацию вебхука")
}
return nil
}
func hasEvents(current, required []string) bool {
return !slices.ContainsFunc(required, func(event string) bool { return !slices.Contains(current, event) })
}
func unionEvents(current, required []string) []string {
result := slices.Clone(current)
for _, event := range required {
if !slices.Contains(result, event) {
result = append(result, event)
}
}
return result
}