Бот для трансляции файлов 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 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: zipsigns:
- 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"]