This commit is contained in:
Nonoo 2023-08-14 14:56:49 +02:00
parent 895e23624e
commit 392e09bc20
17 changed files with 1487 additions and 1 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/run.sh
/yt-dlp-telegram-bot

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"go.lintTool": "golangci-lint"
}

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2023 Norbert Varga
Copyright (c) 2023 Norbert "Nonoo" Varga <nonoo@nonoo.hu>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

96
README.md Normal file
View File

@ -0,0 +1,96 @@
# yt-dlp-telegram-bot
This bot downloads videos from various supported sources
(see [yt-dlp](https://github.com/yt-dlp/yt-dlp) and then re-uploads them
to Telegram, so they can be viewed with Telegram's built-in video player.
<p align="center"><img src="screenshot.png?raw=true"/></p>
The bot displays the progress and further information during processing by
responding to the message with the URL. Requests are queued, only one gets
processed at a time.
The bot uses the [Telegram MTProto API](https://github.com/gotd/td), which
supports larger video uploads than the default 50MB with the standard
Telegram bot API. Videos are not saved on disk, they are simultaneously
uploaded from the source to Telegram. Incompatible video and audio streams
are automatically converted to match those which are supported by Telegram's
built-in video player.
The only dependencies are [yt-dlp](https://github.com/yt-dlp/yt-dlp) and
[ffmpeg](https://github.com/FFmpeg/FFmpeg). Tested on Linux, but should be
able to run on other operating systems.
## Compiling
You'll need Go installed on your computer. Install a recent package of `golang`.
Then:
```
go get github.com/nonoo/yt-dlp-telegram-bot
go install github.com/nonoo/yt-dlp-telegram-bot
```
This will typically install `yt-dlp-telegram-bot` into `$HOME/go/bin`.
Or just enter `go build` in the cloned Git source repo directory.
## Prerequisites
1. Create a Telegram bot using [BotFather](https://t.me/BotFather) and get the
bot's `token`.
2. [Get your Telegram API Keys](https://my.telegram.org/apps)
(`api_id` and `api_hash`). You'll need to create an app if you haven't
created one already. Description is optional, set the category to "other".
If an error dialog pops up, then try creating the app using your phone's
browser.
3. Make sure `yt-dlp`, `ffprobe` and `ffmpeg` commands are available on your
system.
## Running
You can get the available command line arguments with `-h`.
Mandatory arguments are:
- `-api-id`: set this to your Telegram app `api_id`
- `-api-hash`: set this to your Telegram app `api_hash`
- `-bot-token`: set this to your Telegram bot's `token`
Set your Telegram user ID as an admin with the `-admin-user-ids` argument.
Admins will get a message when the bot starts and when a newer version of
`yt-dlp` is available (checked every 24 hours).
Other user/group IDs can be set with the `-allowed-user-ids` and
`-allowed-group-ids` arguments. IDs should be separated by commas.
You can get Telegram user IDs by writing a message to the bot and checking
the app's log, as it logs all incoming messages.
All command line arguments can be set through OS environment variables.
Note that using a command line argument overwrites a setting by the environment
variable. Available OS environment variables are:
- `APP_ID`
- `API_HASH`
- `BOT_TOKEN`
- `YTDLP_PATH`
- `ALLOWED_USERIDS`
- `ADMIN_USERIDS`
- `ALLOWED_GROUPIDS`
## Supported commands
- `/dlp` - Download
- `/dlpcancel` - Cancel ongoing download
You don't need to enter the `/dlp` command if you send an URL to the bot using
a private chat.
## Contributors
- Norbert Varga [nonoo@nonoo.hu](mailto:nonoo@nonoo.hu)
- Akos Marton
## Donations
If you find this bot useful then [buy me a beer](https://paypal.me/ha2non). :)

52
cmd.go Normal file
View File

@ -0,0 +1,52 @@
package main
import (
"context"
"os/exec"
"syscall"
)
// https://stackoverflow.com/questions/71714228/go-exec-commandcontext-is-not-being-terminated-after-context-timeout
type Cmd struct {
ctx context.Context
*exec.Cmd
}
// NewCommand is like exec.CommandContext but ensures that subprocesses
// are killed when the context times out, not just the top level process.
func NewCommand(ctx context.Context, command string, args ...string) *Cmd {
return &Cmd{ctx, exec.Command(command, args...)}
}
func (c *Cmd) Start() error {
// Force-enable setpgid bit so that we can kill child processes when the
// context times out or is canceled.
if c.Cmd.SysProcAttr == nil {
c.Cmd.SysProcAttr = &syscall.SysProcAttr{}
}
c.Cmd.SysProcAttr.Setpgid = true
err := c.Cmd.Start()
if err != nil {
return err
}
go func() {
<-c.ctx.Done()
p := c.Cmd.Process
if p == nil {
return
}
// Kill by negative PID to kill the process group, which includes
// the top-level process we spawned as well as any subprocesses
// it spawned.
_ = syscall.Kill(-p.Pid, syscall.SIGKILL)
}()
return nil
}
func (c *Cmd) Run() error {
if err := c.Start(); err != nil {
return err
}
return c.Wait()
}

246
convert.go Normal file
View File

@ -0,0 +1,246 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
"net"
"os"
"path"
"regexp"
"strconv"
"strings"
"time"
ffmpeg_go "github.com/u2takey/ffmpeg-go"
"golang.org/x/exp/slices"
)
const probeTimeout = 10 * time.Second
const maxFFmpegProbeBytes = 20 * 1024 * 1024
var compatibleVideoCodecs = []string{"h264", "vp9"}
var compatibleAudioCodecs = []string{"aac", "opus", "mp3"}
type ffmpegProbeDataStreamsStream struct {
CodecName string `json:"codec_name"`
CodecType string `json:"codec_type"`
}
type ffmpegProbeDataFormat struct {
FormatName string `json:"format_name"`
Duration string `json:"duration"`
}
type ffmpegProbeData struct {
Streams []ffmpegProbeDataStreamsStream `json:"streams"`
Format ffmpegProbeDataFormat `json:"format"`
}
type Converter struct {
VideoCodecs string
VideoConvertNeeded bool
SingleVideoStreamNeeded bool
AudioCodecs string
AudioConvertNeeded bool
SingleAudioStreamNeeded bool
Duration float64
UpdateProgressPercentCallback UpdateProgressPercentCallbackFunc
}
func (c *Converter) Probe(rr *ReReadCloser) error {
defer func() {
// Restart and replay buffer data used when probing
rr.Restarted = true
}()
fmt.Println(" probing file...")
i, err := ffmpeg_go.ProbeReaderWithTimeout(io.LimitReader(rr, maxFFmpegProbeBytes), probeTimeout, nil)
if err != nil {
return fmt.Errorf("error probing file: %w", err)
}
pd := ffmpegProbeData{}
err = json.Unmarshal([]byte(i), &pd)
if err != nil {
return fmt.Errorf("error decoding probe result: %w", err)
}
c.Duration, err = strconv.ParseFloat(pd.Format.Duration, 64)
if err != nil {
fmt.Println(" error parsing duration:", err)
}
gotVideoStream := false
gotAudioStream := false
for _, stream := range pd.Streams {
if stream.CodecType == "video" {
if c.VideoCodecs != "" {
c.VideoCodecs += ", "
}
c.VideoCodecs += stream.CodecName
if gotVideoStream {
fmt.Println(" got additional video stream")
c.SingleVideoStreamNeeded = true
} else if !c.VideoConvertNeeded {
if !slices.Contains(compatibleVideoCodecs, stream.CodecName) {
fmt.Println(" found incompatible video codec:", stream.CodecName)
c.VideoConvertNeeded = true
} else {
fmt.Println(" found video codec:", stream.CodecName)
}
gotVideoStream = true
}
} else if stream.CodecType == "audio" {
if c.AudioCodecs != "" {
c.AudioCodecs += ", "
}
c.AudioCodecs += stream.CodecName
if gotAudioStream {
fmt.Println(" got additional audio stream")
c.SingleAudioStreamNeeded = true
} else if !c.AudioConvertNeeded {
if !slices.Contains(compatibleAudioCodecs, stream.CodecName) {
fmt.Println(" found not compatible audio codec:", stream.CodecName)
c.AudioConvertNeeded = true
} else {
fmt.Println(" found audio codec:", stream.CodecName)
}
gotAudioStream = true
}
}
}
if !gotVideoStream {
return fmt.Errorf("no video stream found in file")
}
return nil
}
func (c *Converter) ffmpegProgressSock() (sockFilename string, sock net.Listener, err error) {
sockFilename = path.Join(os.TempDir(), fmt.Sprintf("yt-dlp-telegram-bot-%d.sock", rand.Int()))
sock, err = net.Listen("unix", sockFilename)
if err != nil {
fmt.Println(" ffmpeg progress socket create error:", err)
return
}
go func() {
re := regexp.MustCompile(`out_time_ms=(\d+)\n`)
fd, err := sock.Accept()
if err != nil {
fmt.Println(" ffmpeg progress socket accept error:", err)
return
}
defer fd.Close()
buf := make([]byte, 64)
data := ""
for {
_, err := fd.Read(buf)
if err != nil {
return
}
data += string(buf)
a := re.FindAllStringSubmatch(data, -1)
if len(a) > 0 && len(a[len(a)-1]) > 0 {
data = ""
l, _ := strconv.Atoi(a[len(a)-1][len(a[len(a)-1])-1])
c.UpdateProgressPercentCallback(int(100 * float64(l) / c.Duration / 1000000))
}
if strings.Contains(data, "progress=end") {
c.UpdateProgressPercentCallback(100)
}
}
}()
return
}
func (c *Converter) GetActionsNeeded() string {
var convertNeeded []string
if c.VideoConvertNeeded || c.SingleVideoStreamNeeded {
convertNeeded = append(convertNeeded, "video")
}
if c.AudioConvertNeeded || c.SingleAudioStreamNeeded {
convertNeeded = append(convertNeeded, "audio")
}
return strings.Join(convertNeeded, ", ")
}
func (c *Converter) ConvertIfNeeded(ctx context.Context, rr *ReReadCloser) (io.ReadCloser, error) {
reader, writer := io.Pipe()
var cmd *Cmd
fmt.Print(" converting ", c.GetActionsNeeded(), "...\n")
args := ffmpeg_go.KwArgs{"format": "mp4", "movflags": "frag_keyframe+empty_moov+faststart"}
if c.VideoConvertNeeded {
args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"c:v": "libx264", "crf": 30, "preset": "veryfast"}})
} else {
args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"c:v": "copy"}})
}
if c.AudioConvertNeeded {
args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"c:a": "mp3", "q:a": 0}})
} else {
args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"c:a": "copy"}})
}
if c.SingleVideoStreamNeeded || c.SingleAudioStreamNeeded {
args = ffmpeg_go.MergeKwArgs([]ffmpeg_go.KwArgs{args, {"map": "0:v:0,0:a:0"}})
}
ff := ffmpeg_go.Input("pipe:0").Output("pipe:1", args)
var err error
var progressSock net.Listener
if c.UpdateProgressPercentCallback != nil {
if c.Duration > 0 {
var progressSockFilename string
progressSockFilename, progressSock, err = c.ffmpegProgressSock()
if err == nil {
ff = ff.GlobalArgs("-progress", "unix:"+progressSockFilename)
}
} else {
c.UpdateProgressPercentCallback(-1)
}
}
ffCmd := ff.WithInput(rr).WithOutput(writer).Compile()
// Creating a new cmd with a timeout context, which will kill the cmd if it takes too long.
cmd = NewCommand(ctx, ffCmd.Args[0], ffCmd.Args[1:]...)
cmd.Stdin = ffCmd.Stdin
cmd.Stdout = ffCmd.Stdout
// This goroutine handles copying from the input (either rr or cmd.Stdout) to writer.
go func() {
err = cmd.Run()
writer.Close()
if progressSock != nil {
progressSock.Close()
}
}()
if err != nil {
writer.Close()
return nil, fmt.Errorf("error converting: %w", err)
}
return reader, nil
}

78
dl.go Normal file
View File

@ -0,0 +1,78 @@
package main
import (
"context"
"fmt"
"io"
"time"
"github.com/wader/goutubedl"
)
const downloadAndConvertTimeout = 5 * time.Minute
type ProbeStartCallbackFunc func(ctx context.Context)
type ConvertStartCallbackFunc func(ctx context.Context, videoCodecs, audioCodecs, convertActionsNeeded string)
type UpdateProgressPercentCallbackFunc func(progressPercent int)
type Downloader struct {
ProbeStartFunc ProbeStartCallbackFunc
ConvertStartFunc ConvertStartCallbackFunc
UpdateProgressPercentFunc UpdateProgressPercentCallbackFunc
}
type goYouTubeDLLogger struct{}
func (l goYouTubeDLLogger) Print(v ...interface{}) {
fmt.Print(" yt-dlp dbg:")
fmt.Println(v...)
}
func (d *Downloader) downloadURL(dlCtx context.Context, url string) (rr *ReReadCloser, err error) {
result, err := goutubedl.New(dlCtx, url, goutubedl.Options{
Type: goutubedl.TypeSingle,
DebugLog: goYouTubeDLLogger{},
MergeOutputFormat: "mkv", // This handles VP9 properly. yt-dlp uses mp4 by default, which doesn't.
SortingFormat: "res:720", // Prefer videos no larger than 720p to keep their size small.
})
if err != nil {
return nil, fmt.Errorf("preparing download %q: %w", url, err)
}
dlResult, err := result.Download(dlCtx, "")
if err != nil {
return nil, fmt.Errorf("downloading %q: %w", url, err)
}
return NewReReadCloser(dlResult), nil
}
func (d *Downloader) DownloadAndConvertURL(ctx context.Context, url string) (r io.ReadCloser, err error) {
rr, err := d.downloadURL(ctx, url)
if err != nil {
return nil, err
}
conv := Converter{
UpdateProgressPercentCallback: d.UpdateProgressPercentFunc,
}
if d.ProbeStartFunc != nil {
d.ProbeStartFunc(ctx)
}
if err := conv.Probe(rr); err != nil {
return nil, err
}
if d.ConvertStartFunc != nil {
d.ConvertStartFunc(ctx, conv.VideoCodecs, conv.AudioCodecs, conv.GetActionsNeeded())
}
r, err = conv.ConvertIfNeeded(ctx, rr)
if err != nil {
return nil, err
}
return r, nil
}

45
go.mod Normal file
View File

@ -0,0 +1,45 @@
module github.com/nonoo/yt-dlp-telegram-bot
go 1.20
replace github.com/wader/goutubedl => github.com/nonoo/goutubedl v0.0.0-20230814114826-c1dcced79138
require (
github.com/google/go-github/v53 v53.2.0
github.com/gotd/td v0.84.0
github.com/u2takey/ffmpeg-go v0.5.0
github.com/wader/goutubedl v0.0.0-00010101000000-000000000000
golang.org/x/exp v0.0.0-20230116083435-1de6713980de
)
require (
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect
github.com/aws/aws-sdk-go v1.38.20 // indirect
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
github.com/cloudflare/circl v1.3.3 // indirect
github.com/go-faster/errors v0.6.1 // indirect
github.com/go-faster/jx v1.0.1 // indirect
github.com/go-faster/xor v1.0.0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/gotd/ige v0.2.2 // indirect
github.com/gotd/neo v0.1.5 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/klauspost/compress v1.16.7 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/u2takey/go-utils v0.3.1 // indirect
go.opentelemetry.io/otel v1.16.0 // indirect
go.opentelemetry.io/otel/trace v1.16.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.25.0 // indirect
golang.org/x/crypto v0.11.0 // indirect
golang.org/x/net v0.12.0 // indirect
golang.org/x/oauth2 v0.8.0 // indirect
golang.org/x/sync v0.3.0 // indirect
golang.org/x/sys v0.10.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.28.1 // indirect
nhooyr.io/websocket v1.8.7 // indirect
rsc.io/qr v0.2.0 // indirect
)

184
go.sum Normal file
View File

@ -0,0 +1,184 @@
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA=
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g=
github.com/aws/aws-sdk-go v1.38.20 h1:QbzNx/tdfATbdKfubBpkt84OM6oBkxQZRw6+bW2GyeA=
github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/go-faster/errors v0.6.1 h1:nNIPOBkprlKzkThvS/0YaX8Zs9KewLCOSFQS5BU06FI=
github.com/go-faster/errors v0.6.1/go.mod h1:5MGV2/2T9yvlrbhe9pD9LO5Z/2zCSq2T8j+Jpi2LAyY=
github.com/go-faster/jx v1.0.1 h1:NhSJEZtqj6KmXf63On7Hg7/sjUX+gotSc/eM6bZCZ00=
github.com/go-faster/jx v1.0.1/go.mod h1:vKDNikrKoyUmpzaJ0OkIkRQClNHFX/nF3dnTJZb3skg=
github.com/go-faster/xor v0.3.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=
github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38=
github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=
github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY=
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-github/v53 v53.2.0 h1:wvz3FyF53v4BK+AsnvCmeNhf8AkTaeh2SoYu/XUvTtI=
github.com/google/go-github/v53 v53.2.0/go.mod h1:XhFRObz+m/l+UCm9b7KSIC3lT3NWSXGt7mOsAWEloao=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk=
github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0=
github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ=
github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ=
github.com/gotd/td v0.84.0 h1:oWMp5HczCAFSgKWgWFCuYjELBgcRVcRpGLdQ1bP2kpg=
github.com/gotd/td v0.84.0/go.mod h1:3dQsGL9rxMcS1Z9Na3S7U8e/pLMzbLIT2jM3E5IuUk0=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/nonoo/goutubedl v0.0.0-20230814114826-c1dcced79138 h1:zS49Q/Vxi7m1iPm10Or61K4tXUWFlb/D2jh3lk0sbfw=
github.com/nonoo/goutubedl v0.0.0-20230814114826-c1dcced79138/go.mod h1:5KXd5tImdbmz4JoVhePtbIokCwAfEhUVVx3WLHmjYuw=
github.com/panjf2000/ants/v2 v2.4.2/go.mod h1:f6F0NZVFsGCp5A7QW/Zj/m92atWwOkY0OIhFxRNFr4A=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/u2takey/ffmpeg-go v0.5.0 h1:r7d86XuL7uLWJ5mzSeQ03uvjfIhiJYvsRAJFCW4uklU=
github.com/u2takey/ffmpeg-go v0.5.0/go.mod h1:ruZWkvC1FEiUNjmROowOAps3ZcWxEiOpFoHCvk97kGc=
github.com/u2takey/go-utils v0.3.1 h1:TaQTgmEZZeDHQFYfd+AdUT1cT4QJgJn/XVPELhHw4ys=
github.com/u2takey/go-utils v0.3.1/go.mod h1:6e+v5vEZ/6gu12w/DC2ixZdZtCrNokVxD0JUklcqdCs=
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/wader/osleaktest v0.0.0-20191111175233-f643b0fed071 h1:QkrG4Zr5OVFuC9aaMPmFI0ibfhBZlAgtzDYWfu7tqQk=
github.com/wader/osleaktest v0.0.0-20191111175233-f643b0fed071/go.mod h1:XD6emOFPHVzb0+qQpiNOdPL2XZ0SRUM0N5JHuq6OmXo=
go.opentelemetry.io/otel v1.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s=
go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4=
go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs=
go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.25.0 h1:4Hvk6GtkucQ790dqmj7l1eEnRdKm3k3ZUrUMS2d5+5c=
go.uber.org/zap v1.25.0/go.mod h1:JIAUzQIH94IC4fOJQm7gMmBJP5k7wQfdcnYdPoEXJYk=
gocv.io/x/gocv v0.25.0/go.mod h1:Rar2PS6DV+T4FL+PM535EImD/h13hGVaHhnCu1xarBs=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/exp v0.0.0-20230116083435-1de6713980de h1:DBWn//IJw30uYCgERoxCg84hWtA97F4wMiKOIh00Uf0=
golang.org/x/exp v0.0.0-20230116083435-1de6713980de/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8=
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g=
nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=

66
helper.go Normal file
View File

@ -0,0 +1,66 @@
package main
import (
"context"
"fmt"
"github.com/gotd/td/tg"
)
// Helper function to pretty-print any Telegram API object to find out which it needs to be cast to.
// https://github.com/gotd/td/blob/main/examples/pretty-print/main.go
// func formatObject(input interface{}) string {
// o, ok := input.(tdp.Object)
// if !ok {
// // Handle tg.*Box values.
// rv := reflect.Indirect(reflect.ValueOf(input))
// for i := 0; i < rv.NumField(); i++ {
// if v, ok := rv.Field(i).Interface().(tdp.Object); ok {
// return formatObject(v)
// }
// }
// return fmt.Sprintf("%T (not object)", input)
// }
// return tdp.Format(o)
// }
func getProgressbar(progressPercent, progressBarLen int) (progressBar string) {
i := 0
for ; i < progressPercent/(100/progressBarLen); i++ {
progressBar += "▰"
}
for ; i < progressBarLen; i++ {
progressBar += "▱"
}
progressBar += " " + fmt.Sprint(progressPercent) + "%"
return
}
func resolveMsgSrc(msg *tg.Message) (fromUser *tg.PeerUser, fromGroup *tg.PeerChat) {
fromGroup, isGroupMsg := msg.PeerID.(*tg.PeerChat)
if isGroupMsg {
fromUser = msg.FromID.(*tg.PeerUser)
} else {
fromUser = msg.PeerID.(*tg.PeerUser)
}
return
}
func getFromUsername(entities tg.Entities, fromUID int64) string {
if fromUser, ok := entities.Users[fromUID]; ok {
if un, ok := fromUser.GetUsername(); ok {
return un
}
}
return ""
}
func sendTextToAdmins(ctx context.Context, msg string) {
for _, id := range params.AdminUserIDs {
_, _ = telegramSender.To(&tg.InputPeerUser{
UserID: id,
}).Text(ctx, msg)
}
}

184
main.go Normal file
View File

@ -0,0 +1,184 @@
package main
import (
"context"
"fmt"
"io"
"net"
"net/url"
"os"
"strings"
"time"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/message"
"github.com/gotd/td/telegram/uploader"
"github.com/gotd/td/tg"
"golang.org/x/exp/slices"
)
var dlQueue DownloadQueue
var telegramUploader *uploader.Uploader
var telegramSender *message.Sender
func uploadFile(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, f io.ReadCloser) error {
upload, err := telegramUploader.FromReader(ctx, "yt-dlp", f)
if err != nil {
return fmt.Errorf("uploading %w", err)
}
// Now we have uploaded file handle, sending it as styled message. First, preparing message.
document := message.UploadedDocument(upload).Video()
// Sending message with media.
if _, err := telegramSender.Answer(entities, u).Media(ctx, document); err != nil {
return fmt.Errorf("send: %w", err)
}
return nil
}
func handleCmdDLP(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, msg *tg.Message) {
// Check if message is an URL.
validURI := true
uri, err := url.ParseRequestURI(msg.Message)
if err != nil || (uri.Scheme != "http" && uri.Scheme != "https") {
validURI = false
} else {
_, err = net.LookupHost(uri.Host)
if err != nil {
validURI = false
}
}
if !validURI {
fmt.Println(" (not an url)")
_, _ = telegramSender.Reply(entities, u).Text(ctx, errorStr+": please enter an URL to download")
return
}
dlQueue.Add(ctx, entities, u, msg.Message)
}
func handleCmdDLPCancel(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, msg *tg.Message) {
dlQueue.CancelCurrentEntry(ctx, entities, u, msg.Message)
}
func handleMsg(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage) error {
msg, ok := u.Message.(*tg.Message)
if !ok || msg.Out {
// Outgoing message, not interesting.
return nil
}
fromUser, fromGroup := resolveMsgSrc(msg)
fromUsername := getFromUsername(entities, fromUser.UserID)
fmt.Print("got message")
if fromUsername != "" {
fmt.Print(" from ", fromUsername, "#", fromUser.UserID)
}
fmt.Println(":", msg.Message)
if fromGroup != nil {
fmt.Print(" msg from group #", -fromGroup.ChatID)
if !slices.Contains(params.AllowedGroupIDs, -fromGroup.ChatID) {
fmt.Println(", group not allowed, ignoring")
return nil
}
fmt.Println()
} else {
if !slices.Contains(params.AllowedUserIDs, fromUser.UserID) {
fmt.Println(" user not allowed, ignoring")
return nil
}
}
// Check if message is a command.
if msg.Message[0] == '/' {
cmd := strings.Split(msg.Message, " ")[0]
if strings.Contains(cmd, "@") {
cmd = strings.Split(cmd, "@")[0]
}
msg.Message = strings.TrimPrefix(msg.Message, cmd+" ")
switch cmd {
case "/dlp":
handleCmdDLP(ctx, entities, u, msg)
return nil
case "/dlpcancel":
handleCmdDLPCancel(ctx, entities, u, msg)
return nil
default:
fmt.Println(" (invalid cmd)")
_, _ = telegramSender.Reply(entities, u).Text(ctx, errorStr+": invalid command")
return nil
}
}
handleCmdDLP(ctx, entities, u, msg)
return nil
}
func main() {
fmt.Println("yt-dlp-telegram-bot starting...")
if err := params.Init(); err != nil {
fmt.Println("error:", err)
os.Exit(1)
}
// Dispatcher handles incoming updates.
dispatcher := tg.NewUpdateDispatcher()
opts := telegram.Options{
UpdateHandler: dispatcher,
}
var err error
opts, err = telegram.OptionsFromEnvironment(opts)
if err != nil {
panic(fmt.Sprint("options from env err: ", err))
}
client := telegram.NewClient(params.ApiID, params.ApiHash, opts)
if err := client.Run(context.Background(), func(ctx context.Context) error {
status, err := client.Auth().Status(ctx)
if err != nil {
panic(fmt.Sprint("auth status err: ", err))
}
if !status.Authorized { // Not logged in?
fmt.Println("logging in...")
if _, err := client.Auth().Bot(ctx, params.BotToken); err != nil {
panic(fmt.Sprint("login err: ", err))
}
}
api := client.API()
telegramUploader = uploader.NewUploader(api)
telegramSender = message.NewSender(api).WithUploader(telegramUploader)
dlQueue.Init(ctx)
dispatcher.OnNewMessage(handleMsg)
fmt.Println("telegram connection up")
ytdlpVersionCheckStr, _ := ytdlpVersionCheckGetStr(ctx)
sendTextToAdmins(ctx, "🤖 Bot started, "+ytdlpVersionCheckStr)
go func() {
for {
time.Sleep(24 * time.Hour)
if s, updateNeededOrError := ytdlpVersionCheckGetStr(ctx); updateNeededOrError {
sendTextToAdmins(ctx, s)
}
}
}()
<-ctx.Done()
return nil
}); err != nil {
panic(err)
}
}

131
params.go Normal file
View File

@ -0,0 +1,131 @@
package main
import (
"flag"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"github.com/wader/goutubedl"
"golang.org/x/exp/slices"
)
type paramsType struct {
ApiID int
ApiHash string
BotToken string
AllowedUserIDs []int64
AdminUserIDs []int64
AllowedGroupIDs []int64
}
var params paramsType
func (p *paramsType) Init() error {
// Further available environment variables:
// SESSION_FILE: path to session file
// SESSION_DIR: path to session directory, if SESSION_FILE is not set
var apiID string
flag.StringVar(&apiID, "api-id", "", "telegram api_id")
flag.StringVar(&p.ApiHash, "api-hash", "", "telegram api_hash")
flag.StringVar(&p.BotToken, "bot-token", "", "telegram bot token")
flag.StringVar(&goutubedl.Path, "yt-dlp-path", "", "yt-dlp path")
var allowedUserIDs string
flag.StringVar(&allowedUserIDs, "allowed-user-ids", "", "allowed telegram user ids")
var adminUserIDs string
flag.StringVar(&adminUserIDs, "admin-user-ids", "", "admin telegram user ids")
var allowedGroupIDs string
flag.StringVar(&allowedGroupIDs, "allowed-group-ids", "", "allowed telegram group ids")
flag.Parse()
var err error
if apiID == "" {
apiID = os.Getenv("API_ID")
}
if apiID == "" {
return fmt.Errorf("api id not set")
}
p.ApiID, err = strconv.Atoi(apiID)
if err != nil {
return fmt.Errorf("invalid env var API_ID")
}
if p.ApiHash == "" {
p.ApiHash = os.Getenv("API_HASH")
}
if p.ApiHash == "" {
return fmt.Errorf("api hash not set")
}
if p.BotToken == "" {
p.BotToken = os.Getenv("BOT_TOKEN")
}
if p.BotToken == "" {
return fmt.Errorf("bot token not set")
}
if goutubedl.Path == "" {
goutubedl.Path = os.Getenv("YTDLP_PATH")
}
if goutubedl.Path == "" {
goutubedl.Path = "yt-dlp"
}
goutubedl.Path, err = exec.LookPath(goutubedl.Path)
if err != nil {
return fmt.Errorf("yt-dlp not found")
}
if allowedUserIDs == "" {
allowedUserIDs = os.Getenv("ALLOWED_USERIDS")
}
sa := strings.Split(allowedUserIDs, ",")
for _, idStr := range sa {
if idStr == "" {
continue
}
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
return fmt.Errorf("env var ALLOWED_USERIDS contains invalid user ID: " + idStr)
}
p.AllowedUserIDs = append(p.AllowedUserIDs, id)
}
if adminUserIDs == "" {
adminUserIDs = os.Getenv("ADMIN_USERIDS")
}
sa = strings.Split(adminUserIDs, ",")
for _, idStr := range sa {
if idStr == "" {
continue
}
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
return fmt.Errorf("env var ADMIN_USERIDS contains invalid user ID: " + idStr)
}
p.AdminUserIDs = append(p.AdminUserIDs, id)
if !slices.Contains(p.AllowedUserIDs, id) {
p.AllowedUserIDs = append(p.AllowedUserIDs, id)
}
}
if allowedGroupIDs == "" {
allowedGroupIDs = os.Getenv("ALLOWED_GROUPIDS")
}
sa = strings.Split(allowedGroupIDs, ",")
for _, idStr := range sa {
if idStr == "" {
continue
}
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
return fmt.Errorf("env var ALLOWED_GROUPIDS contains invalid group ID: " + idStr)
}
p.AllowedGroupIDs = append(p.AllowedUserIDs, id)
}
return nil
}

262
queue.go Normal file
View File

@ -0,0 +1,262 @@
package main
import (
"context"
"fmt"
"sync"
"time"
"github.com/gotd/td/telegram/message"
"github.com/gotd/td/tg"
)
const processStartStr = "🔍 Getting information..."
const processStr = "🔨 Processing"
const processDoneStr = "🏁 Processing"
const errorStr = "❌ Error"
const canceledStr = "❌ Canceled"
const maxProgressPercentUpdateInterval = time.Second
const progressBarLength = 10
type DownloadQueueEntry struct {
URL string
OrigEntities tg.Entities
OrigMsgUpdate *tg.UpdateNewMessage
OrigMsg *tg.Message
FromUser *tg.PeerUser
FromGroup *tg.PeerChat
Reply *message.Builder
ReplyMsg *tg.UpdateShortSentMessage
Ctx context.Context
CtxCancel context.CancelFunc
Canceled bool
}
// func (e *DownloadQueueEntry) getTypingActionDst() tg.InputPeerClass {
// if e.FromGroup != nil {
// return &tg.InputPeerChat{
// ChatID: e.FromGroup.ChatID,
// }
// }
// return &tg.InputPeerUser{
// UserID: e.FromUser.UserID,
// }
// }
func (e *DownloadQueueEntry) sendTypingAction(ctx context.Context) {
// _ = telegramSender.To(e.getTypingActionDst()).TypingAction().Typing(ctx)
}
func (e *DownloadQueueEntry) sendTypingCancelAction(ctx context.Context) {
// _ = telegramSender.To(e.getTypingActionDst()).TypingAction().Cancel(ctx)
}
func (e *DownloadQueueEntry) editReply(ctx context.Context, s string) {
_, _ = e.Reply.Edit(e.ReplyMsg.ID).Text(ctx, s)
e.sendTypingAction(ctx)
}
type DownloadQueue struct {
mutex sync.Mutex
entries []DownloadQueueEntry
processReqChan chan bool
}
func (e *DownloadQueue) getQueuePositionString(pos int) string {
return "👨‍👦‍👦 Request queued at position #" + fmt.Sprint(pos)
}
func (q *DownloadQueue) Add(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, url string) {
q.mutex.Lock()
var replyStr string
if len(q.entries) == 0 {
replyStr = processStartStr
} else {
fmt.Println(" queueing request at position #", len(q.entries))
replyStr = q.getQueuePositionString(len(q.entries))
}
newEntry := DownloadQueueEntry{
URL: url,
OrigEntities: entities,
OrigMsgUpdate: u,
OrigMsg: u.Message.(*tg.Message),
}
newEntry.Reply = telegramSender.Reply(entities, u)
replyText, _ := newEntry.Reply.Text(ctx, replyStr)
newEntry.ReplyMsg = replyText.(*tg.UpdateShortSentMessage)
newEntry.FromUser, newEntry.FromGroup = resolveMsgSrc(newEntry.OrigMsg)
q.entries = append(q.entries, newEntry)
q.mutex.Unlock()
select {
case q.processReqChan <- true:
default:
}
}
func (q *DownloadQueue) CancelCurrentEntry(ctx context.Context, entities tg.Entities, u *tg.UpdateNewMessage, url string) {
q.mutex.Lock()
if len(q.entries) > 0 {
q.entries[0].Canceled = true
q.entries[0].CtxCancel()
} else {
fmt.Println(" no active request to cancel")
_, _ = telegramSender.Reply(entities, u).Text(ctx, errorStr+": no active request to cancel")
}
q.mutex.Unlock()
}
func (q *DownloadQueue) updateProgress(ctx context.Context, qEntry *DownloadQueueEntry, progressPercent int, sourceCodecInfo string) {
if progressPercent < 0 {
qEntry.editReply(ctx, processStr+"... (no progress available)\n"+sourceCodecInfo)
return
}
if progressPercent == 0 {
qEntry.editReply(ctx, processStr+"...\n"+sourceCodecInfo)
return
}
fmt.Print(" progress: ", progressPercent, "%\n")
qEntry.editReply(ctx, processStr+": "+getProgressbar(progressPercent, progressBarLength)+"\n"+sourceCodecInfo)
}
func (q *DownloadQueue) processQueueEntry(ctx context.Context, qEntry *DownloadQueueEntry) {
fromUsername := getFromUsername(qEntry.OrigEntities, qEntry.FromUser.UserID)
fmt.Print("processing request by")
if fromUsername != "" {
fmt.Print(" from ", fromUsername, "#", qEntry.FromUser.UserID)
}
fmt.Println(":", qEntry.URL)
qEntry.editReply(ctx, processStartStr)
var disableProgressPercentUpdate bool
var lastProgressPercentUpdateAt time.Time
var lastProgressPercent int
var progressUpdateTimer *time.Timer
var sourceCodecInfo string
downloader := Downloader{
ProbeStartFunc: func(ctx context.Context) {
qEntry.editReply(ctx, "🎬 Getting video format...")
},
ConvertStartFunc: func(ctx context.Context, videoCodecs, audioCodecs, convertActionsNeeded string) {
sourceCodecInfo = "🎬 Source: " + videoCodecs
if audioCodecs == "" {
sourceCodecInfo += ", no audio"
} else {
sourceCodecInfo += " / " + audioCodecs
}
if convertActionsNeeded == "" {
sourceCodecInfo += " (no conversion needed)"
} else {
sourceCodecInfo += " (converting: " + convertActionsNeeded + ")"
}
qEntry.editReply(ctx, "🎬 Preparing download...\n"+sourceCodecInfo)
},
UpdateProgressPercentFunc: func(progressPercent int) {
if disableProgressPercentUpdate || lastProgressPercent == progressPercent {
return
}
lastProgressPercent = progressPercent
if progressPercent < 0 {
disableProgressPercentUpdate = true
q.updateProgress(ctx, qEntry, progressPercent, sourceCodecInfo)
return
}
if progressUpdateTimer != nil {
progressUpdateTimer.Stop()
select {
case <-progressUpdateTimer.C:
default:
}
}
timeElapsedSinceLastUpdate := time.Since(lastProgressPercentUpdateAt)
if timeElapsedSinceLastUpdate < maxProgressPercentUpdateInterval {
progressUpdateTimer = time.AfterFunc(maxProgressPercentUpdateInterval-timeElapsedSinceLastUpdate, func() {
q.updateProgress(ctx, qEntry, progressPercent, sourceCodecInfo)
lastProgressPercentUpdateAt = time.Now()
})
return
}
q.updateProgress(ctx, qEntry, progressPercent, sourceCodecInfo)
lastProgressPercentUpdateAt = time.Now()
},
}
r, err := downloader.DownloadAndConvertURL(qEntry.Ctx, qEntry.OrigMsg.Message)
if err != nil {
fmt.Println(" error downloading:", err)
qEntry.editReply(ctx, fmt.Sprint(errorStr+": ", err))
return
}
// Feeding the returned io.ReadCloser to the uploader.
fmt.Println(" processing...")
q.updateProgress(ctx, qEntry, lastProgressPercent, sourceCodecInfo)
err = uploadFile(ctx, qEntry.OrigEntities, qEntry.OrigMsgUpdate, r)
if err != nil {
fmt.Println(" error processing:", err)
disableProgressPercentUpdate = true
r.Close()
qEntry.editReply(ctx, fmt.Sprint(errorStr+": ", err))
return
}
disableProgressPercentUpdate = true
r.Close()
if qEntry.Canceled {
fmt.Print(" canceled\n")
qEntry.editReply(ctx, canceledStr+": "+getProgressbar(lastProgressPercent, progressBarLength)+"\n"+sourceCodecInfo)
} else if lastProgressPercent < 100 {
fmt.Print(" progress: 100%\n")
qEntry.editReply(ctx, processDoneStr+": "+getProgressbar(100, progressBarLength)+"\n"+sourceCodecInfo)
}
qEntry.sendTypingCancelAction(ctx)
}
func (q *DownloadQueue) processor(ctx context.Context) {
for {
q.mutex.Lock()
if (len(q.entries)) == 0 {
q.mutex.Unlock()
<-q.processReqChan
continue
}
// Updating queue positions for all waiting entries.
for i := 1; i < len(q.entries); i++ {
q.entries[i].editReply(ctx, q.getQueuePositionString(i))
q.entries[i].sendTypingCancelAction(ctx)
}
q.entries[0].Ctx, q.entries[0].CtxCancel = context.WithTimeout(ctx, downloadAndConvertTimeout)
qEntry := &q.entries[0]
q.mutex.Unlock()
q.processQueueEntry(ctx, qEntry)
q.mutex.Lock()
q.entries[0].CtxCancel()
q.entries = q.entries[1:]
if len(q.entries) == 0 {
fmt.Print("finished queue processing\n")
}
q.mutex.Unlock()
}
}
func (q *DownloadQueue) Init(ctx context.Context) {
q.processReqChan = make(chan bool)
go q.processor(ctx)
}

78
rereader.go Normal file
View File

@ -0,0 +1,78 @@
package main
// Copyright (c) 2016 Mattias Wadman
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import (
"bytes"
"io"
)
type restartBuffer struct {
Buffer bytes.Buffer
Restarted bool
}
func (rb *restartBuffer) Read(r io.Reader, p []byte) (n int, err error) {
if rb.Restarted {
if rb.Buffer.Len() > 0 {
return rb.Buffer.Read(p)
}
n, err = r.Read(p)
return n, err
}
n, err = r.Read(p)
rb.Buffer.Write(p[:n])
return n, err
}
// ReReader transparently buffers all reads from a reader until Restarted
// is set to true. When restarted buffered data will be replayed on read and
// after that normal reading from the reader continues.
type ReReader struct {
io.Reader
restartBuffer
}
// NewReReader return a initialized ReReader
func NewReReader(r io.Reader) *ReReader {
return &ReReader{Reader: r}
}
func (rr *ReReader) Read(p []byte) (n int, err error) {
return rr.restartBuffer.Read(rr.Reader, p)
}
// ReReadCloser is same as ReReader but also forwards Close calls
type ReReadCloser struct {
io.ReadCloser
restartBuffer
}
// NewReReadCloser return a initialized ReReadCloser
func NewReReadCloser(rc io.ReadCloser) *ReReadCloser {
return &ReReadCloser{ReadCloser: rc}
}
func (rc *ReReadCloser) Read(p []byte) (n int, err error) {
return rc.restartBuffer.Read(rc.ReadCloser, p)
}

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

52
vercheck.go Normal file
View File

@ -0,0 +1,52 @@
package main
import (
"context"
"fmt"
"os/exec"
"strings"
"time"
"github.com/google/go-github/v53/github"
"github.com/wader/goutubedl"
)
const ytdlpVersionCheckTimeout = time.Second * 10
func ytdlpVersionCheck(ctx context.Context) (latestVersion, currentVersion string, err error) {
client := github.NewClient(nil)
release, _, err := client.Repositories.GetLatestRelease(ctx, "yt-dlp", "yt-dlp")
if err != nil {
return "", "", fmt.Errorf("getting latest yt-dlp version: %w", err)
}
latestVersion = release.GetTagName()
out, err := exec.Command(goutubedl.Path, "--version").Output()
if err != nil {
return "", "", fmt.Errorf("getting current yt-dlp version: %w", err)
}
currentVersion = strings.TrimSpace(string(out))
return
}
func ytdlpVersionCheckGetStr(ctx context.Context) (res string, updateNeededOrError bool) {
verCheckCtx, verCheckCtxCancel := context.WithTimeout(ctx, ytdlpVersionCheckTimeout)
defer verCheckCtxCancel()
var latestVersion, currentVersion string
var err error
if latestVersion, currentVersion, err = ytdlpVersionCheck(verCheckCtx); err != nil {
return errorStr + ": " + err.Error(), true
}
updateNeededOrError = currentVersion != latestVersion
res = "yt-dlp version: " + currentVersion
if updateNeededOrError {
res = "📢 " + res + " 📢 Update needed! Latest version is " + latestVersion + " 📢"
} else {
res += " (up to date)"
}
return
}

View File

@ -0,0 +1,7 @@
{
"folders": [
{
"path": "."
}
]
}