Ctrl K
ComunidadGuías…

max_webhook.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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
}