Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acd16a91a3 | ||
|
|
75f79e8abc | ||
|
|
1065acfdb8 | ||
|
|
fef7d37a7e | ||
|
|
b5e9cf987a | ||
|
|
c58fa454bb | ||
|
|
2c5d6f0e57 | ||
|
|
7d57ad30a9 | ||
|
|
4f314bd37f |
@@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
RUN apk add --no-cache curl ffmpeg
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
@@ -76,96 +68,3 @@ func handleSilentSaveMedia(ctx *ext.Context, update *ext.Update) error {
|
||||
}
|
||||
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, stor, dirutil.PathFromContext(ctx), file, msg.ID)
|
||||
}
|
||||
|
||||
type MediaGroupHandler struct {
|
||||
groups map[int64][]tfile.TGFileMessage
|
||||
timers map[int64]*time.Timer
|
||||
mu sync.Mutex
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
var mediaGroupHandler = &MediaGroupHandler{
|
||||
groups: make(map[int64][]tfile.TGFileMessage),
|
||||
timers: make(map[int64]*time.Timer),
|
||||
timeout: 1 * time.Second,
|
||||
}
|
||||
|
||||
func handleGroupMediaMessage(ctx *ext.Context, update *ext.Update, message *tg.Message, groupID int64) error {
|
||||
logger := log.FromContext(ctx)
|
||||
media := message.Media
|
||||
supported := mediautil.IsSupported(media)
|
||||
if !supported {
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
file, err := tfile.FromMediaMessage(media, ctx.Raw, message, tfile.WithNameIfEmpty(
|
||||
tgutil.GenFileNameFromMessage(*message),
|
||||
))
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get file from media: %s", err)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
mediaGroupHandler.mu.Lock()
|
||||
defer mediaGroupHandler.mu.Unlock()
|
||||
if mediaGroupHandler.groups[groupID] == nil {
|
||||
mediaGroupHandler.groups[groupID] = make([]tfile.TGFileMessage, 0)
|
||||
}
|
||||
mediaGroupHandler.groups[groupID] = append(mediaGroupHandler.groups[groupID], file)
|
||||
|
||||
if timer, exists := mediaGroupHandler.timers[groupID]; exists {
|
||||
timer.Stop()
|
||||
}
|
||||
mediaGroupHandler.timers[groupID] = time.AfterFunc(mediaGroupHandler.timeout, func() {
|
||||
processMediaGroup(ctx, update, groupID)
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func processMediaGroup(ctx *ext.Context, update *ext.Update, groupID int64) {
|
||||
logger := log.FromContext(ctx)
|
||||
mediaGroupHandler.mu.Lock()
|
||||
items := mediaGroupHandler.groups[groupID]
|
||||
delete(mediaGroupHandler.groups, groupID)
|
||||
delete(mediaGroupHandler.timers, groupID)
|
||||
mediaGroupHandler.mu.Unlock()
|
||||
if len(items) == 0 {
|
||||
logger.Warn("No media items to process for group", "groupID", groupID)
|
||||
return
|
||||
}
|
||||
logger.Debugf("Processing media group %d with %d items", groupID, len(items))
|
||||
|
||||
userId := update.GetUserChat().GetID()
|
||||
msg, err := ctx.Reply(update, ext.ReplyTextString("正在保存文件..."), nil)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to reply: %s", err)
|
||||
return
|
||||
}
|
||||
stor := storage.FromContext(ctx)
|
||||
if stor != nil {
|
||||
// In silent mode
|
||||
if len(items) == 1 {
|
||||
shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userId, stor, "", items[0], msg.ID)
|
||||
return
|
||||
}
|
||||
shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userId, stor, "", items, msg.ID)
|
||||
return
|
||||
}
|
||||
|
||||
stors := storage.GetUserStorages(ctx, userId)
|
||||
markup, err := msgelem.BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
|
||||
Files: items,
|
||||
AsBatch: len(items) > 1,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("构建存储选择键盘失败: %s", err)
|
||||
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
|
||||
ID: msg.ID,
|
||||
Message: "构建存储选择键盘失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
|
||||
ID: msg.ID,
|
||||
Message: fmt.Sprintf("共 %d 个文件, 请选择存储位置", len(items)),
|
||||
ReplyMarkup: markup,
|
||||
})
|
||||
}
|
||||
|
||||
126
client/bot/handlers/media_group.go
Normal file
126
client/bot/handlers/media_group.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
|
||||
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
type MediaGroupHandler struct {
|
||||
groups map[int64][]tfile.TGFileMessage
|
||||
timers map[int64]*time.Timer
|
||||
mu sync.Mutex
|
||||
timeout time.Duration
|
||||
setupOnce sync.Once
|
||||
}
|
||||
|
||||
func (m *MediaGroupHandler) SetupTimeout(timeoutSec int) {
|
||||
m.setupOnce.Do(func() {
|
||||
if timeoutSec < 1 {
|
||||
timeoutSec = 1
|
||||
}
|
||||
m.timeout = time.Duration(timeoutSec) * time.Second
|
||||
})
|
||||
}
|
||||
|
||||
var (
|
||||
mediaGroupHandler = &MediaGroupHandler{
|
||||
groups: make(map[int64][]tfile.TGFileMessage),
|
||||
timers: make(map[int64]*time.Timer),
|
||||
mu: sync.Mutex{},
|
||||
}
|
||||
)
|
||||
|
||||
func handleGroupMediaMessage(ctx *ext.Context, update *ext.Update, message *tg.Message, groupID int64) error {
|
||||
mediaGroupHandler.SetupTimeout(max(config.C().Telegram.MediaGroupTimeout, 1))
|
||||
logger := log.FromContext(ctx)
|
||||
media := message.Media
|
||||
supported := mediautil.IsSupported(media)
|
||||
if !supported {
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
file, err := tfile.FromMediaMessage(media, ctx.Raw, message, tfile.WithNameIfEmpty(
|
||||
tgutil.GenFileNameFromMessage(*message),
|
||||
))
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get file from media: %s", err)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
mediaGroupHandler.mu.Lock()
|
||||
defer mediaGroupHandler.mu.Unlock()
|
||||
if mediaGroupHandler.groups[groupID] == nil {
|
||||
mediaGroupHandler.groups[groupID] = make([]tfile.TGFileMessage, 0)
|
||||
}
|
||||
mediaGroupHandler.groups[groupID] = append(mediaGroupHandler.groups[groupID], file)
|
||||
|
||||
if timer, exists := mediaGroupHandler.timers[groupID]; exists {
|
||||
timer.Stop()
|
||||
}
|
||||
mediaGroupHandler.timers[groupID] = time.AfterFunc(mediaGroupHandler.timeout, func() {
|
||||
processMediaGroup(ctx, update, groupID)
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func processMediaGroup(ctx *ext.Context, update *ext.Update, groupID int64) {
|
||||
logger := log.FromContext(ctx)
|
||||
mediaGroupHandler.mu.Lock()
|
||||
items := mediaGroupHandler.groups[groupID]
|
||||
delete(mediaGroupHandler.groups, groupID)
|
||||
delete(mediaGroupHandler.timers, groupID)
|
||||
mediaGroupHandler.mu.Unlock()
|
||||
if len(items) == 0 {
|
||||
logger.Warn("No media items to process for group", "groupID", groupID)
|
||||
return
|
||||
}
|
||||
logger.Debugf("Processing media group %d with %d items", groupID, len(items))
|
||||
|
||||
userId := update.GetUserChat().GetID()
|
||||
msg, err := ctx.Reply(update, ext.ReplyTextString("正在保存文件..."), nil)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to reply: %s", err)
|
||||
return
|
||||
}
|
||||
stor := storage.FromContext(ctx)
|
||||
if stor != nil {
|
||||
// In silent mode
|
||||
if len(items) == 1 {
|
||||
shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userId, stor, "", items[0], msg.ID)
|
||||
return
|
||||
}
|
||||
shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userId, stor, "", items, msg.ID)
|
||||
return
|
||||
}
|
||||
|
||||
stors := storage.GetUserStorages(ctx, userId)
|
||||
markup, err := msgelem.BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
|
||||
Files: items,
|
||||
AsBatch: len(items) > 1,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("构建存储选择键盘失败: %s", err)
|
||||
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
|
||||
ID: msg.ID,
|
||||
Message: "构建存储选择键盘失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
|
||||
ID: msg.ID,
|
||||
Message: fmt.Sprintf("共 %d 个文件, 请选择存储位置", len(items)),
|
||||
ReplyMarkup: markup,
|
||||
})
|
||||
}
|
||||
13
config/tg.go
13
config/tg.go
@@ -1,12 +1,13 @@
|
||||
package config
|
||||
|
||||
type telegramConfig struct {
|
||||
Token string `toml:"token" mapstructure:"token"`
|
||||
AppID int `toml:"app_id" mapstructure:"app_id" json:"app_id"`
|
||||
AppHash string `toml:"app_hash" mapstructure:"app_hash" json:"app_hash"`
|
||||
Proxy tgProxyConfig `toml:"proxy" mapstructure:"proxy"`
|
||||
RpcRetry int `toml:"rpc_retry" mapstructure:"rpc_retry" json:"rpc_retry"`
|
||||
Userbot userbotConfig `toml:"userbot" mapstructure:"userbot" json:"userbot"`
|
||||
Token string `toml:"token" mapstructure:"token"`
|
||||
AppID int `toml:"app_id" mapstructure:"app_id" json:"app_id"`
|
||||
AppHash string `toml:"app_hash" mapstructure:"app_hash" json:"app_hash"`
|
||||
Proxy tgProxyConfig `toml:"proxy" mapstructure:"proxy"`
|
||||
RpcRetry int `toml:"rpc_retry" mapstructure:"rpc_retry" json:"rpc_retry"`
|
||||
Userbot userbotConfig `toml:"userbot" mapstructure:"userbot" json:"userbot"`
|
||||
MediaGroupTimeout int `toml:"media_group_timeout" mapstructure:"media_group_timeout" json:"media_group_timeout"`
|
||||
}
|
||||
|
||||
type userbotConfig struct {
|
||||
|
||||
@@ -11,7 +11,7 @@ if [ -n "$CONFIG_URL" ]; then
|
||||
fi
|
||||
|
||||
if [ ! -f /app/config.toml ]; then
|
||||
echo "[ERROR] Missing config.toml: 请通过挂载或 CONFIG_URL 提供配置文件"
|
||||
echo "[ERROR] Missing config.toml: Please provide the configuration file via mounting or CONFIG_URL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
6
go.mod
6
go.mod
@@ -13,13 +13,15 @@ require (
|
||||
github.com/goccy/go-yaml v1.18.0
|
||||
github.com/gotd/contrib v0.21.1
|
||||
github.com/gotd/td v0.132.0
|
||||
github.com/krau/ffmpeg-go v0.6.0
|
||||
github.com/minio/minio-go/v7 v7.0.95
|
||||
github.com/playwright-community/playwright-go v0.5200.1
|
||||
github.com/rhysd/go-github-selfupdate v1.2.3
|
||||
github.com/rs/xid v1.6.0
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/net v0.46.0
|
||||
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c
|
||||
golang.org/x/net v0.47.0
|
||||
golang.org/x/time v0.14.0
|
||||
)
|
||||
|
||||
@@ -97,7 +99,7 @@ require (
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/oauth2 v0.32.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
|
||||
14
go.sum
14
go.sum
@@ -166,6 +166,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/krau/ffmpeg-go v0.6.0 h1:F4HWvOrKXQsfLsFTOnUfP0HY6WISJqOrsAFGSIzkKto=
|
||||
github.com/krau/ffmpeg-go v0.6.0/go.mod h1:sa7/bWHB6fO9j4lhmxnWQ1U07o+dE1leFjhctotxU7A=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
@@ -249,6 +251,7 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
@@ -265,6 +268,8 @@ github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c h1:xA2TJS9Hu/ivzaZIrDcwvpJ3Fnpsk5fDOJ4iSnL6J0w=
|
||||
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c/go.mod h1:WSZ59bidJOO40JSJmLqlkBJrjZCtjbKKkygEMfzY/kc=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
@@ -288,8 +293,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
@@ -305,8 +310,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
@@ -366,6 +371,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -30,7 +30,6 @@ var (
|
||||
)
|
||||
|
||||
func Add(p ...parser.Parser) {
|
||||
configOnce.Do(configParsers)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
parsers = append(parsers, p...)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by go-enum DO NOT EDIT.
|
||||
// Version: 0.6.1
|
||||
// Revision: a6f63bddde05aca4221df9c8e9e6d7d9674b1cb4
|
||||
// Build Date: 2025-03-18T23:42:14Z
|
||||
// Version: 0.9.1
|
||||
// Revision: 42b1ed55945781de07471bb2db52b3f9edee19b0
|
||||
// Build Date: 2025-08-02T17:25:40Z
|
||||
// Built By: goreleaser
|
||||
|
||||
package ctxkey
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by go-enum DO NOT EDIT.
|
||||
// Version: 0.6.1
|
||||
// Revision: a6f63bddde05aca4221df9c8e9e6d7d9674b1cb4
|
||||
// Build Date: 2025-03-18T23:42:14Z
|
||||
// Version: 0.9.1
|
||||
// Revision: 42b1ed55945781de07471bb2db52b3f9edee19b0
|
||||
// Build Date: 2025-08-02T17:25:40Z
|
||||
// Built By: goreleaser
|
||||
|
||||
package fnamest
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by go-enum DO NOT EDIT.
|
||||
// Version: 0.6.1
|
||||
// Revision: a6f63bddde05aca4221df9c8e9e6d7d9674b1cb4
|
||||
// Build Date: 2025-03-18T23:42:14Z
|
||||
// Version: 0.9.1
|
||||
// Revision: 42b1ed55945781de07471bb2db52b3f9edee19b0
|
||||
// Build Date: 2025-08-02T17:25:40Z
|
||||
// Built By: goreleaser
|
||||
|
||||
package storage
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by go-enum DO NOT EDIT.
|
||||
// Version: 0.6.1
|
||||
// Revision: a6f63bddde05aca4221df9c8e9e6d7d9674b1cb4
|
||||
// Build Date: 2025-03-18T23:42:14Z
|
||||
// Version: 0.9.1
|
||||
// Revision: 42b1ed55945781de07471bb2db52b3f9edee19b0
|
||||
// Build Date: 2025-08-02T17:25:40Z
|
||||
// Built By: goreleaser
|
||||
|
||||
package tasktype
|
||||
|
||||
1
storage/telegram/.gitignore
vendored
Normal file
1
storage/telegram/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
tests/
|
||||
@@ -68,8 +68,8 @@ func (t *Telegram) Save(ctx context.Context, r io.Reader, storagePath string) er
|
||||
if err := t.limiter.Wait(ctx); err != nil {
|
||||
return fmt.Errorf("rate limit failed: %w", err)
|
||||
}
|
||||
rs, ok := r.(io.ReadSeeker)
|
||||
if !ok || rs == nil {
|
||||
rs, seekable := r.(io.ReadSeeker)
|
||||
if !seekable || rs == nil {
|
||||
return fmt.Errorf("reader must implement io.ReadSeeker")
|
||||
}
|
||||
tctx := tgutil.ExtFromContext(ctx)
|
||||
@@ -137,18 +137,44 @@ func (t *Telegram) Save(ctx context.Context, r io.Reader, storagePath string) er
|
||||
if strings.HasPrefix(mtype.String(), "image/") && size >= tglimit.MaxPhotoSize {
|
||||
forceFile = true
|
||||
}
|
||||
docb := message.UploadedDocument(file, caption).
|
||||
doc := message.UploadedDocument(file, caption).
|
||||
Filename(filename).
|
||||
ForceFile(forceFile).
|
||||
MIME(mtype.String())
|
||||
|
||||
var media message.MediaOption = docb
|
||||
var media message.MediaOption = doc
|
||||
|
||||
switch mtypeStr := mtype.String(); {
|
||||
case strings.HasPrefix(mtypeStr, "video/"):
|
||||
media = docb.Video().SupportsStreaming()
|
||||
media = doc.Video().SupportsStreaming()
|
||||
thumb, err := extractThumbFrame(rs)
|
||||
if err == nil {
|
||||
thumb, err := upler.FromBytes(ctx, "thumb.jpg", thumb)
|
||||
if err == nil {
|
||||
doc = doc.Thumb(thumb)
|
||||
}
|
||||
}
|
||||
rs.Seek(0, io.SeekStart)
|
||||
switch mtypeStr {
|
||||
case "video/mp4":
|
||||
info, err := getMP4Meta(rs)
|
||||
if err == nil {
|
||||
media = doc.Video().
|
||||
Duration(time.Duration(info.Duration)*time.Second).
|
||||
Resolution(info.Width, info.Height).
|
||||
SupportsStreaming()
|
||||
}
|
||||
default:
|
||||
info, err := getVideoMetadata(rs)
|
||||
if err == nil {
|
||||
media = doc.Video().
|
||||
Duration(time.Duration(info.Duration)*time.Second).
|
||||
Resolution(info.Width, info.Height).
|
||||
SupportsStreaming()
|
||||
}
|
||||
}
|
||||
case strings.HasPrefix(mtypeStr, "audio/"):
|
||||
media = docb.Audio().Title(filename)
|
||||
media = doc.Audio().Title(filename)
|
||||
case strings.HasPrefix(mtypeStr, "image/") && !strings.HasSuffix(mtypeStr, "webp"):
|
||||
media = message.UploadedPhoto(file, caption)
|
||||
}
|
||||
|
||||
135
storage/telegram/util.go
Normal file
135
storage/telegram/util.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/krau/ffmpeg-go"
|
||||
"github.com/yapingcat/gomedia/go-mp4"
|
||||
)
|
||||
|
||||
type VideoMetadata struct {
|
||||
Duration int
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
// a go native way to get mp4 video metadata
|
||||
func getMP4Meta(rs io.ReadSeeker) (*VideoMetadata, error) {
|
||||
d := mp4.CreateMp4Demuxer(rs)
|
||||
|
||||
tracks, err := d.ReadHead()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, track := range tracks {
|
||||
if track.Cid == mp4.MP4_CODEC_H264 {
|
||||
info := d.GetMp4Info()
|
||||
return &VideoMetadata{
|
||||
Duration: int(info.Duration / info.Timescale),
|
||||
Width: int(track.Width),
|
||||
Height: int(track.Height),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no h264 track found")
|
||||
}
|
||||
|
||||
// getVideoMetadata uses ffprobe to get video metadata
|
||||
func getVideoMetadata(rs io.ReadSeeker) (*VideoMetadata, error) {
|
||||
pipeReader, pipeWriter := io.Pipe()
|
||||
|
||||
go func() {
|
||||
defer pipeWriter.Close()
|
||||
rs.Seek(0, io.SeekStart)
|
||||
io.Copy(pipeWriter, rs)
|
||||
}()
|
||||
|
||||
result, err := ffmpeg.ProbeReaderWithTimeout(
|
||||
pipeReader,
|
||||
time.Second*10,
|
||||
ffmpeg.KwArgs{
|
||||
"select_streams": "v:0",
|
||||
"show_entries": "stream=width,height:format=duration",
|
||||
"of": "json",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Streams []struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"streams"`
|
||||
Format struct {
|
||||
Duration string `json:"duration"`
|
||||
} `json:"format"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(result), &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 转换 duration
|
||||
var durationFloat float64
|
||||
if data.Format.Duration != "" {
|
||||
fmt.Sscanf(data.Format.Duration, "%f", &durationFloat)
|
||||
}
|
||||
|
||||
meta := &VideoMetadata{
|
||||
Duration: int(durationFloat),
|
||||
}
|
||||
|
||||
if len(data.Streams) > 0 {
|
||||
meta.Width = data.Streams[0].Width
|
||||
meta.Height = data.Streams[0].Height
|
||||
}
|
||||
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func extractThumbFrame(rs io.ReadSeeker) ([]byte, error) {
|
||||
data, err := extractFrameAt(rs, 1.0)
|
||||
if err == nil && len(data) > 0 {
|
||||
return data, nil
|
||||
}
|
||||
return extractFrameAt(rs, 0.0)
|
||||
}
|
||||
|
||||
func extractFrameAt(rs io.ReadSeeker, timestamp float64) ([]byte, error) {
|
||||
pipeReader, pipeWriter := io.Pipe()
|
||||
|
||||
go func() {
|
||||
defer pipeWriter.Close()
|
||||
rs.Seek(0, io.SeekStart)
|
||||
io.Copy(pipeWriter, rs)
|
||||
}()
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
err := ffmpeg.
|
||||
Input("pipe:0", ffmpeg.KwArgs{
|
||||
"ss": fmt.Sprintf("%.3f", timestamp),
|
||||
}).
|
||||
Output("pipe:1", ffmpeg.KwArgs{
|
||||
"vframes": 1,
|
||||
"f": "mjpeg",
|
||||
}).
|
||||
WithInput(pipeReader).
|
||||
WithOutput(&out).
|
||||
OverWriteOutput().
|
||||
Run()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
34
storage/telegram/util_test.go
Normal file
34
storage/telegram/util_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractThumbFrame(t *testing.T) {
|
||||
file, err := os.Open("tests/testvideo")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open test video: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
thumb, err := extractThumbFrame(file)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to extract thumb frame: %v", err)
|
||||
}
|
||||
os.WriteFile("tests/testthumb.jpg", thumb, 0644)
|
||||
}
|
||||
|
||||
func TestGetVideoMetadata(t *testing.T) {
|
||||
file, err := os.Open("tests/testvideo")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open test video: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
meta, err := getVideoMetadata(file)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get video metadata: %v", err)
|
||||
}
|
||||
if meta.Duration == 0 || meta.Width == 0 || meta.Height == 0 {
|
||||
t.Fatalf("invalid video metadata: %+v", meta)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user