Ctrl K
ComunidadGuías…

telegram.go

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

Archivo original ↗Preguntar a KiBot
package main

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"log/slog"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strconv"
	"strings"

	"github.com/go-telegram/bot"
	"github.com/go-telegram/bot/models"
)

// tgDownloadLimit is the largest file the cloud Bot API lets a bot download.
// A local Bot API server (TELEGRAM_API_URL, --local mode) has no such limit.
const tgDownloadLimit = 20 << 20

// errNotOnVolume means the local server's file is not where it said: the
// volume is not shared with perepost. Retrying will not help.
var errNotOnVolume = errors.New("файл от сервера Bot API не найден на общем томе — у perepost и telegram-bot-api должен быть один том по одному пути")

var errTooBig = errors.New("файл больше 20 МБ: облачный Bot API такие не отдаёт — поможет свой сервер Bot API (TELEGRAM_API_URL, см. README)")

// tgFile is a downloaded Telegram file: in memory from the cloud Bot API, or
// on a volume shared with a local Bot API server, which answers getFile with
// an absolute path instead of a download link. The path contains the bot
// token, so it never goes into errors or logs.
type tgFile struct {
	data []byte
	path string
	size int64
}

// open is called for every upload attempt: a file on disk is streamed, not
// loaded into memory — a local server hands out files up to 2 GB.
func (f tgFile) open() (io.ReadCloser, error) {
	if f.path == "" {
		return io.NopCloser(bytes.NewReader(f.data)), nil
	}
	file, err := os.Open(f.path)
	if err != nil {
		return nil, fmt.Errorf("чтение скачанного файла: %w", withoutPath(err))
	}

	return file, nil
}

// remove deletes a file the local server downloaded, so the volume does not
// fill up with videos. Telegram downloads it again if it is ever requested.
func (f tgFile) remove() {
	if f.path == "" {
		return
	}
	if err := os.Remove(f.path); err != nil {
		slog.Warn("не удалось удалить скачанный файл", "err", withoutPath(err))
	}
}

// checkWebhook refuses a bot that has a webhook: Telegram does not hand its
// updates to getUpdates, and deleting the webhook would break whoever set it.
func configureTelegramWebhook(ctx context.Context, tg *bot.Bot, address, secret string, events []string, configure bool) error {
	hook, err := tg.GetWebhookInfo(ctx)
	if err != nil {
		return fmt.Errorf("не удалось проверить вебхук Telegram-бота: %w", err)
	}
	if hook.URL == address && (!configure || address == "") {
		if address != "" && !hasEvents(hook.AllowedUpdates, events) {
			return errors.New("в подписке Telegram отсутствуют нужные события")
		}

		return nil
	}
	if address != "" && configure && (hook.URL == "" || hook.URL == address) {
		ok, err := tg.SetWebhook(ctx, &bot.SetWebhookParams{
			URL: address, SecretToken: secret, MaxConnections: 1, AllowedUpdates: events,
		})
		if err != nil {
			return fmt.Errorf("не удалось подключить вебхук Telegram: %w", err)
		}
		if !ok {
			return errors.New("регистрация вебхука отклонена Telegram")
		}

		return nil
	}
	if hook.URL == "" {
		return errors.New("вебхук Telegram по TELEGRAM_WEBHOOK_URL не зарегистрирован")
	}

	host := "другой адрес" // not the URL itself: it may contain a token
	if u, err := url.Parse(hook.URL); err == nil {
		host = u.Host
	}

	return fmt.Errorf("у Telegram-бота настроен вебхук на %s — похоже, токен уже занят другим сервисом. "+
		"Заведите для пересылки отдельного бота", host)
}

// useChannel finds a channel by link, @name or numeric id and checks that the
// bot receives its posts.
func useChannel(ctx context.Context, tg *bot.Bot, ref string) (*models.ChatFullInfo, error) {
	var chatID any = "@" + channelName(ref, "t.me")
	if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
		chatID = id
	}
	chat, err := tg.GetChat(ctx, &bot.GetChatParams{ChatID: chatID})
	if err != nil {
		return nil, fmt.Errorf("канал Telegram %q не найден (у закрытого канала укажите числовой id): %w", ref, err)
	}
	if chat.Type != models.ChatTypeChannel {
		return nil, fmt.Errorf("%q — не канал", ref)
	}

	return chat, checkTelegramAdmin(ctx, tg, chat)
}

// checkTelegramAdmin checks that the bot is an administrator of the channel:
// Telegram sends channel posts to administrators only. A bot removed from the
// admins gets no error — posts just stop coming.
func checkTelegramAdmin(ctx context.Context, tg *bot.Bot, chat *models.ChatFullInfo) error {
	member, err := tg.GetChatMember(ctx, &bot.GetChatMemberParams{ChatID: chat.ID, UserID: tg.ID()})
	if err != nil {
		return fmt.Errorf("не удалось проверить права бота в канале Telegram «%s»: %w", chat.Title, err)
	}
	if member.Type != models.ChatMemberTypeAdministrator {
		return fmt.Errorf("бот не администратор канала Telegram «%s»: посты канала Telegram присылает только администраторам", chat.Title)
	}

	return nil
}

func checkTelegramPost(ctx context.Context, tg *bot.Bot, chat *models.ChatFullInfo) error {
	member, err := tg.GetChatMember(ctx, &bot.GetChatMemberParams{ChatID: chat.ID, UserID: tg.ID()})
	if err != nil {
		return fmt.Errorf("не удалось проверить право публикации в Telegram «%s»: %w", chat.Title, err)
	}
	if member.Administrator == nil || !member.Administrator.CanPostMessages {
		return fmt.Errorf("боту нужно право публикации в канале Telegram «%s»", chat.Title)
	}

	return nil
}

// downloadFile fetches a file: a local Bot API server has already put it on
// the shared volume, the cloud one is downloaded whole — MAX needs the size up front.
func downloadFile(ctx context.Context, tg *bot.Bot, client *http.Client, fileID string) (tgFile, error) {
	file, err := tg.GetFile(ctx, &bot.GetFileParams{FileID: fileID})
	if err != nil {
		return tgFile{}, err
	}
	if filepath.IsAbs(file.FilePath) {
		info, err := os.Stat(file.FilePath)
		if err != nil {
			return tgFile{}, fmt.Errorf("%w (%w)", errNotOnVolume, withoutPath(err))
		}

		return tgFile{path: file.FilePath, size: info.Size()}, nil
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, tg.FileDownloadLink(file), nil)
	if err != nil {
		return tgFile{}, errors.New("не удалось собрать запрос на скачивание")
	}
	resp, err := client.Do(req)
	if err != nil {
		return tgFile{}, fmt.Errorf("скачивание файла: %w", withoutURL(err)) // the URL contains the bot token
	}
	defer func() { _ = resp.Body.Close() }()

	if resp.StatusCode != http.StatusOK {
		return tgFile{}, fmt.Errorf("скачивание файла: Telegram ответил %s", resp.Status)
	}
	data, err := io.ReadAll(io.LimitReader(resp.Body, tgDownloadLimit+1))
	if err != nil {
		return tgFile{}, fmt.Errorf("скачивание файла: %w", err)
	}
	if len(data) > tgDownloadLimit {
		return tgFile{}, errTooBig
	}

	return tgFile{data: data, size: int64(len(data))}, nil
}

// withoutURL strips the request URL from an error: it may hold a token or secret.
func withoutURL(err error) error {
	if urlErr, ok := errors.AsType[*url.Error](err); ok {
		return urlErr.Err
	}

	return err
}

// withoutPath strips the file path from an error: a local server's paths
// contain the bot token.
func withoutPath(err error) error {
	if pathErr, ok := errors.AsType[*fs.PathError](err); ok {
		return fmt.Errorf("%s: %w", pathErr.Op, pathErr.Err)
	}

	return err
}

// channelName extracts a channel name from a link (https://t.me/name,
// t.me/name) or from @name.
func channelName(ref, host string) string {
	name := strings.TrimSpace(ref)
	name = strings.TrimPrefix(strings.TrimPrefix(name, "https://"), "http://")
	name = strings.TrimPrefix(strings.TrimPrefix(name, "www."), host+"/")
	name = strings.TrimSuffix(name, "/")

	return strings.TrimPrefix(name, "@")
}