October 20, 2025

Бот для трансляции файлов Telegram

Telegram-бот для создания прямой ссылки на ваши файлы в Telegram.

Как сделать свой собственный

Развертывание в Koyeb

Важный

Вам нужно будет развернуть раздел «Переменные среды и файлы» и обновить переменные среды, прежде чем нажимать кнопку «Развернуть».

Примечание

При этом развертывается последняя версия Docker, а НЕ последняя фиксация. Поскольку используется готовый Docker-контейнер, скорость развертывания будет значительно выше.

Развертывание в Heroku

Примечание

Чтобы развернуть приложение на Heroku, вам нужно форкнуть этот репозиторий.

Нажмите кнопку ниже, чтобы быстро развернуть приложение на Heroku

Нажмите сюда, чтобы узнать, как добавить/изменить переменные среды в Heroku.


Загрузка из выпусков

  • Перейдите на вкладку релизы и в разделе предварительный релиз скачайте версию для вашей платформы и архитектуры.
  • Распакуйте zip-файл в папку.
  • Создайте файл с именем fsb.env и добавьте в него все переменные (см. файл fsb.sample.env для справки).
  • Предоставьте исполняемому файлу разрешение на запуск с помощью команды chmod +x fsb (для Windows не требуется).
  • Запустите бота с помощью ./fsb run команды. (./fsb.exe run для Windows)

Запуск с помощью docker-compose

  • Клонировать репозиторий
git clone https://github.com/EverythingSuckz/TG-FileStreamBot
cd TG-FileStreamBot
  • Создайте файл с именем fsb.env и добавьте в него все переменные (см. файл fsb.sample.env для справки).
нано фсб.env
  • Соберите и запустите файл docker-compose
docker-compose up -d

или

настройка docker compose up -d

Запуск с помощью docker

docker run --env-file fsb.env ghcr.io/everythingsuckz/fsb:latest

Где fsb.env — это файл среды, содержащий все переменные.


Сборка из исходного кода

Убунту

Примечание

Обязательно установите Go 1.21 или более позднюю версию. См. https://stackoverflow.com/a/17566846/15807350

git clone https://github.com/EverythingSuckz/TG-FileStreamBot
cd TG-FileStreamBot
go build ./cmd/fsb/
chmod +x fsb
mv fsb.sample.env fsb.env
nano fsb.env
# (добавьте свои переменные среды, подробнее см. в следующем разделе)
./fsb run

а чтобы остановить программу, сделайте CTRL+C

Windows

Примечание

Обязательно установите Go версии 1.21 или выше.

git clone https://github.com/EverythingSuckz/TG-FileStreamBot
cd TG-FileStreamBot
go build ./cmd/fsb/
Rename-Item -LiteralPath ".\fsb.sample.env" -NewName ".\fsb.env"
notepad fsb.env
# (добавьте свои переменные среды, подробнее см. в следующем разделе)
.\ управление ФСБ

а чтобы остановить программу, сделайте CTRL+C

Налаживание отношений

Если вы используете локальный хостинг, создайте файл с именем fsb.env в корневом каталоге и добавьте в него все переменные. Вы можете проверить fsb.sample.env. Пример файла fsb.env:

API_ID=452525
API_HASH=esx576f8738x883f3sfzx83
BOT_TOKEN=55838383:вашботтокенздесь
LOG_CHANNEL=-10045145224562
PORT=8080
HOST=http://вашсерверип
# (если вы хотите настроить несколько ботов)
MULTI_TOKEN1=55838373: ваш токен для работника
MULTI_TOKEN2=55838355: ваш токен для работника

Требуемые переменные

Перед запуском бота необходимо настроить следующие обязательные переменные:

  • API_ID Это идентификатор API для вашей учётной записи Telegram, который можно получить на сайте my.telegram.org.
  • API_HASH Это хэш API для вашей учётной записи в Telegram, который также можно получить на сайте my.telegram.org.
  • BOT_TOKEN : Это токен бота Telegram Media Streamer Bot, который можно получить у @BotFather.
  • LOG_CHANNEL : Это идентификатор канала для журнала, в который бот будет пересылать медиафайлы и сохранять их, чтобы сгенерированные прямые ссылки работали. Чтобы получить идентификатор канала, создайте новый канал в Telegram (открытый или закрытый), опубликуйте что-нибудь в канале, перешлите сообщение на @missrose_bot и ответьте на пересланное сообщение командой /id. Скопируйте идентификатор пересланного канала и вставьте его в это поле.

Необязательные переменные

Помимо обязательных переменных, вы также можете задать следующие необязательные переменные:

  • PORT Здесь указывается порт, который будет прослушивать ваше веб-приложение. Значение по умолчанию — 8080.
  • HOST Полное доменное имя, если оно есть, или IP-адрес вашего сервера. (Например, https://example.com или http://14.1.154.2:8080)
  • HASH_LENGTH : Пользовательская длина хеша для сгенерированных URL-адресов. Длина хеша должна быть больше 5 и меньше или равна 32. Значение по умолчанию — 6.
  • USE_SESSION_FILE : Используйте файлы сеанса для рабочих клиентов. Это ускоряет запуск рабочего бота. (по умолчанию: false)
  • USER_SESSION : Строка сеанса Pyrogram для пользовательского бота. Используется для автоматического добавления ботов в LOG_CHANNEL. (по умолчанию: null)
  • ALLOWED_USERS : Список идентификаторов пользователей, разделённых запятыми (,). Если этот параметр задан, только пользователи из этого списка смогут использовать бота. (по умолчанию: null)

Используйте несколько ботов, чтобы ускорить процесс

Примечание

Что такое многопользовательская функция и для чего она нужна?
Эта функция распределяет запросы Telegram API между рабочими ботами, чтобы ускорить загрузку, когда сервер используют многие пользователи, и избежать ограничений на количество запросов, установленных Telegram.

Примечание

Вы можете добавить до 50 ботов, так как 50 — это максимальное количество администраторов ботов, которых можно назначить в Telegram-канале.

Чтобы включить многопользовательский режим, сгенерируйте новые токены бота и добавьте их в fsb.env со следующими именами ключей.

MULTI_TOKEN1: Добавьте сюда свой первый токен бота.

MULTI_TOKEN2: Добавьте сюда токен второго бота.

Вы также можете добавить столько ботов, сколько захотите. (максимальное количество — 50) MULTI_TOKEN3, MULTI_TOKEN4, и т. д.

Предупреждение

Не забудьте добавить всех этих рабочих ботов в LOG_CHANNEL для корректной работы

Использование сеанса пользователя для автоматической регистрации ботов

Предупреждение

Иногда это может привести к блокировке или удалению вашей учётной записи. Этому подвержены только недавно созданные учётные записи.

Чтобы воспользоваться этой функцией, вам нужно сгенерировать строку сеанса pyrogram для учётной записи пользователя и добавить её в переменную USER_SESSION в файле fsb.env .

Что он делает?

Эта функция используется для автоматического добавления рабочих ботов в LOG_CHANNEL при их запуске. Это удобно, если у вас много рабочих ботов и вы не хотите добавлять их в LOG_CHANNEL вручную.

Как сгенерировать строку сеанса?

Самый простой способ сгенерировать строку сеанса — запустить

./fsb session --api-id <ваш идентификатор API> --api-hash <ваш хэш API>

Это позволит сгенерировать строку сеанса для вашей учётной записи с помощью аутентификации по QR-коду. Аутентификация по номеру телефона пока не поддерживается и будет добавлена в будущем.

Сам код:

✉.vscode

↪️launch.json

{
    // Use IntelliSense to learn about possible attributes.
    // Hover to view descriptions of existing attributes.
    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Launch Package",
            "type": "go",
            "request": "launch",
            "mode": "auto",
            "program": "./cmd/fsb/",
            "args": [
                "run"
            ]
        },
        {
            "name": "Generate Session",
            "type": "go",
            "request": "launch",
            "mode": "auto",
            "program": "./cmd/fsb/",
            "args": [
                "session"
            ],
            "console": "integratedTerminal"
        }
    ]
}

✉cmd/fsb

↪️main.go

package main
import (
	"EverythingSuckz/fsb/config"
	"fmt"
	"os"
	"github.com/spf13/cobra"
)
const versionString = "3.1.0"
var rootCmd = &cobra.Command{
	Use:               "fsb [command]",
	Short:             "Telegram File Stream Bot",
	Long:              "Telegram Bot to generate direct streamable links for telegram media.",
	Example:           "fsb run --port 8080",
	Version:           versionString,
	CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true},
	Run: func(cmd *cobra.Command, args []string) {
		cmd.Help()
	},
}
func init() {
	config.SetFlagsFromConfig(runCmd)
	rootCmd.AddCommand(runCmd)
	rootCmd.AddCommand(sessionCmd)
	rootCmd.SetVersionTemplate(fmt.Sprintf(`Telegram File Stream Bot version %s`, versionString))
}
func main() {
	if err := rootCmd.Execute(); err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
}

↪️run.go

package main
import (
	"EverythingSuckz/fsb/config"
	"EverythingSuckz/fsb/internal/bot"
	"EverythingSuckz/fsb/internal/cache"
	"EverythingSuckz/fsb/internal/routes"
	"EverythingSuckz/fsb/internal/types"
	"EverythingSuckz/fsb/internal/utils"
	"fmt"
	"net/http"
	"time"
	"github.com/spf13/cobra"
	"github.com/gin-gonic/gin"
	"go.uber.org/zap"
)
var runCmd = &cobra.Command{
	Use:                "run",
	Short:              "Run the bot with the given configuration.",
	DisableSuggestions: false,
	Run:                runApp,
}
var startTime time.Time = time.Now()
func runApp(cmd *cobra.Command, args []string) {
	utils.InitLogger(config.ValueOf.Dev)
	log := utils.Logger
	mainLogger := log.Named("Main")
	mainLogger.Info("Starting server")
	config.Load(log, cmd)
	router := getRouter(log)
	mainBot, err := bot.StartClient(log)
	if err != nil {
		log.Panic("Failed to start main bot", zap.Error(err))
	}
	cache.InitCache(log)
	workers, err := bot.StartWorkers(log)
	if err != nil {
		log.Panic("Failed to start workers", zap.Error(err))
		return
	}
	workers.AddDefaultClient(mainBot, mainBot.Self)
	bot.StartUserBot(log)
	mainLogger.Info("Server started", zap.Int("port", config.ValueOf.Port))
	mainLogger.Info("File Stream Bot", zap.String("version", versionString))
	mainLogger.Sugar().Infof("Server is running at %s", config.ValueOf.Host)
	err = router.Run(fmt.Sprintf(":%d", config.ValueOf.Port))
	if err != nil {
		mainLogger.Sugar().Fatalln(err)
	}
}
func getRouter(log *zap.Logger) *gin.Engine {
	if config.ValueOf.Dev {
		gin.SetMode(gin.DebugMode)
	} else {
		gin.SetMode(gin.ReleaseMode)
	}
	router := gin.Default()
	router.Use(gin.ErrorLogger())
	router.GET("/", func(ctx *gin.Context) {
		ctx.JSON(http.StatusOK, types.RootResponse{
			Message: "Server is running.",
			Ok:      true,
			Uptime:  utils.TimeFormat(uint64(time.Since(startTime).Seconds())),
			Version: versionString,
		})
	})
	routes.Load(log, router)
	return router
}

↪️session.go

package main
import (
	"fmt"
	"EverythingSuckz/fsb/pkg/qrlogin"
	"github.com/spf13/cobra"
)
var sessionCmd = &cobra.Command{
	Use:                "session",
	Short:              "Generate a string session.",
	DisableSuggestions: false,
	Run:                generateSession,
}
func init() {
	sessionCmd.Flags().StringP("login-type", "T", "qr", "The login type to use. Can be either 'qr' or 'phone'")
	sessionCmd.Flags().Int32P("api-id", "I", 0, "The API ID to use for the session (required).")
	sessionCmd.Flags().StringP("api-hash", "H", "", "The API hash to use for the session (required).")
	sessionCmd.MarkFlagRequired("api-id")
	sessionCmd.MarkFlagRequired("api-hash")
}
func generateSession(cmd *cobra.Command, args []string) {
	loginType, _ := cmd.Flags().GetString("login-type")
	apiId, _ := cmd.Flags().GetInt32("api-id")
	apiHash, _ := cmd.Flags().GetString("api-hash")
	if loginType == "qr" {
		qrlogin.GenerateQRSession(int(apiId), apiHash)
	} else if loginType == "phone" {
		generatePhoneSession()
	} else {
		fmt.Println("Invalid login type. Please use either 'qr' or 'phone'")
	}
}
func generatePhoneSession() {
	fmt.Println("Phone session is not implemented yet.")
}

✉config

↪️config.go

package config
import (
	"errors"
	"io"
	"net"
	"net/http"
	"os"
	"path/filepath"
	"reflect"
	"regexp"
	"strconv"
	"strings"
	"github.com/joho/godotenv"
	"github.com/kelseyhightower/envconfig"
	"github.com/spf13/cobra"
	"go.uber.org/zap"
)
var ValueOf = &config{}
type allowedUsers []int64
func (au *allowedUsers) Decode(value string) error {
	if value == "" {
		return nil
	}
	ids := strings.Split(string(value), ",")
	for _, id := range ids {
		idInt, err := strconv.ParseInt(id, 10, 64)
		if err != nil {
			return err
		}
		*au = append(*au, idInt)
	}
	return nil
}
type config struct {
	ApiID          int32        `envconfig:"API_ID" required:"true"`
	ApiHash        string       `envconfig:"API_HASH" required:"true"`
	BotToken       string       `envconfig:"BOT_TOKEN" required:"true"`
	LogChannelID   int64        `envconfig:"LOG_CHANNEL" required:"true"`
	Dev            bool         `envconfig:"DEV" default:"false"`
	Port           int          `envconfig:"PORT" default:"8080"`
	Host           string       `envconfig:"HOST" default:""`
	HashLength     int          `envconfig:"HASH_LENGTH" default:"6"`
	UseSessionFile bool         `envconfig:"USE_SESSION_FILE" default:"true"`
	UserSession    string       `envconfig:"USER_SESSION"`
	UsePublicIP    bool         `envconfig:"USE_PUBLIC_IP" default:"false"`
	AllowedUsers   allowedUsers `envconfig:"ALLOWED_USERS"`
	MultiTokens    []string
}
var botTokenRegex = regexp.MustCompile(`MULTI\_TOKEN\d+=(.*)`)
func (c *config) loadFromEnvFile(log *zap.Logger) {
	envPath := filepath.Clean("fsb.env")
	log.Sugar().Infof("Trying to load ENV vars from %s", envPath)
	err := godotenv.Load(envPath)
	if err != nil {
		if os.IsNotExist(err) {
			log.Sugar().Errorf("ENV file not found: %s", envPath)
			log.Sugar().Info("Please create fsb.env file")
			log.Sugar().Info("For more info, refer: https://github.com/EverythingSuckz/TG-FileStreamBot/tree/golang#setting-up-things")
			log.Sugar().Info("Please ignore this message if you are hosting it in a service like Heroku or other alternatives.")
		} else {
			log.Fatal("Unknown error while parsing env file.", zap.Error(err))
		}
	}
}
func SetFlagsFromConfig(cmd *cobra.Command) {
	cmd.Flags().Int32("api-id", ValueOf.ApiID, "Telegram API ID")
	cmd.Flags().String("api-hash", ValueOf.ApiHash, "Telegram API Hash")
	cmd.Flags().String("bot-token", ValueOf.BotToken, "Telegram Bot Token")
	cmd.Flags().Int64("log-channel", ValueOf.LogChannelID, "Telegram Log Channel ID")
	cmd.Flags().Bool("dev", ValueOf.Dev, "Enable development mode")
	cmd.Flags().IntP("port", "p", ValueOf.Port, "Server port")
	cmd.Flags().String("host", ValueOf.Host, "Server host that will be included in links")
	cmd.Flags().Int("hash-length", ValueOf.HashLength, "Hash length in links")
	cmd.Flags().Bool("use-session-file", ValueOf.UseSessionFile, "Use session files")
	cmd.Flags().String("user-session", ValueOf.UserSession, "Pyrogram user session")
	cmd.Flags().Bool("use-public-ip", ValueOf.UsePublicIP, "Use public IP instead of local IP")
	cmd.Flags().String("multi-token-txt-file", "", "Multi token txt file (Not implemented)")
}
func (c *config) loadConfigFromArgs(log *zap.Logger, cmd *cobra.Command) {
	apiID, _ := cmd.Flags().GetInt32("api-id")
	if apiID != 0 {
		os.Setenv("API_ID", strconv.Itoa(int(apiID)))
	}
	apiHash, _ := cmd.Flags().GetString("api-hash")
	if apiHash != "" {
		os.Setenv("API_HASH", apiHash)
	}
	botToken, _ := cmd.Flags().GetString("bot-token")
	if botToken != "" {
		os.Setenv("BOT_TOKEN", botToken)
	}
	logChannelID, _ := cmd.Flags().GetString("log-channel")
	if logChannelID != "" {
		os.Setenv("LOG_CHANNEL", logChannelID)
	}
	dev, _ := cmd.Flags().GetBool("dev")
	if dev {
		os.Setenv("DEV", strconv.FormatBool(dev))
	}
	port, _ := cmd.Flags().GetInt("port")
	if port != 0 {
		os.Setenv("PORT", strconv.Itoa(port))
	}
	host, _ := cmd.Flags().GetString("host")
	if host != "" {
		os.Setenv("HOST", host)
	}
	hashLength, _ := cmd.Flags().GetInt("hash-length")
	if hashLength != 0 {
		os.Setenv("HASH_LENGTH", strconv.Itoa(hashLength))
	}
	useSessionFile, _ := cmd.Flags().GetBool("use-session-file")
	if useSessionFile {
		os.Setenv("USE_SESSION_FILE", strconv.FormatBool(useSessionFile))
	}
	userSession, _ := cmd.Flags().GetString("user-session")
	if userSession != "" {
		os.Setenv("USER_SESSION", userSession)
	}
	usePublicIP, _ := cmd.Flags().GetBool("use-public-ip")
	if usePublicIP {
		os.Setenv("USE_PUBLIC_IP", strconv.FormatBool(usePublicIP))
	}
	multiTokens, _ := cmd.Flags().GetString("multi-token-txt-file")
	if multiTokens != "" {
		os.Setenv("MULTI_TOKEN_TXT_FILE", multiTokens)
		// TODO: Add support for importing tokens from a separate file
	}
}
func (c *config) setupEnvVars(log *zap.Logger, cmd *cobra.Command) {
	c.loadFromEnvFile(log)
	c.loadConfigFromArgs(log, cmd)
	err := envconfig.Process("", c)
	if err != nil {
		log.Fatal("Error while parsing env variables", zap.Error(err))
	}
	var ipBlocked bool
	ip, err := getIP(c.UsePublicIP)
	if err != nil {
		log.Error("Error while getting IP", zap.Error(err))
		ipBlocked = true
	}
	if c.Host == "" {
		c.Host = "http://" + ip + ":" + strconv.Itoa(c.Port)
		if c.UsePublicIP {
			if ipBlocked {
				log.Sugar().Warn("Can't get public IP, using local IP")
			} else {
				log.Sugar().Warn("You are using a public IP, please be aware of the security risks while exposing your IP to the internet.")
				log.Sugar().Warn("Use 'HOST' variable to set a domain name")
			}
		}
		log.Sugar().Info("HOST not set, automatically set to " + c.Host)
	}
	val := reflect.ValueOf(c).Elem()
	for _, env := range os.Environ() {
		if strings.HasPrefix(env, "MULTI_TOKEN") {
			c.MultiTokens = append(c.MultiTokens, botTokenRegex.FindStringSubmatch(env)[1])
		}
	}
	val.FieldByName("MultiTokens").Set(reflect.ValueOf(c.MultiTokens))
}
func Load(log *zap.Logger, cmd *cobra.Command) {
	log = log.Named("Config")
	defer log.Info("Loaded config")
	ValueOf.setupEnvVars(log, cmd)
	ValueOf.LogChannelID = int64(stripInt(log, int(ValueOf.LogChannelID)))
	if ValueOf.HashLength == 0 {
		log.Sugar().Info("HASH_LENGTH can't be 0, defaulting to 6")
		ValueOf.HashLength = 6
	}
	if ValueOf.HashLength > 32 {
		log.Sugar().Info("HASH_LENGTH can't be more than 32, changing to 32")
		ValueOf.HashLength = 32
	}
	if ValueOf.HashLength < 5 {
		log.Sugar().Info("HASH_LENGTH can't be less than 5, defaulting to 6")
		ValueOf.HashLength = 6
	}
}
func getIP(public bool) (string, error) {
	var ip string
	var err error
	if public {
		ip, err = GetPublicIP()
	} else {
		ip, err = getInternalIP()
	}
	if ip == "" {
		ip = "localhost"
	}
	if err != nil {
		return "localhost", err
	}
	return ip, nil
}
// https://stackoverflow.com/a/23558495/15807350
func getInternalIP() (string, error) {
	conn, err := net.Dial("udp", "8.8.8.8:80")
	if err != nil {
		return "", errors.New("no internet connection")
	}
	defer conn.Close()
	localAddr := conn.LocalAddr().(*net.UDPAddr)
	return localAddr.IP.String(), nil
}
func GetPublicIP() (string, error) {
	resp, err := http.Get("https://api.ipify.org?format=text")
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	ip, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", err
	}
	if !checkIfIpAccessible(string(ip)) {
		return string(ip), errors.New("PORT is blocked by firewall")
	}
	return string(ip), nil
}
func checkIfIpAccessible(ip string) bool {
	conn, err := net.Dial("tcp", ip+":80")
	if err != nil {
		return false
	}
	defer conn.Close()
	return true
}
func stripInt(log *zap.Logger, a int) int {
	strA := strconv.Itoa(abs(a))
	lastDigits := strings.Replace(strA, "100", "", 1)
	result, err := strconv.Atoi(lastDigits)
	if err != nil {
		log.Sugar().Fatalln(err)
		return 0
	}
	return result
}
func abs(x int) int {
	if x < 0 {
		return -x
	}
	return x
}

✉internal

✉bot

↪️client.go

package bot
import (
	"EverythingSuckz/fsb/config"
	"EverythingSuckz/fsb/internal/commands"
	"context"
	"time"
	"go.uber.org/zap"
	"github.com/celestix/gotgproto"
	"github.com/celestix/gotgproto/sessionMaker"
	"github.com/glebarez/sqlite"
)
var Bot *gotgproto.Client
func StartClient(log *zap.Logger) (*gotgproto.Client, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
	defer cancel()
	resultChan := make(chan struct {
		client *gotgproto.Client
		err    error
	})
	go func(ctx context.Context) {
		client, err := gotgproto.NewClient(
			int(config.ValueOf.ApiID),
			config.ValueOf.ApiHash,
			gotgproto.ClientTypeBot(config.ValueOf.BotToken),
			&gotgproto.ClientOpts{
				Session: sessionMaker.SqlSession(
					sqlite.Open("fsb.session"),
				),
				DisableCopyright: true,
			},
		)
		resultChan <- struct {
			client *gotgproto.Client
			err    error
		}{client, err}
	}(ctx)
	select {
	case <-ctx.Done():
		return nil, ctx.Err()
	case result := <-resultChan:
		if result.err != nil {
			return nil, result.err
		}
		commands.Load(log, result.client.Dispatcher)
		log.Info("Client started", zap.String("username", result.client.Self.Username))
		Bot = result.client
		return result.client, nil
	}
}

↪️middleware.go

package bot
import (
	"time"
	"github.com/gotd/contrib/middleware/floodwait"
	"github.com/gotd/contrib/middleware/ratelimit"
	"github.com/gotd/td/telegram"
	"go.uber.org/zap"
	"golang.org/x/time/rate"
)
func GetFloodMiddleware(log *zap.Logger) []telegram.Middleware {
	waiter := floodwait.NewSimpleWaiter().WithMaxRetries(10)
	ratelimiter := ratelimit.New(rate.Every(time.Millisecond*100), 5)
	return []telegram.Middleware{
		waiter,
		ratelimiter,
	}
}

↪️userbot.go

package bot
import (
	"EverythingSuckz/fsb/config"
	"errors"
	"github.com/celestix/gotgproto"
	"github.com/celestix/gotgproto/sessionMaker"
	"github.com/gotd/td/tg"
	"go.uber.org/zap"
)
type UserBotStruct struct {
	log    *zap.Logger
	client *gotgproto.Client
}
var UserBot *UserBotStruct = &UserBotStruct{}
func StartUserBot(l *zap.Logger) {
	log := l.Named("USERBOT")
	if config.ValueOf.UserSession == "" {
		log.Warn("User session is empty")
		return
	}
	log.Sugar().Infoln("Starting userbot")
	client, err := gotgproto.NewClient(
		int(config.ValueOf.ApiID),
		config.ValueOf.ApiHash,
		gotgproto.ClientTypePhone(""),
		&gotgproto.ClientOpts{
			Session:          sessionMaker.PyrogramSession(config.ValueOf.UserSession),
			DisableCopyright: true,
		},
	)
	if err != nil {
		log.Error("Failed to start userbot", zap.Error(err))
		return
	}
	UserBot.log = log
	UserBot.client = client
	log.Info("Userbot started", zap.String("username", client.Self.Username), zap.String("FirstName", client.Self.FirstName), zap.String("LastName", client.Self.LastName))
	if err := UserBot.AddBotsAsAdmins(); err != nil {
		log.Error("Failed to add bots as admins", zap.Error(err))
		return
	}
}
func (u *UserBotStruct) AddBotsAsAdmins() error {
	u.log.Info("Preparing to add bots as admins")
	ctx := u.client.CreateContext()
	channel := config.ValueOf.LogChannelID
	channelInfos, err := u.client.API().ChannelsGetChannels(
		ctx,
		[]tg.InputChannelClass{
			&tg.InputChannel{
				ChannelID: channel,
			},
		},
	)
	if err != nil {
		u.log.Error("Failed to get channel info", zap.Error(err))
		return errors.New("failed to get channel info")
	}
	if len(channelInfos.GetChats()) == 0 {
		return errors.New("no channels found")
	}
	inputChannel := channelInfos.GetChats()[0].(*tg.Channel).AsInput()
	currentAdmins := []int64{}
	admins, err := u.client.API().ChannelsGetParticipants(ctx, &tg.ChannelsGetParticipantsRequest{
		Channel: inputChannel,
		Filter:  &tg.ChannelParticipantsAdmins{},
		Offset:  0,
		Limit:   100,
	})
	if err != nil {
		u.log.Error("Failed to get admins", zap.Error(err))
		return err
	}
	for _, admin := range admins.(*tg.ChannelsChannelParticipants).Participants {
		if user, ok := admin.(*tg.ChannelParticipantAdmin); ok {
			currentAdmins = append(currentAdmins, user.UserID)
		}
	}
	for _, bot := range Workers.Bots {
		isAdmin := false
		for _, admin := range currentAdmins {
			if admin == bot.Self.ID {
				u.log.Sugar().Infof("Bot @%s is already an admin", bot.Self.Username)
				isAdmin = true
				continue
			}
		}
		if isAdmin {
			continue
		}
		botInfo, err := ctx.ResolveUsername(bot.Self.Username)
		if err != nil {
			u.log.Warn(err.Error())
		}
		_, err = u.client.API().ChannelsEditAdmin(
			u.client.CreateContext().Context,
			&tg.ChannelsEditAdminRequest{
				Channel: inputChannel,
				UserID:  botInfo.GetInputUser(),
				AdminRights: tg.ChatAdminRights{
					PostMessages: true,
				},
				Rank: "admin",
			},
		)
		if err != nil {
			u.log.Sugar().Warnf("Failed to add @%s as admin", bot.Self.Username)
			u.log.Warn(err.Error())
		}
		u.log.Sugar().Infof("Added @%s as admin", bot.Self.Username)
	}
	return nil
}

↪️workers.go

package bot
import (
	"EverythingSuckz/fsb/config"
	"context"
	"fmt"
	"os"
	"path/filepath"
	"sync"
	"sync/atomic"
	"time"
	"github.com/celestix/gotgproto"
	"github.com/celestix/gotgproto/sessionMaker"
	"github.com/glebarez/sqlite"
	"github.com/gotd/td/tg"
	"go.uber.org/zap"
)
type Worker struct {
	ID     int
	Client *gotgproto.Client
	Self   *tg.User
	log    *zap.Logger
}
func (w *Worker) String() string {
	return fmt.Sprintf("{Worker (%d|@%s)}", w.ID, w.Self.Username)
}
type BotWorkers struct {
	Bots     []*Worker
	starting int
	index    int
	mut      sync.Mutex
	log      *zap.Logger
}
var Workers *BotWorkers = &BotWorkers{
	log:  nil,
	Bots: make([]*Worker, 0),
}
func (w *BotWorkers) Init(log *zap.Logger) {
	w.log = log.Named("Workers")
}
func (w *BotWorkers) AddDefaultClient(client *gotgproto.Client, self *tg.User) {
	if w.Bots == nil {
		w.Bots = make([]*Worker, 0)
	}
	w.incStarting()
	w.Bots = append(w.Bots, &Worker{
		Client: client,
		ID:     w.starting,
		Self:   self,
		log:    w.log,
	})
	w.log.Sugar().Info("Default bot loaded")
}
func (w *BotWorkers) incStarting() {
	w.mut.Lock()
	defer w.mut.Unlock()
	w.starting++
}
func (w *BotWorkers) Add(token string) (err error) {
	w.incStarting()
	var botID int = w.starting
	client, err := startWorker(w.log, token, botID)
	if err != nil {
		return err
	}
	w.log.Sugar().Infof("Bot @%s loaded with ID %d", client.Self.Username, botID)
	w.Bots = append(w.Bots, &Worker{
		Client: client,
		ID:     botID,
		Self:   client.Self,
		log:    w.log,
	})
	return nil
}
func GetNextWorker() *Worker {
	Workers.mut.Lock()
	defer Workers.mut.Unlock()
	index := (Workers.index + 1) % len(Workers.Bots)
	Workers.index = index
	worker := Workers.Bots[index]
	Workers.log.Sugar().Debugf("Using worker %d", worker.ID)
	return worker
}
func StartWorkers(log *zap.Logger) (*BotWorkers, error) {
	Workers.Init(log)
	if len(config.ValueOf.MultiTokens) == 0 {
		Workers.log.Sugar().Info("No worker bot tokens provided, skipping worker initialization")
		return Workers, nil
	}
	Workers.log.Sugar().Info("Starting")
	if config.ValueOf.UseSessionFile {
		Workers.log.Sugar().Info("Using session file for workers")
		newpath := filepath.Join(".", "sessions")
		if err := os.MkdirAll(newpath, os.ModePerm); err != nil {
			Workers.log.Error("Failed to create sessions directory", zap.Error(err))
			return nil, err
		}
	}
	var wg sync.WaitGroup
	var successfulStarts int32
	totalBots := len(config.ValueOf.MultiTokens)
	for i := 0; i < totalBots; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
			defer cancel()
			done := make(chan error, 1)
			go func() {
				err := Workers.Add(config.ValueOf.MultiTokens[i])
				done <- err
			}()
			select {
			case err := <-done:
				if err != nil {
					Workers.log.Error("Failed to start worker", zap.Int("index", i), zap.Error(err))
				} else {
					atomic.AddInt32(&successfulStarts, 1)
				}
			case <-ctx.Done():
				Workers.log.Error("Timed out starting worker", zap.Int("index", i))
			}
		}(i)
	}
	wg.Wait() // Wait for all goroutines to finish
	Workers.log.Sugar().Infof("Successfully started %d/%d bots", successfulStarts, totalBots)
	return Workers, nil
}
func startWorker(l *zap.Logger, botToken string, index int) (*gotgproto.Client, error) {
	log := l.Named("Worker").Sugar()
	log.Infof("Starting worker with index - %d", index)
	var sessionType sessionMaker.SessionConstructor
	if config.ValueOf.UseSessionFile {
		sessionType = sessionMaker.SqlSession(sqlite.Open(fmt.Sprintf("sessions/worker-%d.session", index)))
	} else {
		sessionType = sessionMaker.SimpleSession()
	}
	client, err := gotgproto.NewClient(
		int(config.ValueOf.ApiID),
		config.ValueOf.ApiHash,
		gotgproto.ClientTypeBot(botToken),
		&gotgproto.ClientOpts{
			Session:          sessionType,
			DisableCopyright: true,
			Middlewares:      GetFloodMiddleware(log.Desugar()),
		},
	)
	if err != nil {
		return nil, err
	}
	return client, nil
}

✉cache

↪️cache.go

package cache
import (
	"EverythingSuckz/fsb/internal/types"
	"bytes"
	"encoding/gob"
	"sync"
	"github.com/coocood/freecache"
	"github.com/gotd/td/tg"
	"go.uber.org/zap"
)
var cache *Cache
type Cache struct {
	cache *freecache.Cache
	mu    sync.RWMutex
	log   *zap.Logger
}
func InitCache(log *zap.Logger) {
	log = log.Named("cache")
	gob.Register(types.File{})
	gob.Register(tg.InputDocumentFileLocation{})
	gob.Register(tg.InputPhotoFileLocation{})
	defer log.Sugar().Info("Initialized")
	cache = &Cache{cache: freecache.NewCache(10 * 1024 * 1024), log: log}
}
func GetCache() *Cache {
	return cache
}
func (c *Cache) Get(key string, value *types.File) error {
	c.mu.RLock()
	defer c.mu.RUnlock()
	data, err := cache.cache.Get([]byte(key))
	if err != nil {
		return err
	}
	dec := gob.NewDecoder(bytes.NewReader(data))
	err = dec.Decode(&value)
	if err != nil {
		return err
	}
	return nil
}
func (c *Cache) Set(key string, value *types.File, expireSeconds int) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	var buf bytes.Buffer
	enc := gob.NewEncoder(&buf)
	err := enc.Encode(value)
	if err != nil {
		return err
	}
	cache.cache.Set([]byte(key), buf.Bytes(), expireSeconds)
	return nil
}
func (c *Cache) Delete(key string) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	cache.cache.Del([]byte(key))
	return nil
}

✉commands

↪️commands.go

package commands
import (
	"reflect"
	"github.com/celestix/gotgproto/dispatcher"
	"go.uber.org/zap"
)
type command struct {
	log *zap.Logger
}
func Load(log *zap.Logger, dispatcher dispatcher.Dispatcher) {
	log = log.Named("commands")
	defer log.Info("Initialized all command handlers")
	Type := reflect.TypeOf(&command{log})
	Value := reflect.ValueOf(&command{log})
	for i := 0; i < Type.NumMethod(); i++ {
		Type.Method(i).Func.Call([]reflect.Value{Value, reflect.ValueOf(dispatcher)})
	}
}

↪️start.go

package commands
import (
	"EverythingSuckz/fsb/config"
	"EverythingSuckz/fsb/internal/utils"
	"github.com/celestix/gotgproto/dispatcher"
	"github.com/celestix/gotgproto/dispatcher/handlers"
	"github.com/celestix/gotgproto/ext"
	"github.com/celestix/gotgproto/storage"
)
func (m *command) LoadStart(dispatcher dispatcher.Dispatcher) {
	log := m.log.Named("start")
	defer log.Sugar().Info("Loaded")
	dispatcher.AddHandler(handlers.NewCommand("start", start))
}
func start(ctx *ext.Context, u *ext.Update) error {
	chatId := u.EffectiveChat().GetID()
	peerChatId := ctx.PeerStorage.GetPeerById(chatId)
	if peerChatId.Type != int(storage.TypeUser) {
		return dispatcher.EndGroups
	}
	if len(config.ValueOf.AllowedUsers) != 0 && !utils.Contains(config.ValueOf.AllowedUsers, chatId) {
		ctx.Reply(u, "You are not allowed to use this bot.", nil)
		return dispatcher.EndGroups
	}
	ctx.Reply(u, "Hi, send me any file to get a direct streamble link to that file.", nil)
	return dispatcher.EndGroups
}

↪️stream.go

package commands
import (
	"fmt"
	"strings"
	"EverythingSuckz/fsb/config"
	"EverythingSuckz/fsb/internal/utils"
	"github.com/celestix/gotgproto/dispatcher"
	"github.com/celestix/gotgproto/dispatcher/handlers"
	"github.com/celestix/gotgproto/ext"
	"github.com/celestix/gotgproto/storage"
	"github.com/celestix/gotgproto/types"
	"github.com/gotd/td/telegram/message/styling"
	"github.com/gotd/td/tg"
)
func (m *command) LoadStream(dispatcher dispatcher.Dispatcher) {
	log := m.log.Named("start")
	defer log.Sugar().Info("Loaded")
	dispatcher.AddHandler(
		handlers.NewMessage(nil, sendLink),
	)
}
func supportedMediaFilter(m *types.Message) (bool, error) {
	if not := m.Media == nil; not {
		return false, dispatcher.EndGroups
	}
	switch m.Media.(type) {
	case *tg.MessageMediaDocument:
		return true, nil
	case *tg.MessageMediaPhoto:
		return true, nil
	case tg.MessageMediaClass:
		return false, dispatcher.EndGroups
	default:
		return false, nil
	}
}
func sendLink(ctx *ext.Context, u *ext.Update) error {
	chatId := u.EffectiveChat().GetID()
	peerChatId := ctx.PeerStorage.GetPeerById(chatId)
	if peerChatId.Type != int(storage.TypeUser) {
		return dispatcher.EndGroups
	}
	if len(config.ValueOf.AllowedUsers) != 0 && !utils.Contains(config.ValueOf.AllowedUsers, chatId) {
		ctx.Reply(u, "You are not allowed to use this bot.", nil)
		return dispatcher.EndGroups
	}
	supported, err := supportedMediaFilter(u.EffectiveMessage)
	if err != nil {
		return err
	}
	if !supported {
		ctx.Reply(u, "Sorry, this message type is unsupported.", nil)
		return dispatcher.EndGroups
	}
	update, err := utils.ForwardMessages(ctx, chatId, config.ValueOf.LogChannelID, u.EffectiveMessage.ID)
	if err != nil {
		utils.Logger.Sugar().Error(err)
		ctx.Reply(u, fmt.Sprintf("Error - %s", err.Error()), nil)
		return dispatcher.EndGroups
	}
	messageID := update.Updates[0].(*tg.UpdateMessageID).ID
	doc := update.Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message).Media
	file, err := utils.FileFromMedia(doc)
	if err != nil {
		ctx.Reply(u, fmt.Sprintf("Error - %s", err.Error()), nil)
		return dispatcher.EndGroups
	}
	fullHash := utils.PackFile(
		file.FileName,
		file.FileSize,
		file.MimeType,
		file.ID,
	)
	hash := utils.GetShortHash(fullHash)
	link := fmt.Sprintf("%s/stream/%d?hash=%s", config.ValueOf.Host, messageID, hash)
	text := []styling.StyledTextOption{styling.Code(link)}
	row := tg.KeyboardButtonRow{
		Buttons: []tg.KeyboardButtonClass{
			&tg.KeyboardButtonURL{
				Text: "Download",
				URL:  link + "&d=true",
			},
		},
	}
	if strings.Contains(file.MimeType, "video") || strings.Contains(file.MimeType, "audio") || strings.Contains(file.MimeType, "pdf") {
		row.Buttons = append(row.Buttons, &tg.KeyboardButtonURL{
			Text: "Stream",
			URL:  link,
		})
	}
	markup := &tg.ReplyInlineMarkup{
		Rows: []tg.KeyboardButtonRow{row},
	}
	if strings.Contains(link, "http://localhost") {
		_, err = ctx.Reply(u, text, &ext.ReplyOpts{
			NoWebpage:        false,
			ReplyToMessageId: u.EffectiveMessage.ID,
		})
	} else {
		_, err = ctx.Reply(u, text, &ext.ReplyOpts{
			Markup:           markup,
			NoWebpage:        false,
			ReplyToMessageId: u.EffectiveMessage.ID,
		})
	}
	if err != nil {
		utils.Logger.Sugar().Error(err)
		ctx.Reply(u, fmt.Sprintf("Error - %s", err.Error()), nil)
	}
	return dispatcher.EndGroups
}

✉routes

↪️routes.go

package routes
import (
	"reflect"
	"github.com/gin-gonic/gin"
	"go.uber.org/zap"
)
type Route struct {
	Name   string
	Engine *gin.Engine
}
func (r *Route) Init(engine *gin.Engine) {
	r.Engine = engine
}
type allRoutes struct {
	log *zap.Logger
}
func Load(log *zap.Logger, r *gin.Engine) {
	log = log.Named("routes")
	defer log.Sugar().Info("Loaded all API Routes")
	route := &Route{Name: "/", Engine: r}
	route.Init(r)
	Type := reflect.TypeOf(&allRoutes{log})
	Value := reflect.ValueOf(&allRoutes{log})
	for i := 0; i < Type.NumMethod(); i++ {
		Type.Method(i).Func.Call([]reflect.Value{Value, reflect.ValueOf(route)})
	}
}

↪️stream.go

package routes
import (
	"EverythingSuckz/fsb/internal/bot"
	"EverythingSuckz/fsb/internal/utils"
	"fmt"
	"io"
	"net/http"
	"strconv"
	"github.com/gotd/td/tg"
	range_parser "github.com/quantumsheep/range-parser"
	"go.uber.org/zap"
	"github.com/gin-gonic/gin"
)
var log *zap.Logger
func (e *allRoutes) LoadHome(r *Route) {
	log = e.log.Named("Stream")
	defer log.Info("Loaded stream route")
	r.Engine.GET("/stream/:messageID", getStreamRoute)
}
func getStreamRoute(ctx *gin.Context) {
	w := ctx.Writer
	r := ctx.Request
	messageIDParm := ctx.Param("messageID")
	messageID, err := strconv.Atoi(messageIDParm)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	authHash := ctx.Query("hash")
	if authHash == "" {
		http.Error(w, "missing hash param", http.StatusBadRequest)
		return
	}
	worker := bot.GetNextWorker()
	file, err := utils.FileFromMessage(ctx, worker.Client, messageID)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	expectedHash := utils.PackFile(
		file.FileName,
		file.FileSize,
		file.MimeType,
		file.ID,
	)
	if !utils.CheckHash(authHash, expectedHash) {
		http.Error(w, "invalid hash", http.StatusBadRequest)
		return
	}
	// for photo messages
	if file.FileSize == 0 {
		res, err := worker.Client.API().UploadGetFile(ctx, &tg.UploadGetFileRequest{
			Location: file.Location,
			Offset:   0,
			Limit:    1024 * 1024,
		})
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		result, ok := res.(*tg.UploadFile)
		if !ok {
			http.Error(w, "unexpected response", http.StatusInternalServerError)
			return
		}
		fileBytes := result.GetBytes()
		ctx.Header("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", file.FileName))
		if r.Method != "HEAD" {
			ctx.Data(http.StatusOK, file.MimeType, fileBytes)
		}
		return
	}
	ctx.Header("Accept-Ranges", "bytes")
	var start, end int64
	rangeHeader := r.Header.Get("Range")
	if rangeHeader == "" {
		start = 0
		end = file.FileSize - 1
		w.WriteHeader(http.StatusOK)
	} else {
		ranges, err := range_parser.Parse(file.FileSize, r.Header.Get("Range"))
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		start = ranges[0].Start
		end = ranges[0].End
		ctx.Header("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, file.FileSize))
		log.Info("Content-Range", zap.Int64("start", start), zap.Int64("end", end), zap.Int64("fileSize", file.FileSize))
		w.WriteHeader(http.StatusPartialContent)
	}
	contentLength := end - start + 1
	mimeType := file.MimeType
	if mimeType == "" {
		mimeType = "application/octet-stream"
	}
	ctx.Header("Content-Type", mimeType)
	ctx.Header("Content-Length", strconv.FormatInt(contentLength, 10))
	disposition := "inline"
	if ctx.Query("d") == "true" {
		disposition = "attachment"
	}
	ctx.Header("Content-Disposition", fmt.Sprintf("%s; filename=\"%s\"", disposition, file.FileName))
	if r.Method != "HEAD" {
		lr, _ := utils.NewTelegramReader(ctx, worker.Client, file.Location, start, end, contentLength)
		if _, err := io.CopyN(w, lr, contentLength); err != nil {
			log.Error("Error while copying stream", zap.Error(err))
		}
	}
}

✉types

↪️file.go

package types
import (
	"crypto/md5"
	"encoding/hex"
	"reflect"
	"strconv"
	"github.com/gotd/td/tg"
)
type File struct {
	Location tg.InputFileLocationClass
	FileSize int64
	FileName string
	MimeType string
	ID       int64
}
type HashableFileStruct struct {
	FileName string
	FileSize int64
	MimeType string
	FileID   int64
}
func (f *HashableFileStruct) Pack() string {
	hasher := md5.New()
	val := reflect.ValueOf(*f)
	for i := 0; i < val.NumField(); i++ {
		field := val.Field(i)
		var fieldValue []byte
		switch field.Kind() {
		case reflect.String:
			fieldValue = []byte(field.String())
		case reflect.Int64:
			fieldValue = []byte(strconv.FormatInt(field.Int(), 10))
		}
		hasher.Write(fieldValue)
	}
	return hex.EncodeToString(hasher.Sum(nil))
}

↪️response.go

package types
type RootResponse struct {
	Message string `json:"message"`
	Ok      bool   `json:"ok"`
	Uptime  string `json:"uptime"`
	Version string `json:"version"`
}

✉utils

↪️hashing.go

package utils
import (
	"EverythingSuckz/fsb/config"
	"EverythingSuckz/fsb/internal/types"
)
func PackFile(fileName string, fileSize int64, mimeType string, fileID int64) string {
	return (&types.HashableFileStruct{FileName: fileName, FileSize: fileSize, MimeType: mimeType, FileID: fileID}).Pack()
}
func GetShortHash(fullHash string) string {
	return fullHash[:config.ValueOf.HashLength]
}
func CheckHash(inputHash string, expectedHash string) bool {
	return inputHash == GetShortHash(expectedHash)
}

↪️helpers.go

package utils
import (
	"EverythingSuckz/fsb/config"
	"EverythingSuckz/fsb/internal/cache"
	"EverythingSuckz/fsb/internal/types"
	"context"
	"errors"
	"fmt"
	"math/rand"
	"github.com/celestix/gotgproto"
	"github.com/celestix/gotgproto/ext"
	"github.com/celestix/gotgproto/storage"
	"github.com/gotd/td/tg"
	"go.uber.org/zap"
)
// https://stackoverflow.com/a/70802740/15807350
func Contains[T comparable](s []T, e T) bool {
	for _, v := range s {
		if v == e {
			return true
		}
	}
	return false
}
func GetTGMessage(ctx context.Context, client *gotgproto.Client, messageID int) (*tg.Message, error) {
	inputMessageID := tg.InputMessageClass(&tg.InputMessageID{ID: messageID})
	channel, err := GetLogChannelPeer(ctx, client.API(), client.PeerStorage)
	if err != nil {
		return nil, err
	}
	messageRequest := tg.ChannelsGetMessagesRequest{Channel: channel, ID: []tg.InputMessageClass{inputMessageID}}
	res, err := client.API().ChannelsGetMessages(ctx, &messageRequest)
	if err != nil {
		return nil, err
	}
	messages := res.(*tg.MessagesChannelMessages)
	message := messages.Messages[0]
	if _, ok := message.(*tg.Message); ok {
		return message.(*tg.Message), nil
	} else {
		return nil, fmt.Errorf("this file was deleted")
	}
}
func FileFromMedia(media tg.MessageMediaClass) (*types.File, error) {
	switch media := media.(type) {
	case *tg.MessageMediaDocument:
		document, ok := media.Document.AsNotEmpty()
		if !ok {
			return nil, fmt.Errorf("unexpected type %T", media)
		}
		var fileName string
		for _, attribute := range document.Attributes {
			if name, ok := attribute.(*tg.DocumentAttributeFilename); ok {
				fileName = name.FileName
				break
			}
		}
		return &types.File{
			Location: document.AsInputDocumentFileLocation(),
			FileSize: document.Size,
			FileName: fileName,
			MimeType: document.MimeType,
			ID:       document.ID,
		}, nil
	case *tg.MessageMediaPhoto:
		photo, ok := media.Photo.AsNotEmpty()
		if !ok {
			return nil, fmt.Errorf("unexpected type %T", media)
		}
		sizes := photo.Sizes
		if len(sizes) == 0 {
			return nil, errors.New("photo has no sizes")
		}
		photoSize := sizes[len(sizes)-1]
		size, ok := photoSize.AsNotEmpty()
		if !ok {
			return nil, errors.New("photo size is empty")
		}
		location := new(tg.InputPhotoFileLocation)
		location.ID = photo.GetID()
		location.AccessHash = photo.GetAccessHash()
		location.FileReference = photo.GetFileReference()
		location.ThumbSize = size.GetType()
		return &types.File{
			Location: location,
			FileSize: 0, // caller should judge if this is a photo or not
			FileName: fmt.Sprintf("photo_%d.jpg", photo.GetID()),
			MimeType: "image/jpeg",
			ID:       photo.GetID(),
		}, nil
	}
	return nil, fmt.Errorf("unexpected type %T", media)
}
func FileFromMessage(ctx context.Context, client *gotgproto.Client, messageID int) (*types.File, error) {
	key := fmt.Sprintf("file:%d:%d", messageID, client.Self.ID)
	log := Logger.Named("GetMessageMedia")
	var cachedMedia types.File
	err := cache.GetCache().Get(key, &cachedMedia)
	if err == nil {
		log.Debug("Using cached media message properties", zap.Int("messageID", messageID), zap.Int64("clientID", client.Self.ID))
		return &cachedMedia, nil
	}
	log.Debug("Fetching file properties from message ID", zap.Int("messageID", messageID), zap.Int64("clientID", client.Self.ID))
	message, err := GetTGMessage(ctx, client, messageID)
	if err != nil {
		return nil, err
	}
	file, err := FileFromMedia(message.Media)
	if err != nil {
		return nil, err
	}
	err = cache.GetCache().Set(
		key,
		file,
		3600,
	)
	if err != nil {
		return nil, err
	}
	return file, nil
}
func GetLogChannelPeer(ctx context.Context, api *tg.Client, peerStorage *storage.PeerStorage) (*tg.InputChannel, error) {
	cachedInputPeer := peerStorage.GetInputPeerById(config.ValueOf.LogChannelID)
	switch peer := cachedInputPeer.(type) {
	case *tg.InputPeerEmpty:
		break
	case *tg.InputPeerChannel:
		return &tg.InputChannel{
			ChannelID:  peer.ChannelID,
			AccessHash: peer.AccessHash,
		}, nil
	default:
		return nil, errors.New("unexpected type of input peer")
	}
	inputChannel := &tg.InputChannel{
		ChannelID: config.ValueOf.LogChannelID,
	}
	channels, err := api.ChannelsGetChannels(ctx, []tg.InputChannelClass{inputChannel})
	if err != nil {
		return nil, err
	}
	if len(channels.GetChats()) == 0 {
		return nil, errors.New("no channels found")
	}
	channel, ok := channels.GetChats()[0].(*tg.Channel)
	if !ok {
		return nil, errors.New("type assertion to *tg.Channel failed")
	}
	// Bruh, I literally have to call library internal functions at this point
	peerStorage.AddPeer(channel.GetID(), channel.AccessHash, storage.TypeChannel, "")
	return channel.AsInput(), nil
}
func ForwardMessages(ctx *ext.Context, fromChatId, toChatId int64, messageID int) (*tg.Updates, error) {
	fromPeer := ctx.PeerStorage.GetInputPeerById(fromChatId)
	if fromPeer.Zero() {
		return nil, fmt.Errorf("fromChatId: %d is not a valid peer", fromChatId)
	}
	toPeer, err := GetLogChannelPeer(ctx, ctx.Raw, ctx.PeerStorage)
	if err != nil {
		return nil, err
	}
	update, err := ctx.Raw.MessagesForwardMessages(ctx, &tg.MessagesForwardMessagesRequest{
		RandomID: []int64{rand.Int63()},
		FromPeer: fromPeer,
		ID:       []int{messageID},
		ToPeer:   &tg.InputPeerChannel{ChannelID: toPeer.ChannelID, AccessHash: toPeer.AccessHash},
	})
	if err != nil {
		return nil, err
	}
	return update.(*tg.Updates), nil
}

↪️logger.go

package utils
import (
	"os"
	"time"
	"go.uber.org/zap"
	"go.uber.org/zap/zapcore"
	"gopkg.in/natefinch/lumberjack.v2"
)
var Logger *zap.Logger
func InitLogger(debugMode bool) {
	customTimeEncoder := func(t time.Time, enc zapcore.PrimitiveArrayEncoder) {
		enc.AppendString(t.Format("02/01/2006 03:04 PM"))
	}
	consoleConfig := zap.NewDevelopmentEncoderConfig()
	consoleConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
	consoleConfig.EncodeTime = customTimeEncoder
	consoleEncoder := zapcore.NewConsoleEncoder(consoleConfig)
	fileEncoderConfig := zap.NewProductionEncoderConfig()
	fileEncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
	fileEncoder := zapcore.NewJSONEncoder(fileEncoderConfig)
	fileWriter := zapcore.AddSync(&lumberjack.Logger{
		Filename:   "logs/app.log",
		MaxSize:    10,
		MaxBackups: 3,
		MaxAge:     7,
		Compress:   true,
	})
	var consoleLevel zapcore.Level
	if debugMode {
		consoleLevel = zapcore.DebugLevel
	} else {
		consoleLevel = zapcore.InfoLevel
	}
	core := zapcore.NewTee(
		zapcore.NewCore(consoleEncoder, zapcore.AddSync(os.Stdout), consoleLevel),
		zapcore.NewCore(fileEncoder, fileWriter, zapcore.DebugLevel),
	)
	Logger = zap.New(core, zap.AddStacktrace(zapcore.FatalLevel))
}

↪️reader.go

package utils
import (
	"context"
	"fmt"
	"io"
	"github.com/celestix/gotgproto"
	"github.com/gotd/td/tg"
	"go.uber.org/zap"
)
type telegramReader struct {
	ctx           context.Context
	log           *zap.Logger
	client        *gotgproto.Client
	location      tg.InputFileLocationClass
	start         int64
	end           int64
	next          func() ([]byte, error)
	buffer        []byte
	bytesread     int64
	chunkSize     int64
	i             int64
	contentLength int64
}
func (*telegramReader) Close() error {
	return nil
}
func NewTelegramReader(
	ctx context.Context,
	client *gotgproto.Client,
	location tg.InputFileLocationClass,
	start int64,
	end int64,
	contentLength int64,
) (io.ReadCloser, error) {
	r := &telegramReader{
		ctx:           ctx,
		log:           Logger.Named("telegramReader"),
		location:      location,
		client:        client,
		start:         start,
		end:           end,
		chunkSize:     int64(1024 * 1024),
		contentLength: contentLength,
	}
	r.log.Sugar().Debug("Start")
	r.next = r.partStream()
	return r, nil
}
func (r *telegramReader) Read(p []byte) (n int, err error) {
	if r.bytesread == r.contentLength {
		r.log.Sugar().Debug("EOF (bytesread == contentLength)")
		return 0, io.EOF
	}
	if r.i >= int64(len(r.buffer)) {
		r.buffer, err = r.next()
		r.log.Debug("Next Buffer", zap.Int64("len", int64(len(r.buffer))))
		if err != nil {
			return 0, err
		}
		if len(r.buffer) == 0 {
			r.next = r.partStream()
			r.buffer, err = r.next()
			if err != nil {
				return 0, err
			}
		}
		r.i = 0
	}
	n = copy(p, r.buffer[r.i:])
	r.i += int64(n)
	r.bytesread += int64(n)
	return n, nil
}
func (r *telegramReader) chunk(offset int64, limit int64) ([]byte, error) {
	req := &tg.UploadGetFileRequest{
		Offset:   offset,
		Limit:    int(limit),
		Location: r.location,
	}
	res, err := r.client.API().UploadGetFile(r.ctx, req)
	if err != nil {
		return nil, err
	}
	switch result := res.(type) {
	case *tg.UploadFile:
		return result.Bytes, nil
	default:
		return nil, fmt.Errorf("unexpected type %T", r)
	}
}
func (r *telegramReader) partStream() func() ([]byte, error) {
	start := r.start
	end := r.end
	offset := start - (start % r.chunkSize)
	firstPartCut := start - offset
	lastPartCut := (end % r.chunkSize) + 1
	partCount := int((end - offset + r.chunkSize) / r.chunkSize)
	currentPart := 1
	readData := func() ([]byte, error) {
		if currentPart > partCount {
			return make([]byte, 0), nil
		}
		res, err := r.chunk(offset, r.chunkSize)
		if err != nil {
			return nil, err
		}
		if len(res) == 0 {
			return res, nil
		} else if partCount == 1 {
			res = res[firstPartCut:lastPartCut]
		} else if currentPart == 1 {
			res = res[firstPartCut:]
		} else if currentPart == partCount {
			res = res[:lastPartCut]
		}
		currentPart++
		offset += r.chunkSize
		r.log.Sugar().Debugf("Part %d/%d", currentPart, partCount)
		return res, nil
	}
	return readData
}

↪️time_format.go

package utils
import (
	"fmt"
	"math/bits"
)
func TimeFormat(seconds uint64) (timeStr string) {
	hours, remainder := bits.Div64(0, seconds, 3600)
	minutes, seconds := bits.Div64(0, remainder, 60)
	days, hours := bits.Div64(0, hours, 24)
	timeStr = ""
	if days > 0 {
		if days == 1 {
			timeStr += fmt.Sprintf("%d day, ", days)
		} else {
			timeStr += fmt.Sprintf("%d days, ", days)
		}
	}
	if hours > 0 {
		if hours == 1 {
			timeStr += fmt.Sprintf("%d hour, ", hours)
		} else {
			timeStr += fmt.Sprintf("%d hours, ", hours)
		}
	}
	if minutes > 0 {
		if minutes == 1 {
			timeStr += fmt.Sprintf("%d minute, ", minutes)
		} else {
			timeStr += fmt.Sprintf("%d minutes, ", minutes)
		}
	}
	if seconds > 0 {
		if seconds == 1 {
			timeStr += fmt.Sprintf("%d second", seconds)
		} else {
			timeStr += fmt.Sprintf("%d seconds", seconds)
		}
	}
	return timeStr
}

✉pkg/qrlogin

✉qrlogin

↪️encoder.go

// This file is a part of EverythingSuckz/TG-FileStreamBot
// And is licenced under the Affero General Public License.
// Any distributions of this code MUST be accompanied by a copy of the AGPL
// with proper attribution to the original author(s).
package qrlogin
import (
	"bytes"
	"encoding/base64"
	"encoding/binary"
	"errors"
	"strings"
	"github.com/gotd/td/session"
)
func EncodeToPyrogramSession(data *session.Data, appID int32) (string, error) {
	buf := new(bytes.Buffer)
	if err := buf.WriteByte(byte(data.DC)); err != nil {
		return "", err
	}
	if err := binary.Write(buf, binary.BigEndian, appID); err != nil {
		return "", err
	}
	var testMode byte
	if data.Config.TestMode {
		testMode = 1
	}
	if err := buf.WriteByte(testMode); err != nil {
		return "", err
	}
	if len(data.AuthKey) != 256 {
		return "", errors.New("auth key must be 256 bytes long")
	}
	if _, err := buf.Write(data.AuthKey); err != nil {
		return "", err
	}
	if len(data.AuthKeyID) != 8 {
		return "", errors.New("auth key ID must be 8 bytes long")
	}
	if _, err := buf.Write(data.AuthKeyID); err != nil {
		return "", err
	}
	if err := buf.WriteByte(0); err != nil {
		return "", err
	}
	// Convert the bytes buffer to a base64 string
	encodedString := base64.URLEncoding.EncodeToString(buf.Bytes())
	trimmedEncoded := strings.TrimRight(encodedString, "=")
	return trimmedEncoded, nil
}

↪️qrcode.go

// This file is a part of EverythingSuckz/TG-FileStreamBot
// And is licenced under the Affero General Public License.
// Any distributions of this code MUST be accompanied by a copy of the AGPL
// with proper attribution to the original author(s).
package qrlogin
import (
	"bufio"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"runtime"
	"strings"
	"time"
	"github.com/gotd/td/session"
	"github.com/gotd/td/telegram"
	"github.com/gotd/td/telegram/auth/qrlogin"
	"github.com/gotd/td/tg"
	"github.com/gotd/td/tgerr"
	"github.com/mdp/qrterminal"
)
type CustomWriter struct {
	LineLength int
}
func (w *CustomWriter) Write(p []byte) (n int, err error) {
	for _, c := range p {
		if c == '\n' {
			w.LineLength++
		}
	}
	return os.Stdout.Write(p)
}
func printQrCode(data string, writer *CustomWriter) {
	qrterminal.GenerateHalfBlock(data, qrterminal.L, writer)
}
func clearQrCode(writer *CustomWriter) {
	for i := 0; i < writer.LineLength; i++ {
		fmt.Printf("\033[F\033[K")
	}
	writer.LineLength = 0
}
func GenerateQRSession(apiId int, apiHash string) error {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	fmt.Println("Generating QR session...")
	reader := bufio.NewReader(os.Stdin)
	dispatcher := tg.NewUpdateDispatcher()
	loggedIn := qrlogin.OnLoginToken(dispatcher)
	sessionStorage := &session.StorageMemory{}
	client := telegram.NewClient(apiId, apiHash, telegram.Options{
		UpdateHandler:  dispatcher,
		SessionStorage: sessionStorage,
		Device: telegram.DeviceConfig{
			DeviceModel:   "Pyrogram",
			SystemVersion: runtime.GOOS,
			AppVersion:    "2.0",
		},
	})
	var stringSession string
	qrWriter := &CustomWriter{}
	tickerCtx, cancelTicker := context.WithCancel(context.Background())
	err := client.Run(ctx, func(ctx context.Context) error {
		authorization, err := client.QR().Auth(ctx, loggedIn, func(ctx context.Context, token qrlogin.Token) error {
			if qrWriter.LineLength == 0 {
				fmt.Printf("\033[F\033[K")
			}
			clearQrCode(qrWriter)
			printQrCode(token.URL(), qrWriter)
			qrWriter.Write([]byte("\nTo log in, Open your Telegram app and go to Settings > Devices > Scan QR and scan the QR code.\n"))
			go func(ctx context.Context) {
				ticker := time.NewTicker(1 * time.Second)
				defer ticker.Stop()
				for {
					select {
					case <-ctx.Done():
						return
					case <-ticker.C:
						expiresIn := time.Until(token.Expires())
						if expiresIn <= 0 {
							return
						}
						fmt.Printf("\rThis code expires in %s", expiresIn.Truncate(time.Second))
					}
				}
			}(tickerCtx)
			return nil
		})
		if err != nil {
			if tgerr.Is(err, "SESSION_PASSWORD_NEEDED") {
				cancelTicker()
				fmt.Println("\n2FA password is required, enter it below: ")
				passkey, _ := reader.ReadString('\n')
				strippedPasskey := strings.TrimSpace(passkey)
				authorization, err = client.Auth().Password(ctx, strippedPasskey)
				if err != nil {
					if err.Error() == "invalid password" {
						fmt.Println("Invalid password, please try again.")
					}
					fmt.Println("Error while logging in: ", err)
					return nil
				}
			}
		}
		if authorization == nil {
			cancel()
			return errors.New("authorization is nil")
		}
		user, err := client.Self(ctx)
		if err != nil {
			return err
		}
		if user.Username == "" {
			fmt.Println("Logged in as ", user.FirstName, user.LastName)
		} else {
			fmt.Println("Logged in as @", user.Username)
		}
		res, _ := sessionStorage.LoadSession(ctx)
		type jsonDataStruct struct {
			Version int
			Data    session.Data
		}
		var jsonData jsonDataStruct
		json.Unmarshal(res, &jsonData)
		stringSession, err = EncodeToPyrogramSession(&jsonData.Data, int32(apiId))
		if err != nil {
			return err
		}
		fmt.Println("Your pyrogram session string:", stringSession)
		client.API().MessagesSendMessage(
			ctx,
			&tg.MessagesSendMessageRequest{
				NoWebpage: true,
				Peer:      &tg.InputPeerSelf{},
				Message:   "Your pyrogram session string: " + stringSession,
			},
		)
		return nil
	})
	if err != nil {
		return err
	}
	return nil
}

↪️.gitattributes

# Auto detect text files and perform LF normalization
* text=auto

↪️.gitignore

# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories
vendor/
# Go workspace file
go.work
# Env files
fsb.env
.env
# Session files
*.session*
sessons/
# build folder
dist/
# logs folder
logs/
*.log

↪️.goreleaser.yaml

version: 1
project_name: TG-FileStreamBot
env:
  - GO111MODULE=on
before:
  hooks:
    - go mod tidy
    - go generate ./...
builds:
  - main: ./cmd/fsb
    env:
      - CGO_ENABLED=0
    flags: -tags=musl
    ldflags: "-extldflags -static -s -w"
    binary: fsb
    goos:
      - linux
      - windows
      - darwin
    goarch:
      - amd64
      - arm64
    mod_timestamp: '{{ .CommitTimestamp }}'
archives:
  - format: tar.gz
    name_template: "{{ .ProjectName }}-{{ .Tag }}-{{ .Os }}-{{ .Arch }}"
    format_overrides:
      - goos: windows
        format: zip
signs:
  - artifacts: checksum
    cmd: gpg2
    args:
      - "--batch"
      - "-u"
      - "{{ .Env.GPG_FINGERPRINT }}"
      - "--output"
      - "${signature}"
      - "--detach-sign"
      - "${artifact}"
checksum:
  name_template: "{{ .ProjectName }}-{{ .Tag }}-checksums.txt"
changelog:
  sort: asc
  filters:
    exclude:
      - "^docs:"
      - "^test:"

↪️Dockerfile

FROM golang:1.21-alpine3.18 as builder
RUN apk update && apk upgrade --available && sync
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o /app/fsb -ldflags="-w -s" ./cmd/fsb
FROM scratch
COPY --from=builder /app/fsb /app/fsb
EXPOSE ${PORT}
ENTRYPOINT ["/app/fsb", "run"]

↪️Procfile

web: fsb run
 ↪️app.json
{
    "name": "TG FileStreamBot",
    "description": "Stream Telegram files to web",
    "keywords": [
        "telegram",
        "web",
        "go",
        "golang",
        "file-streaming",
        "file-to-link",
        "TG-FileStreamBot"
    ],
    "repository": "https://github.com/EverythingSuckz/TG-FileStreamBot",
    "logo": "https://telegra.ph/file/a8bb3f6b334ad1200ddb4.png",
    "env": {
        "API_ID": {
            "description": "Get this value from https://my.telegram.org"
        },
        "API_HASH": {
            "description": "Get this value from https://my.telegram.org"
        },
        "BOT_TOKEN": {
            "description": "Get this value from @BotFather"
        },
        "LOG_CHANNEL": {
            "description": "channel ID for the log channel where the bot will forward media messages and store these files"
        },
        "HOST": {
            "description": "A Fully Qualified Domain Name or Heroku App URL. (eg. https://example.herokuapp.com). Update it After Deploying the Bot",
            "required": false
        },
        "HASH_LENGTH": {
            "description": "Custom hash length for generated URLs. The hash length must be greater than 5 and less than or equal to 32. Default to 6",
            "value": "6",
            "required": false
        },
        "USE_SESSION_FILE": {
            "description": "Use session files for worker client(s). This speeds up the worker bot startups. default to false",
            "required": false
        },
        "USER_SESSION": {
            "description": "A pyrogram session string for a user bot. Used for auto adding the bots to LOG_CHANNEL. Default to null",
            "required": false
        }
    },
    "buildpacks": [{
        "url": "heroku/go"
      }],
    "formation": {
        "web": {
            "quantity": 1,
            "size": "Eco"
        }
    }
}

↪️docker-compose.yaml

name: TG File Stream Bot
services:
  fsb-run:
    image: ghcr.io/everythingsuckz/fsb
    container_name: fsb
    restart: always
    volumes:
      - ./logs:/app/logs
      - ./fsb.env:/app/fsb.env
    ports:
      - "${PORT:-8038}:${PORT:-8038}"
    env_file:
      - path: ./fsb.env
        required: true

↪️fsb.sample.env

# Required Variables (DO NOT SKIP THESE)
API_ID=
API_HASH=
BOT_TOKEN=
LOG_CHANNEL=
# Optional Variables
PORT=8080
# The length of the hash in your URLs
# https://domain.tld/1254?hash=asd45a
#                              ^^^^^^
#                                /
#                         This is the hash
HASH_LENGTH=6
# you can use IP address
# HOST=http://<ip address>:<PORT>
# Or you can also use a domain name
# HOST=https://example.com
# For muti token support
# Refer https://github.com/EverythingSuckz/TG-FileStreamBot/tree/golang#use-multiple-bots-to-speed-up
# MULTI_TOKEN1=1857821156:AAEvrINCsduhjkjhahadvHRdk7oF46KZnc
# MULTI_TOKEN2=1355359001:AAF4dgddVVxDCt51FZqy1unh9h0SOTw0gU
# MULTI_TOKEN3=6941936497:AAGJzfoMHXshS8gVcsefUzpwyrbfU7gKRMM
# MULTI_TOKEN4=6546079247:AAF2k3uvO9Hqadfhjaskjds8jnzOAfQYUzTZ

↪️goreleaser.Dockerfile

FROM golang:1.21
CMD ["/app/fsb"]