Ctrl K
ComunidadGuías…

reverse.go

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

Archivo original ↗Preguntar a KiBot
package main

import (
	"cmp"
	"context"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"mime"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"slices"
	"strconv"
	"strings"
	"unicode/utf16"

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

type maxSource struct {
	channel maxChat
	to      []*models.ChatFullInfo
	changes map[int64]syncOptions
}

type maxFile struct {
	kind     model.AttachmentType
	path     string
	name     string
	size     int64
	sourceID string
}

var maxEntityTypes = map[string]models.MessageEntityType{
	"strong":        models.MessageEntityTypeBold,
	"emphasized":    models.MessageEntityTypeItalic,
	"underline":     models.MessageEntityTypeUnderline,
	"strikethrough": models.MessageEntityTypeStrikethrough,
	"monospaced":    models.MessageEntityTypeCode,
	"quote":         models.MessageEntityTypeBlockquote,
	"link":          models.MessageEntityTypeTextLink,
	"heading":       models.MessageEntityTypeBold,
	"highlighted":   models.MessageEntityTypeUnderline,
	"user_mention":  models.MessageEntityTypeTextLink,
}

func (b maxMessageBody) richText() richText {
	text := newRichText(b.Text, nil)
	for _, span := range b.Markup {
		if span.Type == "user_mention" {
			span.URL = ""
			username := strings.TrimPrefix(span.UserLink, "@")
			if validMaxUsername.MatchString(username) {
				span.URL = "https://max.ru/" + username
			}
		}
		kind, ok := maxEntityTypes[span.Type]
		if !ok || span.From < 0 || span.From >= len(text.units) || span.Length <= 0 || span.Length > len(text.units)-span.From {
			continue
		}
		if kind == models.MessageEntityTypeTextLink && !webLink(span.URL) {
			continue
		}
		end := span.From + span.Length
		if lowSurrogate(text.units[span.From]) || (end < len(text.units) && lowSurrogate(text.units[end])) {
			continue
		}
		text.entities = append(text.entities, models.MessageEntity{Type: kind, Offset: span.From, Length: span.Length, URL: span.URL})
	}

	return text
}

func lowSurrogate(unit uint16) bool { return unit >= 0xDC00 && unit <= 0xDFFF }

func webLink(value string) bool {
	u, err := url.Parse(value)

	return err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Hostname() != "" && u.User == nil
}

type telegramText struct {
	text     string
	entities []models.MessageEntity
}

func (t richText) telegramPart(from, limit int) (telegramText, int) {
	from = skipSpace(t.units, from)
	end := trimRight(t.units, len(t.units))
	if from >= end {
		return telegramText{}, len(t.units)
	}
	to := min(from+limit, end)
	if to < end {
		start := from + limit/2
		for _, sep := range []uint16{'\n', ' '} {
			for at, unit := range slices.Backward(t.units[start:to]) {
				if unit == sep {
					to = start + at

					break
				}
			}
			if to < from+limit {
				break
			}
		}
		if lowSurrogate(t.units[to]) {
			to--
		}
	}
	next := to
	to = trimRight(t.units, to)
	part := telegramText{text: string(utf16.Decode(t.units[from:to]))}
	for _, e := range t.entities {
		left, right := max(e.Offset, from), min(e.Offset+e.Length, to)
		if left < right {
			e.Offset, e.Length = left-from, right-left
			part.entities = append(part.entities, e)
		}
	}

	return part, next
}

func (r *relay) forwardMax(ctx context.Context, src *maxSource, message maxMessage, book *deliveryBook) {
	log := slog.With("channel", src.channel.title, "post", book.post)
	needed := make([]bool, len(src.to))
	for i, dst := range src.to {
		var err error
		needed[i], err = book.needsDelivery(ctx, channelKey{telegramPlatform, dst.ID})
		if err != nil {
			return
		}
	}
	if !slices.Contains(needed, true) {
		return
	}
	body := message.Body
	text := maxPostText(message)
	lost := false
	if message.Link != nil && message.Link.Type == "forward" {
		lost = true
		// A forwarded post can have no text or attachments of its own.
		body.Attachments = append(body.Attachments, message.Link.Message.Attachments...)
	}
	if slices.ContainsFunc(body.Attachments, func(a maxMedia) bool {
		return a.Type == model.AttachImage || a.Type == model.AttachVideo || a.Type == model.AttachAudio || a.Type == model.AttachFile
	}) {
		select {
		case r.mediaSlot <- struct{}{}:
			defer func() { <-r.mediaSlot }()
		case <-ctx.Done():
			return
		}
	}
	var files []maxFile
	defer func() {
		for _, f := range files {
			_ = os.Remove(f.path)
		}
	}()
	var problems []string
	var refreshed maxMessage
	refreshTried := false
	remaining := r.maxPostLimit
	for i, a := range body.Attachments {
		switch a.Type {
		case model.AttachShare:
			continue // Telegram builds previews from the links in the text
		case model.AttachImage, model.AttachVideo, model.AttachAudio, model.AttachFile:
		default:
			lost = true

			continue
		}
		limit := min(r.maxFileLimit, remaining)
		file, err := r.downloadMax(ctx, a, limit)
		if errors.Is(err, errMaxMediaExpired) && message.Body.Mid != "" && i < len(message.Body.Attachments) {
			if !refreshTried {
				refreshTried = true
				if err := r.max.get(ctx, "/messages/"+url.PathEscape(message.Body.Mid), &refreshed); err != nil {
					log.Warn("не удалось обновить ссылки на вложения MAX", "err", err)
				}
			}
			for j, fresh := range refreshed.Body.Attachments {
				if fresh.Type == a.Type && ((a.Payload.Token != "" && fresh.Payload.Token == a.Payload.Token) || (a.Payload.Token == "" && i == j)) {
					file, err = r.downloadMax(ctx, fresh, limit)

					break
				}
			}
		}
		if err != nil {
			log.Warn("вложение MAX не скачано", "type", a.Type, "err", err)
			problems = append(problems, fmt.Sprintf("%s: %v", a.Type, err))
			lost = true

			continue
		}
		files = append(files, file)
		files[len(files)-1].sourceID = fmt.Sprintf("%s:%d", body.Mid, i)
		remaining -= file.size
	}
	if lost && webLink(message.URL) {
		text.append(linkText("Открыть пост в MAX", message.URL))
	}
	for i, dst := range src.to {
		if !needed[i] {
			continue
		}
		var plan []plannedMessage
		for _, msg := range telegramPostMessages(text, files) {
			part := plannedMessage{Text: msg.text.text, Entities: msg.text.entities, Quiet: msg.quiet}
			for _, file := range msg.files {
				part.Media = append(part.Media, plannedMedia{file.sourceID, string(file.kind)})
			}
			plan = append(plan, part)
		}
		if err := book.checkPlan(ctx, channelKey{telegramPlatform, dst.ID}, plan); err != nil {
			log.Error("продолжение публикации остановлено", "to", dst.Title, "err", err)
			return
		}
		err := book.deliver(ctx, channelKey{telegramPlatform, dst.ID}, func(publish publish) error {
			err := r.sendTelegramPost(ctx, dst.ID, text, files, publish)
			if err == nil && (len(files) > 0 || len(text.units) > 0) {
				log.Info("переслано в Telegram", "to", dst.Title)
			}

			return err
		})
		if err != nil {
			log.Error("не отправлено в Telegram", "to", dst.Title, "err", err)
			problems = append(problems, fmt.Sprintf("«%s»: %v", dst.Title, err))
		}
	}
	if len(problems) > 0 && ctx.Err() == nil {
		ref := message.URL
		if !webLink(ref) {
			ref = fmt.Sprintf("«%s», %s", src.channel.title, book.post)
		}
		_ = r.alert(ctx, fmt.Sprintf("пост %s перенесён с ошибками:\n— %s", ref, strings.Join(problems, "\n— ")))
	}
}

var (
	errMaxMediaExpired = errors.New("ссылка на вложение MAX недоступна")
	errMaxMediaSize    = errors.New("вложение MAX превышает лимит пересылки")
)

func (r *relay) downloadMax(ctx context.Context, a maxMedia, limit int64) (maxFile, error) {
	if size := cmp.Or(a.Payload.Size, int64(a.Size)); limit <= 0 || size > limit {
		return maxFile{}, errMaxMediaSize
	}
	address := a.Payload.URL
	if a.Type == model.AttachVideo && a.Payload.Token != "" {
		var details model.VideoAttachmentDetails
		// Without the details the video falls back to the URL from the message.
		if err := retry(ctx, func() (err error) {
			details, err = r.max.api.Messages.GetVideoAttachmentDetails(ctx, a.Payload.Token)

			return err
		}); err != nil {
			slog.Warn("не удалось получить ссылки на видео MAX", "err", err)
		}
		if u := details.Urls; u != nil {
			for _, mp4 := range []*string{u.Mp41080, u.Mp4720, u.Mp4480, u.Mp4360, u.Mp4240, u.Mp4144} {
				if mp4 != nil && *mp4 != "" {
					address = *mp4

					break
				}
			}
		}
	}
	var file maxFile
	err := retry(ctx, func() (err error) {
		file, err = r.fetchMaxFile(ctx, a, address, limit)

		return err
	})

	return file, err
}

func (r *relay) fetchMaxFile(ctx context.Context, a maxMedia, address string, limit int64) (maxFile, error) {
	if !strings.HasPrefix(address, "https://") || !webLink(address) {
		return maxFile{}, errMaxMediaExpired
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil)
	if err != nil {
		return maxFile{}, errMaxMediaExpired
	}
	resp, err := r.maxMediaHTTP.Do(req)
	if err != nil {
		return maxFile{}, withoutURL(err)
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode != http.StatusOK {
		if definitivelyRejected(resp.StatusCode) && resp.StatusCode != http.StatusTooManyRequests {
			return maxFile{}, errMaxMediaExpired
		}

		return maxFile{}, fmt.Errorf("скачивание MAX: HTTP %d", resp.StatusCode)
	}
	if resp.ContentLength > limit {
		return maxFile{}, errMaxMediaSize
	}
	kind := a.Type
	name := cmp.Or(a.Payload.FileName, a.FileName)
	if name == "" {
		_, disposition, _ := mime.ParseMediaType(resp.Header.Get("Content-Disposition"))
		name = cmp.Or(disposition["filename"], map[model.AttachmentType]string{
			model.AttachImage: "photo.jpg", model.AttachVideo: "video.mp4", model.AttachAudio: "audio.ogg", model.AttachFile: "file",
		}[kind])
	}
	name = filepath.Base(strings.ReplaceAll(name, "\\", "/"))
	name = strings.Map(func(r rune) rune {
		if r < 32 || r == 127 {
			return -1
		}

		return r
	}, name)
	file, err := os.CreateTemp(r.mediaDir, "download-")
	if err != nil {
		return maxFile{}, withoutPath(err)
	}
	size, err := io.Copy(file, io.LimitReader(resp.Body, limit+1))
	closeErr := file.Close()
	if err != nil || closeErr != nil || size > limit {
		_ = os.Remove(file.Name())
		if size > limit {
			return maxFile{}, errMaxMediaSize
		}

		return maxFile{}, withoutPath(errors.Join(err, closeErr))
	}

	downloaded := maxFile{kind: kind, path: file.Name(), name: cmp.Or(name, "file"), size: size}
	prepared, err := downloaded.forTelegram()
	if err != nil {
		_ = os.Remove(file.Name())
	}
	return prepared, err
}

func visual(f maxFile) bool { return f.kind == model.AttachImage || f.kind == model.AttachVideo }

type telegramPostPart struct {
	text  telegramText
	files []maxFile
	quiet bool
}

func telegramPostMessages(text richText, files []maxFile) []telegramPostPart {
	var messages []telegramPostPart
	from, sent := 0, false
	for len(files) > 0 {
		n := 1
		if visual(files[0]) {
			for n < min(10, len(files)) && visual(files[n]) {
				n++
			}
		}
		caption := telegramText{}
		if !sent {
			caption, from = text.telegramPart(from, 1024)
		}
		messages = append(messages, telegramPostPart{caption, files[:n], sent})
		sent, files = true, files[n:]
	}
	for from < len(text.units) {
		part, next := text.telegramPart(from, 4096)
		if part.text != "" {
			messages = append(messages, telegramPostPart{part, nil, sent})
			sent = true
		}
		from = next
	}

	return messages
}

func (r *relay) sendTelegramPost(ctx context.Context, chatID int64, text richText, files []maxFile, publish publish) error {
	for _, msg := range telegramPostMessages(text, files) {
		if err := r.sendTelegram(ctx, chatID, msg.text, msg.files, msg.quiet, publish); err != nil {
			return err
		}
	}
	return nil
}

func (r *relay) sendTelegram(ctx context.Context, chatID int64, text telegramText, files []maxFile, quiet bool, publish publish) error {
	params := map[string]any{"chat_id": chatID, "disable_notification": quiet}
	method := "sendMessage"
	formatted := params
	entityKey := "entities"
	switch len(files) {
	case 0:
		params["text"] = text.text
	case 1:
		field := "document"
		switch files[0].kind {
		case model.AttachImage:
			field = "photo"
		case model.AttachVideo:
			field = "video"
			params["supports_streaming"] = true
		}
		method = "send" + strings.ToUpper(field[:1]) + field[1:]
		params[field] = "attach://" + fileField(0)
		params["caption"] = text.text
		entityKey = "caption_entities"
	default:
		method = "sendMediaGroup"
		album := make([]map[string]any, 0, len(files))
		for i, file := range files {
			item := map[string]any{"type": "photo", "media": "attach://" + fileField(i)}
			if file.kind == model.AttachVideo {
				item["type"] = "video"
				item["supports_streaming"] = true
			}
			album = append(album, item)
		}
		album[0]["caption"] = text.text
		params["media"] = album
		formatted = album[0]
		entityKey = "caption_entities"
	}
	if len(text.entities) > 0 {
		formatted[entityKey] = text.entities
	}
	kinds := make([]mirrorKind, max(1, len(files)))
	for i := range kinds {
		kinds[i] = mirrorMedia
	}
	if len(files) == 0 {
		kinds[0] = mirrorText
	} else if !quiet {
		kinds[0] = mirrorCaption
	}
	return publish(ctx, func() ([]string, error) {
		return retryPublish(ctx, func() ([]string, error) {
			var messages []struct {
				ID int `json:"message_id"`
			}
			var err error
			if method == "sendMediaGroup" {
				err = r.tgAPI.call(ctx, method, params, files, &messages)
			} else {
				messages = append(messages, struct {
					ID int `json:"message_id"`
				}{})
				err = r.tgAPI.call(ctx, method, params, files, &messages[0])
			}
			if err != nil {
				return nil, err
			}
			ids := make([]string, len(messages))
			missing := len(messages) != max(1, len(files))
			for i, message := range messages {
				if message.ID == 0 {
					missing = true

					continue
				}
				ids[i] = strconv.Itoa(message.ID)
			}
			if missing {
				return ids, errors.New("неполный список id в ответе Telegram")
			}

			return ids, nil
		})
	}, kinds...)
}