This commit is contained in:
stefanodvx 2025-04-14 13:05:43 +02:00
parent 264c97183e
commit 3faede7b1c
74 changed files with 6228 additions and 1 deletions

134
bot/core/default.go Normal file
View file

@ -0,0 +1,134 @@
package core
import (
"fmt"
"govd/database"
"govd/models"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
func HandleDefaultFormatDownload(
bot *gotgbot.Bot,
ctx *ext.Context,
dlCtx *models.DownloadContext,
) error {
storedMedias, err := database.GetDefaultMedias(
dlCtx.Extractor.CodeName,
dlCtx.MatchedContentID,
)
if err != nil {
return fmt.Errorf("failed to get default medias: %w", err)
}
if len(storedMedias) > 0 {
return HandleDefaultStoredFormatDownload(
bot, ctx, dlCtx, storedMedias,
)
}
response, err := dlCtx.Extractor.Run(dlCtx)
if err != nil {
return fmt.Errorf("extractor fetch run failed: %w", err)
}
mediaList := response.MediaList
if len(mediaList) == 0 {
return fmt.Errorf("no media found for content ID: %s", dlCtx.MatchedContentID)
}
for i := range mediaList {
defaultFormat := mediaList[i].GetDefaultFormat()
if defaultFormat == nil {
return fmt.Errorf("no default format found for media at index %d", i)
}
if len(defaultFormat.URL) == 0 {
return fmt.Errorf("media format at index %d has no URL", i)
}
// ensure we can merge video and audio formats
ensureMergeFormats(mediaList[i], defaultFormat)
mediaList[i].Format = defaultFormat
}
medias, err := DownloadMedias(mediaList, nil)
if err != nil {
return fmt.Errorf("failed to download media list: %w", err)
}
if len(medias) == 0 {
return fmt.Errorf("no formats downloaded")
}
isCaptionEnabled := true
if dlCtx.GroupSettings != nil && !*dlCtx.GroupSettings.Captions {
isCaptionEnabled = false
}
messageCaption := FormatCaption(
mediaList[0],
isCaptionEnabled,
)
// plugins act as post-processing for the media.
// they are run after the media is downloaded
// and before it is sent to the user
// this allows for things like merging audio and video, etc.
for _, media := range medias {
for _, plugin := range media.Media.Format.Plugins {
err = plugin(media)
if err != nil {
return fmt.Errorf("failed to run plugin: %w", err)
}
}
}
_, err = SendMedias(
bot, ctx, dlCtx,
medias,
&models.SendMediaFormatsOptions{
Caption: messageCaption,
IsStored: false,
},
)
if err != nil {
return fmt.Errorf("failed to send formats: %w", err)
}
return nil
}
func HandleDefaultStoredFormatDownload(
bot *gotgbot.Bot,
ctx *ext.Context,
dlCtx *models.DownloadContext,
storedMedias []*models.Media,
) error {
isCaptionEnabled := true
if dlCtx.GroupSettings != nil && !*dlCtx.GroupSettings.Captions {
isCaptionEnabled = false
}
messageCaption := FormatCaption(
storedMedias[0],
isCaptionEnabled,
)
var formats []*models.DownloadedMedia
for _, media := range storedMedias {
formats = append(formats, &models.DownloadedMedia{
FilePath: "",
ThumbnailFilePath: "",
Media: media,
})
}
_, err := SendMedias(
bot, ctx, dlCtx,
formats,
&models.SendMediaFormatsOptions{
Caption: messageCaption,
IsStored: true,
},
)
if err != nil {
return fmt.Errorf("failed to send media: %w", err)
}
return nil
}

187
bot/core/download.go Normal file
View file

@ -0,0 +1,187 @@
package core
import (
"context"
"fmt"
"path/filepath"
"sort"
"sync"
"govd/enums"
"govd/models"
"govd/util"
)
func downloadMediaItem(
ctx context.Context,
media *models.Media,
config *models.DownloadConfig,
idx int,
) (*models.DownloadedMedia, error) {
if config == nil {
config = util.DefaultConfig()
}
format := media.Format
if format == nil {
return nil, fmt.Errorf("media format is nil")
}
fileName := format.GetFileName()
var filePath string
var thumbnailFilePath string
if format.Type != enums.MediaTypePhoto {
if len(format.Segments) == 0 {
path, err := util.DownloadFile(
ctx, format.URL,
fileName, config,
)
if err != nil {
return nil, fmt.Errorf("failed to download file: %w", err)
}
filePath = path
} else {
path, err := util.DownloadFileWithSegments(
ctx, format.Segments,
fileName, config,
)
if err != nil {
return nil, fmt.Errorf("failed to download segments: %w", err)
}
filePath = path
}
if format.Type == enums.MediaTypeVideo || format.Type == enums.MediaTypeAudio {
path, err := getFileThumbnail(format, filePath)
if err != nil {
return nil, fmt.Errorf("failed to get thumbnail: %w", err)
}
thumbnailFilePath = path
}
if format.Type == enums.MediaTypeVideo {
if format.Width == 0 || format.Height == 0 || format.Duration == 0 {
insertVideoInfo(format, filePath)
}
}
} else {
file, err := util.DownloadFileInMemory(ctx, format.URL, config)
if err != nil {
return nil, fmt.Errorf("failed to download image: %w", err)
}
path := filepath.Join(config.DownloadDir, fileName)
if err := util.ImgToJPEG(file, path); err != nil {
return nil, fmt.Errorf("failed to convert image: %w", err)
}
filePath = path
}
return &models.DownloadedMedia{
FilePath: filePath,
ThumbnailFilePath: thumbnailFilePath,
Media: media,
Index: idx,
}, nil
}
func StartDownloadTask(
media *models.Media,
idx int,
config *models.DownloadConfig,
) (*models.DownloadedMedia, error) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
return downloadMediaItem(ctx, media, config, idx)
}
func StartConcurrentDownload(
media *models.Media,
resultsChan chan<- models.DownloadedMedia,
config *models.DownloadConfig,
errChan chan<- error,
wg *sync.WaitGroup,
idx int,
) {
defer wg.Done()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
result, err := downloadMediaItem(ctx, media, config, idx)
if err != nil {
errChan <- err
return
}
resultsChan <- *result
}
func DownloadMedia(
media *models.Media,
config *models.DownloadConfig,
) (*models.DownloadedMedia, error) {
return StartDownloadTask(media, 0, config)
}
func DownloadMedias(
medias []*models.Media,
config *models.DownloadConfig,
) ([]*models.DownloadedMedia, error) {
if len(medias) == 0 {
return []*models.DownloadedMedia{}, nil
}
if len(medias) == 1 {
result, err := DownloadMedia(medias[0], config)
if err != nil {
return nil, err
}
return []*models.DownloadedMedia{result}, nil
}
resultsChan := make(chan models.DownloadedMedia, len(medias))
errChan := make(chan error, len(medias))
var wg sync.WaitGroup
for idx, media := range medias {
wg.Add(1)
go StartConcurrentDownload(media, resultsChan, config, errChan, &wg, idx)
}
go func() {
wg.Wait()
close(resultsChan)
close(errChan)
}()
var results []*models.DownloadedMedia
var firstError error
select {
case err := <-errChan:
if err != nil {
firstError = err
}
default:
// no errors (yet)
}
for result := range resultsChan {
resultCopy := result // create a copy to avoid pointer issues
results = append(results, &resultCopy)
}
if firstError != nil {
return results, firstError
}
if len(results) > 1 {
sort.SliceStable(results, func(i, j int) bool {
return results[i].Index < results[j].Index
})
}
return results, nil
}

251
bot/core/inline.go Normal file
View file

@ -0,0 +1,251 @@
package core
import (
"fmt"
"log"
"github.com/google/uuid"
"github.com/pkg/errors"
"govd/database"
"govd/enums"
"govd/models"
"govd/util"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
var InlineTasks = make(map[string]*models.DownloadContext)
func HandleInline(
bot *gotgbot.Bot,
ctx *ext.Context,
dlCtx *models.DownloadContext,
) error {
if dlCtx.Extractor.Type != enums.ExtractorTypeSingle {
return util.ErrNotImplemented
}
contentID := dlCtx.MatchedContentID
cached, err := database.GetDefaultMedias(
dlCtx.Extractor.CodeName,
contentID,
)
if err != nil {
return err
}
if len(cached) > 0 {
if len(cached) > 1 {
return util.ErrInlineMediaGroup
}
err = HandleInlineCached(
bot, ctx,
dlCtx, cached[0],
)
if err != nil {
return err
}
return nil
}
err = StartInlineTask(bot, ctx, dlCtx)
if err != nil {
return err
}
return nil
}
func HandleInlineCached(
bot *gotgbot.Bot,
ctx *ext.Context,
dlCtx *models.DownloadContext,
media *models.Media,
) error {
var result gotgbot.InlineQueryResult
format := media.Format
resultID := fmt.Sprintf("%d:%s", ctx.EffectiveUser.Id, format.FormatID)
resultTitle := "share"
mediaCaption := FormatCaption(media, true)
_, inputFileType := format.GetFormatInfo()
switch inputFileType {
case "photo":
result = &gotgbot.InlineQueryResultCachedPhoto{
Id: resultID,
PhotoFileId: format.FileID,
Title: resultTitle,
Caption: mediaCaption,
ParseMode: "HTML",
}
case "video":
result = &gotgbot.InlineQueryResultCachedVideo{
Id: resultID,
VideoFileId: format.FileID,
Title: resultTitle,
Caption: mediaCaption,
ParseMode: "HTML",
}
case "audio":
result = &gotgbot.InlineQueryResultCachedAudio{
Id: resultID,
AudioFileId: format.FileID,
Caption: mediaCaption,
ParseMode: "HTML",
}
case "document":
result = &gotgbot.InlineQueryResultCachedDocument{
Id: resultID,
DocumentFileId: format.FileID,
Title: resultTitle,
Caption: mediaCaption,
ParseMode: "HTML",
}
default:
return errors.New("unsupported input file type")
}
ctx.InlineQuery.Answer(
bot, []gotgbot.InlineQueryResult{result},
&gotgbot.AnswerInlineQueryOpts{
CacheTime: 1,
IsPersonal: true,
},
)
return nil
}
func HandleInlineCachedResult(
bot *gotgbot.Bot,
ctx *ext.Context,
dlCtx *models.DownloadContext,
media *models.Media,
) error {
format := media.Format
messageCaption := FormatCaption(media, true)
inputMedia, err := format.GetInputMediaWithFileID(messageCaption)
if err != nil {
return err
}
_, _, err = bot.EditMessageMedia(
inputMedia,
&gotgbot.EditMessageMediaOpts{
InlineMessageId: ctx.ChosenInlineResult.InlineMessageId,
},
)
if err != nil {
return err
}
return nil
}
func StartInlineTask(
bot *gotgbot.Bot,
ctx *ext.Context,
dlCtx *models.DownloadContext,
) error {
randomID, err := uuid.NewUUID()
if err != nil {
return errors.New("could not generate task ID")
}
taskID := randomID.String()
inlineResult := &gotgbot.InlineQueryResultArticle{
Id: taskID,
Title: "share",
InputMessageContent: &gotgbot.InputTextMessageContent{
MessageText: "loading media plese wait...",
ParseMode: "HTML",
LinkPreviewOptions: &gotgbot.LinkPreviewOptions{
IsDisabled: true,
},
},
ReplyMarkup: &gotgbot.InlineKeyboardMarkup{
InlineKeyboard: [][]gotgbot.InlineKeyboardButton{
{
{
Text: "...",
CallbackData: "inline:loading",
},
},
},
},
}
ok, err := ctx.InlineQuery.Answer(
bot, []gotgbot.InlineQueryResult{inlineResult},
&gotgbot.AnswerInlineQueryOpts{
CacheTime: 1,
IsPersonal: true,
},
)
if err != nil {
log.Println("failed to answer inline query:", err)
}
if !ok {
log.Println("failed to answer inline query")
return nil
}
InlineTasks[taskID] = dlCtx
return nil
}
func GetInlineFormat(
bot *gotgbot.Bot,
ctx *ext.Context,
dlCtx *models.DownloadContext,
mediaChan chan<- *models.Media,
errChan chan<- error,
) {
response, err := dlCtx.Extractor.Run(dlCtx)
if err != nil {
errChan <- fmt.Errorf("failed to get media: %w", err)
return
}
mediaList := response.MediaList
if len(mediaList) == 0 {
errChan <- fmt.Errorf("no media found for content ID: %s", dlCtx.MatchedContentID)
}
if len(mediaList) > 1 {
errChan <- util.ErrInlineMediaGroup
return
}
for i := range mediaList {
defaultFormat := mediaList[i].GetDefaultFormat()
if defaultFormat == nil {
errChan <- fmt.Errorf("no default format found for media at index %d", i)
return
}
if len(defaultFormat.URL) == 0 {
errChan <- fmt.Errorf("media format at index %d has no URL", i)
return
}
// ensure we can merge video and audio formats
ensureMergeFormats(mediaList[i], defaultFormat)
mediaList[i].Format = defaultFormat
}
messageCaption := FormatCaption(mediaList[0], true)
medias, err := DownloadMedias(mediaList, nil)
if err != nil {
errChan <- fmt.Errorf("failed to download medias: %w", err)
return
}
msgs, err := SendMedias(
bot, ctx, dlCtx,
medias, &models.SendMediaFormatsOptions{
Caption: messageCaption,
},
)
if err != nil {
errChan <- fmt.Errorf("failed to send media: %w", err)
return
}
msg := &msgs[0]
msg.Delete(bot, nil)
err = StoreMedias(
dlCtx, msgs,
medias,
)
if err != nil {
errChan <- fmt.Errorf("failed to store media: %w", err)
return
}
mediaChan <- medias[0].Media
}

147
bot/core/main.go Normal file
View file

@ -0,0 +1,147 @@
package core
import (
"fmt"
"os"
"slices"
"time"
"github.com/pkg/errors"
"govd/enums"
"govd/models"
"govd/util"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
func HandleDownloadRequest(
bot *gotgbot.Bot,
ctx *ext.Context,
dlCtx *models.DownloadContext,
) error {
chatID := ctx.EffectiveMessage.Chat.Id
if dlCtx.Extractor.Type == enums.ExtractorTypeSingle {
TypingEffect(bot, ctx, chatID)
err := HandleDefaultFormatDownload(bot, ctx, dlCtx)
if err != nil {
return err
}
return nil
}
return util.ErrUnsupportedExtractorType
}
func SendMedias(
bot *gotgbot.Bot,
ctx *ext.Context,
dlCtx *models.DownloadContext,
medias []*models.DownloadedMedia,
options *models.SendMediaFormatsOptions,
) ([]gotgbot.Message, error) {
var chatID int64
var messageOptions *gotgbot.SendMediaGroupOpts
if dlCtx.GroupSettings != nil {
if len(medias) > dlCtx.GroupSettings.MediaGroupLimit {
return nil, util.ErrMediaGroupLimitExceeded
}
if !*dlCtx.GroupSettings.NSFW {
for _, media := range medias {
if media.Media.NSFW {
return nil, util.ErrNSFWNotAllowed
}
}
}
}
switch {
case ctx.Message != nil:
chatID = ctx.EffectiveMessage.Chat.Id
messageOptions = &gotgbot.SendMediaGroupOpts{
ReplyParameters: &gotgbot.ReplyParameters{
MessageId: ctx.EffectiveMessage.MessageId,
},
}
case ctx.CallbackQuery != nil:
chatID = ctx.CallbackQuery.Message.GetChat().Id
messageOptions = nil
case ctx.InlineQuery != nil:
chatID = ctx.InlineQuery.From.Id
messageOptions = nil
case ctx.ChosenInlineResult != nil:
chatID = ctx.ChosenInlineResult.From.Id
messageOptions = &gotgbot.SendMediaGroupOpts{
DisableNotification: true,
}
default:
return nil, errors.New("failed to get chat id")
}
var sentMessages []gotgbot.Message
mediaGroupChunks := slices.Collect(
slices.Chunk(medias, 10),
)
for _, chunk := range mediaGroupChunks {
var inputMediaList []gotgbot.InputMedia
for idx, media := range chunk {
var caption string
if idx == 0 {
caption = options.Caption
}
inputMedia, err := media.Media.Format.GetInputMedia(
media.FilePath,
media.ThumbnailFilePath,
caption,
)
if err != nil {
return nil, fmt.Errorf("failed to get input media: %w", err)
}
inputMediaList = append(inputMediaList, inputMedia)
}
mediaType := chunk[0].Media.Format.Type
SendingEffect(bot, ctx, chatID, mediaType)
msgs, err := bot.SendMediaGroup(
chatID,
inputMediaList,
messageOptions,
)
if err != nil {
return nil, err
}
for _, media := range chunk {
if media.FilePath != "" {
os.Remove(media.FilePath)
}
if media.ThumbnailFilePath != "" {
os.Remove(media.ThumbnailFilePath)
}
}
sentMessages = append(sentMessages, msgs...)
if sentMessages[0].Chat.Type != "private" {
if len(mediaGroupChunks) > 1 {
time.Sleep(3 * time.Second)
} // avoid floodwait?
}
}
if len(sentMessages) == 0 {
return nil, errors.New("no messages sent")
}
if !options.IsStored {
err := StoreMedias(
dlCtx,
sentMessages,
medias,
)
if err != nil {
return nil, fmt.Errorf("failed to cache formats: %w", err)
}
}
return sentMessages, nil
}

280
bot/core/util.go Normal file
View file

@ -0,0 +1,280 @@
package core
import (
"context"
"fmt"
"log"
"path/filepath"
"strings"
"github.com/pkg/errors"
"govd/database"
"govd/enums"
"govd/models"
"govd/plugins"
"govd/util"
"govd/util/av"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
func getFileThumbnail(
format *models.MediaFormat,
filePath string,
) (string, error) {
fileDir := filepath.Dir(filePath)
fileName := filepath.Base(filePath)
fileExt := filepath.Ext(fileName)
fileBaseName := fileName[:len(fileName)-len(fileExt)]
thumbnailFilePath := filepath.Join(fileDir, fileBaseName+".thumb.jpeg")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if len(format.Thumbnail) > 0 {
file, err := util.DownloadFileInMemory(ctx, format.Thumbnail, nil)
if err != nil {
return "", fmt.Errorf("failed to download file in memory: %w", err)
}
err = util.ImgToJPEG(file, thumbnailFilePath)
if err != nil {
return "", fmt.Errorf("failed to convert to JPEG: %w", err)
}
return thumbnailFilePath, nil
}
if format.Type == enums.MediaTypeVideo {
err := av.ExtractVideoThumbnail(filePath, thumbnailFilePath)
if err != nil {
return "", fmt.Errorf("failed to extract video thumbnail: %w", err)
}
return thumbnailFilePath, nil
}
return "", nil
}
func insertVideoInfo(
format *models.MediaFormat,
filePath string,
) {
width, height, duration := av.GetVideoInfo(filePath)
format.Width = width
format.Height = height
format.Duration = duration
}
func GetMessageFileID(msg *gotgbot.Message) string {
switch {
case msg.Video != nil:
return msg.Video.FileId
case msg.Animation != nil:
return msg.Animation.FileId
case msg.Photo != nil:
return msg.Photo[len(msg.Photo)-1].FileId
case msg.Document != nil:
return msg.Document.FileId
case msg.Audio != nil:
return msg.Audio.FileId
case msg.Voice != nil:
return msg.Voice.FileId
default:
return ""
}
}
func GetMessageFileSize(msg *gotgbot.Message) int64 {
switch {
case msg.Video != nil:
return msg.Video.FileSize
case msg.Animation != nil:
return msg.Animation.FileSize
case msg.Photo != nil:
return msg.Photo[len(msg.Photo)-1].FileSize
case msg.Document != nil:
return msg.Document.FileSize
case msg.Audio != nil:
return msg.Audio.FileSize
case msg.Voice != nil:
return msg.Voice.FileSize
default:
return 0
}
}
func StoreMedias(
dlCtx *models.DownloadContext,
msgs []gotgbot.Message,
medias []*models.DownloadedMedia,
) error {
var storedMedias []*models.Media
if len(medias) == 0 {
return fmt.Errorf("no media to store")
}
for idx, msg := range msgs {
fileID := GetMessageFileID(&msg)
if len(fileID) == 0 {
return fmt.Errorf("no file ID found for media at index %d", idx)
}
fileSize := GetMessageFileSize(&msg)
medias[idx].Media.Format.FileID = fileID
medias[idx].Media.Format.FileSize = fileSize
storedMedias = append(
storedMedias,
medias[idx].Media,
)
}
for _, media := range storedMedias {
err := database.StoreMedia(
dlCtx.Extractor.CodeName,
media.ContentID,
media,
)
if err != nil {
return fmt.Errorf("failed to store media: %w", err)
}
}
return nil
}
func FormatCaption(
media *models.Media,
isEnabled bool,
) string {
newCaption := fmt.Sprintf(
"<a href='%s'>source</a> - @govd_bot\n",
media.ContentURL,
)
if isEnabled && media.Caption.Valid {
text := media.Caption.String
if len(text) > 600 {
text = text[:600] + "..."
}
newCaption += fmt.Sprintf(
"<blockquote expandable>%s</blockquote>\n",
util.EscapeCaption(text),
)
}
return newCaption
}
func TypingEffect(
bot *gotgbot.Bot,
ctx *ext.Context,
chatID int64,
) {
bot.SendChatAction(
chatID,
"typing",
nil,
)
}
func SendingEffect(
bot *gotgbot.Bot,
ctx *ext.Context,
chatID int64,
mediaType enums.MediaType,
) {
action := "upload_document"
if mediaType == enums.MediaTypeVideo {
action = "upload_video"
}
if mediaType == enums.MediaTypeAudio {
action = "upload_audio"
}
if mediaType == enums.MediaTypePhoto {
action = "upload_photo"
}
bot.SendChatAction(
chatID,
action,
nil,
)
}
func HandleErrorMessage(
bot *gotgbot.Bot,
ctx *ext.Context,
err error,
) {
currentError := err
for currentError != nil {
var botError *util.Error
if errors.As(currentError, &botError) {
SendErrorMessage(bot, ctx, fmt.Sprintf(
"error occurred when downloading: %s",
currentError.Error(),
))
return
}
currentError = errors.Unwrap(currentError)
}
lastError := util.GetLastError(err)
errorMessage := fmt.Sprintf(
"error occurred when downloading: %s",
lastError.Error(),
)
if strings.Contains(errorMessage, bot.Token) {
errorMessage = "telegram related error, probably connection issue"
}
SendErrorMessage(bot, ctx, errorMessage)
}
func SendErrorMessage(
bot *gotgbot.Bot,
ctx *ext.Context,
errorMessage string,
) {
log.Println(errorMessage)
switch {
case ctx.Update.Message != nil:
ctx.EffectiveMessage.Reply(
bot,
errorMessage,
nil,
)
case ctx.Update.InlineQuery != nil:
ctx.InlineQuery.Answer(
bot,
nil,
&gotgbot.AnswerInlineQueryOpts{
CacheTime: 1,
Button: &gotgbot.InlineQueryResultsButton{
Text: errorMessage,
StartParameter: "start",
},
},
)
case ctx.ChosenInlineResult != nil:
bot.EditMessageText(
errorMessage,
&gotgbot.EditMessageTextOpts{
InlineMessageId: ctx.ChosenInlineResult.InlineMessageId,
})
}
}
func ensureMergeFormats(
media *models.Media,
videoFormat *models.MediaFormat,
) {
if videoFormat.Type != enums.MediaTypeVideo {
return
}
if videoFormat.AudioCodec != "" {
return
}
// video with no audio
audioFormat := media.GetDefaultAudioFormat()
if audioFormat == nil {
return
}
videoFormat.AudioCodec = audioFormat.AudioCodec
videoFormat.Plugins = append(videoFormat.Plugins, plugins.MergeAudio)
}

44
bot/handlers/ext.go Normal file
View file

@ -0,0 +1,44 @@
package handlers
import (
extractors "govd/ext"
"strings"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
func ExtractorsHandler(bot *gotgbot.Bot, ctx *ext.Context) error {
ctx.CallbackQuery.Answer(bot, nil)
messageText := "available extractors:\n"
extractorNames := make([]string, 0, len(extractors.List))
for _, extractor := range extractors.List {
if extractor.IsRedirect {
continue
}
extractorNames = append(extractorNames, extractor.Name)
}
messageText += strings.Join(extractorNames, ", ")
ctx.EffectiveMessage.EditText(
bot,
messageText,
&gotgbot.EditMessageTextOpts{
LinkPreviewOptions: &gotgbot.LinkPreviewOptions{
IsDisabled: true,
},
ReplyMarkup: gotgbot.InlineKeyboardMarkup{
InlineKeyboard: [][]gotgbot.InlineKeyboardButton{
{
{
Text: "back",
CallbackData: "start",
},
},
},
},
},
)
return nil
}

45
bot/handlers/help.go Normal file
View file

@ -0,0 +1,45 @@
package handlers
import (
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
var helpMessage = "usage:\n" +
"- you can add the bot to a group " +
"to start catching sent links\n" +
"- you can send a link to the bot privately " +
"to download the media too\n\n" +
"group commands:\n" +
"- /settings = show current settings\n" +
"- /captions (true|false) = enable/disable descriptions\n" +
"- /nsfw (true|false) = enable/disable nsfw content\n" +
"- /limit (int) = set max items in media groups\n\n" +
"note: the bot is still in beta, " +
"so expect some bugs and missing features.\n"
var helpKeyboard = gotgbot.InlineKeyboardMarkup{
InlineKeyboard: [][]gotgbot.InlineKeyboardButton{
{
{
Text: "back",
CallbackData: "start",
},
},
},
}
func HelpHandler(bot *gotgbot.Bot, ctx *ext.Context) error {
ctx.CallbackQuery.Answer(bot, nil)
ctx.EffectiveMessage.EditText(
bot,
helpMessage,
&gotgbot.EditMessageTextOpts{
LinkPreviewOptions: &gotgbot.LinkPreviewOptions{
IsDisabled: true,
},
ReplyMarkup: helpKeyboard,
},
)
return nil
}

91
bot/handlers/inline.go Normal file
View file

@ -0,0 +1,91 @@
package handlers
import (
"context"
"govd/bot/core"
"govd/models"
"govd/util"
"strings"
"time"
extractors "govd/ext"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
func InlineDownloadHandler(
bot *gotgbot.Bot,
ctx *ext.Context,
) error {
url := strings.TrimSpace(ctx.InlineQuery.Query)
if url == "" {
ctx.InlineQuery.Answer(bot, []gotgbot.InlineQueryResult{}, &gotgbot.AnswerInlineQueryOpts{
CacheTime: 1,
IsPersonal: true,
})
return nil
}
dlCtx, err := extractors.CtxByURL(url)
if err != nil || dlCtx == nil || dlCtx.Extractor == nil {
ctx.InlineQuery.Answer(bot, []gotgbot.InlineQueryResult{}, &gotgbot.AnswerInlineQueryOpts{
CacheTime: 1,
IsPersonal: true,
})
return nil
}
return core.HandleInline(bot, ctx, dlCtx)
}
func InlineDownloadResultHandler(
bot *gotgbot.Bot,
ctx *ext.Context,
) error {
dlCtx, ok := core.InlineTasks[ctx.ChosenInlineResult.ResultId]
if !ok {
return nil
}
defer delete(core.InlineTasks, ctx.ChosenInlineResult.ResultId)
mediaChan := make(chan *models.Media, 1)
errChan := make(chan error, 1)
timeout, cancel := context.WithTimeout(
context.Background(),
5*time.Minute,
)
defer cancel()
go core.GetInlineFormat(
bot, ctx, dlCtx,
mediaChan, errChan,
)
select {
case media := <-mediaChan:
err := core.HandleInlineCachedResult(
bot, ctx,
dlCtx, media,
)
if err != nil {
core.HandleErrorMessage(bot, ctx, err)
return nil
}
case err := <-errChan:
core.HandleErrorMessage(bot, ctx, err)
return nil
case <-timeout.Done():
core.HandleErrorMessage(bot, ctx, util.ErrTimeout)
return nil
}
return nil
}
func InlineLoadingHandler(
bot *gotgbot.Bot,
ctx *ext.Context,
) error {
ctx.CallbackQuery.Answer(bot, &gotgbot.AnswerCallbackQueryOpts{
Text: "wait !",
ShowAlert: true,
})
return nil
}

72
bot/handlers/instances.go Normal file
View file

@ -0,0 +1,72 @@
package handlers
import (
"fmt"
"os"
"runtime"
"strings"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
var buildHash = "unknown"
var branchName = "unknown"
func getInstanceMessage() string {
return "current instance\n" +
"go version: %s\n" +
"build: <a href='%s'>%s</a>\n" +
"branch: <a href='%s'>%s</a>\n\n" +
"public instances\n" +
"- @govd_bot | main official instance\n" +
"\nwant to add your own instance? reach us on @govdsupport"
}
func InstancesHandler(bot *gotgbot.Bot, ctx *ext.Context) error {
var commitURL string
var branchURL string
repoURL := os.Getenv("REPO_URL")
if repoURL != "" {
commitURL = fmt.Sprintf(
"%s/tree/%s",
repoURL,
buildHash,
)
branchURL = fmt.Sprintf(
"%s/tree/%s",
repoURL,
branchName,
)
}
messageText := fmt.Sprintf(
getInstanceMessage(),
strings.TrimPrefix(runtime.Version(), "go"),
commitURL,
buildHash,
branchURL,
branchName,
)
ctx.CallbackQuery.Answer(bot, nil)
ctx.EffectiveMessage.EditText(
bot,
messageText,
&gotgbot.EditMessageTextOpts{
LinkPreviewOptions: &gotgbot.LinkPreviewOptions{
IsDisabled: true,
},
ReplyMarkup: gotgbot.InlineKeyboardMarkup{
InlineKeyboard: [][]gotgbot.InlineKeyboardButton{
{
{
Text: "back",
CallbackData: "start",
},
},
},
},
},
)
return nil
}

213
bot/handlers/settings.go Normal file
View file

@ -0,0 +1,213 @@
package handlers
import (
"fmt"
"govd/database"
"govd/util"
"strconv"
"strings"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
func SettingsHandler(bot *gotgbot.Bot, ctx *ext.Context) error {
if ctx.EffectiveMessage.Chat.Type == "private" {
ctx.EffectiveMessage.Reply(
bot,
"use this command in group chats only",
nil,
)
return nil
}
settings, err := database.GetGroupSettings(ctx.EffectiveMessage.Chat.Id)
if err != nil {
return err
}
ctx.EffectiveMessage.Reply(
bot,
fmt.Sprintf(
"settings for this group\n\ncaptions: %s\nnsfw: %s\nmedia group limit: %d",
strconv.FormatBool(*settings.Captions),
strconv.FormatBool(*settings.NSFW),
settings.MediaGroupLimit,
),
nil,
)
return nil
}
func CaptionsHandler(bot *gotgbot.Bot, ctx *ext.Context) error {
if ctx.EffectiveMessage.Chat.Type == "private" {
return nil
}
chatID := ctx.EffectiveMessage.Chat.Id
userID := ctx.EffectiveMessage.From.Id
args := ctx.Args()
if len(args) != 2 {
ctx.EffectiveMessage.Reply(
bot,
"usage: /captions (true|false)",
nil,
)
return nil
}
if !util.IsUserAdmin(bot, chatID, userID) {
ctx.EffectiveMessage.Reply(
bot,
"you don't have permission to change settings",
nil,
)
return nil
}
userInput := strings.ToLower(args[1])
value, err := strconv.ParseBool(userInput)
if err != nil {
ctx.EffectiveMessage.Reply(
bot,
fmt.Sprintf("invalid value (%s), use true or false", userInput),
nil,
)
return nil
}
settings, err := database.GetGroupSettings(chatID)
if err != nil {
return err
}
settings.Captions = &value
err = database.UpdateGroupSettings(chatID, settings)
if err != nil {
return err
}
var message string
if value {
message = "captions enabled"
} else {
message = "captions disabled"
}
ctx.EffectiveMessage.Reply(
bot,
message,
nil,
)
return nil
}
func NSFWHandler(bot *gotgbot.Bot, ctx *ext.Context) error {
if ctx.EffectiveMessage.Chat.Type == "private" {
return nil
}
chatID := ctx.EffectiveMessage.Chat.Id
userID := ctx.EffectiveMessage.From.Id
args := ctx.Args()
if len(args) != 2 {
ctx.EffectiveMessage.Reply(
bot,
"usage: /nsfw (true|false)",
nil,
)
return nil
}
if !util.IsUserAdmin(bot, chatID, userID) {
ctx.EffectiveMessage.Reply(
bot,
"you don't have permission to change settings",
nil,
)
return nil
}
userInput := strings.ToLower(args[1])
value, err := strconv.ParseBool(userInput)
if err != nil {
ctx.EffectiveMessage.Reply(
bot,
fmt.Sprintf("invalid value (%s), use true or false", userInput),
nil,
)
return nil
}
settings, err := database.GetGroupSettings(chatID)
if err != nil {
return err
}
settings.NSFW = &value
err = database.UpdateGroupSettings(chatID, settings)
if err != nil {
return err
}
var message string
if value {
message = "nsfw enabled"
} else {
message = "nsfw disabled"
}
ctx.EffectiveMessage.Reply(
bot,
message,
nil,
)
return nil
}
func MediaGroupLimitHandler(bot *gotgbot.Bot, ctx *ext.Context) error {
if ctx.EffectiveMessage.Chat.Type == "private" {
return nil
}
chatID := ctx.EffectiveMessage.Chat.Id
userID := ctx.EffectiveMessage.From.Id
args := ctx.Args()
if len(args) != 2 {
ctx.EffectiveMessage.Reply(
bot,
"usage: /limit (int)",
nil,
)
return nil
}
if !util.IsUserAdmin(bot, chatID, userID) {
ctx.EffectiveMessage.Reply(
bot,
"you don't have permission to change settings",
nil,
)
return nil
}
value, err := strconv.Atoi(args[1])
if err != nil {
ctx.EffectiveMessage.Reply(
bot,
fmt.Sprintf("invalid value (%s), use a number", args[1]),
nil,
)
return nil
}
if value < 1 || value > 20 {
ctx.EffectiveMessage.Reply(
bot,
"media group limit must be between 1 and 20",
nil,
)
return nil
}
settings, err := database.GetGroupSettings(chatID)
if err != nil {
return err
}
settings.MediaGroupLimit = value
err = database.UpdateGroupSettings(chatID, settings)
if err != nil {
return err
}
ctx.EffectiveMessage.Reply(
bot,
fmt.Sprintf("media group limit set to %d", value),
nil,
)
return nil
}

88
bot/handlers/start.go Normal file
View file

@ -0,0 +1,88 @@
package handlers
import (
"fmt"
"os"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
var startMessage = "govd is an open-source telegram bot " +
"that allows you to download medias from " +
"various platforms. the project born after " +
"the discontinuation of an " +
"highly popular bot, known as UVD."
func getStartKeyboard(bot *gotgbot.Bot) gotgbot.InlineKeyboardMarkup {
return gotgbot.InlineKeyboardMarkup{
InlineKeyboard: [][]gotgbot.InlineKeyboardButton{
{
{
Text: "add to group",
Url: fmt.Sprintf(
"https://t.me/%s?startgroup=true",
bot.Username,
),
},
},
{
{
Text: "usage",
CallbackData: "help",
},
{
Text: "stats",
CallbackData: "stats",
},
},
{
{
Text: "extractors",
CallbackData: "extractors",
},
{
Text: "support",
Url: "https://t.me/govdsupport",
},
},
{
{
Text: "instances",
CallbackData: "instances",
},
{
Text: "github",
Url: os.Getenv("REPO_URL"),
},
},
},
}
}
func StartHandler(bot *gotgbot.Bot, ctx *ext.Context) error {
if ctx.EffectiveMessage.Chat.Type != "private" {
return nil
}
keyboard := getStartKeyboard(bot)
if ctx.Update.Message != nil {
ctx.EffectiveMessage.Reply(
bot,
startMessage,
&gotgbot.SendMessageOpts{
ReplyMarkup: &keyboard,
},
)
} else if ctx.Update.CallbackQuery != nil {
ctx.CallbackQuery.Answer(bot, nil)
ctx.EffectiveMessage.EditText(
bot,
startMessage,
&gotgbot.EditMessageTextOpts{
ReplyMarkup: keyboard,
},
)
}
return nil
}

89
bot/handlers/stats.go Normal file
View file

@ -0,0 +1,89 @@
package handlers
import (
"fmt"
"govd/database"
"time"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
)
type Stats struct {
TotalUsers int64
TotalGroups int64
TotalDailyUsers int64
TotalMedia int64
UpdatedAt time.Time
}
var lastSavedStats *Stats
var statsMessage = "users: %d\nusers today: %d\ngroups: %d\ndownloads: %d\n\nupdates every 10 minutes"
func StatsHandler(bot *gotgbot.Bot, ctx *ext.Context) error {
if ctx.EffectiveMessage.Chat.Type != "private" {
return nil
}
ctx.CallbackQuery.Answer(bot, nil)
stats := GetStats()
ctx.EffectiveMessage.EditText(
bot,
fmt.Sprintf(
statsMessage,
stats.TotalUsers,
stats.TotalDailyUsers,
stats.TotalGroups,
stats.TotalMedia,
),
&gotgbot.EditMessageTextOpts{
ReplyMarkup: gotgbot.InlineKeyboardMarkup{
InlineKeyboard: [][]gotgbot.InlineKeyboardButton{
{
{
Text: "back",
CallbackData: "start",
},
},
},
},
},
)
return nil
}
func UpdateStats() {
totalUsers, err := database.GetUsersCount()
if err != nil {
return
}
totalGroups, err := database.GetGroupsCount()
if err != nil {
return
}
totalDailyUsers, err := database.GetDailyUserCount()
if err != nil {
return
}
totalMedia, err := database.GetMediaCount()
if err != nil {
return
}
lastSavedStats = &Stats{
TotalUsers: totalUsers,
TotalGroups: totalGroups,
TotalDailyUsers: totalDailyUsers,
TotalMedia: totalMedia,
UpdatedAt: time.Now(),
}
}
func GetStats() *Stats {
if lastSavedStats == nil {
UpdateStats()
}
if lastSavedStats.UpdatedAt.Add(10 * time.Minute).Before(time.Now()) {
UpdateStats()
}
return lastSavedStats
}

65
bot/handlers/url.go Normal file
View file

@ -0,0 +1,65 @@
package handlers
import (
"govd/bot/core"
"govd/database"
extractors "govd/ext"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
"github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/message"
)
func URLHandler(bot *gotgbot.Bot, ctx *ext.Context) error {
messageURL := getMessageURL(ctx.EffectiveMessage)
if messageURL == "" {
return nil
}
dlCtx, err := extractors.CtxByURL(messageURL)
if err != nil {
core.HandleErrorMessage(
bot, ctx, err)
return nil
}
if dlCtx == nil || dlCtx.Extractor == nil {
return nil
}
userID := ctx.EffectiveMessage.From.Id
if ctx.EffectiveMessage.Chat.Type != "private" {
settings, err := database.GetGroupSettings(ctx.EffectiveMessage.Chat.Id)
if err != nil {
return err
}
dlCtx.GroupSettings = settings
}
if userID != 1087968824 {
// groupAnonymousBot
_, err = database.GetUser(userID)
if err != nil {
return err
}
}
err = core.HandleDownloadRequest(bot, ctx, dlCtx)
if err != nil {
core.HandleErrorMessage(
bot, ctx, err)
}
return nil
}
func URLFilter(msg *gotgbot.Message) bool {
return message.Text(msg) && !message.Command(msg) && containsURL(msg)
}
func containsURL(msg *gotgbot.Message) bool {
return message.Entity("url")(msg)
}
func getMessageURL(msg *gotgbot.Message) string {
for _, entity := range msg.Entities {
if entity.Type == "url" {
return msg.Text[entity.Offset : entity.Offset+entity.Length]
}
}
return ""
}

118
bot/main.go Normal file
View file

@ -0,0 +1,118 @@
package bot
import (
"log"
"os"
"time"
botHandlers "govd/bot/handlers"
"github.com/PaulSonOfLars/gotgbot/v2"
"github.com/PaulSonOfLars/gotgbot/v2/ext"
"github.com/PaulSonOfLars/gotgbot/v2/ext/handlers"
"github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/callbackquery"
"github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/choseninlineresult"
"github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/inlinequery"
)
var AllowedUpdates = []string{
"message",
"callback_query",
"inline_query",
"chosen_inline_result",
}
func Start() {
token := os.Getenv("BOT_TOKEN")
if token == "" {
log.Fatalf("BOT_TOKEN is not provided")
}
b, err := gotgbot.NewBot(token, &gotgbot.BotOpts{
BotClient: NewBotClient(),
})
if err != nil {
log.Fatalf("failed to create bot: %v", err)
}
dispatcher := ext.NewDispatcher(&ext.DispatcherOpts{
Error: func(b *gotgbot.Bot, ctx *ext.Context, err error) ext.DispatcherAction {
log.Println("an error occurred while handling update:", err.Error())
return ext.DispatcherActionNoop
},
MaxRoutines: ext.DefaultMaxRoutines,
})
updater := ext.NewUpdater(dispatcher, nil)
registerHandlers(dispatcher)
err = updater.StartPolling(b, &ext.PollingOpts{
DropPendingUpdates: true,
GetUpdatesOpts: &gotgbot.GetUpdatesOpts{
Timeout: 9 * 60,
RequestOpts: &gotgbot.RequestOpts{
Timeout: time.Minute * 10,
},
AllowedUpdates: AllowedUpdates,
},
})
if err != nil {
log.Fatalf("failed to start polling: %v", err)
}
log.Printf("bot started on: %s\n", b.User.Username)
}
func registerHandlers(dispatcher *ext.Dispatcher) {
dispatcher.AddHandler(handlers.NewMessage(
botHandlers.URLFilter,
botHandlers.URLHandler,
))
dispatcher.AddHandler(handlers.NewCommand(
"start",
botHandlers.StartHandler,
))
dispatcher.AddHandler(handlers.NewCallback(
callbackquery.Equal("start"),
botHandlers.StartHandler,
))
dispatcher.AddHandler(handlers.NewCallback(
callbackquery.Equal("help"),
botHandlers.HelpHandler,
))
dispatcher.AddHandler(handlers.NewCommand(
"settings",
botHandlers.SettingsHandler,
))
dispatcher.AddHandler(handlers.NewCommand(
"captions",
botHandlers.CaptionsHandler,
))
dispatcher.AddHandler(handlers.NewCommand(
"nsfw",
botHandlers.NSFWHandler,
))
dispatcher.AddHandler(handlers.NewCommand(
"limit",
botHandlers.MediaGroupLimitHandler,
))
dispatcher.AddHandler(handlers.NewCallback(
callbackquery.Equal("stats"),
botHandlers.StatsHandler,
))
dispatcher.AddHandler(handlers.NewCallback(
callbackquery.Equal("extractors"),
botHandlers.ExtractorsHandler,
))
dispatcher.AddHandler(handlers.NewCallback(
callbackquery.Equal("instances"),
botHandlers.InstancesHandler,
))
dispatcher.AddHandler(handlers.NewInlineQuery(
inlinequery.All,
botHandlers.InlineDownloadHandler,
))
dispatcher.AddHandler(handlers.NewChosenInlineResult(
choseninlineresult.All,
botHandlers.InlineDownloadResultHandler,
))
dispatcher.AddHandler(handlers.NewCallback(
callbackquery.Equal("inline:loading"),
botHandlers.InlineLoadingHandler,
))
}

56
bot/middleware.go Normal file
View file

@ -0,0 +1,56 @@
package bot
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/PaulSonOfLars/gotgbot/v2"
)
type BotClient struct {
gotgbot.BotClient
}
func (b BotClient) RequestWithContext(
ctx context.Context,
token string,
method string,
params map[string]string,
data map[string]gotgbot.FileReader,
opts *gotgbot.RequestOpts,
) (json.RawMessage, error) {
if strings.HasPrefix(method, "send") || method == "copyMessage" {
params["allow_sending_without_reply"] = "true"
}
if strings.HasPrefix(method, "send") || strings.HasPrefix(method, "edit") {
params["parse_mode"] = "HTML"
}
val, err := b.BotClient.RequestWithContext(ctx, token, method, params, data, opts)
if err != nil {
return nil, err
}
return val, err
}
func NewBotClient() BotClient {
botAPIURL := os.Getenv("BOT_API_URL")
if botAPIURL == "" {
log.Println("BOT_API_URL is not provided, using default")
botAPIURL = gotgbot.DefaultAPIURL
}
return BotClient{
BotClient: &gotgbot.BaseBotClient{
Client: http.Client{},
UseTestEnvironment: false,
DefaultRequestOpts: &gotgbot.RequestOpts{
Timeout: 10 * time.Minute,
APIURL: botAPIURL,
},
},
}
}