Ctrl K
ComunidadGuías…

changes.go

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

Archivo original ↗Preguntar a KiBot
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"net/url"
	"slices"
	"strconv"
	"strings"

	"github.com/go-telegram/bot/models"
	maxbot "github.com/max-messenger/max-bot-api-client-go/v2"
	"github.com/max-messenger/max-bot-api-client-go/v2/model"
)

type syncOptions struct{ edits, deletes bool }

var errMirrorMissing = errors.New("копия сообщения отсутствует")

func addSyncOptions(changes map[int64]syncOptions, id int64, route route) {
	old := changes[id]
	changes[id] = syncOptions{edits: old.edits || route.syncEdits, deletes: old.deletes || route.syncDeletes}
}

func (s *source) hasEdits() bool {
	for _, options := range s.changes {
		if options.edits {
			return true
		}
	}

	return false
}

func (s *maxSource) hasChange(kind string) bool {
	for _, options := range s.changes {
		if kind == maxEdit && options.edits || kind == maxDelete && options.deletes {
			return true
		}
	}

	return false
}

func telegramPostText(msgs []*models.Message) (richText, bool) {
	var text richText
	preview := true
	if from, ok := forwardedFrom(msgs[0]); ok {
		text.append(from)
	}
	for _, m := range msgs {
		if m.Text != "" {
			text.append(newRichText(m.Text, m.Entities))
		} else {
			text.append(newRichText(m.Caption, m.CaptionEntities))
		}
		if o := m.LinkPreviewOptions; o != nil && o.IsDisabled != nil && *o.IsDisabled {
			preview = false
		}
	}

	return text, preview
}

func maxPostText(message maxMessage) richText {
	text := message.Body.richText()
	if message.Link != nil && message.Link.Type == "forward" {
		text.append(message.Link.Message.richText())
	}

	return text
}

func telegramMessages(payloads [][]byte) ([]*models.Message, error) {
	var messages []*models.Message
	var problems []error
	for _, payload := range payloads {
		var m models.Message
		if err := json.Unmarshal(payload, &m); err != nil {
			problems = append(problems, err)
			if _, partial := errors.AsType[*json.UnmarshalTypeError](err); !partial {
				continue
			}
		}
		messages = append(messages, &m)
	}

	return messages, errors.Join(problems...)
}

func (b *deliveryBook) startChange(ctx context.Context, p pendingPost) (change, bool) {
	var event change
	if len(p.payloads) == 0 {
		return event, false
	}
	if err := json.Unmarshal(p.payloads[0], &event); err != nil {
		slog.Error("не удалось разобрать правку или удаление", "err", err)

		return event, false
	}
	b.root = "message:" + event.Mid
	if event.Group != "" {
		b.root = event.Group
	}
	var echo bool
	b.err = b.store.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM published WHERE channel = ? AND mid = ?)`,
		b.source.String(), event.Mid).Scan(&echo)
	if b.err != nil || echo {
		return event, false
	}
	ok, err := b.store.acceptRevision(ctx, b.source, event)
	b.err = err

	return event, ok && err == nil
}

func (r *relay) editFromTelegram(ctx context.Context, src *source, p pendingPost, book *deliveryBook) {
	event, ok := book.startChange(ctx, p)
	if !ok || event.Kind != telegramEdit {
		return
	}
	payloads := [][]byte{event.Message}
	if strings.HasPrefix(event.Group, "album:") {
		if _, err := telegramMessages(payloads); err != nil {
			r.changeError(ctx, book, "", err)

			return
		}
		book.root, payloads, book.err = book.store.reviseAlbum(ctx, book.source, event)
		if book.err != nil {
			return
		}
	}
	messages, err := telegramMessages(payloads)
	if err != nil {
		r.changeError(ctx, book, "", err)

		return
	}
	if len(messages) == 0 {
		return
	}
	text, preview := telegramPostText(messages)
	if src.channel.Username != "" && slices.ContainsFunc(messages, unsupported) {
		text.append(linkText("Открыть пост в Telegram", src.postRef(messages[0].ID)))
	}
	for _, dst := range src.to {
		if !src.changes[dst.id].edits || book.err != nil {
			continue
		}
		key := channelKey{maxPlatform, dst.id}
		err := syncMirrorText(ctx, book, key, func(withCaption bool) (string, []string) {
			parts := text.split(maxTextLimit)
			if withCaption && len(parts) > 0 {
				return parts[0], parts[1:]
			}

			return "", parts
		}, func(part mirrorPart, value string) error {
			return r.max.editText(ctx, dst.id, part.id, value)
		}, func(part mirrorPart) error {
			return r.max.deleteMirror(ctx, dst.id, part.id)
		}, func(part string, publish publish) error {
			return publish(ctx, func() ([]string, error) {
				return retryPublish(ctx, func() ([]string, error) { return r.max.send(ctx, dst.id, part, nil, preview) })
			}, mirrorText)
		})
		r.changeError(ctx, book, dst.title, err)
	}
}

func (r *relay) changeFromMax(ctx context.Context, src *maxSource, p pendingPost, book *deliveryBook) {
	event, ok := book.startChange(ctx, p)
	if !ok {
		return
	}
	var text richText
	if event.Kind == maxEdit {
		var m maxMessage
		if err := json.Unmarshal(event.Message, &m); err != nil {
			r.changeError(ctx, book, "", err)

			return
		}
		text = maxPostText(m)
		if m.Link != nil && m.Link.Type == "forward" && webLink(m.URL) {
			text.append(linkText("Открыть пост в MAX", m.URL))
		}
	}
	for _, dst := range src.to {
		if book.err != nil {
			return
		}
		key := channelKey{telegramPlatform, dst.ID}
		remove := func(part mirrorPart) error { return r.tgAPI.deleteMirror(ctx, dst.ID, part) }
		var err error
		switch {
		case event.Kind == maxDelete && src.changes[dst.ID].deletes:
			err = removeMirrors(ctx, book, key, remove)
		case event.Kind == maxEdit && src.changes[dst.ID].edits:
			err = syncMirrorText(ctx, book, key, func(withCaption bool) (telegramText, []telegramText) {
				var caption telegramText
				from := 0
				if withCaption {
					caption, from = text.telegramPart(from, 1024)
				}
				var parts []telegramText
				for from < len(text.units) {
					part, next := text.telegramPart(from, 4096)
					if part.text != "" {
						parts = append(parts, part)
					}
					from = next
				}

				return caption, parts
			}, func(part mirrorPart, value telegramText) error {
				return r.tgAPI.editText(ctx, dst.ID, part, value)
			}, remove, func(part telegramText, publish publish) error {
				return r.sendTelegram(ctx, dst.ID, part, nil, true, publish)
			})
		}
		r.changeError(ctx, book, dst.Title, err)
	}
}

func (r *relay) changeError(ctx context.Context, book *deliveryBook, destination string, err error) {
	if err == nil || ctx.Err() != nil {
		return
	}
	slog.Warn("не удалось синхронизировать пост", "source", book.source, "post", book.root, "to", destination, "err", err)
	_ = r.alert(ctx, fmt.Sprintf("не удалось синхронизировать пост %s %s → «%s»: %v", book.source, book.root, destination, err))
}

func syncMirrorText[T any](ctx context.Context, book *deliveryBook, dst channelKey, render func(bool) (T, []T),
	edit func(mirrorPart, T) error, remove func(mirrorPart) error, appendPart func(T, publish) error) error {
	parts, err := book.mirrors(ctx, dst)
	if err != nil {
		book.err = err

		return err
	}
	if len(parts) == 0 {
		return nil // no known copy: do not turn a historical edit into a new post
	}
	captionAt := slices.IndexFunc(parts, func(p mirrorPart) bool { return p.kind == mirrorCaption })
	caption, desired := render(captionAt >= 0)
	if captionAt >= 0 {
		if err := edit(parts[captionAt], caption); err != nil {
			if errors.Is(err, errMirrorMissing) {
				return nil
			}
			return err
		}
	}
	existing := slices.DeleteFunc(parts, func(p mirrorPart) bool { return p.kind != mirrorText })
	for i := range min(len(existing), len(desired)) {
		if err := edit(existing[i], desired[i]); err != nil {
			if errors.Is(err, errMirrorMissing) {
				// Keep its position in the saved copy and skip this destination.
				// Forgetting it or appending a longer revision would resurrect
				// a post (or part) the channel's administrator removed.
				return nil
			}
			return err
		}
	}
	if len(existing) > len(desired) {
		return deleteMirrorParts(ctx, book, dst, existing[len(desired):], remove)
	}
	if len(existing) < len(desired) {
		if err := book.store.checkUncertain(ctx, dst); err != nil {
			return err
		}
		return book.deliver(ctx, dst, func(publish publish) error {
			for _, part := range desired[len(existing):] {
				if err := appendPart(part, publish); err != nil {
					return err
				}
			}

			return nil
		})
	}

	return nil
}

func removeMirrors(ctx context.Context, book *deliveryBook, dst channelKey, remove func(mirrorPart) error) error {
	parts, err := book.mirrors(ctx, dst)
	if err != nil {
		book.err = err

		return err
	}

	return deleteMirrorParts(ctx, book, dst, parts, remove)
}

func deleteMirrorParts(ctx context.Context, book *deliveryBook, dst channelKey, parts []mirrorPart, remove func(mirrorPart) error) error {
	var problems []error
	for _, part := range parts {
		if err := remove(part); err != nil {
			problems = append(problems, err)

			continue
		}
		if err := book.forgetMirror(ctx, dst, part); err != nil {
			book.err = err

			return err
		}
	}

	return errors.Join(problems...)
}

func (t telegramAPI) editText(ctx context.Context, chatID int64, part mirrorPart, text telegramText) error {
	method, textKey, entityKey := "editMessageText", "text", "entities"
	if part.kind == mirrorCaption {
		method, textKey, entityKey = "editMessageCaption", "caption", "caption_entities"
	}
	mid, err := strconv.Atoi(part.id)
	if err != nil {
		return err
	}
	return retry(ctx, func() error {
		var result json.RawMessage
		params := map[string]any{"chat_id": chatID, "message_id": mid, textKey: text.text}
		if len(text.entities) > 0 {
			params[entityKey] = text.entities
		}
		err := t.call(ctx, method, params, nil, &result)
		if errors.Is(err, errRejected) && strings.Contains(err.Error(), "message is not modified") {
			return nil
		}
		if errors.Is(err, errRejected) && strings.Contains(err.Error(), "message to edit not found") {
			slog.Info("копия Telegram отсутствует, правка пропущена", "chat_id", chatID, "message_id", mid)
			return fmt.Errorf("%w: %w", errMirrorMissing, err)
		}

		return err
	})
}

func (t telegramAPI) deleteMirror(ctx context.Context, chatID int64, part mirrorPart) error {
	mid, err := strconv.Atoi(part.id)
	if err != nil {
		return err
	}
	return retry(ctx, func() error {
		var result bool
		err := t.call(ctx, "deleteMessage", map[string]any{"chat_id": chatID, "message_id": mid}, nil, &result)
		if errors.Is(err, errRejected) && strings.Contains(err.Error(), "message to delete not found") {
			return nil
		}
		if errors.Is(err, errRejected) && strings.Contains(err.Error(), "message can't be deleted") {
			// Telegram cannot remove copies older than 48 hours. Its own
			// channel posts can still be edited; remove their stale contents.
			if part.kind == mirrorMedia {
				part.kind = mirrorCaption
			}
			err := t.editText(ctx, chatID, part, telegramText{text: "Содержимое удалено."})
			if errors.Is(err, errMirrorMissing) {
				return nil
			}
			return err
		}
		if err == nil && !result {
			return &publishError{message: "Telegram не подтвердил удаление"}
		}

		return err
	})
}

func (m *maxClient) editText(ctx context.Context, chatID int64, mid, text string) error {
	payload, err := json.Marshal(struct {
		Text   string           `json:"text"`
		Format model.TextFormat `json:"format"`
	}{text, model.FormatHTML})
	if err != nil {
		return err
	}
	return retry(ctx, func() error { return m.mutateMessage(ctx, http.MethodPut, chatID, mid, payload) })
}

func (m *maxClient) deleteMirror(ctx context.Context, chatID int64, mid string) error {
	return retry(ctx, func() error { return m.mutateMessage(ctx, http.MethodDelete, chatID, mid, nil) })
}

func (m *maxClient) mutateMessage(ctx context.Context, method string, chatID int64, mid string, payload []byte) error {
	ctx = withPacingChannel(ctx, strconv.FormatInt(chatID, 10))
	query := url.Values{"message_id": {mid}}
	req, err := http.NewRequestWithContext(ctx, method, m.baseURL+"/messages?"+query.Encode(), bytes.NewReader(payload))
	if err != nil {
		return &publishError{message: err.Error()}
	}
	req.Header.Set(maxbot.AuthorizationHeader, m.token)
	if payload != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	resp, err := m.publish.Do(req)
	if err != nil {
		return withoutURL(err)
	}
	defer func() { _ = resp.Body.Close() }()
	if method == http.MethodDelete && resp.StatusCode == http.StatusNotFound {
		return nil
	}
	if resp.StatusCode >= 300 {
		var failure maxbot.Error
		_ = json.NewDecoder(resp.Body).Decode(&failure)
		message := fmt.Sprintf("MAX ответил %s: %v", resp.Status, failure)
		if resp.StatusCode == http.StatusTooManyRequests {
			return &publishError{message, rateLimitDelay(resp.Header.Get("Retry-After"))}
		}
		if definitivelyRejected(resp.StatusCode) {
			return &publishError{message: message}
		}

		return errors.New(message)
	}
	var result model.SimpleQueryResult
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return err
	}
	if !result.Success {
		return &publishError{message: "MAX не подтвердил изменение"}
	}

	return nil
}